diff --git a/backend/db/queries/queries.user.sql.go b/backend/db/queries/queries.user.sql.go index 5276e83..8d983e2 100644 --- a/backend/db/queries/queries.user.sql.go +++ b/backend/db/queries/queries.user.sql.go @@ -5,23 +5,27 @@ package queries import ( "context" "fmt" + "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" ) const getUserByIDSQL = `SELECT * FROM users WHERE id = $1;` type GetUserByIDRow struct { - ID string `json:"id"` - Username string `json:"username"` - DisplayName *string `json:"display_name"` - Bio *string `json:"bio"` - AvatarUrls []string `json:"avatar_urls"` - Links []string `json:"links"` - Discord *string `json:"discord"` - DiscordUsername *string `json:"discord_username"` - MaxInvites int32 `json:"max_invites"` - Names []FieldEntry `json:"names"` - Pronouns []PronounEntry `json:"pronouns"` + ID string `json:"id"` + Username string `json:"username"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + AvatarUrls []string `json:"avatar_urls"` + Links []string `json:"links"` + Discord *string `json:"discord"` + DiscordUsername *string `json:"discord_username"` + MaxInvites int32 `json:"max_invites"` + Names []FieldEntry `json:"names"` + Pronouns []PronounEntry `json:"pronouns"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + SelfDelete *bool `json:"self_delete"` + DeleteReason *string `json:"delete_reason"` } // GetUserByID implements Querier.GetUserByID. @@ -31,7 +35,7 @@ func (q *DBQuerier) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, var item GetUserByIDRow namesArray := q.types.newFieldEntryArray() pronounsArray := q.types.newPronounEntryArray() - if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil { + if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil { return item, fmt.Errorf("query GetUserByID: %w", err) } if err := namesArray.AssignTo(&item.Names); err != nil { @@ -54,7 +58,7 @@ func (q *DBQuerier) GetUserByIDScan(results pgx.BatchResults) (GetUserByIDRow, e var item GetUserByIDRow namesArray := q.types.newFieldEntryArray() pronounsArray := q.types.newPronounEntryArray() - if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil { + if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil { return item, fmt.Errorf("scan GetUserByIDBatch row: %w", err) } if err := namesArray.AssignTo(&item.Names); err != nil { @@ -69,17 +73,20 @@ func (q *DBQuerier) GetUserByIDScan(results pgx.BatchResults) (GetUserByIDRow, e const getUserByUsernameSQL = `SELECT * FROM users WHERE username = $1;` type GetUserByUsernameRow struct { - ID string `json:"id"` - Username string `json:"username"` - DisplayName *string `json:"display_name"` - Bio *string `json:"bio"` - AvatarUrls []string `json:"avatar_urls"` - Links []string `json:"links"` - Discord *string `json:"discord"` - DiscordUsername *string `json:"discord_username"` - MaxInvites int32 `json:"max_invites"` - Names []FieldEntry `json:"names"` - Pronouns []PronounEntry `json:"pronouns"` + ID string `json:"id"` + Username string `json:"username"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + AvatarUrls []string `json:"avatar_urls"` + Links []string `json:"links"` + Discord *string `json:"discord"` + DiscordUsername *string `json:"discord_username"` + MaxInvites int32 `json:"max_invites"` + Names []FieldEntry `json:"names"` + Pronouns []PronounEntry `json:"pronouns"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + SelfDelete *bool `json:"self_delete"` + DeleteReason *string `json:"delete_reason"` } // GetUserByUsername implements Querier.GetUserByUsername. @@ -89,7 +96,7 @@ func (q *DBQuerier) GetUserByUsername(ctx context.Context, username string) (Get var item GetUserByUsernameRow namesArray := q.types.newFieldEntryArray() pronounsArray := q.types.newPronounEntryArray() - if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil { + if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil { return item, fmt.Errorf("query GetUserByUsername: %w", err) } if err := namesArray.AssignTo(&item.Names); err != nil { @@ -112,7 +119,7 @@ func (q *DBQuerier) GetUserByUsernameScan(results pgx.BatchResults) (GetUserByUs var item GetUserByUsernameRow namesArray := q.types.newFieldEntryArray() pronounsArray := q.types.newPronounEntryArray() - if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil { + if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil { return item, fmt.Errorf("scan GetUserByUsernameBatch row: %w", err) } if err := namesArray.AssignTo(&item.Names); err != nil { @@ -137,17 +144,20 @@ type UpdateUserNamesPronounsParams struct { } type UpdateUserNamesPronounsRow struct { - ID string `json:"id"` - Username string `json:"username"` - DisplayName *string `json:"display_name"` - Bio *string `json:"bio"` - AvatarUrls []string `json:"avatar_urls"` - Links []string `json:"links"` - Discord *string `json:"discord"` - DiscordUsername *string `json:"discord_username"` - MaxInvites int32 `json:"max_invites"` - Names []FieldEntry `json:"names"` - Pronouns []PronounEntry `json:"pronouns"` + ID string `json:"id"` + Username string `json:"username"` + DisplayName *string `json:"display_name"` + Bio *string `json:"bio"` + AvatarUrls []string `json:"avatar_urls"` + Links []string `json:"links"` + Discord *string `json:"discord"` + DiscordUsername *string `json:"discord_username"` + MaxInvites int32 `json:"max_invites"` + Names []FieldEntry `json:"names"` + Pronouns []PronounEntry `json:"pronouns"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + SelfDelete *bool `json:"self_delete"` + DeleteReason *string `json:"delete_reason"` } // UpdateUserNamesPronouns implements Querier.UpdateUserNamesPronouns. @@ -157,7 +167,7 @@ func (q *DBQuerier) UpdateUserNamesPronouns(ctx context.Context, params UpdateUs var item UpdateUserNamesPronounsRow namesArray := q.types.newFieldEntryArray() pronounsArray := q.types.newPronounEntryArray() - if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil { + if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil { return item, fmt.Errorf("query UpdateUserNamesPronouns: %w", err) } if err := namesArray.AssignTo(&item.Names); err != nil { @@ -180,7 +190,7 @@ func (q *DBQuerier) UpdateUserNamesPronounsScan(results pgx.BatchResults) (Updat var item UpdateUserNamesPronounsRow namesArray := q.types.newFieldEntryArray() pronounsArray := q.types.newPronounEntryArray() - if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray); err != nil { + if err := row.Scan(&item.ID, &item.Username, &item.DisplayName, &item.Bio, &item.AvatarUrls, &item.Links, &item.Discord, &item.DiscordUsername, &item.MaxInvites, namesArray, pronounsArray, &item.DeletedAt, &item.SelfDelete, &item.DeleteReason); err != nil { return item, fmt.Errorf("scan UpdateUserNamesPronounsBatch row: %w", err) } if err := namesArray.AssignTo(&item.Names); err != nil { diff --git a/backend/db/tokens.go b/backend/db/tokens.go index 6e07367..3a1ddd5 100644 --- a/backend/db/tokens.go +++ b/backend/db/tokens.go @@ -96,3 +96,16 @@ func (db *DB) InvalidateToken(ctx context.Context, userID xid.ID, tokenID xid.ID } return t, nil } + +func (db *DB) InvalidateAllTokens(ctx context.Context, q querier, userID xid.ID) error { + sql, args, err := sq.Update("tokens").Where("user_id = ?", userID).Set("invalidated", true).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = q.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} diff --git a/backend/db/user.go b/backend/db/user.go index 506806f..8aae9f4 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -3,11 +3,13 @@ package db import ( "context" "regexp" + "time" "codeberg.org/u1f320/pronouns.cc/backend/db/queries" "emperror.dev/errors" "github.com/bwmarrin/discordgo" "github.com/jackc/pgconn" + "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/rs/xid" ) @@ -28,6 +30,10 @@ type User struct { DiscordUsername *string MaxInvites int + + DeletedAt *time.Time + SelfDelete *bool + DeleteReason *string } // usernames must match this regex @@ -134,6 +140,11 @@ func (db *DB) getUser(ctx context.Context, q querier, id xid.ID) (u User, err er return u, errors.Wrap(err, "getting user from database") } + var deletedAt *time.Time + if qu.DeletedAt.Status == pgtype.Present { + deletedAt = &qu.DeletedAt.Time + } + u = User{ ID: id, Username: qu.Username, @@ -146,6 +157,9 @@ func (db *DB) getUser(ctx context.Context, q querier, id xid.ID) (u User, err er Discord: qu.Discord, DiscordUsername: qu.DiscordUsername, MaxInvites: int(qu.MaxInvites), + DeletedAt: deletedAt, + SelfDelete: qu.SelfDelete, + DeleteReason: qu.DeleteReason, } return u, nil @@ -283,3 +297,20 @@ func (db *DB) UpdateUser( } return u, nil } + +func (db *DB) DeleteUser(ctx context.Context, q querier, id xid.ID, selfDelete bool, reason string) error { + builder := sq.Update("users").Set("deleted_at", time.Now().UTC()).Set("self_delete", selfDelete).Where("id = ?", id) + if !selfDelete { + builder = builder.Set("delete_reason", reason) + } + sql, args, err := builder.ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = q.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + return nil +} diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go index 8b1bed5..deb6eba 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -84,7 +84,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, true) + token, err := s.Auth.CreateToken(u.ID, tokenID, false, true, true) if err != nil { return err } @@ -217,7 +217,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { // create token // TODO: implement user + token permissions tokenID := xid.New() - token, err := s.Auth.CreateToken(u.ID, tokenID, false, true) + token, err := s.Auth.CreateToken(u.ID, tokenID, false, true, true) if err != nil { return errors.Wrap(err, "creating token") } diff --git a/backend/routes/user/delete_user.go b/backend/routes/user/delete_user.go new file mode 100644 index 0000000..80b498a --- /dev/null +++ b/backend/routes/user/delete_user.go @@ -0,0 +1,42 @@ +package user + +import ( + "net/http" + + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" +) + +func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if claims.APIToken || !claims.TokenWrite { + return server.APIError{Code: server.ErrMissingPermissions} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "creating transaction") + } + defer tx.Rollback(ctx) + + err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "") + if err != nil { + return errors.Wrap(err, "setting user as deleted") + } + + err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID) + if err != nil { + return errors.Wrap(err, "invalidating tokens") + } + + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + render.NoContent(w, r) + return nil +} diff --git a/backend/routes/user/routes.go b/backend/routes/user/routes.go index 76b428c..550fa75 100644 --- a/backend/routes/user/routes.go +++ b/backend/routes/user/routes.go @@ -18,6 +18,7 @@ func Mount(srv *server.Server, r chi.Router) { r.With(server.MustAuth).Group(func(r chi.Router) { r.Get("/@me", server.WrapHandler(s.getMeUser)) r.Patch("/@me", server.WrapHandler(s.patchUser)) + r.Delete("/@me", server.WrapHandler(s.deleteUser)) }) }) } diff --git a/backend/server/auth/auth.go b/backend/server/auth/auth.go index 07f1072..e9d2bfb 100644 --- a/backend/server/auth/auth.go +++ b/backend/server/auth/auth.go @@ -18,6 +18,9 @@ type Claims struct { TokenID xid.ID `json:"jti"` UserIsAdmin bool `json:"adm"` + // APIToken specifies whether this token was generated for the API or for the website. + // API tokens cannot perform some destructive actions, such as DELETE /users/@me. + APIToken bool `json:"atn"` // TokenWrite specifies whether this token can be used for write actions. // If set to false, this token can only be used for read actions. TokenWrite bool `json:"twr"` @@ -48,7 +51,7 @@ const ExpireDays = 30 // CreateToken creates a token for the given user ID. // It expires after 30 days. -func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isWriteToken bool) (token string, err error) { +func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isAPIToken bool, isWriteToken bool) (token string, err error) { now := time.Now() expires := now.Add(ExpireDays * 24 * time.Hour) @@ -56,6 +59,7 @@ func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isWriteToke UserID: userID, TokenID: tokenID, UserIsAdmin: isAdmin, + APIToken: isAPIToken, TokenWrite: isWriteToken, RegisteredClaims: jwt.RegisteredClaims{ Issuer: "pronouns", diff --git a/backend/server/errors.go b/backend/server/errors.go index 25da292..6701268 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -83,6 +83,7 @@ const ( ErrInvitesDisabled = 1008 // invites are disabled (unneeded) ErrInviteLimitReached = 1009 // invite limit reached (when creating invites) ErrInviteAlreadyUsed = 1010 // invite already used (when signing up) + ErrDeletionPending = 1011 // own user deletion pending, returned with undo code // User-related error codes ErrUserNotFound = 2001 @@ -94,7 +95,8 @@ const ( ErrNotOwnMember = 3004 // General request error codes - ErrRequestTooBig = 4001 + ErrRequestTooBig = 4001 + ErrMissingPermissions = 4002 ) var errCodeMessages = map[int]string{ @@ -115,6 +117,7 @@ var errCodeMessages = map[int]string{ ErrInvitesDisabled: "Invites are disabled", ErrInviteLimitReached: "Your account has reached the invite limit", ErrInviteAlreadyUsed: "That invite code has already been used", + ErrDeletionPending: "Your account is pending deletion", ErrUserNotFound: "User not found", @@ -123,7 +126,8 @@ var errCodeMessages = map[int]string{ ErrMemberNameInUse: "Member name already in use", ErrNotOwnMember: "Not your member", - 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", } var errCodeStatuses = map[int]int{ @@ -144,6 +148,7 @@ var errCodeStatuses = map[int]int{ ErrInvitesDisabled: http.StatusForbidden, ErrInviteLimitReached: http.StatusForbidden, ErrInviteAlreadyUsed: http.StatusBadRequest, + ErrDeletionPending: http.StatusBadRequest, ErrUserNotFound: http.StatusNotFound, @@ -152,5 +157,6 @@ var errCodeStatuses = map[int]int{ ErrMemberNameInUse: http.StatusBadRequest, ErrNotOwnMember: http.StatusForbidden, - ErrRequestTooBig: http.StatusBadRequest, + ErrRequestTooBig: http.StatusBadRequest, + ErrMissingPermissions: http.StatusForbidden, } diff --git a/scripts/migrate/005_delete_users.sql b/scripts/migrate/005_delete_users.sql new file mode 100644 index 0000000..3bb2ac1 --- /dev/null +++ b/scripts/migrate/005_delete_users.sql @@ -0,0 +1,10 @@ +-- +migrate Up + +-- 2023-03-07: add delete functionality + +-- if not null, the user is soft deleted +alter table users add column deleted_at timestamptz; +-- if true, the user deleted their account themselves + should have option to reactivate; should also be deleted after 30 days +alter table users add column self_delete boolean; +-- delete reason if the user was deleted by a moderator +alter table users add column delete_reason text;