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 ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"crypto/sha256" | ||||||
|  | 	"encoding/hex" | ||||||
| 	"regexp" | 	"regexp" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
|  | @ -34,6 +36,7 @@ type User struct { | ||||||
| 	FediverseInstance *string | 	FediverseInstance *string | ||||||
| 
 | 
 | ||||||
| 	MaxInvites int | 	MaxInvites int | ||||||
|  | 	IsAdmin    bool | ||||||
| 
 | 
 | ||||||
| 	DeletedAt    *time.Time | 	DeletedAt    *time.Time | ||||||
| 	SelfDelete   *bool | 	SelfDelete   *bool | ||||||
|  | @ -416,3 +419,93 @@ func (db *DB) ForceDeleteUser(ctx context.Context, id xid.ID) error { | ||||||
| 	} | 	} | ||||||
| 	return nil | 	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/bot" | ||||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/routes/member" | 	"codeberg.org/u1f320/pronouns.cc/backend/routes/member" | ||||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/routes/meta" | 	"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/routes/user" | ||||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/server" | 	"codeberg.org/u1f320/pronouns.cc/backend/server" | ||||||
| 	"github.com/go-chi/chi/v5" | 	"github.com/go-chi/chi/v5" | ||||||
|  | @ -20,5 +21,6 @@ func mountRoutes(s *server.Server) { | ||||||
| 		member.Mount(s, r) | 		member.Mount(s, r) | ||||||
| 		bot.Mount(s, r) | 		bot.Mount(s, r) | ||||||
| 		meta.Mount(s, r) | 		meta.Mount(s, r) | ||||||
|  | 		mod.Mount(s, r) | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -43,8 +43,10 @@ type discordCallbackResponse struct { | ||||||
| 	Ticket        string `json:"ticket,omitempty"` | 	Ticket        string `json:"ticket,omitempty"` | ||||||
| 	RequireInvite bool   `json:"require_invite"` // require an invite for signing up | 	RequireInvite bool   `json:"require_invite"` // require an invite for signing up | ||||||
| 
 | 
 | ||||||
| 	IsDeleted bool       `json:"is_deleted"` | 	IsDeleted    bool       `json:"is_deleted"` | ||||||
| 	DeletedAt *time.Time `json:"deleted_at,omitempty"` | 	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 { | 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) | 	u, err := s.DB.DiscordUser(ctx, du.ID) | ||||||
| 	if err == nil { | 	if err == nil { | ||||||
| 		if u.DeletedAt != nil && *u.SelfDelete { | 		if u.DeletedAt != nil { | ||||||
| 			// store cancel delete token | 			// store cancel delete token | ||||||
| 			token := undeleteToken() | 			token := undeleteToken() | ||||||
| 			err = s.saveUndeleteToken(ctx, u.ID, token) | 			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{ | 			render.JSON(w, r, discordCallbackResponse{ | ||||||
| 				HasAccount: true, | 				HasAccount:   true, | ||||||
| 				Token:      token, | 				Token:        token, | ||||||
| 				User:       dbUserToUserResponse(u, []db.Field{}), | 				User:         dbUserToUserResponse(u, []db.Field{}), | ||||||
| 				IsDeleted:  true, | 				IsDeleted:    true, | ||||||
| 				DeletedAt:  u.DeletedAt, | 				DeletedAt:    u.DeletedAt, | ||||||
|  | 				SelfDelete:   u.SelfDelete, | ||||||
|  | 				DeleteReason: u.DeleteReason, | ||||||
| 			}) | 			}) | ||||||
| 			return nil | 			return nil | ||||||
| 		} | 		} | ||||||
|  | @ -107,7 +111,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { | ||||||
| 
 | 
 | ||||||
| 		// TODO: implement user + token permissions | 		// TODO: implement user + token permissions | ||||||
| 		tokenID := xid.New() | 		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 { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -31,8 +31,10 @@ type fediCallbackResponse struct { | ||||||
| 	Ticket        string `json:"ticket,omitempty"` | 	Ticket        string `json:"ticket,omitempty"` | ||||||
| 	RequireInvite bool   `json:"require_invite"` // require an invite for signing up | 	RequireInvite bool   `json:"require_invite"` // require an invite for signing up | ||||||
| 
 | 
 | ||||||
| 	IsDeleted bool       `json:"is_deleted"` | 	IsDeleted    bool       `json:"is_deleted"` | ||||||
| 	DeletedAt *time.Time `json:"deleted_at,omitempty"` | 	DeletedAt    *time.Time `json:"deleted_at,omitempty"` | ||||||
|  | 	SelfDelete   *bool      `json:"self_delete,omitempty"` | ||||||
|  | 	DeleteReason *string    `json:"delete_reason,omitempty"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type partialMastodonAccount struct { | 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) | 	u, err := s.DB.FediverseUser(ctx, mu.ID, app.ID) | ||||||
| 	if err == nil { | 	if err == nil { | ||||||
| 		if u.DeletedAt != nil && *u.SelfDelete { | 		if u.DeletedAt != nil { | ||||||
| 			// store cancel delete token | 			// store cancel delete token | ||||||
| 			token := undeleteToken() | 			token := undeleteToken() | ||||||
| 			err = s.saveUndeleteToken(ctx, u.ID, token) | 			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{ | 			render.JSON(w, r, fediCallbackResponse{ | ||||||
| 				HasAccount: true, | 				HasAccount:   true, | ||||||
| 				Token:      token, | 				Token:        token, | ||||||
| 				User:       dbUserToUserResponse(u, []db.Field{}), | 				User:         dbUserToUserResponse(u, []db.Field{}), | ||||||
| 				IsDeleted:  true, | 				IsDeleted:    true, | ||||||
| 				DeletedAt:  u.DeletedAt, | 				DeletedAt:    u.DeletedAt, | ||||||
|  | 				SelfDelete:   u.SelfDelete, | ||||||
|  | 				DeleteReason: u.DeleteReason, | ||||||
| 			}) | 			}) | ||||||
| 			return nil | 			return nil | ||||||
| 		} | 		} | ||||||
|  | @ -128,7 +132,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error | ||||||
| 
 | 
 | ||||||
| 		// TODO: implement user + token permissions | 		// TODO: implement user + token permissions | ||||||
| 		tokenID := xid.New() | 		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 { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -6,11 +6,9 @@ import ( | ||||||
| 	"encoding/base64" | 	"encoding/base64" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 
 | 
 | ||||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/db" |  | ||||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/log" | 	"codeberg.org/u1f320/pronouns.cc/backend/log" | ||||||
| 	"codeberg.org/u1f320/pronouns.cc/backend/server" | 	"codeberg.org/u1f320/pronouns.cc/backend/server" | ||||||
| 	"emperror.dev/errors" | 	"emperror.dev/errors" | ||||||
| 	"github.com/georgysavva/scany/pgxscan" |  | ||||||
| 	"github.com/go-chi/render" | 	"github.com/go-chi/render" | ||||||
| 	"github.com/mediocregopher/radix/v4" | 	"github.com/mediocregopher/radix/v4" | ||||||
| 	"github.com/rs/xid" | 	"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 | 		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) | 	err = s.DB.UndoDeleteUser(ctx, id) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Errorf("executing undelete query: %v", err) | 		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 | 		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 { | 	if err != nil { | ||||||
| 		log.Errorf("getting user: %v", err) | 		log.Errorf("cleaning user data: %v", err) | ||||||
| 		return errors.Wrap(err, "getting user") | 		return errors.Wrap(err, "cleaning user") | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if u.Avatar != nil { | 	err = s.DB.ForceDeleteUser(ctx, id) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Errorf("force deleting user: %v", err) | 		log.Errorf("force deleting user: %v", err) | ||||||
| 		return errors.Wrap(err, "deleting user") | 		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 { | type GetMeResponse struct { | ||||||
| 	GetUserResponse | 	GetUserResponse | ||||||
| 
 | 
 | ||||||
| 	MaxInvites      int     `json:"max_invites"` | 	MaxInvites int  `json:"max_invites"` | ||||||
|  | 	IsAdmin    bool `json:"is_admin"` | ||||||
|  | 
 | ||||||
| 	Discord         *string `json:"discord"` | 	Discord         *string `json:"discord"` | ||||||
| 	DiscordUsername *string `json:"discord_username"` | 	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{ | 	render.JSON(w, r, GetMeResponse{ | ||||||
| 		GetUserResponse:   dbUserToResponse(u, fields, members), | 		GetUserResponse:   dbUserToResponse(u, fields, members), | ||||||
| 		MaxInvites:        u.MaxInvites, | 		MaxInvites:        u.MaxInvites, | ||||||
|  | 		IsAdmin:           u.IsAdmin, | ||||||
| 		Discord:           u.Discord, | 		Discord:           u.Discord, | ||||||
| 		DiscordUsername:   u.DiscordUsername, | 		DiscordUsername:   u.DiscordUsername, | ||||||
| 		Fediverse:         u.Fediverse, | 		Fediverse:         u.Fediverse, | ||||||
|  |  | ||||||
|  | @ -232,6 +232,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { | ||||||
| 	render.JSON(w, r, GetMeResponse{ | 	render.JSON(w, r, GetMeResponse{ | ||||||
| 		GetUserResponse:   dbUserToResponse(u, fields, nil), | 		GetUserResponse:   dbUserToResponse(u, fields, nil), | ||||||
| 		MaxInvites:        u.MaxInvites, | 		MaxInvites:        u.MaxInvites, | ||||||
|  | 		IsAdmin:           u.IsAdmin, | ||||||
| 		Discord:           u.Discord, | 		Discord:           u.Discord, | ||||||
| 		DiscordUsername:   u.DiscordUsername, | 		DiscordUsername:   u.DiscordUsername, | ||||||
| 		Fediverse:         u.Fediverse, | 		Fediverse:         u.Fediverse, | ||||||
|  |  | ||||||
|  | @ -110,6 +110,10 @@ const ( | ||||||
| 	// General request error codes | 	// General request error codes | ||||||
| 	ErrRequestTooBig      = 4001 | 	ErrRequestTooBig      = 4001 | ||||||
| 	ErrMissingPermissions = 4002 | 	ErrMissingPermissions = 4002 | ||||||
|  | 
 | ||||||
|  | 	// Moderation related error codes | ||||||
|  | 	ErrReportAlreadyHandled = 5001 | ||||||
|  | 	ErrNotSelfDelete        = 5002 | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| var errCodeMessages = map[int]string{ | var errCodeMessages = map[int]string{ | ||||||
|  | @ -146,6 +150,9 @@ var errCodeMessages = map[int]string{ | ||||||
| 
 | 
 | ||||||
| 	ErrRequestTooBig:      "Request too big (max 2 MB)", | 	ErrRequestTooBig:      "Request too big (max 2 MB)", | ||||||
| 	ErrMissingPermissions: "Your account or current token is missing required permissions for this action", | 	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{ | var errCodeStatuses = map[int]int{ | ||||||
|  | @ -182,4 +189,7 @@ var errCodeStatuses = map[int]int{ | ||||||
| 
 | 
 | ||||||
| 	ErrRequestTooBig:      http.StatusBadRequest, | 	ErrRequestTooBig:      http.StatusBadRequest, | ||||||
| 	ErrMissingPermissions: http.StatusForbidden, | 	ErrMissingPermissions: http.StatusForbidden, | ||||||
|  | 
 | ||||||
|  | 	ErrReportAlreadyHandled: http.StatusBadRequest, | ||||||
|  | 	ErrNotSelfDelete:        http.StatusForbidden, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -81,6 +81,28 @@ export interface Invite { | ||||||
|   used: boolean; |   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 { | export interface APIError { | ||||||
|   code: ErrorCode; |   code: ErrorCode; | ||||||
|   message?: string; |   message?: string; | ||||||
|  |  | ||||||
|  | @ -31,6 +31,7 @@ | ||||||
|   import ErrorAlert from "$lib/components/ErrorAlert.svelte"; |   import ErrorAlert from "$lib/components/ErrorAlert.svelte"; | ||||||
|   import { goto } from "$app/navigation"; |   import { goto } from "$app/navigation"; | ||||||
|   import renderMarkdown from "$lib/api/markdown"; |   import renderMarkdown from "$lib/api/markdown"; | ||||||
|  |   import ReportButton from "./ReportButton.svelte"; | ||||||
| 
 | 
 | ||||||
|   export let data: PageData; |   export let data: PageData; | ||||||
| 
 | 
 | ||||||
|  | @ -149,6 +150,11 @@ | ||||||
|         </div> |         </div> | ||||||
|       {/each} |       {/each} | ||||||
|     </div> |     </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)} |     {#if data.members.length > 0 || ($userStore && $userStore.id === data.id)} | ||||||
|       <div class="row"> |       <div class="row"> | ||||||
|         <div class="col"> |         <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 { PUBLIC_BASE_URL } from "$env/static/public"; | ||||||
|   import { userStore } from "$lib/store"; |   import { userStore } from "$lib/store"; | ||||||
|   import renderMarkdown from "$lib/api/markdown"; |   import renderMarkdown from "$lib/api/markdown"; | ||||||
|  |   import ReportButton from "../ReportButton.svelte"; | ||||||
| 
 | 
 | ||||||
|   export let data: PageData; |   export let data: PageData; | ||||||
| 
 | 
 | ||||||
|  | @ -86,6 +87,11 @@ | ||||||
|         </div> |         </div> | ||||||
|       {/each} |       {/each} | ||||||
|     </div> |     </div> | ||||||
|  |     {#if $userStore && $userStore.id !== data.user.id} | ||||||
|  |       <div class="row"> | ||||||
|  |         <ReportButton subject="member" reportUrl="/members/{data.id}/reports" /> | ||||||
|  |       </div> | ||||||
|  |     {/if} | ||||||
|   </div> |   </div> | ||||||
| </div> | </div> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ | ||||||
|   import ErrorAlert from "$lib/components/ErrorAlert.svelte"; |   import ErrorAlert from "$lib/components/ErrorAlert.svelte"; | ||||||
|   import { userStore } from "$lib/store"; |   import { userStore } from "$lib/store"; | ||||||
|   import { addToast } from "$lib/toast"; |   import { addToast } from "$lib/toast"; | ||||||
|  |   import { DateTime } from "luxon"; | ||||||
|   import { onMount } from "svelte"; |   import { onMount } from "svelte"; | ||||||
|   import { |   import { | ||||||
|     Alert, |     Alert, | ||||||
|  | @ -26,6 +27,8 @@ | ||||||
|   export let token: string | undefined; |   export let token: string | undefined; | ||||||
|   export let user: MeUser | undefined; |   export let user: MeUser | undefined; | ||||||
|   export let deletedAt: string | undefined; |   export let deletedAt: string | undefined; | ||||||
|  |   export let selfDelete: boolean | undefined; | ||||||
|  |   export let deleteReason: string | undefined; | ||||||
| 
 | 
 | ||||||
|   onMount(() => { |   onMount(() => { | ||||||
|     if (!isDeleted && token && user) { |     if (!isDeleted && token && user) { | ||||||
|  | @ -136,8 +139,12 @@ | ||||||
|     </div> |     </div> | ||||||
|     <Button type="submit" color="primary">Sign up</Button> |     <Button type="submit" color="primary">Sign up</Button> | ||||||
|   </form> |   </form> | ||||||
| {:else if isDeleted && token} | {:else if isDeleted && token && selfDelete && deletedAt} | ||||||
|   <p>Your account is pending deletion since {deletedAt}.</p> |   <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>If you wish to cancel deletion, press the button below.</p> | ||||||
|   <p> |   <p> | ||||||
|     <Button color="primary" on:click={cancelDelete} disabled={deleteCancelled} |     <Button color="primary" on:click={cancelDelete} disabled={deleteCancelled} | ||||||
|  | @ -152,34 +159,6 @@ | ||||||
|   <p> |   <p> | ||||||
|     <Button color="link" on:click={toggleForceDeleteModal}>Force delete account</Button> |     <Button color="link" on:click={toggleForceDeleteModal}>Force delete account</Button> | ||||||
|   </p> |   </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} |   {#if deleteCancelled} | ||||||
|     <Alert color="secondary" fade={false}> |     <Alert color="secondary" fade={false}> | ||||||
|       Account deletion cancelled! You can now <a href="/auth/login">log in</a> again. |       Account deletion cancelled! You can now <a href="/auth/login">log in</a> again. | ||||||
|  | @ -188,6 +167,50 @@ | ||||||
|   {#if deleteError} |   {#if deleteError} | ||||||
|     <ErrorAlert error={deleteError} /> |     <ErrorAlert error={deleteError} /> | ||||||
|   {/if} |   {/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} | {:else} | ||||||
|   Loading... |   Loading... | ||||||
| {/if} | {/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; |   is_deleted: boolean; | ||||||
|   deleted_at?: string; |   deleted_at?: string; | ||||||
|  |   self_delete?: boolean; | ||||||
|  |   delete_reason?: string; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -57,6 +57,8 @@ | ||||||
|   token={data.token} |   token={data.token} | ||||||
|   user={data.user} |   user={data.user} | ||||||
|   deletedAt={data.deleted_at} |   deletedAt={data.deleted_at} | ||||||
|  |   selfDelete={data.self_delete} | ||||||
|  |   deleteReason={data.delete_reason} | ||||||
|   {linkAccount} |   {linkAccount} | ||||||
|   {signupForm} |   {signupForm} | ||||||
| /> | /> | ||||||
|  |  | ||||||
|  | @ -33,4 +33,6 @@ interface CallbackResponse { | ||||||
| 
 | 
 | ||||||
|   is_deleted: boolean; |   is_deleted: boolean; | ||||||
|   deleted_at?: string; |   deleted_at?: string; | ||||||
|  |   self_delete?: boolean; | ||||||
|  |   delete_reason?: string; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -59,6 +59,8 @@ | ||||||
|   token={data.token} |   token={data.token} | ||||||
|   user={data.user} |   user={data.user} | ||||||
|   deletedAt={data.deleted_at} |   deletedAt={data.deleted_at} | ||||||
|  |   selfDelete={data.self_delete} | ||||||
|  |   deleteReason={data.delete_reason} | ||||||
|   {linkAccount} |   {linkAccount} | ||||||
|   {signupForm} |   {signupForm} | ||||||
| /> | /> | ||||||
|  |  | ||||||
|  | @ -1,8 +1,10 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|   import { onMount } from "svelte"; |   import { onMount } from "svelte"; | ||||||
|   import { browser } from "$app/environment"; |   import { browser } from "$app/environment"; | ||||||
|  |   import { decodeJwt } from "jose"; | ||||||
| 
 | 
 | ||||||
|   import { |   import { | ||||||
|  |     Badge, | ||||||
|     Collapse, |     Collapse, | ||||||
|     Icon, |     Icon, | ||||||
|     Nav, |     Nav, | ||||||
|  | @ -15,13 +17,24 @@ | ||||||
| 
 | 
 | ||||||
|   import Logo from "./Logo.svelte"; |   import Logo from "./Logo.svelte"; | ||||||
|   import { userStore, themeStore } from "$lib/store"; |   import { userStore, themeStore } from "$lib/store"; | ||||||
|   import { ErrorCode, type APIError, type MeUser } from "$lib/api/entities"; |   import { | ||||||
|   import { apiFetch } from "$lib/api/fetch"; |     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 theme: string; | ||||||
|   let currentUser: MeUser | null; |   let currentUser: MeUser | null; | ||||||
|   let showMenu: boolean = false; |   let showMenu: boolean = false; | ||||||
| 
 | 
 | ||||||
|  |   let isAdmin = false; | ||||||
|  |   let numReports = 0; | ||||||
|  |   let numWarnings = 0; | ||||||
|  | 
 | ||||||
|   $: currentUser = $userStore; |   $: currentUser = $userStore; | ||||||
|   $: theme = $themeStore; |   $: theme = $themeStore; | ||||||
| 
 | 
 | ||||||
|  | @ -47,6 +60,32 @@ | ||||||
|             localStorage.removeItem("pronouns-user"); |             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> |           <NavLink href="/@{currentUser.name}">@{currentUser.name}</NavLink> | ||||||
|         </NavItem> |         </NavItem> | ||||||
|         <NavItem> |         <NavItem> | ||||||
|           <NavLink href="/settings">Settings</NavLink> |           <NavLink href="/settings"> | ||||||
|  |             Settings | ||||||
|  |             {#if numWarnings} | ||||||
|  |               <Badge color="danger">{numWarnings}</Badge> | ||||||
|  |             {/if} | ||||||
|  |           </NavLink> | ||||||
|         </NavItem> |         </NavItem> | ||||||
|  |         {#if isAdmin} | ||||||
|  |           <NavItem> | ||||||
|  |             <NavLink href="/reports"> | ||||||
|  |               Reports | ||||||
|  |               {#if numReports !== 0} | ||||||
|  |                 <Badge color="danger">{numReports}</Badge> | ||||||
|  |               {/if} | ||||||
|  |             </NavLink> | ||||||
|  |           </NavItem> | ||||||
|  |         {/if} | ||||||
|       {:else} |       {:else} | ||||||
|         <NavItem> |         <NavItem> | ||||||
|           <NavLink href="/auth/login">Log in</NavLink> |           <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"> | <script lang="ts"> | ||||||
|   import { page } from "$app/stores"; |   import { page } from "$app/stores"; | ||||||
|   import type { LayoutData } from "./$types"; |   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 { userStore } from "$lib/store"; | ||||||
|   import { goto } from "$app/navigation"; |   import { goto } from "$app/navigation"; | ||||||
|   import { addToast } from "$lib/toast"; |   import { addToast } from "$lib/toast"; | ||||||
|  | @ -20,6 +28,9 @@ | ||||||
|     addToast({ header: "Logged out", body: "Successfully logged out!" }); |     addToast({ header: "Logged out", body: "Successfully logged out!" }); | ||||||
|     goto("/"); |     goto("/"); | ||||||
|   }; |   }; | ||||||
|  | 
 | ||||||
|  |   let unreadWarnings: number; | ||||||
|  |   $: unreadWarnings = data.warnings.filter((w) => !w.read).length; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <svelte:head> | <svelte:head> | ||||||
|  | @ -58,6 +69,16 @@ | ||||||
|         > |         > | ||||||
|           Tokens |           Tokens | ||||||
|         </ListGroupItem> |         </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 |         <ListGroupItem | ||||||
|           tag="a" |           tag="a" | ||||||
|           active={$page.url.pathname === "/settings/export"} |           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 { apiFetchClient } from "$lib/api/fetch"; | ||||||
| import type { LayoutLoad } from "./$types"; | import type { LayoutLoad } from "./$types"; | ||||||
| 
 | 
 | ||||||
|  | @ -6,6 +12,7 @@ export const ssr = false; | ||||||
| 
 | 
 | ||||||
| export const load = (async ({ parent }) => { | export const load = (async ({ parent }) => { | ||||||
|   const user = await apiFetchClient<MeUser>("/users/@me"); |   const user = await apiFetchClient<MeUser>("/users/@me"); | ||||||
|  |   const warnings = await apiFetchClient<Warning[]>("/auth/warnings?all=true"); | ||||||
| 
 | 
 | ||||||
|   let invites: Invite[] = []; |   let invites: Invite[] = []; | ||||||
|   let invitesEnabled = true; |   let invitesEnabled = true; | ||||||
|  | @ -24,5 +31,6 @@ export const load = (async ({ parent }) => { | ||||||
|     user, |     user, | ||||||
|     invites, |     invites, | ||||||
|     invitesEnabled, |     invitesEnabled, | ||||||
|  |     warnings, | ||||||
|   }; |   }; | ||||||
| }) satisfies LayoutLoad; | }) 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