feat: add working POST /v2/admin/users/{id}/actions

This commit is contained in:
sam 2023-12-28 02:46:08 +01:00
parent 9726827706
commit 2dd37f15bf
No known key found for this signature in database
GPG key ID: B4EF20DDE721CAA1
8 changed files with 447 additions and 27 deletions

View file

@ -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) }

73
backend/db/audit_log.go Normal file
View file

@ -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
}

View file

@ -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")
}

View file

@ -8,6 +8,12 @@ import (
"github.com/go-chi/render"
)
const (
ActionTypeIgnore = "ignore"
ActionTypeWarn = "warn"
ActionTypeSuspend = "suspend"
)
type Server struct {
*server.Server
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 <id>"
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;