From 2dd37f15bfad31aeb780d3775ba46d24b3900261 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 28 Dec 2023 02:46:08 +0100 Subject: [PATCH] feat: add working POST /v2/admin/users/{id}/actions --- backend/common/snowflake_types.go | 12 + backend/db/audit_log.go | 73 ++++++ backend/db/report.go | 2 +- backend/routes/v2/admin/routes.go | 6 + backend/routes/v2/admin/user_action.go | 312 ++++++++++++++++++++++++- backend/server/error_v2.go | 21 +- backend/server/server.go | 18 ++ scripts/migrate/023_mod_log.sql | 30 +++ 8 files changed, 447 insertions(+), 27 deletions(-) create mode 100644 backend/db/audit_log.go create mode 100644 scripts/migrate/023_mod_log.sql diff --git a/backend/common/snowflake_types.go b/backend/common/snowflake_types.go index 3e9848f..49fd95d 100644 --- a/backend/common/snowflake_types.go +++ b/backend/common/snowflake_types.go @@ -37,3 +37,15 @@ func (id FlagID) Increment() uint16 { return Snowflake(id).Increment() } func (id FlagID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() } func (id *FlagID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) } + +type AuditLogID Snowflake + +func (id AuditLogID) String() string { return Snowflake(id).String() } +func (id AuditLogID) Time() time.Time { return Snowflake(id).Time() } +func (id AuditLogID) IsValid() bool { return Snowflake(id).IsValid() } +func (id AuditLogID) Worker() uint8 { return Snowflake(id).Worker() } +func (id AuditLogID) PID() uint8 { return Snowflake(id).PID() } +func (id AuditLogID) Increment() uint16 { return Snowflake(id).Increment() } + +func (id AuditLogID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() } +func (id *AuditLogID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) } diff --git a/backend/db/audit_log.go b/backend/db/audit_log.go new file mode 100644 index 0000000..a5806dc --- /dev/null +++ b/backend/db/audit_log.go @@ -0,0 +1,73 @@ +package db + +import ( + "context" + + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "emperror.dev/errors" + "github.com/georgysavva/scany/v2/pgxscan" + "github.com/jackc/pgx/v5" +) + +type AuditLogEntry struct { + ID common.AuditLogID + TargetUserID common.UserID + TargetMemberID *common.MemberID + ModeratorID common.UserID + ReportID *int64 + + Reason string + ActionTaken string + ClearedData *AuditLogClearedData +} + +type AuditLogClearedData struct { + DisplayName *string `json:"display_name,omitempty"` + Bio *string `json:"bio,omitempty"` + Links []string `json:"links,omitempty"` + Names []FieldEntry `json:"names,omitempty"` + Pronouns []PronounEntry `json:"pronouns,omitempty"` + Fields []Field `json:"fields,omitempty"` + CustomPreferences []CustomPreference `json:"custom_preferences"` +} + +// Returns a max of 100 audit log entries created before the time in `before`. +// If `before` is 0, returns the latest entries. +func (db *DB) AuditLog(ctx context.Context, before common.AuditLogID) (es []AuditLogEntry, err error) { + b := sq.Select("*").From("audit_log").Limit(100).OrderBy("id DESC") + if before.IsValid() { + b = b.Where("id < ?", before) + } + sql, args, err := b.ToSql() + if err != nil { + return nil, errors.Wrap(err, "building query") + } + + err = pgxscan.Select(ctx, db, &es, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + return NotNull(es), nil +} + +func (db *DB) CreateAuditLogEntry(ctx context.Context, tx pgx.Tx, data AuditLogEntry) (e AuditLogEntry, err error) { + sql, args, err := sq.Insert("audit_log").SetMap(map[string]any{ + "id": common.GenerateID(), + "target_user_id": data.TargetUserID, + "target_member_id": data.TargetMemberID, + "moderator_id": data.ModeratorID, + "report_id": data.ReportID, + "reason": data.Reason, + "action_taken": data.ActionTaken, + "cleared_data": data.ClearedData, + }).Suffix("RETURNING *").ToSql() + if err != nil { + return e, errors.Wrap(err, "building query") + } + + err = pgxscan.Get(ctx, tx, &e, sql, args...) + if err != nil { + return e, errors.Wrap(err, "executing query") + } + return e, nil +} diff --git a/backend/db/report.go b/backend/db/report.go index 2f6c4c0..7a9bbdd 100644 --- a/backend/db/report.go +++ b/backend/db/report.go @@ -174,7 +174,7 @@ func (db *DB) CreateWarning(ctx context.Context, tx pgx.Tx, userID xid.ID, reaso sql, args, err := sq.Insert("warnings").SetMap(map[string]any{ "user_id": userID, "reason": reason, - }).ToSql() + }).Suffix("RETURNING *").ToSql() if err != nil { return w, errors.Wrap(err, "building sql") } diff --git a/backend/routes/v2/admin/routes.go b/backend/routes/v2/admin/routes.go index e7a673e..fac47a6 100644 --- a/backend/routes/v2/admin/routes.go +++ b/backend/routes/v2/admin/routes.go @@ -8,6 +8,12 @@ import ( "github.com/go-chi/render" ) +const ( + ActionTypeIgnore = "ignore" + ActionTypeWarn = "warn" + ActionTypeSuspend = "suspend" +) + type Server struct { *server.Server } diff --git a/backend/routes/v2/admin/user_action.go b/backend/routes/v2/admin/user_action.go index 90e4569..48296d2 100644 --- a/backend/routes/v2/admin/user_action.go +++ b/backend/routes/v2/admin/user_action.go @@ -1,10 +1,17 @@ package admin import ( + "context" "net/http" + "sort" + "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" "github.com/aarondl/opt/omit" + "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/google/uuid" ) @@ -28,13 +35,11 @@ type UserActionRequest struct { } `json:"clear"` } -const ( - ActionTypeIgnore = "ignore" - ActionTypeWarn = "warn" - ActionTypeSuspend = "suspend" -) +type UserActionResponse struct { + AuditLogID common.AuditLogID `json:"audit_log_id"` +} -func (req UserActionRequest) Validate() (errs []server.ModelParseError) { +func (req UserActionRequest) Validate(u db.User, fields []db.Field) (errs []server.ModelParseError) { switch req.Type { case ActionTypeIgnore, ActionTypeWarn, ActionTypeSuspend: default: @@ -58,22 +63,313 @@ func (req UserActionRequest) Validate() (errs []server.ModelParseError) { errs = append(errs, server.NewModelParseError("clear", "clear cannot be set if report is being ignored")) } + // check names + if len(req.Clear.Names) > len(u.Names) { + errs = append(errs, server.NewModelParseError("clear.names", "cannot have more indexes than there are names")) + } + if !allUnique(req.Clear.Names) { + errs = append(errs, server.NewModelParseError("clear.names", "all indexes in clear.names must be unique")) + } + namesOutOfRange := false + for _, i := range req.Clear.Names { + if i >= len(u.Names) { + namesOutOfRange = true + break + } + } + if namesOutOfRange { + errs = append(errs, server.NewModelParseError("clear.names", "one or more indexes is out of range")) + } + + // check pronouns + if len(req.Clear.Pronouns) > len(u.Pronouns) { + errs = append(errs, server.NewModelParseError("clear.pronouns", "cannot have more indexes than there are pronouns")) + } + if !allUnique(req.Clear.Pronouns) { + errs = append(errs, server.NewModelParseError("clear.pronouns", "all indexes in clear.pronouns must be unique")) + } + pronounsOutOfRange := false + for _, i := range req.Clear.Pronouns { + if i >= len(u.Pronouns) { + pronounsOutOfRange = true + break + } + } + if pronounsOutOfRange { + errs = append(errs, server.NewModelParseError("clear.pronouns", "one or more indexes is out of range")) + } + + // check fields + if len(req.Clear.Fields) > len(fields) { + errs = append(errs, server.NewModelParseError("clear.fields", "cannot have more indexes than there are fields")) + } + if !allUnique(req.Clear.Fields) { + errs = append(errs, server.NewModelParseError("clear.fields", "all indexes in clear.fields must be unique")) + } + fieldsOutOfRange := false + for _, i := range req.Clear.Fields { + if i >= len(fields) { + fieldsOutOfRange = true + break + } + } + if fieldsOutOfRange { + errs = append(errs, server.NewModelParseError("clear.fields", "one or more indexes is out of range")) + } + + // check custom preferences + if !allUnique(req.Clear.CustomPreferences) { + errs = append(errs, server.NewModelParseError("clear.custom_preferences", "all IDs in clear.custom_preferences must be unique")) + } + invalidIDs := false + for _, id := range req.Clear.CustomPreferences { + if _, ok := u.CustomPreferences[id.String()]; !ok { + invalidIDs = true + break + } + } + if invalidIDs { + errs = append(errs, server.NewModelParseError("clear.custom_preferences", "unknown ID specified")) + } + return errs } +func (req UserActionRequest) HasClear() bool { + return req.Clear.DisplayName || req.Clear.Bio || req.Clear.Links || + len(req.Clear.Names) != 0 || len(req.Clear.Pronouns) != 0 || + len(req.Clear.Fields) != 0 || len(req.Clear.CustomPreferences) != 0 +} + func (s *Server) UserAction(w http.ResponseWriter, r *http.Request) (err error) { ctx := r.Context() claims, _ := server.ClaimsFromContext(ctx) + mod, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting moderator data") + } + + // Parse the user we're taking action against + target, err := s.ParseUser(ctx, chi.URLParam(r, "id")) + if err != nil { + return server.NewV2Error(server.ErrUserNotFound, "") + } + + fields, err := s.DB.UserFields(ctx, target.ID) + if err != nil { + log.Errorf("getting fields for user %v: %v", target.SnowflakeID, fields) + return server.NewV2Error(server.ErrInternalServerError, "") + } req, err := server.Decode[UserActionRequest](r) if err != nil { return err } // validate input - if errs := req.Validate(); len(errs) != 0 { + if errs := req.Validate(target, fields); len(errs) != 0 { return server.NewV2Error(server.ErrBadRequest, "", errs...) } - render.JSON(w, r, claims) + // Store removed names for the audit log + var ( + deletedNames []db.FieldEntry + deletedPronouns []db.PronounEntry + deletedFields []db.Field + deletedPreferences []db.CustomPreference + ) + + // Remove names + // Sort to reverse order, so elements don't shift indexes before we remove them + sort.Sort(sort.Reverse(sort.IntSlice(req.Clear.Names))) + for _, i := range req.Clear.Names { + var deleted db.FieldEntry + target.Names, deleted = deleteIndex(target.Names, i) + deletedNames = append(deletedNames, deleted) + } + // Remove pronouns + sort.Sort(sort.Reverse(sort.IntSlice(req.Clear.Pronouns))) + for _, i := range req.Clear.Pronouns { + var deleted db.PronounEntry + target.Pronouns, deleted = deleteIndex(target.Pronouns, i) + deletedPronouns = append(deletedPronouns, deleted) + } + // Remove fields + sort.Sort(sort.Reverse(sort.IntSlice(req.Clear.Fields))) + for _, i := range req.Clear.Fields { + var deleted db.Field + fields, deleted = deleteIndex(fields, i) + deletedFields = append(deletedFields, deleted) + } + // Remove custom preferences + for _, k := range req.Clear.CustomPreferences { + deletedPreferences = append(deletedPreferences, target.CustomPreferences[k.String()]) + delete(target.CustomPreferences, k.String()) + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + log.Errorf("starting transaction: %v", err) + return errors.Wrap(err, "starting transaction") + } + defer tx.Rollback(ctx) + + // If report_id is set, check whether the report exists. + var reportID *int64 + if id, ok := req.ReportID.Get(); ok { + reportID = &id + _, err := s.DB.Report(ctx, tx, *reportID) + if err != nil { + if err == db.ErrReportNotFound { + return server.NewV2Error(server.ErrBadRequest, "", server.NewModelParseError("report_id", "invalid report ID")) + } + log.Errorf("getting report: %v", err) + return errors.Wrap(err, "getting report") + } + } + + // Build the cleared data object + var clearedData *db.AuditLogClearedData + if req.HasClear() { + clearedData = &db.AuditLogClearedData{ + Names: deletedNames, + Pronouns: deletedPronouns, + Fields: deletedFields, + CustomPreferences: deletedPreferences, + } + if req.Clear.DisplayName { + clearedData.DisplayName = target.DisplayName + } + if req.Clear.Bio { + clearedData.Bio = target.Bio + } + if req.Clear.Links { + clearedData.Links = target.Links + } + } + + // Make sure there's a non-empty reason + reason := req.Reason + if req.Reason == "" { + reason = "None" + } + + // Create audit log entry + auditLogEntry, err := s.DB.CreateAuditLogEntry(ctx, tx, db.AuditLogEntry{ + TargetUserID: target.SnowflakeID, + TargetMemberID: nil, + ModeratorID: mod.SnowflakeID, + ReportID: reportID, + Reason: reason, + ActionTaken: req.Type, + ClearedData: clearedData, + }) + if err != nil { + log.Errorf("creating audit log entry: %v", err) + return errors.Wrap(err, "creating audit log entry") + } + + // Resolve report, if an ID was given + if req.ReportID.IsSet() { + err = s.DB.ResolveReport(ctx, tx, req.ReportID.GetOrZero(), mod.ID, req.Reason) + if err != nil { + log.Errorf("resolving report: %v", err) + return errors.Wrap(err, "resolving report") + } + } + + // Create user warning + if req.Type == ActionTypeWarn || req.Type == ActionTypeSuspend { + _, err = s.DB.CreateWarning(ctx, tx, target.ID, req.Reason) + if err != nil { + log.Errorf("creating warning: %v", err) + return errors.Wrap(err, "creating warning") + } + } + + if req.Type == ActionTypeSuspend { + err = s.DB.DeleteUser(ctx, tx, target.ID, false, req.Reason) + if err != nil { + log.Errorf("suspending user: %v", err) + return errors.Wrap(err, "suspending user") + } + } + + // If we're clearing some data, do that + if req.HasClear() { + var displayName *string + if req.Clear.DisplayName { + displayName = ptr("") + } + var bio *string + if req.Clear.Bio { + bio = ptr("") + } + var links *[]string + if req.Clear.Links { + links = &[]string{} + } + + // Update most columns + _, err = s.DB.UpdateUser(ctx, tx, target.ID, displayName, bio, nil, nil, links, nil, nil, &target.CustomPreferences) + if err != nil { + log.Errorf("updating user: %v", err) + return errors.Wrap(err, "updating user") + } + + // Set fields + err = s.DB.SetUserFields(ctx, tx, target.ID, fields) + if err != nil { + log.Errorf("updating fields: %v", err) + return errors.Wrap(err, "updating fields") + } + + // Set names/pronouns + err = s.DB.SetUserNamesPronouns(ctx, tx, target.ID, target.Names, target.Pronouns) + if err != nil { + log.Errorf("updating names/pronouns: %v", err) + return errors.Wrap(err, "updating names/pronouns") + } + } + + // Commit transaction and save everything + err = tx.Commit(ctx) + if err != nil { + log.Errorf("committing transaction: %v", err) + return errors.Wrap(err, "committing transaction") + } + + render.JSON(w, r, UserActionResponse{ + AuditLogID: auditLogEntry.ID, + }) return nil } + +func (s *Server) ParseUser(ctx context.Context, id string) (u db.User, err error) { + sf, err := common.ParseSnowflake(id) + if err != nil { + return u, err + } + + return s.DB.UserBySnowflake(ctx, common.UserID(sf)) +} + +func allUnique[T comparable](slice []T) bool { + m := make(map[T]struct{}) + for _, entry := range slice { + _, ok := m[entry] + if ok { + return false + } + m[entry] = struct{}{} + } + return true +} + +func deleteIndex[T any](slice []T, idx int) ([]T, T) { + deleted := slice[idx] + return append(slice[:idx], slice[idx+1:]...), deleted +} + +func ptr[T any](t T) *T { + return &t +} diff --git a/backend/server/error_v2.go b/backend/server/error_v2.go index e413c54..db71a33 100644 --- a/backend/server/error_v2.go +++ b/backend/server/error_v2.go @@ -3,8 +3,6 @@ package server import ( "encoding/json" "fmt" - "io" - "net/http" json2 "github.com/aarondl/json" "github.com/getsentry/sentry-go" @@ -104,6 +102,8 @@ func ParseJSONError(err error) *ModelParseError { ActualType: pe.Value, } case *json2.UnmarshalTypeError: + // TODO: remove the need for this, somehow + // This seems to be a bug in the key := pe.Field if key == "" { key = "parse" @@ -111,6 +111,7 @@ func ParseJSONError(err error) *ModelParseError { return &ModelParseError{ Key: key, + Message: "Invalid type passed for a field. Make sure your types match those in the documentation.", ExpectedType: pe.Type.String(), ActualType: pe.Value, } @@ -118,19 +119,3 @@ func ParseJSONError(err error) *ModelParseError { return nil } - -func Decode[T any](r *http.Request) (T, error) { - var t T - - b, _ := io.ReadAll(r.Body) - err := json.Unmarshal(b, &t) - // err := render.Decode(r, &t) - if err != nil { - pe := ParseJSONError(err) - if pe != nil { - return t, NewV2Error(ErrBadRequest, "", *pe) - } - return t, NewV2Error(ErrBadRequest, "") - } - return t, nil -} diff --git a/backend/server/server.go b/backend/server/server.go index bfc785d..af3f75e 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -154,3 +154,21 @@ type ctxKey int const ( ctxKeyClaims ctxKey = 1 ) + +// Decodes a JSON request into a value of T. +// If an error is returned, transform that into a ModelParseError. +// Known issue: parse errors in `github.com/aarondl/opt` types don't return a field name, +// so those are put in the `parse` key of the returned error. +func Decode[T any](r *http.Request) (T, error) { + var t T + + err := render.Decode(r, &t) + if err != nil { + pe := ParseJSONError(err) + if pe != nil { + return t, NewV2Error(ErrBadRequest, "", *pe) + } + return t, NewV2Error(ErrBadRequest, "") + } + return t, nil +} diff --git a/scripts/migrate/023_mod_log.sql b/scripts/migrate/023_mod_log.sql new file mode 100644 index 0000000..06440c3 --- /dev/null +++ b/scripts/migrate/023_mod_log.sql @@ -0,0 +1,30 @@ +-- 2023-12-27: Add moderation log + +-- +migrate Up + +create table audit_log ( + id bigint primary key, + -- the target user of this action + -- this is *not* a foreign key. if the user is deleted the audit log entry should stay, just showing as "deleted user " + target_user_id bigint not null, + -- the target member of this action + target_member_id bigint, + -- the moderator that took this action + -- as with target_user_id, the audit log entry should not be deleted if the moderator's account is. + moderator_id bigint not null, + -- the report this was in response to. may be null if this action was taken by a moderator not responding to a report. + report_id integer references reports (id) on delete set null, + + -- the reason for this action. always set, but may be 'None' if the action taken was 'ignore'. + reason text not null default 'None', + action_taken text not null, -- enum, currently: 'ignore', 'warn', 'suspend' + cleared_data jsonb null -- backup of the cleared data. may be null if no data was cleared +); + +create index audit_log_target_user_idx on audit_log (target_user_id); +create index audit_log_target_member_idx on audit_log (target_member_id); +create index audit_log_moderator_idx on audit_log (moderator_id); + +-- +migrate Down + +drop table audit_log;