diff --git a/backend/db/report.go b/backend/db/report.go index 3f04d4f..70e2614 100644 --- a/backend/db/report.go +++ b/backend/db/report.go @@ -6,6 +6,7 @@ import ( "emperror.dev/errors" "github.com/georgysavva/scany/pgxscan" + "github.com/jackc/pgx/v4" "github.com/rs/xid" ) @@ -25,6 +26,7 @@ type Report struct { } const ReportPageSize = 100 +const ErrReportNotFound = errors.Sentinel("report not found") func (db *DB) Reports(ctx context.Context, closed bool, before int) (rs []Report, err error) { builder := sq.Select("*", @@ -96,6 +98,23 @@ func (db *DB) ReportsByReporter(ctx context.Context, reporterID xid.ID, before i return rs, nil } +func (db *DB) Report(ctx context.Context, tx pgx.Tx, id int64) (r Report, err error) { + sql, args, err := sq.Select("*").From("reports").Where("id = ?", id).ToSql() + if err != nil { + return r, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &r, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return r, ErrReportNotFound + } + + return r, errors.Wrap(err, "executing query") + } + return r, nil +} + func (db *DB) CreateReport(ctx context.Context, reporterID, userID xid.ID, memberID *xid.ID, reason string) (r Report, err error) { sql, args, err := sq.Insert("reports").SetMap(map[string]any{ "user_id": userID, @@ -113,3 +132,86 @@ func (db *DB) CreateReport(ctx context.Context, reporterID, userID xid.ID, membe } return r, nil } + +func (db *DB) ResolveReport(ctx context.Context, ex Execer, id int64, adminID xid.ID, comment string) error { + sql, args, err := sq.Update("reports"). + Set("admin_id", adminID). + Set("admin_comment", comment). + Set("resolved_at", time.Now().UTC()). + Where("id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +type Warning struct { + ID int64 `json:"id"` + UserID xid.ID `json:"-"` + Reason string `json:"reason"` + CreatedAt time.Time `json:"created_at"` + ReadAt *time.Time `json:"-"` +} + +func (db *DB) CreateWarning(ctx context.Context, tx pgx.Tx, userID xid.ID, reason string) (w Warning, err error) { + sql, args, err := sq.Insert("warnings").SetMap(map[string]any{ + "user_id": userID, + "reason": reason, + }).ToSql() + if err != nil { + return w, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, tx, &w, sql, args...) + if err != nil { + return w, errors.Wrap(err, "executing query") + } + return w, nil +} + +func (db *DB) Warnings(ctx context.Context, userID xid.ID, unread bool) (ws []Warning, err error) { + builder := sq.Select("*").From("warnings").Where("user_id = ?", userID).OrderBy("id DESC") + if unread { + builder = builder.Where("read_at IS NULL") + } + sql, args, err := builder.ToSql() + + if err != nil { + return ws, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &ws, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + if len(ws) == 0 { + return []Warning{}, nil + } + return ws, nil +} + +func (db *DB) AckWarning(ctx context.Context, userID xid.ID, id int64) (ok bool, err error) { + sql, args, err := sq.Update("warnings"). + Set("read_at", time.Now().UTC()). + Where("user_id = ?", userID). + Where("id = ?", id). + Where("read_at IS NULL").ToSql() + if err != nil { + return false, errors.Wrap(err, "building sql") + } + + ct, err := db.Exec(ctx, sql, args...) + if err != nil { + return false, errors.Wrap(err, "executing query") + } + + if ct.RowsAffected() == 0 { + return false, nil + } + return true, nil +} diff --git a/backend/db/user.go b/backend/db/user.go index f92f321..ab450fa 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -2,6 +2,8 @@ package db import ( "context" + "crypto/sha256" + "encoding/hex" "regexp" "time" @@ -417,3 +419,92 @@ func (db *DB) ForceDeleteUser(ctx context.Context, id xid.ID) error { } return nil } + +func (db *DB) DeleteUserMembers(ctx context.Context, tx pgx.Tx, id xid.ID) error { + sql, args, err := sq.Delete("members").Where("user_id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +func (db *DB) ResetUser(ctx context.Context, tx pgx.Tx, id xid.ID) error { + err := db.SetUserFields(ctx, tx, id, []Field{}) + if err != nil { + return errors.Wrap(err, "deleting fields") + } + + hasher := sha256.New() + _, err = hasher.Write(id.Bytes()) + if err != nil { + return errors.Wrap(err, "hashing user id") + } + hash := hex.EncodeToString(hasher.Sum(nil)) + + sql, args, err := sq.Update("users"). + Set("username", "deleted-"+hash). + Set("display_name", nil). + Set("bio", nil). + Set("names", "[]"). + Set("pronouns", "[]"). + Set("avatar", nil). + Where("id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = tx.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} + +func (db *DB) CleanUser(ctx context.Context, id xid.ID) error { + u, err := db.User(ctx, id) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Avatar != nil { + err = db.DeleteUserAvatar(ctx, u.ID, *u.Avatar) + if err != nil { + return errors.Wrap(err, "deleting user avatar") + } + } + + var exports []DataExport + err = pgxscan.Select(ctx, db, &exports, "SELECT * FROM data_exports WHERE user_id = $1", u.ID) + if err != nil { + return errors.Wrap(err, "getting export iles") + } + + for _, de := range exports { + err = db.DeleteExport(ctx, de) + if err != nil { + continue + } + } + + members, err := db.UserMembers(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting members") + } + + for _, m := range members { + if m.Avatar == nil { + continue + } + + err = db.DeleteMemberAvatar(ctx, m.ID, *m.Avatar) + if err != nil { + continue + } + } + return nil +} diff --git a/backend/routes/auth/undelete.go b/backend/routes/auth/undelete.go index 8768b2f..236b4ce 100644 --- a/backend/routes/auth/undelete.go +++ b/backend/routes/auth/undelete.go @@ -6,11 +6,9 @@ import ( "encoding/base64" "net/http" - "codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/u1f320/pronouns.cc/backend/server" "emperror.dev/errors" - "github.com/georgysavva/scany/pgxscan" "github.com/go-chi/render" "github.com/mediocregopher/radix/v4" "github.com/rs/xid" @@ -84,60 +82,13 @@ func (s *Server) forceDelete(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrNotFound} // assume invalid token } - u, err := s.DB.User(ctx, id) + err = s.DB.CleanUser(ctx, id) if err != nil { - log.Errorf("getting user: %v", err) - return errors.Wrap(err, "getting user") + log.Errorf("cleaning user data: %v", err) + return errors.Wrap(err, "cleaning user") } - if u.Avatar != nil { - err = s.DB.DeleteUserAvatar(ctx, u.ID, *u.Avatar) - if err != nil { - log.Errorf("deleting avatars for user %v: %v", u.ID, err) - return errors.Wrap(err, "deleting user avatar") - } - } - - var exports []db.DataExport - err = pgxscan.Select(ctx, s.DB, &exports, "SELECT * FROM data_exports WHERE user_id = $1", u.ID) - if err != nil { - log.Errorf("getting to-be-deleted export files: %v", err) - return errors.Wrap(err, "getting export iles") - } - - for _, de := range exports { - err = s.DB.DeleteExport(ctx, de) - if err != nil { - log.Errorf("deleting export %v: %v", de.ID, err) - continue - } - - log.Debugf("deleted export %v", de.ID) - } - - members, err := s.DB.UserMembers(ctx, u.ID) - if err != nil { - log.Errorf("getting members for user %v: %v", u.ID, err) - return errors.Wrap(err, "getting members") - } - - for _, m := range members { - if m.Avatar == nil { - continue - } - - log.Debugf("deleting avatars for member %v", m.ID) - - err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar) - if err != nil { - log.Errorf("deleting avatars for member %v: %v", m.ID, err) - continue - } - - log.Debugf("deleted avatars for member %v", m.ID) - } - - err = s.DB.ForceDeleteUser(ctx, u.ID) + err = s.DB.ForceDeleteUser(ctx, id) if err != nil { log.Errorf("force deleting user: %v", err) return errors.Wrap(err, "deleting user") diff --git a/backend/routes/mod/resolve_report.go b/backend/routes/mod/resolve_report.go new file mode 100644 index 0000000..f9a91a9 --- /dev/null +++ b/backend/routes/mod/resolve_report.go @@ -0,0 +1,108 @@ +package mod + +import ( + "net/http" + "strconv" + + "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type resolveReportRequest struct { + Warn bool `json:"warn"` + Ban bool `json:"ban"` + Delete bool `json:"delete"` + Reason string `json:"reason"` +} + +func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + var req resolveReportRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if req.Reason == "" { + return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot be empty"} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + log.Errorf("creating transaction: %v", err) + return errors.Wrap(err, "creating transaction") + } + defer tx.Rollback(ctx) + + report, err := s.DB.Report(ctx, tx, id) + if err != nil { + if err == db.ErrReportNotFound { + return server.APIError{Code: server.ErrNotFound} + } + log.Errorf("getting report: %v", err) + return errors.Wrap(err, "getting report") + } + + if report.ResolvedAt != nil { + return server.APIError{Code: server.ErrReportAlreadyHandled} + } + + err = s.DB.ResolveReport(ctx, tx, report.ID, claims.UserID, req.Reason) + if err != nil { + log.Errorf("resolving report: %v", err) + } + + if req.Warn || req.Ban { + _, err = s.DB.CreateWarning(ctx, tx, report.UserID, req.Reason) + if err != nil { + log.Errorf("creating warning: %v", err) + } + } + + if req.Ban { + err = s.DB.DeleteUser(ctx, tx, report.UserID, false, req.Reason) + if err != nil { + log.Errorf("banning user: %v", err) + } + + if req.Delete { + err = s.DB.CleanUser(ctx, report.UserID) + if err != nil { + log.Errorf("cleaning user data: %v", err) + return errors.Wrap(err, "cleaning user") + } + + err = s.DB.DeleteUserMembers(ctx, tx, report.UserID) + if err != nil { + log.Errorf("deleting members: %v", err) + return errors.Wrap(err, "deleting members") + } + + err = s.DB.ResetUser(ctx, tx, report.UserID) + if err != nil { + log.Errorf("resetting user data: %v", err) + return errors.Wrap(err, "resetting user") + } + } + } + + err = tx.Commit(ctx) + if err != nil { + log.Errorf("committing transaction: %v", err) + return errors.Wrap(err, "committing transaction") + } + + render.JSON(w, r, map[string]any{"success": true}) + return nil +} diff --git a/backend/routes/mod/routes.go b/backend/routes/mod/routes.go index 8b64139..52ff0aa 100644 --- a/backend/routes/mod/routes.go +++ b/backend/routes/mod/routes.go @@ -20,12 +20,14 @@ func Mount(srv *server.Server, r chi.Router) { r.Get("/reports/by-user/{id}", server.WrapHandler(s.getReportsByUser)) r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter)) - r.Get("/reports/{id}", nil) - r.Patch("/reports/{id}", nil) + r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport)) }) r.With(server.MustAuth).Post("/users/{id}/reports", server.WrapHandler(s.createUserReport)) r.With(server.MustAuth).Post("/members/{id}/reports", server.WrapHandler(s.createMemberReport)) + + r.With(server.MustAuth).Get("/auth/warnings", server.WrapHandler(s.getWarnings)) + r.With(server.MustAuth).Post("/auth/warnings/{id}/ack", server.WrapHandler(s.ackWarning)) } func MustAdmin(next http.Handler) http.Handler { diff --git a/backend/routes/mod/warnings.go b/backend/routes/mod/warnings.go new file mode 100644 index 0000000..ba642a9 --- /dev/null +++ b/backend/routes/mod/warnings.go @@ -0,0 +1,63 @@ +package mod + +import ( + "net/http" + "strconv" + + "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type warning struct { + db.Warning + Read bool `json:"read"` +} + +func dbWarningsToResponse(ws []db.Warning) []warning { + out := make([]warning, len(ws)) + for i := range ws { + out[i] = warning{ws[i], ws[i].ReadAt != nil} + } + return out +} + +func (s *Server) getWarnings(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + showAll := r.FormValue("all") == "true" + + warnings, err := s.DB.Warnings(ctx, claims.UserID, !showAll) + if err != nil { + log.Errorf("getting warnings: %v", err) + return errors.Wrap(err, "getting warnings from database") + } + + render.JSON(w, r, dbWarningsToResponse(warnings)) + return nil +} + +func (s *Server) ackWarning(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + ok, err := s.DB.AckWarning(ctx, claims.UserID, id) + if err != nil { + log.Errorf("acknowledging warning: %v", err) + return errors.Wrap(err, "acknowledging warning") + } + if !ok { + return server.APIError{Code: server.ErrNotFound} + } + + render.JSON(w, r, map[string]any{"ok": true}) + return nil +} diff --git a/backend/routes/user/get_user.go b/backend/routes/user/get_user.go index af43134..0b36904 100644 --- a/backend/routes/user/get_user.go +++ b/backend/routes/user/get_user.go @@ -27,7 +27,9 @@ type GetUserResponse struct { type GetMeResponse struct { GetUserResponse - MaxInvites int `json:"max_invites"` + MaxInvites int `json:"max_invites"` + IsAdmin bool `json:"is_admin"` + Discord *string `json:"discord"` DiscordUsername *string `json:"discord_username"` @@ -162,6 +164,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { render.JSON(w, r, GetMeResponse{ GetUserResponse: dbUserToResponse(u, fields, members), MaxInvites: u.MaxInvites, + IsAdmin: u.IsAdmin, Discord: u.Discord, DiscordUsername: u.DiscordUsername, Fediverse: u.Fediverse, diff --git a/backend/routes/user/patch_user.go b/backend/routes/user/patch_user.go index e56898b..b83bcf1 100644 --- a/backend/routes/user/patch_user.go +++ b/backend/routes/user/patch_user.go @@ -232,6 +232,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { render.JSON(w, r, GetMeResponse{ GetUserResponse: dbUserToResponse(u, fields, nil), MaxInvites: u.MaxInvites, + IsAdmin: u.IsAdmin, Discord: u.Discord, DiscordUsername: u.DiscordUsername, Fediverse: u.Fediverse, diff --git a/backend/server/errors.go b/backend/server/errors.go index 6ef68a8..ab657a3 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -110,6 +110,10 @@ const ( // General request error codes ErrRequestTooBig = 4001 ErrMissingPermissions = 4002 + + // Moderation related error codes + ErrReportAlreadyHandled = 5001 + ErrNotSelfDelete = 5002 ) var errCodeMessages = map[int]string{ @@ -146,6 +150,9 @@ var errCodeMessages = map[int]string{ ErrRequestTooBig: "Request too big (max 2 MB)", ErrMissingPermissions: "Your account or current token is missing required permissions for this action", + + ErrReportAlreadyHandled: "Report has already been resolved", + ErrNotSelfDelete: "Cannot cancel deletion for an account deleted by a moderator", } var errCodeStatuses = map[int]int{ @@ -182,4 +189,7 @@ var errCodeStatuses = map[int]int{ ErrRequestTooBig: http.StatusBadRequest, ErrMissingPermissions: http.StatusForbidden, + + ErrReportAlreadyHandled: http.StatusBadRequest, + ErrNotSelfDelete: http.StatusForbidden, } diff --git a/frontend/src/routes/reports/+page.svelte b/frontend/src/routes/reports/+page.svelte index 1fc086e..1fc6262 100644 --- a/frontend/src/routes/reports/+page.svelte +++ b/frontend/src/routes/reports/+page.svelte @@ -1,9 +1,42 @@ @@ -14,27 +47,31 @@

Reports

- {#each data.reports as report} - - - #{report.id} on @{report.user_name} - ({report.user_id}) {#if report.member_id} - (member: {report.member_name}, - {report.member_id}) - {/if} - - -
{report.reason}
-
- - Created {DateTime.fromISO(report.created_at) - .toLocal() - .toLocaleString(DateTime.DATETIME_MED)} • - - - - -
+ {#each data.reports as report, index} + + • + + + + {/each}
+ + + + {#if error} + + {/if} + + +