feat(backend): add DELETE /users/@me endpoint

This commit is contained in:
Sam 2023-03-08 10:32:18 +01:00
parent c4b8b26ec7
commit ff3d612b06
No known key found for this signature in database
GPG Key ID: B4EF20DDE721CAA1
9 changed files with 162 additions and 45 deletions

View File

@ -5,6 +5,7 @@ package queries
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/jackc/pgtype"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
) )
@ -22,6 +23,9 @@ type GetUserByIDRow struct {
MaxInvites int32 `json:"max_invites"` MaxInvites int32 `json:"max_invites"`
Names []FieldEntry `json:"names"` Names []FieldEntry `json:"names"`
Pronouns []PronounEntry `json:"pronouns"` Pronouns []PronounEntry `json:"pronouns"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
SelfDelete *bool `json:"self_delete"`
DeleteReason *string `json:"delete_reason"`
} }
// GetUserByID implements Querier.GetUserByID. // GetUserByID implements Querier.GetUserByID.
@ -31,7 +35,7 @@ func (q *DBQuerier) GetUserByID(ctx context.Context, id string) (GetUserByIDRow,
var item GetUserByIDRow var item GetUserByIDRow
namesArray := q.types.newFieldEntryArray() namesArray := q.types.newFieldEntryArray()
pronounsArray := q.types.newPronounEntryArray() 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) return item, fmt.Errorf("query GetUserByID: %w", err)
} }
if err := namesArray.AssignTo(&item.Names); err != nil { if err := namesArray.AssignTo(&item.Names); err != nil {
@ -54,7 +58,7 @@ func (q *DBQuerier) GetUserByIDScan(results pgx.BatchResults) (GetUserByIDRow, e
var item GetUserByIDRow var item GetUserByIDRow
namesArray := q.types.newFieldEntryArray() namesArray := q.types.newFieldEntryArray()
pronounsArray := q.types.newPronounEntryArray() 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) return item, fmt.Errorf("scan GetUserByIDBatch row: %w", err)
} }
if err := namesArray.AssignTo(&item.Names); err != nil { if err := namesArray.AssignTo(&item.Names); err != nil {
@ -80,6 +84,9 @@ type GetUserByUsernameRow struct {
MaxInvites int32 `json:"max_invites"` MaxInvites int32 `json:"max_invites"`
Names []FieldEntry `json:"names"` Names []FieldEntry `json:"names"`
Pronouns []PronounEntry `json:"pronouns"` Pronouns []PronounEntry `json:"pronouns"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
SelfDelete *bool `json:"self_delete"`
DeleteReason *string `json:"delete_reason"`
} }
// GetUserByUsername implements Querier.GetUserByUsername. // GetUserByUsername implements Querier.GetUserByUsername.
@ -89,7 +96,7 @@ func (q *DBQuerier) GetUserByUsername(ctx context.Context, username string) (Get
var item GetUserByUsernameRow var item GetUserByUsernameRow
namesArray := q.types.newFieldEntryArray() namesArray := q.types.newFieldEntryArray()
pronounsArray := q.types.newPronounEntryArray() 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) return item, fmt.Errorf("query GetUserByUsername: %w", err)
} }
if err := namesArray.AssignTo(&item.Names); err != nil { if err := namesArray.AssignTo(&item.Names); err != nil {
@ -112,7 +119,7 @@ func (q *DBQuerier) GetUserByUsernameScan(results pgx.BatchResults) (GetUserByUs
var item GetUserByUsernameRow var item GetUserByUsernameRow
namesArray := q.types.newFieldEntryArray() namesArray := q.types.newFieldEntryArray()
pronounsArray := q.types.newPronounEntryArray() 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) return item, fmt.Errorf("scan GetUserByUsernameBatch row: %w", err)
} }
if err := namesArray.AssignTo(&item.Names); err != nil { if err := namesArray.AssignTo(&item.Names); err != nil {
@ -148,6 +155,9 @@ type UpdateUserNamesPronounsRow struct {
MaxInvites int32 `json:"max_invites"` MaxInvites int32 `json:"max_invites"`
Names []FieldEntry `json:"names"` Names []FieldEntry `json:"names"`
Pronouns []PronounEntry `json:"pronouns"` Pronouns []PronounEntry `json:"pronouns"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
SelfDelete *bool `json:"self_delete"`
DeleteReason *string `json:"delete_reason"`
} }
// UpdateUserNamesPronouns implements Querier.UpdateUserNamesPronouns. // UpdateUserNamesPronouns implements Querier.UpdateUserNamesPronouns.
@ -157,7 +167,7 @@ func (q *DBQuerier) UpdateUserNamesPronouns(ctx context.Context, params UpdateUs
var item UpdateUserNamesPronounsRow var item UpdateUserNamesPronounsRow
namesArray := q.types.newFieldEntryArray() namesArray := q.types.newFieldEntryArray()
pronounsArray := q.types.newPronounEntryArray() 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) return item, fmt.Errorf("query UpdateUserNamesPronouns: %w", err)
} }
if err := namesArray.AssignTo(&item.Names); err != nil { if err := namesArray.AssignTo(&item.Names); err != nil {
@ -180,7 +190,7 @@ func (q *DBQuerier) UpdateUserNamesPronounsScan(results pgx.BatchResults) (Updat
var item UpdateUserNamesPronounsRow var item UpdateUserNamesPronounsRow
namesArray := q.types.newFieldEntryArray() namesArray := q.types.newFieldEntryArray()
pronounsArray := q.types.newPronounEntryArray() 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) return item, fmt.Errorf("scan UpdateUserNamesPronounsBatch row: %w", err)
} }
if err := namesArray.AssignTo(&item.Names); err != nil { if err := namesArray.AssignTo(&item.Names); err != nil {

View File

@ -96,3 +96,16 @@ func (db *DB) InvalidateToken(ctx context.Context, userID xid.ID, tokenID xid.ID
} }
return t, nil 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
}

View File

@ -3,11 +3,13 @@ package db
import ( import (
"context" "context"
"regexp" "regexp"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/db/queries" "codeberg.org/u1f320/pronouns.cc/backend/db/queries"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/jackc/pgconn" "github.com/jackc/pgconn"
"github.com/jackc/pgtype"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -28,6 +30,10 @@ type User struct {
DiscordUsername *string DiscordUsername *string
MaxInvites int MaxInvites int
DeletedAt *time.Time
SelfDelete *bool
DeleteReason *string
} }
// usernames must match this regex // 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") 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{ u = User{
ID: id, ID: id,
Username: qu.Username, 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, Discord: qu.Discord,
DiscordUsername: qu.DiscordUsername, DiscordUsername: qu.DiscordUsername,
MaxInvites: int(qu.MaxInvites), MaxInvites: int(qu.MaxInvites),
DeletedAt: deletedAt,
SelfDelete: qu.SelfDelete,
DeleteReason: qu.DeleteReason,
} }
return u, nil return u, nil
@ -283,3 +297,20 @@ func (db *DB) UpdateUser(
} }
return u, nil 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
}

View File

@ -84,7 +84,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, true) token, err := s.Auth.CreateToken(u.ID, tokenID, false, true, true)
if err != nil { if err != nil {
return err return err
} }
@ -217,7 +217,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
// create token // create token
// 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, true) token, err := s.Auth.CreateToken(u.ID, tokenID, false, true, true)
if err != nil { if err != nil {
return errors.Wrap(err, "creating token") return errors.Wrap(err, "creating token")
} }

View File

@ -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
}

View File

@ -18,6 +18,7 @@ func Mount(srv *server.Server, r chi.Router) {
r.With(server.MustAuth).Group(func(r chi.Router) { r.With(server.MustAuth).Group(func(r chi.Router) {
r.Get("/@me", server.WrapHandler(s.getMeUser)) r.Get("/@me", server.WrapHandler(s.getMeUser))
r.Patch("/@me", server.WrapHandler(s.patchUser)) r.Patch("/@me", server.WrapHandler(s.patchUser))
r.Delete("/@me", server.WrapHandler(s.deleteUser))
}) })
}) })
} }

View File

@ -18,6 +18,9 @@ type Claims struct {
TokenID xid.ID `json:"jti"` TokenID xid.ID `json:"jti"`
UserIsAdmin bool `json:"adm"` 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. // TokenWrite specifies whether this token can be used for write actions.
// If set to false, this token can only be used for read actions. // If set to false, this token can only be used for read actions.
TokenWrite bool `json:"twr"` TokenWrite bool `json:"twr"`
@ -48,7 +51,7 @@ const ExpireDays = 30
// CreateToken creates a token for the given user ID. // CreateToken creates a token for the given user ID.
// It expires after 30 days. // 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() now := time.Now()
expires := now.Add(ExpireDays * 24 * time.Hour) expires := now.Add(ExpireDays * 24 * time.Hour)
@ -56,6 +59,7 @@ func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isWriteToke
UserID: userID, UserID: userID,
TokenID: tokenID, TokenID: tokenID,
UserIsAdmin: isAdmin, UserIsAdmin: isAdmin,
APIToken: isAPIToken,
TokenWrite: isWriteToken, TokenWrite: isWriteToken,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Issuer: "pronouns", Issuer: "pronouns",

View File

@ -83,6 +83,7 @@ const (
ErrInvitesDisabled = 1008 // invites are disabled (unneeded) ErrInvitesDisabled = 1008 // invites are disabled (unneeded)
ErrInviteLimitReached = 1009 // invite limit reached (when creating invites) ErrInviteLimitReached = 1009 // invite limit reached (when creating invites)
ErrInviteAlreadyUsed = 1010 // invite already used (when signing up) ErrInviteAlreadyUsed = 1010 // invite already used (when signing up)
ErrDeletionPending = 1011 // own user deletion pending, returned with undo code
// User-related error codes // User-related error codes
ErrUserNotFound = 2001 ErrUserNotFound = 2001
@ -95,6 +96,7 @@ const (
// General request error codes // General request error codes
ErrRequestTooBig = 4001 ErrRequestTooBig = 4001
ErrMissingPermissions = 4002
) )
var errCodeMessages = map[int]string{ var errCodeMessages = map[int]string{
@ -115,6 +117,7 @@ var errCodeMessages = map[int]string{
ErrInvitesDisabled: "Invites are disabled", ErrInvitesDisabled: "Invites are disabled",
ErrInviteLimitReached: "Your account has reached the invite limit", ErrInviteLimitReached: "Your account has reached the invite limit",
ErrInviteAlreadyUsed: "That invite code has already been used", ErrInviteAlreadyUsed: "That invite code has already been used",
ErrDeletionPending: "Your account is pending deletion",
ErrUserNotFound: "User not found", ErrUserNotFound: "User not found",
@ -124,6 +127,7 @@ var errCodeMessages = map[int]string{
ErrNotOwnMember: "Not your member", 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{ var errCodeStatuses = map[int]int{
@ -144,6 +148,7 @@ var errCodeStatuses = map[int]int{
ErrInvitesDisabled: http.StatusForbidden, ErrInvitesDisabled: http.StatusForbidden,
ErrInviteLimitReached: http.StatusForbidden, ErrInviteLimitReached: http.StatusForbidden,
ErrInviteAlreadyUsed: http.StatusBadRequest, ErrInviteAlreadyUsed: http.StatusBadRequest,
ErrDeletionPending: http.StatusBadRequest,
ErrUserNotFound: http.StatusNotFound, ErrUserNotFound: http.StatusNotFound,
@ -153,4 +158,5 @@ var errCodeStatuses = map[int]int{
ErrNotOwnMember: http.StatusForbidden, ErrNotOwnMember: http.StatusForbidden,
ErrRequestTooBig: http.StatusBadRequest, ErrRequestTooBig: http.StatusBadRequest,
ErrMissingPermissions: http.StatusForbidden,
} }

View File

@ -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;