merge: merge pull request 'add reports and moderation' (#31) from reports into main
Reviewed-on: https://codeberg.org/u1f320/pronouns.cc/pulls/31
This commit is contained in:
		
						commit
						84b87790ee
					
				
					 37 changed files with 1359 additions and 107 deletions
				
			
		
							
								
								
									
										217
									
								
								backend/db/report.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								backend/db/report.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| } | ||||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| 	}) | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| 		} | ||||
|  |  | |||
|  | @ -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 | ||||
| 		} | ||||
|  |  | |||
|  | @ -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") | ||||
|  |  | |||
							
								
								
									
										111
									
								
								backend/routes/mod/create_report.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								backend/routes/mod/create_report.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| } | ||||
							
								
								
									
										84
									
								
								backend/routes/mod/get_reports.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								backend/routes/mod/get_reports.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| } | ||||
							
								
								
									
										113
									
								
								backend/routes/mod/resolve_report.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								backend/routes/mod/resolve_report.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
| } | ||||
							
								
								
									
										58
									
								
								backend/routes/mod/routes.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								backend/routes/mod/routes.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -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) | ||||
| } | ||||
							
								
								
									
										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, | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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 @@ | |||
|         </div> | ||||
|       {/each} | ||||
|     </div> | ||||
|     {#if $userStore && $userStore.id !== data.id} | ||||
|       <div class="row"> | ||||
|         <ReportButton subject="user" reportUrl="/users/{data.id}/reports" /> | ||||
|       </div> | ||||
|     {/if} | ||||
|     {#if data.members.length > 0 || ($userStore && $userStore.id === data.id)} | ||||
|       <div class="row"> | ||||
|         <div class="col"> | ||||
|  |  | |||
							
								
								
									
										60
									
								
								frontend/src/routes/@[username]/ReportButton.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								frontend/src/routes/@[username]/ReportButton.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,60 @@ | |||
| <script lang="ts"> | ||||
|   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, Icon, Modal, ModalBody, ModalFooter } from "sveltestrap"; | ||||
| 
 | ||||
|   export let subject: string; | ||||
|   export let reportUrl: string; | ||||
| 
 | ||||
|   const MAX_REASON_LENGTH = 2000; | ||||
|   let reason = ""; | ||||
| 
 | ||||
|   let error: APIError | null = null; | ||||
| 
 | ||||
|   let isOpen = false; | ||||
|   const toggle = () => (isOpen = !isOpen); | ||||
| 
 | ||||
|   const doReport = async () => { | ||||
|     try { | ||||
|       await apiFetchClient<any>(reportUrl, "POST", { reason: reason }); | ||||
|       error = null; | ||||
|       addToast({ header: "Sent report", body: "Successfully sent report!" }); | ||||
|       toggle(); | ||||
|     } catch (e) { | ||||
|       error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <div> | ||||
|   <Button color="danger" outline on:click={toggle} | ||||
|     ><Icon name="exclamation-triangle-fill" /> Report {subject}</Button | ||||
|   > | ||||
| </div> | ||||
| 
 | ||||
| <Modal header="Report {subject}" {isOpen} {toggle}> | ||||
|   <ModalBody> | ||||
|     {#if error} | ||||
|       <ErrorAlert {error} /> | ||||
|     {/if} | ||||
|     <FormGroup floating label="Reason" class="mt-2"> | ||||
|       <textarea style="min-height: 100px;" class="form-control" bind:value={reason} /> | ||||
|     </FormGroup> | ||||
|     <p class="text-muted mt-3"> | ||||
|       <Icon name="info-circle-fill" aria-hidden /> Please only report {subject}s for violating the | ||||
|       <a class="text-reset" href="/page/terms" target="_blank" rel="noopener noreferrer" | ||||
|         >terms of service</a | ||||
|       >. | ||||
|     </p> | ||||
|   </ModalBody> | ||||
|   <ModalFooter> | ||||
|     <Button | ||||
|       color="danger" | ||||
|       disabled={!reason || reason.length > MAX_REASON_LENGTH} | ||||
|       on:click={doReport}>Report</Button | ||||
|     > | ||||
|     <Button color="secondary" on:click={toggle}>Cancel</Button> | ||||
|   </ModalFooter> | ||||
| </Modal> | ||||
|  | @ -10,6 +10,7 @@ | |||
|   import { PUBLIC_BASE_URL } from "$env/static/public"; | ||||
|   import { userStore } from "$lib/store"; | ||||
|   import renderMarkdown from "$lib/api/markdown"; | ||||
|   import ReportButton from "../ReportButton.svelte"; | ||||
| 
 | ||||
|   export let data: PageData; | ||||
| 
 | ||||
|  | @ -86,6 +87,11 @@ | |||
|         </div> | ||||
|       {/each} | ||||
|     </div> | ||||
|     {#if $userStore && $userStore.id !== data.user.id} | ||||
|       <div class="row"> | ||||
|         <ReportButton subject="member" reportUrl="/members/{data.id}/reports" /> | ||||
|       </div> | ||||
|     {/if} | ||||
|   </div> | ||||
| </div> | ||||
| 
 | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ | |||
|   import ErrorAlert from "$lib/components/ErrorAlert.svelte"; | ||||
|   import { userStore } from "$lib/store"; | ||||
|   import { addToast } from "$lib/toast"; | ||||
|   import { DateTime } from "luxon"; | ||||
|   import { onMount } from "svelte"; | ||||
|   import { | ||||
|     Alert, | ||||
|  | @ -26,6 +27,8 @@ | |||
|   export let token: string | undefined; | ||||
|   export let user: MeUser | undefined; | ||||
|   export let deletedAt: string | undefined; | ||||
|   export let selfDelete: boolean | undefined; | ||||
|   export let deleteReason: string | undefined; | ||||
| 
 | ||||
|   onMount(() => { | ||||
|     if (!isDeleted && token && user) { | ||||
|  | @ -136,8 +139,12 @@ | |||
|     </div> | ||||
|     <Button type="submit" color="primary">Sign up</Button> | ||||
|   </form> | ||||
| {:else if isDeleted && token} | ||||
|   <p>Your account is pending deletion since {deletedAt}.</p> | ||||
| {:else if isDeleted && token && selfDelete && deletedAt} | ||||
|   <p> | ||||
|     Your account is pending deletion since {DateTime.fromISO(deletedAt) | ||||
|       .toLocal() | ||||
|       .toLocaleString(DateTime.DATETIME_MED)}. | ||||
|   </p> | ||||
|   <p>If you wish to cancel deletion, press the button below.</p> | ||||
|   <p> | ||||
|     <Button color="primary" on:click={cancelDelete} disabled={deleteCancelled} | ||||
|  | @ -152,34 +159,6 @@ | |||
|   <p> | ||||
|     <Button color="link" on:click={toggleForceDeleteModal}>Force delete account</Button> | ||||
|   </p> | ||||
|   <Modal | ||||
|     header="Force delete account" | ||||
|     isOpen={forceDeleteModalOpen} | ||||
|     toggle={toggleForceDeleteModal} | ||||
|   > | ||||
|     <ModalBody> | ||||
|       <p> | ||||
|         If you want to delete your account, type your username below: | ||||
|         <br /> | ||||
|         <b> | ||||
|           This is irreversible! Your account <i>cannot</i> be recovered after you press "Force delete | ||||
|           account". | ||||
|         </b> | ||||
|       </p> | ||||
|       <p> | ||||
|         <input type="text" class="form-control" bind:value={forceDeleteName} /> | ||||
|       </p> | ||||
|       {#if deleteError} | ||||
|         <ErrorAlert error={deleteError} /> | ||||
|       {/if} | ||||
|     </ModalBody> | ||||
|     <ModalFooter> | ||||
|       <Button color="danger" on:click={forceDeleteAccount} disabled={forceDeleteName !== user?.name} | ||||
|         >Force delete account</Button | ||||
|       > | ||||
|       <Button color="secondary" on:click={toggleForceDeleteModal}>Cancel delete</Button> | ||||
|     </ModalFooter> | ||||
|   </Modal> | ||||
|   {#if deleteCancelled} | ||||
|     <Alert color="secondary" fade={false}> | ||||
|       Account deletion cancelled! You can now <a href="/auth/login">log in</a> again. | ||||
|  | @ -188,6 +167,50 @@ | |||
|   {#if deleteError} | ||||
|     <ErrorAlert error={deleteError} /> | ||||
|   {/if} | ||||
| {:else if isDeleted && token && !selfDelete && deletedAt} | ||||
|   <p> | ||||
|     Your account is pending deletion since {DateTime.fromISO(deletedAt) | ||||
|       .toLocal() | ||||
|       .toLocaleString(DateTime.DATETIME_MED)}. | ||||
|   </p> | ||||
|   <p> | ||||
|     <strong>Your account was deactivated by a moderator.</strong> You cannot cancel deletion. The moderator | ||||
|     gave the following reason: | ||||
|   </p> | ||||
|   <blockquote class="blockquote"> | ||||
|     {deleteReason} | ||||
|   </blockquote> | ||||
|   <p> | ||||
|     Your account will be fully deleted 180 days after being deactivated. If you want your data wiped | ||||
|     immediately instead, press the force delete link below. | ||||
|   </p> | ||||
|   <p> | ||||
|     <Button color="link" on:click={toggleForceDeleteModal}>Force delete account</Button> | ||||
|   </p> | ||||
| {:else} | ||||
|   Loading... | ||||
| {/if} | ||||
| 
 | ||||
| <Modal header="Force delete account" isOpen={forceDeleteModalOpen} toggle={toggleForceDeleteModal}> | ||||
|   <ModalBody> | ||||
|     <p> | ||||
|       If you want to delete your account, type your username (<code>{user?.name}</code>) below: | ||||
|       <br /> | ||||
|       <b> | ||||
|         This is irreversible! Your account <i>cannot</i> be recovered after you press "Force delete account". | ||||
|       </b> | ||||
|     </p> | ||||
|     <p> | ||||
|       <input type="text" class="form-control" bind:value={forceDeleteName} /> | ||||
|     </p> | ||||
|     {#if deleteError} | ||||
|       <ErrorAlert error={deleteError} /> | ||||
|     {/if} | ||||
|   </ModalBody> | ||||
|   <ModalFooter> | ||||
|     <Button color="danger" on:click={forceDeleteAccount} disabled={forceDeleteName !== user?.name} | ||||
|       >Force delete account</Button | ||||
|     > | ||||
|     <Button color="secondary" on:click={toggleForceDeleteModal}>Cancel delete</Button> | ||||
|   </ModalFooter> | ||||
| </Modal> | ||||
|  |  | |||
|  | @ -33,4 +33,6 @@ interface CallbackResponse { | |||
| 
 | ||||
|   is_deleted: boolean; | ||||
|   deleted_at?: string; | ||||
|   self_delete?: boolean; | ||||
|   delete_reason?: string; | ||||
| } | ||||
|  |  | |||
|  | @ -57,6 +57,8 @@ | |||
|   token={data.token} | ||||
|   user={data.user} | ||||
|   deletedAt={data.deleted_at} | ||||
|   selfDelete={data.self_delete} | ||||
|   deleteReason={data.delete_reason} | ||||
|   {linkAccount} | ||||
|   {signupForm} | ||||
| /> | ||||
|  |  | |||
|  | @ -33,4 +33,6 @@ interface CallbackResponse { | |||
| 
 | ||||
|   is_deleted: boolean; | ||||
|   deleted_at?: string; | ||||
|   self_delete?: boolean; | ||||
|   delete_reason?: string; | ||||
| } | ||||
|  |  | |||
|  | @ -59,6 +59,8 @@ | |||
|   token={data.token} | ||||
|   user={data.user} | ||||
|   deletedAt={data.deleted_at} | ||||
|   selfDelete={data.self_delete} | ||||
|   deleteReason={data.delete_reason} | ||||
|   {linkAccount} | ||||
|   {signupForm} | ||||
| /> | ||||
|  |  | |||
|  | @ -1,8 +1,10 @@ | |||
| <script lang="ts"> | ||||
|   import { onMount } from "svelte"; | ||||
|   import { browser } from "$app/environment"; | ||||
|   import { decodeJwt } from "jose"; | ||||
| 
 | ||||
|   import { | ||||
|     Badge, | ||||
|     Collapse, | ||||
|     Icon, | ||||
|     Nav, | ||||
|  | @ -15,13 +17,24 @@ | |||
| 
 | ||||
|   import Logo from "./Logo.svelte"; | ||||
|   import { userStore, themeStore } from "$lib/store"; | ||||
|   import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities"; | ||||
|   import { apiFetch } from "$lib/api/fetch"; | ||||
|   import { | ||||
|     ErrorCode, | ||||
|     type APIError, | ||||
|     type MeUser, | ||||
|     type Report, | ||||
|     type Warning, | ||||
|   } from "$lib/api/entities"; | ||||
|   import { apiFetch, apiFetchClient } from "$lib/api/fetch"; | ||||
|   import { addToast } from "$lib/toast"; | ||||
| 
 | ||||
|   let theme: string; | ||||
|   let currentUser: MeUser | null; | ||||
|   let showMenu: boolean = false; | ||||
| 
 | ||||
|   let isAdmin = false; | ||||
|   let numReports = 0; | ||||
|   let numWarnings = 0; | ||||
| 
 | ||||
|   $: currentUser = $userStore; | ||||
|   $: theme = $themeStore; | ||||
| 
 | ||||
|  | @ -47,6 +60,32 @@ | |||
|             localStorage.removeItem("pronouns-user"); | ||||
|           } | ||||
|         }); | ||||
| 
 | ||||
|       isAdmin = !!decodeJwt(token)["adm"]; | ||||
|       if (isAdmin) { | ||||
|         apiFetchClient<Report[]>("/admin/reports") | ||||
|           .then((reports) => { | ||||
|             numReports = reports.length; | ||||
|           }) | ||||
|           .catch((e) => { | ||||
|             console.log("getting reports:", e); | ||||
|           }); | ||||
|       } | ||||
| 
 | ||||
|       apiFetchClient<Warning[]>("/auth/warnings") | ||||
|         .then((warnings) => { | ||||
|           if (warnings.length !== 0) { | ||||
|             numWarnings = warnings.length; | ||||
|             addToast({ | ||||
|               header: "Warnings", | ||||
|               body: "You have unread warnings. Go to your settings to view them.", | ||||
|               duration: -1, | ||||
|             }); | ||||
|           } | ||||
|         }) | ||||
|         .catch((e) => { | ||||
|           console.log("getting warnings:", e); | ||||
|         }); | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|  | @ -83,8 +122,23 @@ | |||
|           <NavLink href="/@{currentUser.name}">@{currentUser.name}</NavLink> | ||||
|         </NavItem> | ||||
|         <NavItem> | ||||
|           <NavLink href="/settings">Settings</NavLink> | ||||
|           <NavLink href="/settings"> | ||||
|             Settings | ||||
|             {#if numWarnings} | ||||
|               <Badge color="danger">{numWarnings}</Badge> | ||||
|             {/if} | ||||
|           </NavLink> | ||||
|         </NavItem> | ||||
|         {#if isAdmin} | ||||
|           <NavItem> | ||||
|             <NavLink href="/reports"> | ||||
|               Reports | ||||
|               {#if numReports !== 0} | ||||
|                 <Badge color="danger">{numReports}</Badge> | ||||
|               {/if} | ||||
|             </NavLink> | ||||
|           </NavItem> | ||||
|         {/if} | ||||
|       {:else} | ||||
|         <NavItem> | ||||
|           <NavLink href="/auth/login">Log in</NavLink> | ||||
|  |  | |||
							
								
								
									
										7
									
								
								frontend/src/routes/page/privacy/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/routes/page/privacy/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| <svelte:head> | ||||
|   <title>Privacy policy - pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <div> | ||||
|   <h1>Privacy policy</h1> | ||||
| </div> | ||||
							
								
								
									
										1
									
								
								frontend/src/routes/page/privacy/+page.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/routes/page/privacy/+page.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| export const prerender = true; | ||||
							
								
								
									
										7
									
								
								frontend/src/routes/page/terms/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/routes/page/terms/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| <svelte:head> | ||||
|   <title>Terms of service - pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <div> | ||||
|   <h1>Terms of service</h1> | ||||
| </div> | ||||
							
								
								
									
										1
									
								
								frontend/src/routes/page/terms/+page.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/routes/page/terms/+page.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| export const prerender = true; | ||||
							
								
								
									
										1
									
								
								frontend/src/routes/reports/+layout.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/routes/reports/+layout.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| <slot /> | ||||
							
								
								
									
										1
									
								
								frontend/src/routes/reports/+layout.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/routes/reports/+layout.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| export const ssr = false; | ||||
							
								
								
									
										172
									
								
								frontend/src/routes/reports/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								frontend/src/routes/reports/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,172 @@ | |||
| <script lang="ts"> | ||||
|   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 banModalOpen = false; | ||||
|   const toggleBanModal = () => (banModalOpen = !banModalOpen); | ||||
| 
 | ||||
|   let ignoreModalOpen = false; | ||||
|   const toggleIgnoreModal = () => (ignoreModalOpen = !ignoreModalOpen); | ||||
| 
 | ||||
|   let reportIndex = -1; | ||||
|   let reason = ""; | ||||
|   let deleteUser = false; | ||||
|   let error: APIError | null = null; | ||||
| 
 | ||||
|   $: console.log(deleteUser); | ||||
| 
 | ||||
|   const openWarnModalFor = (index: number) => { | ||||
|     reportIndex = index; | ||||
|     toggleWarnModal(); | ||||
|   }; | ||||
| 
 | ||||
|   const openBanModalFor = (index: number) => { | ||||
|     reportIndex = index; | ||||
|     toggleBanModal(); | ||||
|   }; | ||||
| 
 | ||||
|   const openIgnoreModalFor = (index: number) => { | ||||
|     reportIndex = index; | ||||
|     toggleIgnoreModal(); | ||||
|   }; | ||||
| 
 | ||||
|   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; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const deactivateUser = async () => { | ||||
|     try { | ||||
|       await apiFetchClient<any>(`/admin/reports/${data.reports[reportIndex].id}`, "PATCH", { | ||||
|         warn: true, | ||||
|         ban: true, | ||||
|         delete: deleteUser, | ||||
|         reason: reason, | ||||
|       }); | ||||
|       error = null; | ||||
| 
 | ||||
|       addToast({ body: "Successfully deactivated user", header: "Deactivated user" }); | ||||
|       toggleBanModal(); | ||||
|       reportIndex = -1; | ||||
|     } catch (e) { | ||||
|       error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const ignoreReport = async () => { | ||||
|     try { | ||||
|       await apiFetchClient<any>(`/admin/reports/${data.reports[reportIndex].id}`, "PATCH", { | ||||
|         reason: reason, | ||||
|       }); | ||||
|       error = null; | ||||
| 
 | ||||
|       addToast({ body: "Successfully acknowledged report", header: "Ignored report" }); | ||||
|       toggleIgnoreModal(); | ||||
|       reportIndex = -1; | ||||
|     } catch (e) { | ||||
|       error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
|   <title>Reports - pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <div class="container"> | ||||
|   <h1>Reports</h1> | ||||
| 
 | ||||
|   <div> | ||||
|     {#each data.reports as report, index} | ||||
|       <div class="my-2"> | ||||
|         <ReportCard {report}> | ||||
|           • | ||||
|           <Button outline color="warning" size="sm" on:click={() => openWarnModalFor(index)} | ||||
|             >Warn user</Button | ||||
|           > | ||||
|           <Button outline color="danger" size="sm" on:click={() => openBanModalFor(index)} | ||||
|             >Deactivate user</Button | ||||
|           > | ||||
|           <Button outline color="secondary" size="sm" on:click={() => openIgnoreModalFor(index)} | ||||
|             >Ignore report</Button | ||||
|           > | ||||
|         </ReportCard> | ||||
|       </div> | ||||
|     {:else} | ||||
|       There are no open reports :) | ||||
|     {/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> | ||||
| 
 | ||||
|   <Modal header="Deactivate user" isOpen={banModalOpen} toggle={toggleBanModal}> | ||||
|     <ModalBody> | ||||
|       {#if error} | ||||
|         <ErrorAlert {error} /> | ||||
|       {/if} | ||||
|       <ReportCard report={data.reports[reportIndex]} /> | ||||
|       <FormGroup floating label="Reason" class="my-2"> | ||||
|         <textarea style="min-height: 100px;" class="form-control" bind:value={reason} /> | ||||
|       </FormGroup> | ||||
|       <div class="form-check"> | ||||
|         <input class="form-check-input" type="checkbox" bind:checked={deleteUser} id="deleteUser" /> | ||||
|         <label class="form-check-label" for="deleteUser">Delete user?</label> | ||||
|       </div> | ||||
|     </ModalBody> | ||||
|     <ModalFooter> | ||||
|       <Button color="danger" on:click={deactivateUser} disabled={!reason}>Deactivate user</Button> | ||||
|       <Button color="secondary" on:click={toggleBanModal}>Cancel</Button> | ||||
|     </ModalFooter> | ||||
|   </Modal> | ||||
| 
 | ||||
|   <Modal header="Ignore report" isOpen={ignoreModalOpen} toggle={toggleIgnoreModal}> | ||||
|     <ModalBody> | ||||
|       {#if error} | ||||
|         <ErrorAlert {error} /> | ||||
|       {/if} | ||||
|       <ReportCard report={data.reports[reportIndex]} /> | ||||
|       <FormGroup floating label="Reason" class="my-2"> | ||||
|         <textarea style="min-height: 100px;" class="form-control" bind:value={reason} /> | ||||
|       </FormGroup> | ||||
|     </ModalBody> | ||||
|     <ModalFooter> | ||||
|       <Button color="warning" on:click={ignoreReport} disabled={!reason}>Ignore report</Button> | ||||
|       <Button color="secondary" on:click={toggleIgnoreModal}>Cancel</Button> | ||||
|     </ModalFooter> | ||||
|   </Modal> | ||||
| </div> | ||||
							
								
								
									
										23
									
								
								frontend/src/routes/reports/+page.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								frontend/src/routes/reports/+page.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,23 @@ | |||
| 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 () => { | ||||
|   try { | ||||
|     const reports = await apiFetchClient<Report[]>("/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 { | ||||
|   page: number; | ||||
|   isClosed: boolean; | ||||
|   userId: string | null; | ||||
|   reporterId: string | null; | ||||
|   reports: Report[]; | ||||
| } | ||||
							
								
								
									
										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> | ||||
|  | @ -1,7 +1,15 @@ | |||
| <script lang="ts"> | ||||
|   import { page } from "$app/stores"; | ||||
|   import type { LayoutData } from "./$types"; | ||||
|   import { Button, ListGroup, ListGroupItem, Modal, ModalBody, ModalFooter } from "sveltestrap"; | ||||
|   import { | ||||
|     Badge, | ||||
|     Button, | ||||
|     ListGroup, | ||||
|     ListGroupItem, | ||||
|     Modal, | ||||
|     ModalBody, | ||||
|     ModalFooter, | ||||
|   } from "sveltestrap"; | ||||
|   import { userStore } from "$lib/store"; | ||||
|   import { goto } from "$app/navigation"; | ||||
|   import { addToast } from "$lib/toast"; | ||||
|  | @ -20,6 +28,9 @@ | |||
|     addToast({ header: "Logged out", body: "Successfully logged out!" }); | ||||
|     goto("/"); | ||||
|   }; | ||||
| 
 | ||||
|   let unreadWarnings: number; | ||||
|   $: unreadWarnings = data.warnings.filter((w) => !w.read).length; | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
|  | @ -58,6 +69,16 @@ | |||
|         > | ||||
|           Tokens | ||||
|         </ListGroupItem> | ||||
|         <ListGroupItem | ||||
|           tag="a" | ||||
|           active={$page.url.pathname === "/settings/warnings"} | ||||
|           href="/settings/warnings" | ||||
|         > | ||||
|           Warnings | ||||
|           {#if unreadWarnings !== 0} | ||||
|             <Badge color="danger">{unreadWarnings}</Badge> | ||||
|           {/if} | ||||
|         </ListGroupItem> | ||||
|         <ListGroupItem | ||||
|           tag="a" | ||||
|           active={$page.url.pathname === "/settings/export"} | ||||
|  |  | |||
|  | @ -1,4 +1,10 @@ | |||
| import { ErrorCode, type APIError, type Invite, type MeUser } from "$lib/api/entities"; | ||||
| import { | ||||
|   ErrorCode, | ||||
|   type Warning, | ||||
|   type APIError, | ||||
|   type Invite, | ||||
|   type MeUser, | ||||
| } from "$lib/api/entities"; | ||||
| import { apiFetchClient } from "$lib/api/fetch"; | ||||
| import type { LayoutLoad } from "./$types"; | ||||
| 
 | ||||
|  | @ -6,6 +12,7 @@ export const ssr = false; | |||
| 
 | ||||
| export const load = (async ({ parent }) => { | ||||
|   const user = await apiFetchClient<MeUser>("/users/@me"); | ||||
|   const warnings = await apiFetchClient<Warning[]>("/auth/warnings?all=true"); | ||||
| 
 | ||||
|   let invites: Invite[] = []; | ||||
|   let invitesEnabled = true; | ||||
|  | @ -24,5 +31,6 @@ export const load = (async ({ parent }) => { | |||
|     user, | ||||
|     invites, | ||||
|     invitesEnabled, | ||||
|     warnings, | ||||
|   }; | ||||
| }) satisfies LayoutLoad; | ||||
|  |  | |||
							
								
								
									
										56
									
								
								frontend/src/routes/settings/warnings/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								frontend/src/routes/settings/warnings/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,56 @@ | |||
| <script lang="ts"> | ||||
|   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 { DateTime } from "luxon"; | ||||
|   import { Button, Card, CardBody, CardFooter, CardHeader } from "sveltestrap"; | ||||
|   import type { PageData } from "./$types"; | ||||
| 
 | ||||
|   export let data: PageData; | ||||
|   let error: APIError | null = null; | ||||
| 
 | ||||
|   const acknowledgeWarning = async (idx: number) => { | ||||
|     try { | ||||
|       await apiFetchClient<any>(`/auth/warnings/${data.warnings[idx].id}/ack`, "POST"); | ||||
|       addToast({ | ||||
|         header: "Acknowledged", | ||||
|         body: `Marked warning #${data.warnings[idx].id} as read.`, | ||||
|       }); | ||||
|       data.warnings[idx].read = true; | ||||
|       data.warnings = data.warnings; | ||||
|     } catch (e) { | ||||
|       error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <h1>Warnings ({data.warnings.length})</h1> | ||||
| 
 | ||||
| {#if error} | ||||
|   <ErrorAlert {error} /> | ||||
| {/if} | ||||
| 
 | ||||
| <div> | ||||
|   {#each data.warnings as warning, index} | ||||
|     <Card class="my-2"> | ||||
|       <CardHeader> | ||||
|         <strong>#{warning.id}</strong> ({DateTime.fromISO(warning.created_at) | ||||
|           .toLocal() | ||||
|           .toLocaleString(DateTime.DATETIME_MED)}) | ||||
|       </CardHeader> | ||||
|       <CardBody> | ||||
|         <blockquote class="blockquote">{warning.reason}</blockquote> | ||||
|       </CardBody> | ||||
|       {#if !warning.read} | ||||
|         <CardFooter> | ||||
|           <Button color="secondary" outline on:click={() => acknowledgeWarning(index)} | ||||
|             >Mark as read</Button | ||||
|           > | ||||
|         </CardFooter> | ||||
|       {/if} | ||||
|     </Card> | ||||
|   {:else} | ||||
|     You have no warnings! | ||||
|   {/each} | ||||
| </div> | ||||
							
								
								
									
										27
									
								
								scripts/migrate/010_reports.sql
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								scripts/migrate/010_reports.sql
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| -- +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 | ||||
| ); | ||||
| 
 | ||||
| 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