feat(backend): add DELETE /users/@me endpoint
This commit is contained in:
parent
c4b8b26ec7
commit
ff3d612b06
|
@ -5,6 +5,7 @@ package queries
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/jackc/pgtype"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
|
@ -22,6 +23,9 @@ type GetUserByIDRow struct {
|
|||
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 {
|
||||
|
@ -80,6 +84,9 @@ type GetUserByUsernameRow struct {
|
|||
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 {
|
||||
|
@ -148,6 +155,9 @@ type UpdateUserNamesPronounsRow struct {
|
|||
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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
@ -95,6 +96,7 @@ const (
|
|||
|
||||
// General request error codes
|
||||
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",
|
||||
|
||||
|
@ -124,6 +127,7 @@ var errCodeMessages = map[int]string{
|
|||
ErrNotOwnMember: "Not your member",
|
||||
|
||||
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,
|
||||
|
||||
|
@ -153,4 +158,5 @@ var errCodeStatuses = map[int]int{
|
|||
ErrNotOwnMember: http.StatusForbidden,
|
||||
|
||||
ErrRequestTooBig: http.StatusBadRequest,
|
||||
ErrMissingPermissions: http.StatusForbidden,
|
||||
}
|
||||
|
|
|
@ -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;
|
Loading…
Reference in New Issue