diff --git a/backend/db/report.go b/backend/db/report.go new file mode 100644 index 0000000..88d5723 --- /dev/null +++ b/backend/db/report.go @@ -0,0 +1,217 @@ +package db + +import ( + "context" + "time" + + "emperror.dev/errors" + "github.com/georgysavva/scany/pgxscan" + "github.com/jackc/pgx/v4" + "github.com/rs/xid" +) + +type Report struct { + ID int64 `json:"id"` + UserID xid.ID `json:"user_id"` + UserName string `json:"user_name"` + MemberID xid.ID `json:"member_id"` + MemberName *string `json:"member_name"` + Reason string `json:"reason"` + ReporterID xid.ID `json:"reporter_id"` + + CreatedAt time.Time `json:"created_at"` + ResolvedAt *time.Time `json:"resolved_at"` + AdminID xid.ID `json:"admin_id"` + AdminComment *string `json:"admin_comment"` +} + +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("*", + "(SELECT username FROM users WHERE id = reports.user_id) AS user_name", + "(SELECT name FROM members WHERE id = reports.member_id) AS member_name"). + From("reports"). + Limit(ReportPageSize). + OrderBy("id DESC") + if before != 0 { + builder = builder.Where("id < ?", before) + } + if closed { + builder = builder.Where("resolved_at IS NOT NULL") + } else { + builder = builder.Where("resolved_at IS NULL") + } + sql, args, err := builder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &rs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + if len(rs) == 0 { + return []Report{}, nil + } + return rs, nil +} + +func (db *DB) ReportsByUser(ctx context.Context, userID xid.ID, before int) (rs []Report, err error) { + builder := sq.Select("*").From("reports").Where("user_id = ?", userID).Limit(ReportPageSize).OrderBy("id DESC") + if before != 0 { + builder = builder.Where("id < ?", before) + } + sql, args, err := builder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &rs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + if len(rs) == 0 { + return []Report{}, nil + } + return rs, nil +} + +func (db *DB) ReportsByReporter(ctx context.Context, reporterID xid.ID, before int) (rs []Report, err error) { + builder := sq.Select("*").From("reports").Where("reporter_id = ?", reporterID).Limit(ReportPageSize).OrderBy("id DESC") + if before != 0 { + builder = builder.Where("id < ?", before) + } + sql, args, err := builder.ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &rs, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "executing query") + } + if len(rs) == 0 { + return []Report{}, nil + } + 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, + "reporter_id": reporterID, + "member_id": memberID, + "reason": reason, + }).Suffix("RETURNING *").ToSql() + if err != nil { + return r, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &r, sql, args...) + if err != nil { + return r, errors.Wrap(err, "executing query") + } + 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 dddffa8..785528a 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" @@ -34,6 +36,7 @@ type User struct { FediverseInstance *string MaxInvites int + IsAdmin bool DeletedAt *time.Time SelfDelete *bool @@ -416,3 +419,93 @@ 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("links", 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.go b/backend/routes.go index f3f44e7..d97a3dc 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -5,6 +5,7 @@ import ( "codeberg.org/u1f320/pronouns.cc/backend/routes/bot" "codeberg.org/u1f320/pronouns.cc/backend/routes/member" "codeberg.org/u1f320/pronouns.cc/backend/routes/meta" + "codeberg.org/u1f320/pronouns.cc/backend/routes/mod" "codeberg.org/u1f320/pronouns.cc/backend/routes/user" "codeberg.org/u1f320/pronouns.cc/backend/server" "github.com/go-chi/chi/v5" @@ -20,5 +21,6 @@ func mountRoutes(s *server.Server) { member.Mount(s, r) bot.Mount(s, r) meta.Mount(s, r) + mod.Mount(s, r) }) } diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go index 7bccfcc..f0f37c7 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -43,8 +43,10 @@ type discordCallbackResponse struct { Ticket string `json:"ticket,omitempty"` RequireInvite bool `json:"require_invite"` // require an invite for signing up - IsDeleted bool `json:"is_deleted"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + SelfDelete *bool `json:"self_delete,omitempty"` + DeleteReason *string `json:"delete_reason,omitempty"` } func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { @@ -81,7 +83,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { u, err := s.DB.DiscordUser(ctx, du.ID) if err == nil { - if u.DeletedAt != nil && *u.SelfDelete { + if u.DeletedAt != nil { // store cancel delete token token := undeleteToken() err = s.saveUndeleteToken(ctx, u.ID, token) @@ -91,11 +93,13 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { } render.JSON(w, r, discordCallbackResponse{ - HasAccount: true, - Token: token, - User: dbUserToUserResponse(u, []db.Field{}), - IsDeleted: true, - DeletedAt: u.DeletedAt, + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, }) return nil } @@ -107,7 +111,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { // TODO: implement user + token permissions tokenID := xid.New() - token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) if err != nil { return err } diff --git a/backend/routes/auth/fedi_mastodon.go b/backend/routes/auth/fedi_mastodon.go index 7a29a1a..0f40d4d 100644 --- a/backend/routes/auth/fedi_mastodon.go +++ b/backend/routes/auth/fedi_mastodon.go @@ -31,8 +31,10 @@ type fediCallbackResponse struct { Ticket string `json:"ticket,omitempty"` RequireInvite bool `json:"require_invite"` // require an invite for signing up - IsDeleted bool `json:"is_deleted"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + SelfDelete *bool `json:"self_delete,omitempty"` + DeleteReason *string `json:"delete_reason,omitempty"` } type partialMastodonAccount struct { @@ -102,7 +104,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error u, err := s.DB.FediverseUser(ctx, mu.ID, app.ID) if err == nil { - if u.DeletedAt != nil && *u.SelfDelete { + if u.DeletedAt != nil { // store cancel delete token token := undeleteToken() err = s.saveUndeleteToken(ctx, u.ID, token) @@ -112,11 +114,13 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error } render.JSON(w, r, fediCallbackResponse{ - HasAccount: true, - Token: token, - User: dbUserToUserResponse(u, []db.Field{}), - IsDeleted: true, - DeletedAt: u.DeletedAt, + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, }) return nil } @@ -128,7 +132,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error // TODO: implement user + token permissions tokenID := xid.New() - token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) if err != nil { return err } diff --git a/backend/routes/auth/undelete.go b/backend/routes/auth/undelete.go index 8768b2f..6bc2314 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" @@ -29,6 +27,16 @@ func (s *Server) cancelDelete(w http.ResponseWriter, r *http.Request) error { return server.APIError{Code: server.ErrNotFound} // assume invalid token } + // only self deleted users can undelete themselves + u, err := s.DB.User(ctx, id) + if err != nil { + log.Errorf("getting user: %v", err) + return errors.Wrap(err, "getting user") + } + if !*u.SelfDelete { + return server.APIError{Code: server.ErrForbidden} + } + err = s.DB.UndoDeleteUser(ctx, id) if err != nil { log.Errorf("executing undelete query: %v", err) @@ -84,60 +92,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/create_report.go b/backend/routes/mod/create_report.go new file mode 100644 index 0000000..7e8264e --- /dev/null +++ b/backend/routes/mod/create_report.go @@ -0,0 +1,111 @@ +package mod + +import ( + "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/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +const MaxReasonLength = 2000 + +type CreateReportRequest struct { + Reason string `json:"reason"` +} + +func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + userID, err := xid.FromString(chi.URLParam(r, "id")) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"} + } + + u, err := s.DB.User(ctx, userID) + if err != nil { + if err == db.ErrUserNotFound { + return server.APIError{Code: server.ErrUserNotFound} + } + + log.Errorf("getting user %v: %v", userID, err) + return errors.Wrap(err, "getting user") + } + + if u.DeletedAt != nil { + return server.APIError{Code: server.ErrUserNotFound} + } + + var req CreateReportRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if len(req.Reason) > MaxReasonLength { + return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"} + } + + report, err := s.DB.CreateReport(ctx, claims.UserID, u.ID, nil, req.Reason) + if err != nil { + log.Errorf("creating report for %v: %v", u.ID, err) + return errors.Wrap(err, "creating report") + } + + render.JSON(w, r, map[string]any{"created": true, "created_at": report.CreatedAt}) + return nil +} + +func (s *Server) createMemberReport(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + memberID, err := xid.FromString(chi.URLParam(r, "id")) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "Invalid member ID"} + } + + m, err := s.DB.Member(ctx, memberID) + if err != nil { + if err == db.ErrMemberNotFound { + return server.APIError{Code: server.ErrMemberNotFound} + } + + log.Errorf("getting member %v: %v", memberID, err) + return errors.Wrap(err, "getting member") + } + + u, err := s.DB.User(ctx, m.UserID) + if err != nil { + log.Errorf("getting user %v: %v", m.UserID, err) + return errors.Wrap(err, "getting user") + } + + if u.DeletedAt != nil { + return server.APIError{Code: server.ErrMemberNotFound} + } + + var req CreateReportRequest + err = render.Decode(r, &req) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if len(req.Reason) > MaxReasonLength { + return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"} + } + + report, err := s.DB.CreateReport(ctx, claims.UserID, u.ID, &m.ID, req.Reason) + if err != nil { + log.Errorf("creating report for %v: %v", m.ID, err) + return errors.Wrap(err, "creating report") + } + + render.JSON(w, r, map[string]any{"created": true, "created_at": report.CreatedAt}) + return nil +} diff --git a/backend/routes/mod/get_reports.go b/backend/routes/mod/get_reports.go new file mode 100644 index 0000000..1aad2e8 --- /dev/null +++ b/backend/routes/mod/get_reports.go @@ -0,0 +1,84 @@ +package mod + +import ( + "net/http" + "strconv" + + "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" + "github.com/rs/xid" +) + +func (s *Server) getReports(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + showClosed := r.FormValue("closed") == "true" + var before int + if s := r.FormValue("before"); s != "" { + before, err = strconv.Atoi(s) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "\"before\": invalid ID"} + } + } + + reports, err := s.DB.Reports(ctx, showClosed, before) + if err != nil { + log.Errorf("getting reports: %v", err) + return errors.Wrap(err, "getting reports from database") + } + + render.JSON(w, r, reports) + return nil +} + +func (s *Server) getReportsByUser(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + var before int + if s := r.FormValue("before"); s != "" { + before, err = strconv.Atoi(s) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "\"before\": invalid ID"} + } + } + + userID, err := xid.FromString(chi.URLParam(r, "id")) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"} + } + + reports, err := s.DB.ReportsByUser(ctx, userID, before) + if err != nil { + log.Errorf("getting reports: %v", err) + return errors.Wrap(err, "getting reports from database") + } + + render.JSON(w, r, reports) + return nil +} + +func (s *Server) getReportsByReporter(w http.ResponseWriter, r *http.Request) (err error) { + ctx := r.Context() + var before int + if s := r.FormValue("before"); s != "" { + before, err = strconv.Atoi(s) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "\"before\": invalid ID"} + } + } + + userID, err := xid.FromString(chi.URLParam(r, "id")) + if err != nil { + return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"} + } + + reports, err := s.DB.ReportsByReporter(ctx, userID, before) + if err != nil { + log.Errorf("getting reports: %v", err) + return errors.Wrap(err, "getting reports from database") + } + + render.JSON(w, r, reports) + return nil +} diff --git a/backend/routes/mod/resolve_report.go b/backend/routes/mod/resolve_report.go new file mode 100644 index 0000000..bf7730c --- /dev/null +++ b/backend/routes/mod/resolve_report.go @@ -0,0 +1,113 @@ +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.InvalidateAllTokens(ctx, tx, report.UserID) + if err != nil { + return errors.Wrap(err, "invalidating tokens") + } + + 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 new file mode 100644 index 0000000..52ff0aa --- /dev/null +++ b/backend/routes/mod/routes.go @@ -0,0 +1,58 @@ +package mod + +import ( + "net/http" + + "codeberg.org/u1f320/pronouns.cc/backend/server" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type Server struct { + *server.Server +} + +func Mount(srv *server.Server, r chi.Router) { + s := &Server{Server: srv} + + r.With(MustAdmin).Route("/admin", func(r chi.Router) { + r.Get("/reports", server.WrapHandler(s.getReports)) + r.Get("/reports/by-user/{id}", server.WrapHandler(s.getReportsByUser)) + r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter)) + + 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 { + fn := func(w http.ResponseWriter, r *http.Request) { + claims, ok := server.ClaimsFromContext(r.Context()) + if !ok { + render.Status(r, http.StatusForbidden) + render.JSON(w, r, server.APIError{ + Code: server.ErrForbidden, + Message: "Forbidden", + }) + return + } + + if !claims.UserIsAdmin { + render.Status(r, http.StatusForbidden) + render.JSON(w, r, server.APIError{ + Code: server.ErrForbidden, + Message: "Forbidden", + }) + return + } + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) +} 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/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 2117de8..8683a58 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -81,6 +81,28 @@ export interface Invite { used: boolean; } +export interface Report { + id: string; + user_id: string; + user_name: string; + member_id: string | null; + member_name: string | null; + reason: string; + reporter_id: string; + + created_at: string; + resolved_at: string | null; + admin_id: string | null; + admin_comment: string | null; +} + +export interface Warning { + id: number; + reason: string; + created_at: string; + read: boolean; +} + export interface APIError { code: ErrorCode; message?: string; diff --git a/frontend/src/routes/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte index d249579..18f8513 100644 --- a/frontend/src/routes/@[username]/+page.svelte +++ b/frontend/src/routes/@[username]/+page.svelte @@ -31,6 +31,7 @@ import ErrorAlert from "$lib/components/ErrorAlert.svelte"; import { goto } from "$app/navigation"; import renderMarkdown from "$lib/api/markdown"; + import ReportButton from "./ReportButton.svelte"; export let data: PageData; @@ -149,6 +150,11 @@ {/each} + {#if $userStore && $userStore.id !== data.id} +
+ +
+ {/if} {#if data.members.length > 0 || ($userStore && $userStore.id === data.id)}
diff --git a/frontend/src/routes/@[username]/ReportButton.svelte b/frontend/src/routes/@[username]/ReportButton.svelte new file mode 100644 index 0000000..cd63685 --- /dev/null +++ b/frontend/src/routes/@[username]/ReportButton.svelte @@ -0,0 +1,60 @@ + + +
+ +
+ + + + {#if error} + + {/if} + +