From 33f903b07d74b40159878885d528c860f1f7e52d Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 19 Mar 2023 16:14:09 +0100 Subject: [PATCH 01/11] start reports/moderation in backend --- backend/db/report.go | 47 +++++++++++++++++++++++++ backend/db/user.go | 1 + backend/routes.go | 2 ++ backend/routes/auth/discord.go | 2 +- backend/routes/auth/fedi_mastodon.go | 2 +- backend/routes/mod/get_reports.go | 13 +++++++ backend/routes/mod/routes.go | 52 ++++++++++++++++++++++++++++ scripts/migrate/010_reports.sql | 19 ++++++++++ 8 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 backend/db/report.go create mode 100644 backend/routes/mod/get_reports.go create mode 100644 backend/routes/mod/routes.go create mode 100644 scripts/migrate/010_reports.sql diff --git a/backend/db/report.go b/backend/db/report.go new file mode 100644 index 0000000..9162638 --- /dev/null +++ b/backend/db/report.go @@ -0,0 +1,47 @@ +package db + +import ( + "context" + "time" + + "emperror.dev/errors" + "github.com/georgysavva/scany/pgxscan" + "github.com/rs/xid" +) + +type Report struct { + ID int64 + UserID xid.ID + MemberID *xid.ID + Reason string + ReporterID xid.ID + + CreatedAt time.Time + ResolvedAt *time.Time + AdminID *xid.ID + AdminComment *string +} + +const reportPageSize = 100 + +func (db *DB) Reports(ctx context.Context, closed bool, page int) (rs []Report, err error) { + builder := sq.Select("*").From("reports").Offset(uint64(reportPageSize * page)).Limit(reportPageSize).OrderBy("id ASC") + 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 +} diff --git a/backend/db/user.go b/backend/db/user.go index 6e288c7..6e875fd 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -34,6 +34,7 @@ type User struct { FediverseInstance *string MaxInvites int + IsAdmin bool DeletedAt *time.Time SelfDelete *bool 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..63a08c1 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -107,7 +107,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..581fa32 100644 --- a/backend/routes/auth/fedi_mastodon.go +++ b/backend/routes/auth/fedi_mastodon.go @@ -128,7 +128,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/mod/get_reports.go b/backend/routes/mod/get_reports.go new file mode 100644 index 0000000..62707d1 --- /dev/null +++ b/backend/routes/mod/get_reports.go @@ -0,0 +1,13 @@ +package mod + +import ( + "fmt" + "net/http" +) + +func (s *Server) getReports(w http.ResponseWriter, r *http.Request) error { + showClosed := r.FormValue("closed") == "true" + + fmt.Println("closed =", showClosed) + return nil +} diff --git a/backend/routes/mod/routes.go b/backend/routes/mod/routes.go new file mode 100644 index 0000000..6d76ee0 --- /dev/null +++ b/backend/routes/mod/routes.go @@ -0,0 +1,52 @@ +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}", nil) + r.Get("/reports/by-reporter/{id}", nil) + + r.Patch("/reports/{id}", nil) + }) +} + +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/scripts/migrate/010_reports.sql b/scripts/migrate/010_reports.sql new file mode 100644 index 0000000..90233cb --- /dev/null +++ b/scripts/migrate/010_reports.sql @@ -0,0 +1,19 @@ +-- +migrate Up + +-- 2023-03-19: Add moderation-related tables + +alter table users add column is_admin boolean not null default false; + +create table reports ( + id serial primary key, + -- we keep deleted users for 180 days after deletion, so it's fine to tie this to a user object + user_id text not null references users (id) on delete cascade, + member_id text null references members (id) on delete set null, + reason text not null, + reporter_id text not null, + + created_at timestamptz not null default now(), + resolved_at timestamptz, + admin_id text null references users (id) on delete set null, + admin_comment text +); From 2309c24bd8498dc8315943c89d03d091f8af8684 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 19 Mar 2023 16:14:47 +0100 Subject: [PATCH 02/11] add terms/privacy pages (no content yet) --- frontend/src/routes/page/privacy/+page.svelte | 7 +++++++ frontend/src/routes/page/privacy/+page.ts | 1 + frontend/src/routes/page/terms/+page.svelte | 7 +++++++ frontend/src/routes/page/terms/+page.ts | 1 + 4 files changed, 16 insertions(+) create mode 100644 frontend/src/routes/page/privacy/+page.svelte create mode 100644 frontend/src/routes/page/privacy/+page.ts create mode 100644 frontend/src/routes/page/terms/+page.svelte create mode 100644 frontend/src/routes/page/terms/+page.ts diff --git a/frontend/src/routes/page/privacy/+page.svelte b/frontend/src/routes/page/privacy/+page.svelte new file mode 100644 index 0000000..7c057fb --- /dev/null +++ b/frontend/src/routes/page/privacy/+page.svelte @@ -0,0 +1,7 @@ + + Privacy policy - pronouns.cc + + +
+

Privacy policy

+
diff --git a/frontend/src/routes/page/privacy/+page.ts b/frontend/src/routes/page/privacy/+page.ts new file mode 100644 index 0000000..189f71e --- /dev/null +++ b/frontend/src/routes/page/privacy/+page.ts @@ -0,0 +1 @@ +export const prerender = true; diff --git a/frontend/src/routes/page/terms/+page.svelte b/frontend/src/routes/page/terms/+page.svelte new file mode 100644 index 0000000..839e8d6 --- /dev/null +++ b/frontend/src/routes/page/terms/+page.svelte @@ -0,0 +1,7 @@ + + Terms of service - pronouns.cc + + +
+

Terms of service

+
diff --git a/frontend/src/routes/page/terms/+page.ts b/frontend/src/routes/page/terms/+page.ts new file mode 100644 index 0000000..189f71e --- /dev/null +++ b/frontend/src/routes/page/terms/+page.ts @@ -0,0 +1 @@ +export const prerender = true; From 799d27b58c13b97d089e03c0c6a94a48886018f5 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 21 Mar 2023 14:27:39 +0100 Subject: [PATCH 03/11] feat: add list reports endpoints --- backend/db/report.go | 49 ++++++++++++++++++-- backend/routes/mod/get_reports.go | 77 +++++++++++++++++++++++++++++-- backend/routes/mod/routes.go | 4 +- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/backend/db/report.go b/backend/db/report.go index 9162638..8137c82 100644 --- a/backend/db/report.go +++ b/backend/db/report.go @@ -22,10 +22,13 @@ type Report struct { AdminComment *string } -const reportPageSize = 100 +const ReportPageSize = 100 -func (db *DB) Reports(ctx context.Context, closed bool, page int) (rs []Report, err error) { - builder := sq.Select("*").From("reports").Offset(uint64(reportPageSize * page)).Limit(reportPageSize).OrderBy("id ASC") +func (db *DB) Reports(ctx context.Context, closed bool, before int) (rs []Report, err error) { + builder := sq.Select("*").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 { @@ -45,3 +48,43 @@ func (db *DB) Reports(ctx context.Context, closed bool, page int) (rs []Report, } 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 +} diff --git a/backend/routes/mod/get_reports.go b/backend/routes/mod/get_reports.go index 62707d1..1aad2e8 100644 --- a/backend/routes/mod/get_reports.go +++ b/backend/routes/mod/get_reports.go @@ -1,13 +1,84 @@ package mod import ( - "fmt" "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) error { +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"} + } + } - fmt.Println("closed =", showClosed) + 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/routes.go b/backend/routes/mod/routes.go index 6d76ee0..88b9657 100644 --- a/backend/routes/mod/routes.go +++ b/backend/routes/mod/routes.go @@ -17,8 +17,8 @@ func Mount(srv *server.Server, r chi.Router) { r.With(MustAdmin).Route("/admin", func(r chi.Router) { r.Get("/reports", server.WrapHandler(s.getReports)) - r.Get("/reports/by-user/{id}", nil) - r.Get("/reports/by-reporter/{id}", nil) + r.Get("/reports/by-user/{id}", server.WrapHandler(s.getReportsByUser)) + r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter)) r.Patch("/reports/{id}", nil) }) From 3bb97b827420bb552f2a8e387b515686df8e08e5 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 21 Mar 2023 17:16:16 +0100 Subject: [PATCH 04/11] feat(backend): add create user report endpoint --- backend/db/report.go | 36 +++++++++++++----- backend/routes/mod/create_report.go | 58 +++++++++++++++++++++++++++++ backend/routes/mod/routes.go | 3 ++ 3 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 backend/routes/mod/create_report.go diff --git a/backend/db/report.go b/backend/db/report.go index 8137c82..f16d1b1 100644 --- a/backend/db/report.go +++ b/backend/db/report.go @@ -10,16 +10,16 @@ import ( ) type Report struct { - ID int64 - UserID xid.ID - MemberID *xid.ID - Reason string - ReporterID xid.ID + ID int64 `json:"id"` + UserID xid.ID `json:"user_id"` + MemberID *xid.ID `json:"member_id"` + Reason string `json:"reason"` + ReporterID xid.ID `json:"reporter_id"` - CreatedAt time.Time - ResolvedAt *time.Time - AdminID *xid.ID - AdminComment *string + 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 @@ -88,3 +88,21 @@ func (db *DB) ReportsByReporter(ctx context.Context, reporterID xid.ID, before i } return rs, 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 +} diff --git a/backend/routes/mod/create_report.go b/backend/routes/mod/create_report.go new file mode 100644 index 0000000..8bc61ed --- /dev/null +++ b/backend/routes/mod/create_report.go @@ -0,0 +1,58 @@ +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") + } + + 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 +} diff --git a/backend/routes/mod/routes.go b/backend/routes/mod/routes.go index 88b9657..c7917f9 100644 --- a/backend/routes/mod/routes.go +++ b/backend/routes/mod/routes.go @@ -22,6 +22,9 @@ func Mount(srv *server.Server, r chi.Router) { r.Patch("/reports/{id}", nil) }) + + r.With(server.MustAuth).Post("/users/{id}/reports", server.WrapHandler(s.createUserReport)) + r.With(server.MustAuth).Post("/members/{id}/reports", nil) } func MustAdmin(next http.Handler) http.Handler { From 9fe6529c1bfc36cb21b41a66e829ec63a7e20955 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 22 Mar 2023 15:53:20 +0100 Subject: [PATCH 05/11] feat(backend): add create member report endpoint --- backend/routes/mod/create_report.go | 53 +++++++++++++++++++++++++++++ backend/routes/mod/routes.go | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/backend/routes/mod/create_report.go b/backend/routes/mod/create_report.go index 8bc61ed..7e8264e 100644 --- a/backend/routes/mod/create_report.go +++ b/backend/routes/mod/create_report.go @@ -37,6 +37,10 @@ func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error 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 { @@ -56,3 +60,52 @@ func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error 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/routes.go b/backend/routes/mod/routes.go index c7917f9..ca3db57 100644 --- a/backend/routes/mod/routes.go +++ b/backend/routes/mod/routes.go @@ -24,7 +24,7 @@ func Mount(srv *server.Server, r chi.Router) { }) r.With(server.MustAuth).Post("/users/{id}/reports", server.WrapHandler(s.createUserReport)) - r.With(server.MustAuth).Post("/members/{id}/reports", nil) + r.With(server.MustAuth).Post("/members/{id}/reports", server.WrapHandler(s.createMemberReport)) } func MustAdmin(next http.Handler) http.Handler { From 77a93fd148dd66da28da3d9716c70b1cfe64f9e9 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 23 Mar 2023 09:30:23 +0100 Subject: [PATCH 06/11] a lil frontend work (as a treat) --- backend/routes/mod/routes.go | 1 + frontend/src/lib/api/entities.ts | 13 +++++++++++++ frontend/src/routes/reports/+layout.svelte | 1 + frontend/src/routes/reports/+layout.ts | 1 + frontend/src/routes/reports/+page.svelte | 5 +++++ frontend/src/routes/reports/+page.ts | 15 +++++++++++++++ 6 files changed, 36 insertions(+) create mode 100644 frontend/src/routes/reports/+layout.svelte create mode 100644 frontend/src/routes/reports/+layout.ts create mode 100644 frontend/src/routes/reports/+page.svelte create mode 100644 frontend/src/routes/reports/+page.ts diff --git a/backend/routes/mod/routes.go b/backend/routes/mod/routes.go index ca3db57..8b64139 100644 --- a/backend/routes/mod/routes.go +++ b/backend/routes/mod/routes.go @@ -20,6 +20,7 @@ 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) }) diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index 2117de8..b285187 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -81,6 +81,19 @@ export interface Invite { used: boolean; } +export interface Report { + id: string; + user_id: string; + member_id: string | null; + reason: string; + reporter_id: string; + + created_at: string; + resolved_at: string | null; + admin_id: string | null; + admin_comment: string | null; +} + export interface APIError { code: ErrorCode; message?: string; diff --git a/frontend/src/routes/reports/+layout.svelte b/frontend/src/routes/reports/+layout.svelte new file mode 100644 index 0000000..4fa864c --- /dev/null +++ b/frontend/src/routes/reports/+layout.svelte @@ -0,0 +1 @@ + diff --git a/frontend/src/routes/reports/+layout.ts b/frontend/src/routes/reports/+layout.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/frontend/src/routes/reports/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/frontend/src/routes/reports/+page.svelte b/frontend/src/routes/reports/+page.svelte new file mode 100644 index 0000000..7f17dcf --- /dev/null +++ b/frontend/src/routes/reports/+page.svelte @@ -0,0 +1,5 @@ + diff --git a/frontend/src/routes/reports/+page.ts b/frontend/src/routes/reports/+page.ts new file mode 100644 index 0000000..260f0d4 --- /dev/null +++ b/frontend/src/routes/reports/+page.ts @@ -0,0 +1,15 @@ +import type { Report } from "$lib/api/entities"; +import { apiFetchClient } from "$lib/api/fetch"; + +export const load = async () => { + const reports = await apiFetchClient("/admin/reports"); + return { page: 0, isClosed: false, userId: null, reporterId: null, reports } as PageLoadData; +}; + +interface PageLoadData { + page: number; + isClosed: boolean; + userId: string | null; + reporterId: string | null; + reports: Report[]; +} From 29274287a255104f0fe84ab8ade8fa80727ce6a0 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 23 Mar 2023 11:30:47 +0100 Subject: [PATCH 07/11] frontend: add reports list --- backend/db/report.go | 9 +++++- frontend/src/lib/api/entities.ts | 2 ++ frontend/src/routes/reports/+page.svelte | 35 ++++++++++++++++++++++++ frontend/src/routes/reports/+page.ts | 14 ++++++++-- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/backend/db/report.go b/backend/db/report.go index f16d1b1..3f04d4f 100644 --- a/backend/db/report.go +++ b/backend/db/report.go @@ -12,7 +12,9 @@ import ( 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"` @@ -25,7 +27,12 @@ type Report struct { const ReportPageSize = 100 func (db *DB) Reports(ctx context.Context, closed bool, before int) (rs []Report, err error) { - builder := sq.Select("*").From("reports").Limit(ReportPageSize).OrderBy("id DESC") + 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) } diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index b285187..3198edb 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -84,7 +84,9 @@ export interface Invite { export interface Report { id: string; user_id: string; + user_name: string; member_id: string | null; + member_name: string | null; reason: string; reporter_id: string; diff --git a/frontend/src/routes/reports/+page.svelte b/frontend/src/routes/reports/+page.svelte index 7f17dcf..1fc086e 100644 --- a/frontend/src/routes/reports/+page.svelte +++ b/frontend/src/routes/reports/+page.svelte @@ -1,5 +1,40 @@ + + + Reports - pronouns.cc + + +
+

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} +
+
diff --git a/frontend/src/routes/reports/+page.ts b/frontend/src/routes/reports/+page.ts index 260f0d4..85ef6f4 100644 --- a/frontend/src/routes/reports/+page.ts +++ b/frontend/src/routes/reports/+page.ts @@ -1,9 +1,17 @@ -import type { Report } from "$lib/api/entities"; +import { ErrorCode, type APIError, type Report } from "$lib/api/entities"; import { apiFetchClient } from "$lib/api/fetch"; +import { error } from "@sveltejs/kit"; export const load = async () => { - const reports = await apiFetchClient("/admin/reports"); - return { page: 0, isClosed: false, userId: null, reporterId: null, reports } as PageLoadData; + try { + const reports = await apiFetchClient("/admin/reports"); + return { page: 0, isClosed: false, userId: null, reporterId: null, reports } as PageLoadData; + } catch (e) { + if ((e as APIError).code === ErrorCode.Forbidden) { + throw error(400, "You're not an admin"); + } + throw e; + } }; interface PageLoadData { From a0bc39bcbadfb5c9721c7435caa6153c74cbbd19 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 23 Mar 2023 14:54:43 +0100 Subject: [PATCH 08/11] feat: backend for warnings, partial frontend for reports --- backend/db/report.go | 102 +++++++++++++++++ backend/db/user.go | 91 +++++++++++++++ backend/routes/auth/undelete.go | 57 +-------- backend/routes/mod/resolve_report.go | 108 ++++++++++++++++++ backend/routes/mod/routes.go | 6 +- backend/routes/mod/warnings.go | 63 ++++++++++ backend/routes/user/get_user.go | 5 +- backend/routes/user/patch_user.go | 1 + backend/server/errors.go | 10 ++ frontend/src/routes/reports/+page.svelte | 83 ++++++++++---- frontend/src/routes/reports/ReportCard.svelte | 24 ++++ scripts/migrate/010_reports.sql | 8 ++ 12 files changed, 479 insertions(+), 79 deletions(-) create mode 100644 backend/routes/mod/resolve_report.go create mode 100644 backend/routes/mod/warnings.go create mode 100644 frontend/src/routes/reports/ReportCard.svelte 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} + + +