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" ) const MaxReasonLength = 2048 type UserActionRequest struct { Type string `json:"type"` // `ignore`, `warn`, `suspend` ReportID omit.Val[int64] `json:"report_id"` // The report ID associated with this action, only required if type is ignore. Will close this report. Reason string `json:"reason"` // The reason for the action. Always logged for moderators, only shown to user for `warn` and `suspend` Clear struct { // Profile fields to clear DisplayName bool `json:"display_name"` Bio bool `json:"bio"` Links bool `json:"links"` Names []int `json:"names"` // Indexes of names to clear Pronouns []int `json:"pronouns"` // Indexes of pronouns to clear Fields []int `json:"fields"` // Indexes of fields to clear CustomPreferences []uuid.UUID `json:"custom_preferences"` // Custom preference IDs to clear // TODO: remove flags, maybe? } `json:"clear"` } type UserActionResponse struct { AuditLogID common.AuditLogID `json:"audit_log_id"` } func (req UserActionRequest) Validate(u db.User, fields []db.Field) (errs []server.ModelParseError) { switch req.Type { case ActionTypeIgnore, ActionTypeWarn, ActionTypeSuspend: default: errs = append(errs, server.NewModelParseErrorWithValues("type", "", []any{ActionTypeIgnore, ActionTypeSuspend, ActionTypeWarn}, req.Type)) } if req.Type != ActionTypeIgnore && req.Reason == "" { errs = append(errs, server.NewModelParseError("reason", "reason cannot be empty if type is not ignore")) } if req.Type == ActionTypeIgnore && req.ReportID.IsUnset() { errs = append(errs, server.NewModelParseError("report_id", "report_id cannot be empty if type is ignore")) } if req.Type == ActionTypeIgnore && (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) { 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(target, fields); len(errs) != 0 { return server.NewV2Error(server.ErrBadRequest, "", errs...) } // 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 }