保存插件独立密钥接口接续测试前基线
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-20 08:05:28 +08:00
parent d57d145d5d
commit 05b1441d69
4 changed files with 132 additions and 9 deletions

View File

@ -0,0 +1,70 @@
package controllers
import (
"errors"
"net/http"
"strings"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/hooks"
"git.pcux.cn/xuans/Smail/services/access"
pluginservice "git.pcux.cn/xuans/Smail/services/plugin"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
// PluginSecret 只维护数据库中的插件密钥,不写入文件配置或候选载荷。
func PluginSecret(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if req.Method != http.MethodGet && req.Method != http.MethodPut {
w.Header().Set("Allow", "GET, PUT")
http.Error(w, "请求方法不支持", http.StatusMethodNotAllowed)
return
}
capability := access.CapabilityPluginReadGlobal
if req.Method == http.MethodPut {
capability = access.CapabilityPluginWriteGlobal
}
if ctx == nil || ctx.UserID <= 0 {
writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied})
return
}
if err := authorizeCapability(ctx, capability, "", false); err != nil {
writeConfigAccessError(w, err)
return
}
pluginID := strings.TrimPrefix(req.URL.Path, "/api/v1/plugin/secrets/")
field := map[string]string{"telegram_push": "botToken", "wechat_push": "appSecret"}[pluginID]
if field == "" {
http.NotFound(w, req)
return
}
service := hooks.PluginConfigService()
if service == nil {
response.NewErrorResponse(response.ServerError, "插件配置服务不可用", "").FPrint(w)
return
}
var status pluginservice.SecretStatus
var err error
if req.Method == http.MethodGet {
status, err = service.GetSecretStatus(pluginID, field)
} else {
var input struct {
Change config.SecretChange `json:"change"`
Version uint64 `json:"version"`
}
if !decodePluginJSONBody(w, req, &input) {
return
}
status, err = service.ChangeSecret(pluginID, field, input.Change, input.Version)
}
if err != nil {
if errors.Is(err, pluginservice.ErrPluginSettingConflict) {
writeConfigError(w, config.ErrGenerationConflict)
} else {
response.NewErrorResponse(response.ParamsError, "插件密钥操作失败,请检查输入后重试", "").FPrint(w)
}
return
}
response.NewSuccessResponse(status).FPrint(w)
}

View File

@ -148,6 +148,9 @@ func registerV1TOTPRoute(mux *http.ServeMux) {
}
func registerV1PluginRoutes(mux *http.ServeMux) {
for _, id := range []string{"telegram_push", "wechat_push"} {
mux.HandleFunc("/api/v1/plugin/secrets/"+id, limitBody(16<<10, contextIterceptor(controllers.PluginSecret)))
}
getUserSettings := contextIterceptor(controllers.GetTelegramUserSettings)
updateUserSettings := limitBody(16<<10, contextIterceptor(controllers.UpdateTelegramUserSettings))
mux.HandleFunc("/api/v1/plugin/settings/telegram_push/user", func(w http.ResponseWriter, r *http.Request) {

View File

@ -76,11 +76,18 @@
type="password"
show-password
clearable
:disabled="!canWrite || sectionLoading || busy || !!sectionError || !!pendingCandidate"
:disabled="!canWrite || sectionLoading || busy || !!sectionError || !!pendingCandidate || (pluginSecretPage && !pluginSecretStatus)"
:placeholder="secretField.placeholder"
autocomplete="new-password"
@update:model-value="updateSecret"
/>
<el-button
v-if="pluginSecretPage && canWrite"
:disabled="busy || sectionLoading || !!sectionError || !!pendingCandidate || !pluginSecretStatus || !secretValue"
@click="savePluginSecret"
>
保存密钥
</el-button>
</div>
<div v-if="canWrite" class="editor-footer">
@ -102,7 +109,7 @@
<el-button
type="primary"
:loading="busy"
:disabled="sectionLoading || busy || !!sectionError || (!pendingCandidate && !hasChanges)"
:disabled="sectionLoading || busy || !!sectionError || (!pendingCandidate && !hasConfigChanges)"
@click="saveAndApply"
>
{{ continuingSave ? '继续保存' : '保存配置' }}
@ -154,11 +161,14 @@ const {
saveError,
secretField,
secretValue,
pluginSecretPage,
pluginSecretStatus,
savePluginSecret,
pendingCandidate,
pendingScopeHint,
managePending,
validationResult,
hasChanges,
hasConfigChanges,
canWrite,
refreshPage,
saveAndApply

View File

@ -62,6 +62,8 @@ export function usePage(pageKey = 'base') {
const sectionError = ref('')
const saveError = ref('')
const secretValue = ref('')
const pluginSecretStatus = ref(null)
const pluginSecretPage = computed(() => ['telegram_push', 'wechat_push'].includes(selectedSection.value))
const confirmed = ref(false)
const savedGeneration = ref(0)
const pendingCandidate = ref(null)
@ -147,6 +149,7 @@ export function usePage(pageKey = 'base') {
})
const restartPreview = ref(null)
const hasChanges = computed(() => editorText.value !== loadedText.value || !!secretValue.value || !!savedGeneration.value)
const hasConfigChanges = computed(() => editorText.value !== loadedText.value || (!pluginSecretPage.value && !!secretValue.value) || !!savedGeneration.value)
const canWrite = computed(() => globalStatus.hasCapability(apiSection() === 'plugins' ? 'plugin.write_global' : 'config.write_global'))
const canRollback = computed(() => globalStatus.hasCapability('config.rollback'))
@ -161,10 +164,10 @@ export function usePage(pageKey = 'base') {
const pluginSecrets = { telegram_push: 'Telegram Bot Token', wechat_push: '微信 Secret' }
const label = pluginSecrets[selectedSection.value]
if (label && formConfig.value.plugins?.[selectedSection.value]) {
const configured = readFormConfig(loadedText.value).plugins?.[selectedSection.value]?.secretStatus?.configured
const configured = pluginSecretStatus.value?.configured
return {
label,
description: `${configured ? '已设置' : '未设置'}。保存后加密存储,不回显;留空保持原值。`,
description: `${configured ? '已设置' : '未设置'}。加密存储,不回显;保存后下次启动插件时生效。`,
placeholder: `输入新的 ${label}(可选)`
}
}
@ -191,6 +194,7 @@ export function usePage(pageKey = 'base') {
}
const updateSecret = value => {
if (saveFlow.value || !canEdit() || !secretField.value || secretValue.value === value) return
if (pluginSecretPage.value && !pluginSecretStatus.value) return
secretValue.value = value
saveError.value = ''
invalidateCandidate()
@ -293,6 +297,7 @@ export function usePage(pageKey = 'base') {
editorText.value = ''
loadedText.value = ''
secretValue.value = ''
pluginSecretStatus.value = null
confirmed.value = false
validationResult.value = null
savedGeneration.value = 0
@ -308,6 +313,16 @@ export function usePage(pageKey = 'base') {
}
editorText.value = JSON.stringify(publicConfig, null, 2)
loadedText.value = editorText.value
if (pluginSecretPage.value) {
const secretResponse = await http.get(`/api/v1/plugin/secrets/${selectedSection.value}`)
if (requestId !== sectionRequestId || disposed) return false
notifyResponseError(secretResponse, '插件密钥状态读取失败')
const secretStatus = secretResponse.data
if (typeof secretStatus?.configured !== 'boolean' || !Number.isSafeInteger(secretStatus.version) || secretStatus.version < 0) {
throw new Error('插件密钥状态不完整,请刷新后重试')
}
pluginSecretStatus.value = secretStatus
}
if (section === 'base') {
const database = publicConfig.database || {}
Object.assign(databaseConfig, { type: database.type || '', name: database.name || '', localRef: database.localRef || '' })
@ -536,9 +551,6 @@ export function usePage(pageKey = 'base') {
if (value && selectedSection.value === 'base' && secretField.value) {
secretChanges['database.password'] = { operation: 'replace', value: { plaintext: value } }
}
if (value && ['telegram_push', 'wechat_push'].includes(selectedSection.value) && secretField.value) {
secretChanges[`plugins.${selectedSection.value}.secret`] = { operation: 'replace', value: { plaintext: value } }
}
const relayPassword = formConfig.value?.relay?.password
if (relayPassword && selectedSection.value === 'mail') {
secretChanges['relay.password'] = { operation: 'replace', value: { plaintext: relayPassword } }
@ -664,6 +676,10 @@ export function usePage(pageKey = 'base') {
const saveAndApply = async () => {
if (saveFlow.value || !canOperate()) return false
if (pluginSecretPage.value && secretValue.value) {
ElMessage.warning('请先保存密钥,再保存运行配置')
return false
}
saveFlow.value = pendingCandidate.value ? 'resume' : 'save'
try {
if (pendingCandidate.value) {
@ -682,6 +698,30 @@ export function usePage(pageKey = 'base') {
return saveAndApply()
}
const savePluginSecret = async () => {
if (!canEdit() || !pluginSecretPage.value || !pluginSecretStatus.value || !secretValue.value) return false
working.value = 'plugin-secret'
saveError.value = ''
try {
const request = { version: pluginSecretStatus.value.version, change: { operation: 'replace', value: { plaintext: secretValue.value } } }
const response = await requestWithRecentAuthentication(() => http.put(`/api/v1/plugin/secrets/${selectedSection.value}`, request))
notifyResponseError(response, '插件密钥保存失败')
if (disposed) return false
secretValue.value = ''
pluginSecretStatus.value = response.data
ElMessage.success('密钥已保存,下次启动插件时生效')
return true
} catch (error) {
if (!disposed && !isRecentAuthenticationCancelled(error)) {
saveError.value = error.message || '插件密钥保存失败'
ElMessage.error(saveError.value)
}
return false
} finally {
working.value = ''
}
}
const previewRollback = async () => {
if (!canRollback.value || working.value || !rollbackGeneration.value) return
working.value = 'preview'
@ -762,7 +802,7 @@ export function usePage(pageKey = 'base') {
saveFlow, busy, showPendingRecovery, continuingSave,
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
selectedSection, currentSection, sectionLoading, editorText, formConfig, formErrors, updateField, updateSecret,
sectionError, saveError, sensitiveHint, secretField, secretValue,
sectionError, saveError, sensitiveHint, secretField, secretValue, pluginSecretPage, pluginSecretStatus, savePluginSecret, hasConfigChanges,
confirmed, savedGeneration, pendingCandidate, managePending, validationResult, canWrite, canRollback,
backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed, rollbackPreview,
loadAll, loadSection, validateConfig, saveConfig, applyConfig, saveAndApply, continuePending,