diff --git a/backend/db/user.go b/backend/db/user.go index eb3a7ae..36134b2 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -284,3 +284,20 @@ func (db *DB) DeleteUser(ctx context.Context, tx pgx.Tx, id xid.ID, selfDelete b } return nil } + +func (db *DB) UndoDeleteUser(ctx context.Context, id xid.ID) error { + sql, args, err := sq.Update("users"). + Set("deleted_at", nil). + Set("self_delete", nil). + Set("delete_reason", nil). + Where("id = ?", id).ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = db.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 0c2b475..8e6f7ba 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -3,6 +3,7 @@ package auth import ( "net/http" "os" + "time" "codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/log" @@ -41,6 +42,9 @@ type discordCallbackResponse struct { Discord string `json:"discord,omitempty"` // username, for UI purposes Ticket string `json:"ticket,omitempty"` RequireInvite bool `json:"require_invite"` // require an invite for signing up + + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { @@ -77,6 +81,25 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { u, err := s.DB.DiscordUser(ctx, du.ID) if err == nil { + if u.DeletedAt != nil && *u.SelfDelete { + // store cancel delete token + token := undeleteToken() + err = s.saveUndeleteToken(ctx, u.ID, token) + if err != nil { + log.Errorf("saving undelete token: %v", err) + return err + } + + render.JSON(w, r, discordCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + }) + return nil + } + err = u.UpdateFromDiscord(ctx, s.DB, du) if err != nil { log.Errorf("updating user %v with Discord info: %v", u.ID, err) diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 05e6628..e19fcf2 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -78,6 +78,10 @@ func Mount(srv *server.Server, r chi.Router) { r.With(server.MustAuth).Get("/tokens", server.WrapHandler(s.getTokens)) r.With(server.MustAuth).Post("/tokens", server.WrapHandler(s.createToken)) r.With(server.MustAuth).Delete("/tokens/{id}", server.WrapHandler(s.deleteToken)) + + // cancel user delete + // uses a special token, so handled in the function itself + r.Get("/cancel-delete", server.WrapHandler(s.cancelDelete)) }) } diff --git a/backend/routes/auth/undelete.go b/backend/routes/auth/undelete.go new file mode 100644 index 0000000..c9db8f1 --- /dev/null +++ b/backend/routes/auth/undelete.go @@ -0,0 +1,70 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "net/http" + + "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" +) + +func (s *Server) cancelDelete(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + token := r.Header.Get("X-Delete-Token") + if token == "" { + return server.APIError{Code: server.ErrForbidden} + } + + id, err := s.getUndeleteToken(ctx, token) + if err != nil { + log.Errorf("getting undelete token: %v", err) + return server.APIError{Code: server.ErrNotFound} // assume invalid token + } + + err = s.DB.UndoDeleteUser(ctx, id) + if err != nil { + log.Errorf("executing undelete query: %v", err) + } + + render.JSON(w, r, map[string]any{"success": true}) + return nil +} + +func undeleteToken() string { + b := make([]byte, 32) + + _, err := rand.Read(b) + if err != nil { + panic(err) + } + + return base64.RawURLEncoding.EncodeToString(b) +} + +func (s *Server) saveUndeleteToken(ctx context.Context, userID xid.ID, token string) error { + err := s.DB.Redis.Do(ctx, radix.Cmd(nil, "SET", "undelete:"+token, userID.String(), "EX", "3600")) + if err != nil { + return errors.Wrap(err, "setting undelete key") + } + return nil +} + +func (s *Server) getUndeleteToken(ctx context.Context, token string) (userID xid.ID, err error) { + var idString string + err = s.DB.Redis.Do(ctx, radix.Cmd(&idString, "GET", "undelete:"+token)) + if err != nil { + return userID, errors.Wrap(err, "getting undelete key") + } + + userID, err = xid.FromString(idString) + if err != nil { + return userID, errors.Wrap(err, "parsing ID") + } + return userID, nil +} diff --git a/backend/server/server.go b/backend/server/server.go index 44365e3..1d419e3 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -77,15 +77,15 @@ func New() (*Server, error) { // set scopes // users rateLimiter.Scope("GET", "/users/*", 60) - rateLimiter.Scope("PATCH", "/users/@me", 5) + rateLimiter.Scope("PATCH", "/users/@me", 10) // members rateLimiter.Scope("GET", "/users/*/members", 60) rateLimiter.Scope("GET", "/users/*/members/*", 60) - rateLimiter.Scope("POST", "/members", 5) + rateLimiter.Scope("POST", "/members", 10) rateLimiter.Scope("GET", "/members/*", 60) - rateLimiter.Scope("PATCH", "/members/*", 5) + rateLimiter.Scope("PATCH", "/members/*", 20) rateLimiter.Scope("DELETE", "/members/*", 5) // auth diff --git a/frontend/src/lib/api/fetch.ts b/frontend/src/lib/api/fetch.ts index b147ea5..3f080c2 100644 --- a/frontend/src/lib/api/fetch.ts +++ b/frontend/src/lib/api/fetch.ts @@ -3,13 +3,18 @@ import { PUBLIC_BASE_URL } from "$env/static/public"; export async function apiFetch( path: string, - { method, body, token }: { method?: string; body?: any; token?: string }, + { + method, + body, + token, + headers, + }: { method?: string; body?: any; token?: string; headers?: Record }, ) { - const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, { method: method || "GET", headers: { ...(token ? { Authorization: token } : {}), + ...(headers ? headers : {}), "Content-Type": "application/json", }, body: body ? JSON.stringify(body) : null, diff --git a/frontend/src/routes/auth/login/discord/+page.server.ts b/frontend/src/routes/auth/login/discord/+page.server.ts index 472ca94..ec5828f 100644 --- a/frontend/src/routes/auth/login/discord/+page.server.ts +++ b/frontend/src/routes/auth/login/discord/+page.server.ts @@ -30,4 +30,7 @@ interface CallbackResponse { discord?: string; ticket?: string; require_invite: boolean; + + is_deleted: boolean; + deleted_at?: Date; } diff --git a/frontend/src/routes/auth/login/discord/+page.svelte b/frontend/src/routes/auth/login/discord/+page.svelte index ca5e80d..703de43 100644 --- a/frontend/src/routes/auth/login/discord/+page.svelte +++ b/frontend/src/routes/auth/login/discord/+page.svelte @@ -1,6 +1,6 @@ @@ -91,8 +110,27 @@ By signing up, you agree to the terms of service and the privacy policy. - + +{:else if data.is_deleted && data.token} +

Your account is pending deletion since {data.deleted_at}.

+

If you wish to cancel deletion, press the button below.

+

+ +

+ {#if deleteCancelled} + + Account deletion cancelled! You can now log in again. + + {/if} + {#if deleteError} + +

An error occurred

+ {deleteError.code}: {deleteError.message} +
+ {/if} {:else} Loading... {/if} diff --git a/frontend/src/routes/settings/invites/+page.ts b/frontend/src/routes/settings/invites/+page.ts index 15179ca..6e98a33 100644 --- a/frontend/src/routes/settings/invites/+page.ts +++ b/frontend/src/routes/settings/invites/+page.ts @@ -17,7 +17,7 @@ export const load = (async () => { data.invitesEnabled = false; data.invites = []; } else { - throw error(500, (e as APIError).message); + throw error((e as APIError).code, (e as APIError).message); } }