This commit is contained in:
parent
ee38207c6f
commit
46fd3a6bff
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
86
backend/db/migration/log_actor_role.go
Normal file
86
backend/db/migration/log_actor_role.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(`
|
||||
<div>
|
||||
请联系管理员进行配置。
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user