From 46fd3a6bff1285aa0fd5a519fe5ad8ba33b7ef4a Mon Sep 17 00:00:00 2001 From: smail Date: Thu, 10 Sep 2026 10:40:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=A7=92=E8=89=B2=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E9=9A=94=E7=A6=BB=E4=B8=8E=E6=8F=92=E4=BB=B6=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/plugin.go | 4 +- backend/controllers/plugin_v1.go | 10 +- backend/db/migration/log_actor_role.go | 86 ++++++++++++++++ backend/db/migration/registry.go | 1 + backend/hooks/spam_block/spam_block.go | 23 +++-- backend/listen/http_server/operation_log.go | 2 +- backend/services/access/catalog.go | 4 +- backend/services/logstore/store.go | 105 +++++++++++++++++--- 8 files changed, 208 insertions(+), 27 deletions(-) create mode 100644 backend/db/migration/log_actor_role.go diff --git a/backend/controllers/plugin.go b/backend/controllers/plugin.go index 2018cbf..0798b72 100644 --- a/backend/controllers/plugin.go +++ b/backend/controllers/plugin.go @@ -19,7 +19,7 @@ const ( ) func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { - if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { + if err := authorizeCapability(ctx, access.CapabilityPluginRead, "", false); err != nil { response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w) return } @@ -32,7 +32,7 @@ func GetPluginList(ctx *context.Context, w http.ResponseWriter, req *http.Reques } func SettingsHtml(ctx *context.Context, w http.ResponseWriter, req *http.Request) { - if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { + if err := authorizeCapability(ctx, access.CapabilityPluginReadGlobal, "", false); err != nil { response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w) return } diff --git a/backend/controllers/plugin_v1.go b/backend/controllers/plugin_v1.go index 9181386..0496c50 100644 --- a/backend/controllers/plugin_v1.go +++ b/backend/controllers/plugin_v1.go @@ -24,7 +24,7 @@ type telegramSettingsRequest struct { } func GetTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req *http.Request) { - if !authenticatedPluginUser(ctx) { + if !authorizedPluginUser(ctx) { response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w) return } @@ -42,7 +42,7 @@ func GetTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req *h } func UpdateTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req *http.Request) { - if !authenticatedPluginUser(ctx) { + if !authorizedPluginUser(ctx) { response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w) return } @@ -71,7 +71,7 @@ func UpdateTelegramUserSettings(ctx *context.Context, w http.ResponseWriter, req } func GetTelegramBotInfo(ctx *context.Context, w http.ResponseWriter, req *http.Request) { - if !authenticatedPluginUser(ctx) { + if !authorizedPluginUser(ctx) { response.NewErrorResponse(response.NeedLogin, "请先登录", "").FPrint(w) return } @@ -138,6 +138,10 @@ func authenticatedPluginUser(ctx *context.Context) bool { return ctx != nil && ctx.UserID > 0 } +func authorizedPluginUser(ctx *context.Context) bool { + return authenticatedPluginUser(ctx) && authorizeCapability(ctx, access.CapabilityPluginRead, "", true) == nil +} + func decodePluginJSONBody(w http.ResponseWriter, req *http.Request, target any) bool { req.Body = http.MaxBytesReader(w, req.Body, maxTelegramUserSettingsBodyBytes) defer req.Body.Close() diff --git a/backend/db/migration/log_actor_role.go b/backend/db/migration/log_actor_role.go new file mode 100644 index 0000000..4732206 --- /dev/null +++ b/backend/db/migration/log_actor_role.go @@ -0,0 +1,86 @@ +package migration + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "git.pcux.cn/xuans/Smail/services/access" +) + +type operationLogActorRole struct { + ID string + Role string +} + +// applyOperationLogActorRoleSchema 将操作发生时的角色从历史载荷回填到独立字段。 +func applyOperationLogActorRoleSchema(ctx context.Context, exec executor, dialect Dialect) error { + definitions := map[Dialect]string{ + DialectSQLite: `TEXT NULL`, + DialectMySQL: `VARCHAR(20) NULL`, + DialectPostgres: `VARCHAR(20) NULL`, + } + definition, ok := definitions[dialect] + if !ok { + return fmt.Errorf("unsupported migration dialect %q", dialect) + } + if err := ensureColumn(ctx, exec, dialect, "operation_log", "actor_role", definition); err != nil { + return err + } + + rows, err := exec.QueryContext(ctx, "SELECT id, payload FROM operation_log") + if err != nil { + return fmt.Errorf("query operation log history: %w", err) + } + items := make([]operationLogActorRole, 0) + for rows.Next() { + var item operationLogActorRole + var payload string + if err := rows.Scan(&item.ID, &payload); err != nil { + _ = rows.Close() + return fmt.Errorf("scan operation log history: %w", err) + } + item.Role = historicalActorRole(payload) + if item.Role != "" { + items = append(items, item) + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate operation log history: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close operation log history: %w", err) + } + + update := `UPDATE "operation_log" SET "actor_role" = ? WHERE "id" = ? AND "actor_role" IS NULL` + if dialect == DialectMySQL { + update = "UPDATE `operation_log` SET `actor_role` = ? WHERE `id` = ? AND `actor_role` IS NULL" + } else if dialect == DialectPostgres { + update = `UPDATE "operation_log" SET "actor_role" = $1 WHERE "id" = $2 AND "actor_role" IS NULL` + } + for _, item := range items { + if err := ctx.Err(); err != nil { + return err + } + if _, err := exec.ExecContext(ctx, update, item.Role, item.ID); err != nil { + return fmt.Errorf("backfill operation log actor role: %w", err) + } + } + return nil +} + +func historicalActorRole(payload string) string { + var event struct { + ActorRole string `json:"actorRole"` + } + if json.Unmarshal([]byte(payload), &event) != nil { + return "" + } + role := access.Role(strings.TrimSpace(event.ActorRole)) + if !role.Valid() { + return "" + } + return string(role) +} diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index eea537f..f18bf1c 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -27,6 +27,7 @@ func DefaultMigrations() []Migration { DefineCustom(15, "expand_security_setup", "expand_security_setup_v1", applySecuritySetupSchema), DefineCustom(16, "expand_user_security_preferences", "expand_user_security_preferences_v1", applyUserSecurityPreferencesSchema), DefineForDialects(17, "create_managed_logs", logStatements()), + DefineCustom(18, "expand_operation_log_actor_role", "expand_operation_log_actor_role_v1", applyOperationLogActorRoleSchema), } } diff --git a/backend/hooks/spam_block/spam_block.go b/backend/hooks/spam_block/spam_block.go index ea23884..ede2cca 100644 --- a/backend/hooks/spam_block/spam_block.go +++ b/backend/hooks/spam_block/spam_block.go @@ -8,6 +8,7 @@ import ( "git.pcux.cn/xuans/Smail/hooks/framework" "git.pcux.cn/xuans/Smail/hooks/spam_block/tools" "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/credential" log "github.com/sirupsen/logrus" @@ -73,10 +74,7 @@ func (s *SpamBlock) Configure(data json.RawMessage) error { if err != nil { return err } - s.mu.Lock() - s.cfg = cfg - s.hc = newHTTPClientForEndpoint(cfg.ApiTimeout, cfg.ApiURL) - s.mu.Unlock() + s.applyConfig(cfg) return nil } @@ -99,13 +97,17 @@ var jquery string // SettingsHtml 插件页面 func (s *SpamBlock) SettingsHtml(ctx *context.Context, url string, requestData string) string { + parts := strings.Split(strings.Trim(url, "/"), "/") + if len(parts) > 0 && parts[len(parts)-1] == "save" { + return "旧版插件配置保存接口已停用,请使用新版插件配置接口" + } if strings.Contains(url, "jquery.js") { return jquery } if strings.Contains(url, "index.html") { - if !ctx.IsAdmin { + if !canManageSpamBlockSettings(ctx) { return fmt.Sprintf(`
请联系管理员进行配置。 @@ -117,9 +119,10 @@ func (s *SpamBlock) SettingsHtml(ctx *context.Context, url string, requestData s s.mu.RUnlock() return fmt.Sprintf(index, template.HTMLEscapeString(cfg.ApiURL), cfg.ApiTimeout, cfg.Threshold) } - if !ctx.IsAdmin { + if !canManageSpamBlockSettings(ctx) { return "没有访问权限" } + return "没有访问权限" cfg, err := decodeSpamBlockConfig([]byte(requestData)) if err != nil { @@ -132,6 +135,14 @@ func (s *SpamBlock) SettingsHtml(ctx *context.Context, url string, requestData s } +func canManageSpamBlockSettings(ctx *context.Context) bool { + if ctx == nil { + return false + } + role := ctx.EffectiveRole() + return role == access.RoleSuperAdmin || role == access.RoleAdmin +} + type ModelResponse struct { Predictions [][]float64 `json:"predictions"` } diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index a3d9ce8..6539728 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -136,7 +136,7 @@ func operationLog(ctx *appcontext.Context, req *http.Request, w http.ResponseWri }{ctx.UserAccount, string(ctx.EffectiveRole()), observed.code, operationObject(path), req.Method + " " + path, operationOutcome(observed.code, result), observed.status}) logstore.Record(context.Background(), logstore.Entry{ - Kind: "operation", CreatedAt: time.Now(), ActorID: strconv.Itoa(ctx.UserID), Action: action, Result: result, Payload: payload, + Kind: "operation", CreatedAt: time.Now(), ActorID: strconv.Itoa(ctx.UserID), ActorRole: string(ctx.EffectiveRole()), Action: action, Result: result, Payload: payload, }) } } diff --git a/backend/services/access/catalog.go b/backend/services/access/catalog.go index 0c3520f..3f13096 100644 --- a/backend/services/access/catalog.go +++ b/backend/services/access/catalog.go @@ -17,7 +17,7 @@ var managementEndpoints = []Endpoint{ {Method: "POST", Path: "/api/user/info", Capability: CapabilityUserRead}, {Method: "POST", Path: "/api/user/list", Capability: CapabilityUserRead}, {Method: "GET", Path: "/api/plugin/list", Capability: CapabilityPluginRead}, - {Method: "*", Path: "/api/plugin/settings/", Capability: CapabilityPluginWriteOwn}, + {Method: "*", Path: "/api/plugin/settings/", Capability: CapabilityPluginReadGlobal}, {Method: "GET", Path: "/api/v1/config/status", Capability: CapabilityConfigRead}, {Method: "GET", Path: "/api/v1/config/backups", Capability: CapabilityConfigRollback}, {Method: "POST", Path: "/api/v1/config/rollback/preview", Capability: CapabilityConfigRollback}, @@ -45,7 +45,7 @@ var managementEndpoints = []Endpoint{ {Method: "POST", Path: "/api/v1/user/info", Capability: CapabilityUserRead}, {Method: "POST", Path: "/api/v1/user/list", Capability: CapabilityUserRead}, {Method: "GET", Path: "/api/v1/plugin/list", Capability: CapabilityPluginRead}, - {Method: "*", Path: "/api/v1/plugin/settings/", Capability: CapabilityPluginWriteOwn}, + {Method: "*", Path: "/api/v1/plugin/settings/", Capability: CapabilityPluginReadGlobal}, } func ManagementEndpoints() []Endpoint { diff --git a/backend/services/logstore/store.go b/backend/services/logstore/store.go index 1d9c2ce..ed504e1 100644 --- a/backend/services/logstore/store.go +++ b/backend/services/logstore/store.go @@ -9,11 +9,13 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "strings" "time" "git.pcux.cn/xuans/Smail/db/migration" "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/utils/logpolicy" ) @@ -25,6 +27,7 @@ type Entry struct { CreatedAt time.Time `json:"createdAt"` Kind string `json:"kind"` ActorID string `json:"actorId"` + ActorRole string `json:"actorRole,omitempty"` Action string `json:"action"` Section string `json:"section"` Result string `json:"result"` @@ -116,6 +119,20 @@ func (s *Store) Write(ctx context.Context, entry Entry) error { if emptyEntry(entry) { return nil } + if isOperationKind(entry.Kind) { + role, present := actorRoleFromPayload(entry.Payload) + if entry.ActorRole != "" { + role = strings.TrimSpace(entry.ActorRole) + present = true + } + if present && !access.Role(role).Valid() { + return ErrInvalid + } + if !present { + role = s.lookupActorRole(ctx, entry.ActorID) + } + entry.ActorRole = role + } if entry.ID == "" { var token [16]byte if _, err := rand.Read(token[:]); err != nil { @@ -123,14 +140,22 @@ func (s *Store) Write(ctx context.Context, entry Entry) error { } entry.ID = "log-" + hex.EncodeToString(token[:]) } - statement := "INSERT INTO " + table + " (id, created_at, kind, actor_id, action, section, result, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + columns := "id, created_at, kind, actor_id, action, section, result, payload" + values := "?, ?, ?, ?, ?, ?, ?, ?" + args := []any{entry.ID, entry.CreatedAt.UTC().UnixNano(), entry.Kind, entry.ActorID, entry.Action, entry.Section, entry.Result, string(entry.Payload)} + if isOperationKind(entry.Kind) { + columns = "id, created_at, kind, actor_id, actor_role, action, section, result, payload" + values = "?, ?, ?, ?, ?, ?, ?, ?, ?" + args = []any{entry.ID, entry.CreatedAt.UTC().UnixNano(), entry.Kind, entry.ActorID, nullableString(entry.ActorRole), entry.Action, entry.Section, entry.Result, string(entry.Payload)} + } + statement := "INSERT INTO " + table + " (" + columns + ") VALUES (" + values + ")" // 仅忽略同一稳定标识的重放,不用 INSERT IGNORE 掩盖截断、缺字段等错误。 if s.dialect == migration.DialectMySQL { statement += " ON DUPLICATE KEY UPDATE id = id" } else { statement += " ON CONFLICT (id) DO NOTHING" } - _, err = s.db.ExecContext(ctx, s.bind(statement), entry.ID, entry.CreatedAt.UTC().UnixNano(), entry.Kind, entry.ActorID, entry.Action, entry.Section, entry.Result, string(entry.Payload)) + _, err = s.db.ExecContext(ctx, s.bind(statement), args...) if err != nil { return ErrUnavailable } @@ -149,7 +174,11 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) { if query.From.After(from) { from = query.From } - statement := "SELECT id, created_at, kind, actor_id, action, section, result, payload FROM " + table + " WHERE created_at >= ? AND kind = ?" + columns := "id, created_at, kind, actor_id, action, section, result, payload" + if isOperationKind(query.Kind) { + columns = "id, created_at, kind, actor_id, actor_role, action, section, result, payload" + } + statement := "SELECT " + columns + " FROM " + table + " WHERE created_at >= ? AND kind = ?" args := []any{from.UnixNano(), query.Kind} if query.ScopeActorID != "" { statement += " AND (actor_id = ?" @@ -163,15 +192,8 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) { statement += "actor_id IN (SELECT CAST(id AS " + idType + ") FROM " + userTable + " WHERE " + models.OrdinaryUserPredicate + ")" // 代管视图还要核对操作发生时的角色,不能因账号降级暴露旧管理操作。 // 缺少历史角色的操作仅本人和超级管理员可见,收发信元数据不包含管理操作。 - if query.Kind == "operation" { - switch s.dialect { - case migration.DialectMySQL: - statement += " AND CASE WHEN JSON_VALID(payload) THEN JSON_UNQUOTE(JSON_EXTRACT(payload, '$.actorRole')) ELSE NULL END = 'user'" - case migration.DialectPostgres: - statement += " AND (payload::jsonb ->> 'actorRole') = 'user'" - default: - statement += " AND CASE WHEN json_valid(payload) THEN json_extract(payload, '$.actorRole') ELSE NULL END = 'user'" - } + if isOperationKind(query.Kind) { + statement += " AND actor_role = 'user'" } statement += ")" } @@ -205,10 +227,19 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) { var entry Entry var timestamp int64 var payload string - if err := rows.Scan(&entry.ID, ×tamp, &entry.Kind, &entry.ActorID, &entry.Action, &entry.Section, &entry.Result, &payload); err != nil { + var actorRole sql.NullString + scanArgs := []any{&entry.ID, ×tamp, &entry.Kind, &entry.ActorID} + if isOperationKind(query.Kind) { + scanArgs = append(scanArgs, &actorRole) + } + scanArgs = append(scanArgs, &entry.Action, &entry.Section, &entry.Result, &payload) + if err := rows.Scan(scanArgs...); err != nil { return nil, ErrUnavailable } entry.CreatedAt = time.Unix(0, timestamp).UTC() + if actorRole.Valid { + entry.ActorRole = actorRole.String + } entry.Payload = json.RawMessage(payload) items = append(items, entry) } @@ -218,6 +249,54 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) { return items, nil } +func isOperationKind(kind string) bool { + return kind == "operation" || kind == "config" +} + +func actorRoleFromPayload(payload []byte) (string, bool) { + var event struct { + ActorRole *string `json:"actorRole"` + } + if json.Unmarshal(payload, &event) != nil || event.ActorRole == nil { + return "", false + } + return strings.TrimSpace(*event.ActorRole), true +} + +func nullableString(value string) any { + if strings.TrimSpace(value) == "" { + return nil + } + return value +} + +func (s *Store) lookupActorRole(ctx context.Context, actorID string) string { + id, err := strconv.Atoi(strings.TrimSpace(actorID)) + if err != nil || id <= 0 { + return "" + } + if ctx == nil { + ctx = context.Background() + } + userTable := `"user"` + if s.dialect == migration.DialectMySQL { + userTable = "`user`" + } + var role sql.NullString + var isAdmin int + err = s.db.QueryRowContext(ctx, s.bind("SELECT role, is_admin FROM "+userTable+" WHERE id = ?"), id).Scan(&role, &isAdmin) + if err != nil { + return "" + } + if access.Role(strings.TrimSpace(role.String)).Valid() { + return strings.TrimSpace(role.String) + } + if isAdmin == 1 { + return string(access.RoleSuperAdmin) + } + return string(access.RoleUser) +} + // Cleanup 每批先按索引选取标识再删除,避免 MySQL 专属 DELETE LIMIT 和大事务锁表。 func (s *Store) Cleanup(ctx context.Context, now time.Time) (int64, error) { if s == nil || s.db == nil {