feat: backend for warnings, partial frontend for reports
This commit is contained in:
		
							parent
							
								
									29274287a2
								
							
						
					
					
						commit
						a0bc39bcba
					
				
					 12 changed files with 479 additions and 79 deletions
				
			
		|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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") | ||||
|  |  | |||
							
								
								
									
										108
									
								
								backend/routes/mod/resolve_report.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								backend/routes/mod/resolve_report.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| } | ||||
|  | @ -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 { | ||||
|  |  | |||
							
								
								
									
										63
									
								
								backend/routes/mod/warnings.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								backend/routes/mod/warnings.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| } | ||||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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, | ||||
| } | ||||
|  |  | |||
|  | @ -1,9 +1,42 @@ | |||
| <script lang="ts"> | ||||
|   import { DateTime } from "luxon"; | ||||
|   import { Button, Card, CardBody, CardFooter, CardHeader } from "sveltestrap"; | ||||
|   import type { APIError } from "$lib/api/entities"; | ||||
|   import { apiFetchClient } from "$lib/api/fetch"; | ||||
|   import ErrorAlert from "$lib/components/ErrorAlert.svelte"; | ||||
|   import { addToast } from "$lib/toast"; | ||||
|   import { Button, FormGroup, Modal, ModalBody, ModalFooter } from "sveltestrap"; | ||||
|   import type { PageData } from "./$types"; | ||||
|   import ReportCard from "./ReportCard.svelte"; | ||||
| 
 | ||||
|   export let data: PageData; | ||||
| 
 | ||||
|   let warnModalOpen = false; | ||||
|   const toggleWarnModal = () => (warnModalOpen = !warnModalOpen); | ||||
| 
 | ||||
|   let reportIndex = -1; | ||||
|   let reason = ""; | ||||
|   let deleteUser = false; | ||||
|   let error: APIError | null = null; | ||||
| 
 | ||||
|   const openWarnModalFor = (index: number) => { | ||||
|     reportIndex = index; | ||||
|     toggleWarnModal(); | ||||
|   }; | ||||
| 
 | ||||
|   const warnUser = async () => { | ||||
|     try { | ||||
|       await apiFetchClient<any>(`/admin/reports/${data.reports[reportIndex].id}`, "PATCH", { | ||||
|         warn: true, | ||||
|         reason: reason, | ||||
|       }); | ||||
|       error = null; | ||||
| 
 | ||||
|       addToast({ body: "Successfully warned user", header: "Warned user" }); | ||||
|       toggleWarnModal(); | ||||
|       reportIndex = -1; | ||||
|     } catch (e) { | ||||
|       error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
|  | @ -14,27 +47,31 @@ | |||
|   <h1>Reports</h1> | ||||
| 
 | ||||
|   <div> | ||||
|     {#each data.reports as report} | ||||
|       <Card> | ||||
|         <CardHeader> | ||||
|           <strong>#{report.id}</strong> on <a href="/@{report.user_name}">@{report.user_name}</a> | ||||
|           ({report.user_id}) {#if report.member_id} | ||||
|             (member: <a href="/@{report.user_name}/{report.member_name}">{report.member_name}</a>, | ||||
|             {report.member_id}) | ||||
|           {/if} | ||||
|         </CardHeader> | ||||
|         <CardBody> | ||||
|           <blockquote class="blockquote">{report.reason}</blockquote> | ||||
|         </CardBody> | ||||
|         <CardFooter> | ||||
|           Created {DateTime.fromISO(report.created_at) | ||||
|             .toLocal() | ||||
|             .toLocaleString(DateTime.DATETIME_MED)} • | ||||
|           <Button outline color="warning" size="sm">Warn user</Button> | ||||
|           <Button outline color="danger" size="sm">Deactivate user</Button> | ||||
|           <Button outline color="secondary" size="sm">Ignore report</Button> | ||||
|         </CardFooter> | ||||
|       </Card> | ||||
|     {#each data.reports as report, index} | ||||
|       <ReportCard {report}> | ||||
|         • | ||||
|         <Button outline color="warning" size="sm" on:click={() => openWarnModalFor(index)} | ||||
|           >Warn user</Button | ||||
|         > | ||||
|         <Button outline color="danger" size="sm">Deactivate user</Button> | ||||
|         <Button outline color="secondary" size="sm">Ignore report</Button> | ||||
|       </ReportCard> | ||||
|     {/each} | ||||
|   </div> | ||||
| 
 | ||||
|   <Modal header="Warn user" isOpen={warnModalOpen} toggle={toggleWarnModal}> | ||||
|     <ModalBody> | ||||
|       {#if error} | ||||
|         <ErrorAlert {error} /> | ||||
|       {/if} | ||||
|       <ReportCard report={data.reports[reportIndex]} /> | ||||
|       <FormGroup floating label="Reason" class="mt-2"> | ||||
|         <textarea style="min-height: 100px;" class="form-control" bind:value={reason} /> | ||||
|       </FormGroup> | ||||
|     </ModalBody> | ||||
|     <ModalFooter> | ||||
|       <Button color="danger" on:click={warnUser} disabled={!reason}>Warn user</Button> | ||||
|       <Button color="secondary" on:click={toggleWarnModal}>Cancel</Button> | ||||
|     </ModalFooter> | ||||
|   </Modal> | ||||
| </div> | ||||
|  |  | |||
							
								
								
									
										24
									
								
								frontend/src/routes/reports/ReportCard.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/src/routes/reports/ReportCard.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | |||
| <script lang="ts"> | ||||
|   import type { Report } from "$lib/api/entities"; | ||||
|   import { DateTime } from "luxon"; | ||||
|   import { Button, Card, CardBody, CardFooter, CardHeader } from "sveltestrap"; | ||||
| 
 | ||||
|   export let report: Report; | ||||
| </script> | ||||
| 
 | ||||
| <Card> | ||||
|   <CardHeader> | ||||
|     <strong>#{report.id}</strong> on <a href="/@{report.user_name}">@{report.user_name}</a> | ||||
|     ({report.user_id}) {#if report.member_id} | ||||
|       (member: <a href="/@{report.user_name}/{report.member_name}">{report.member_name}</a>, | ||||
|       {report.member_id}) | ||||
|     {/if} | ||||
|   </CardHeader> | ||||
|   <CardBody> | ||||
|     <blockquote class="blockquote">{report.reason}</blockquote> | ||||
|   </CardBody> | ||||
|   <CardFooter> | ||||
|     Created {DateTime.fromISO(report.created_at).toLocal().toLocaleString(DateTime.DATETIME_MED)} | ||||
|     <slot /> | ||||
|   </CardFooter> | ||||
| </Card> | ||||
|  | @ -17,3 +17,11 @@ create table reports ( | |||
|     admin_id      text null references users (id) on delete set null, | ||||
|     admin_comment text | ||||
| ); | ||||
| 
 | ||||
| create table warnings ( | ||||
|     id         serial primary key, | ||||
|     user_id    text not null references users (id) on delete cascade, | ||||
|     reason     text not null, | ||||
|     created_at timestamptz not null default now(), | ||||
|     read_at    timestamptz | ||||
| ); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue