pronounss/backend/routes/v2/admin/user_action.go

375 lines
11 KiB
Go

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
}