forked from mirrors/pronouns.cc
375 lines
11 KiB
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
|
|
}
|