This commit is contained in:
parent
d57d145d5d
commit
05b1441d69
70
backend/controllers/plugin_secret.go
Normal file
70
backend/controllers/plugin_secret.go
Normal 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)
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user