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 {