forked from mirrors/pronouns.cc
feat: add working POST /v2/admin/users/{id}/actions
This commit is contained in:
parent
9726827706
commit
2dd37f15bf
8 changed files with 447 additions and 27 deletions
|
@ -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) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
|
||||||
func (id *FlagID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
|
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
73
backend/db/audit_log.go
Normal 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
|
||||||
|
}
|
|
@ -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{
|
sql, args, err := sq.Insert("warnings").SetMap(map[string]any{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
}).ToSql()
|
}).Suffix("RETURNING *").ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return w, errors.Wrap(err, "building sql")
|
return w, errors.Wrap(err, "building sql")
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,12 @@ import (
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionTypeIgnore = "ignore"
|
||||||
|
ActionTypeWarn = "warn"
|
||||||
|
ActionTypeSuspend = "suspend"
|
||||||
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
*server.Server
|
*server.Server
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"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"
|
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||||
|
"emperror.dev/errors"
|
||||||
"github.com/aarondl/opt/omit"
|
"github.com/aarondl/opt/omit"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-chi/render"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
@ -28,13 +35,11 @@ type UserActionRequest struct {
|
||||||
} `json:"clear"`
|
} `json:"clear"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
type UserActionResponse struct {
|
||||||
ActionTypeIgnore = "ignore"
|
AuditLogID common.AuditLogID `json:"audit_log_id"`
|
||||||
ActionTypeWarn = "warn"
|
}
|
||||||
ActionTypeSuspend = "suspend"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (req UserActionRequest) Validate() (errs []server.ModelParseError) {
|
func (req UserActionRequest) Validate(u db.User, fields []db.Field) (errs []server.ModelParseError) {
|
||||||
switch req.Type {
|
switch req.Type {
|
||||||
case ActionTypeIgnore, ActionTypeWarn, ActionTypeSuspend:
|
case ActionTypeIgnore, ActionTypeWarn, ActionTypeSuspend:
|
||||||
default:
|
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"))
|
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
|
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) {
|
func (s *Server) UserAction(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
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)
|
req, err := server.Decode[UserActionRequest](r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// validate input
|
// validate input
|
||||||
if errs := req.Validate(); len(errs) != 0 {
|
if errs := req.Validate(target, fields); len(errs) != 0 {
|
||||||
return server.NewV2Error(server.ErrBadRequest, "", errs...)
|
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
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -3,8 +3,6 @@ package server
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
json2 "github.com/aarondl/json"
|
json2 "github.com/aarondl/json"
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
|
@ -104,6 +102,8 @@ func ParseJSONError(err error) *ModelParseError {
|
||||||
ActualType: pe.Value,
|
ActualType: pe.Value,
|
||||||
}
|
}
|
||||||
case *json2.UnmarshalTypeError:
|
case *json2.UnmarshalTypeError:
|
||||||
|
// TODO: remove the need for this, somehow
|
||||||
|
// This seems to be a bug in the
|
||||||
key := pe.Field
|
key := pe.Field
|
||||||
if key == "" {
|
if key == "" {
|
||||||
key = "parse"
|
key = "parse"
|
||||||
|
@ -111,6 +111,7 @@ func ParseJSONError(err error) *ModelParseError {
|
||||||
|
|
||||||
return &ModelParseError{
|
return &ModelParseError{
|
||||||
Key: key,
|
Key: key,
|
||||||
|
Message: "Invalid type passed for a field. Make sure your types match those in the documentation.",
|
||||||
ExpectedType: pe.Type.String(),
|
ExpectedType: pe.Type.String(),
|
||||||
ActualType: pe.Value,
|
ActualType: pe.Value,
|
||||||
}
|
}
|
||||||
|
@ -118,19 +119,3 @@ func ParseJSONError(err error) *ModelParseError {
|
||||||
|
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -154,3 +154,21 @@ type ctxKey int
|
||||||
const (
|
const (
|
||||||
ctxKeyClaims ctxKey = 1
|
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
|
||||||
|
}
|
||||||
|
|
30
scripts/migrate/023_mod_log.sql
Normal file
30
scripts/migrate/023_mod_log.sql
Normal 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;
|
Loading…
Reference in a new issue