feat: cancel user deletion

This commit is contained in:
Sam 2023-03-14 16:16:07 +01:00
parent 1e6eb66168
commit 9bfabcc1f1
No known key found for this signature in database
GPG Key ID: B4EF20DDE721CAA1
9 changed files with 169 additions and 9 deletions

View File

@ -284,3 +284,20 @@ func (db *DB) DeleteUser(ctx context.Context, tx pgx.Tx, id xid.ID, selfDelete b
} }
return nil 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
}

View File

@ -3,6 +3,7 @@ package auth
import ( import (
"net/http" "net/http"
"os" "os"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/u1f320/pronouns.cc/backend/log"
@ -41,6 +42,9 @@ type discordCallbackResponse struct {
Discord string `json:"discord,omitempty"` // username, for UI purposes Discord string `json:"discord,omitempty"` // username, for UI purposes
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"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
} }
func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { 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) u, err := s.DB.DiscordUser(ctx, du.ID)
if err == nil { 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) err = u.UpdateFromDiscord(ctx, s.DB, du)
if err != nil { if err != nil {
log.Errorf("updating user %v with Discord info: %v", u.ID, err) log.Errorf("updating user %v with Discord info: %v", u.ID, err)

View File

@ -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).Get("/tokens", server.WrapHandler(s.getTokens))
r.With(server.MustAuth).Post("/tokens", server.WrapHandler(s.createToken)) r.With(server.MustAuth).Post("/tokens", server.WrapHandler(s.createToken))
r.With(server.MustAuth).Delete("/tokens/{id}", server.WrapHandler(s.deleteToken)) 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))
}) })
} }

View File

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

View File

@ -77,15 +77,15 @@ func New() (*Server, error) {
// set scopes // set scopes
// users // users
rateLimiter.Scope("GET", "/users/*", 60) rateLimiter.Scope("GET", "/users/*", 60)
rateLimiter.Scope("PATCH", "/users/@me", 5) rateLimiter.Scope("PATCH", "/users/@me", 10)
// members // members
rateLimiter.Scope("GET", "/users/*/members", 60) rateLimiter.Scope("GET", "/users/*/members", 60)
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("GET", "/members/*", 60)
rateLimiter.Scope("PATCH", "/members/*", 5) rateLimiter.Scope("PATCH", "/members/*", 20)
rateLimiter.Scope("DELETE", "/members/*", 5) rateLimiter.Scope("DELETE", "/members/*", 5)
// auth // auth

View File

@ -3,13 +3,18 @@ import { PUBLIC_BASE_URL } from "$env/static/public";
export async function apiFetch<T>( export async function apiFetch<T>(
path: string, path: string,
{ method, body, token }: { method?: string; body?: any; token?: string }, {
method,
body,
token,
headers,
}: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
) { ) {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, { const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
method: method || "GET", method: method || "GET",
headers: { headers: {
...(token ? { Authorization: token } : {}), ...(token ? { Authorization: token } : {}),
...(headers ? headers : {}),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: body ? JSON.stringify(body) : null, body: body ? JSON.stringify(body) : null,

View File

@ -30,4 +30,7 @@ interface CallbackResponse {
discord?: string; discord?: string;
ticket?: string; ticket?: string;
require_invite: boolean; require_invite: boolean;
is_deleted: boolean;
deleted_at?: Date;
} }

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { Alert, Icon } from "sveltestrap"; import { Alert, Button, Icon } from "sveltestrap";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { APIError, MeUser } from "$lib/api/entities"; import type { APIError, MeUser } from "$lib/api/entities";
@ -16,7 +16,7 @@
export let data: PageData; export let data: PageData;
onMount(() => { onMount(() => {
if (data.token && data.user) { if (!data.is_deleted && data.token && data.user) {
localStorage.setItem("pronouns-token", data.token); localStorage.setItem("pronouns-token", data.token);
localStorage.setItem("pronouns-user", JSON.stringify(data.user)); localStorage.setItem("pronouns-user", JSON.stringify(data.user));
userStore.set(data.user); userStore.set(data.user);
@ -46,6 +46,25 @@
data.error = e as APIError; data.error = e as APIError;
} }
}; };
let deleteCancelled = false;
let deleteError: APIError | null = null;
const cancelDelete = async () => {
try {
await apiFetch<any>("/auth/cancel-delete", {
method: "GET",
headers: {
"X-Delete-Token": data.token!,
},
});
deleteCancelled = true;
deleteError = null;
} catch (e) {
deleteCancelled = false;
deleteError = e as APIError;
}
};
</script> </script>
<svelte:head> <svelte:head>
@ -91,8 +110,27 @@
By signing up, you agree to the <a href="/page/tos">terms of service</a> and the By signing up, you agree to the <a href="/page/tos">terms of service</a> and the
<a href="/page/privacy">privacy policy</a>. <a href="/page/privacy">privacy policy</a>.
</div> </div>
<button type="submit" class="btn btn-primary">Sign up</button> <Button type="submit" color="primary">Sign up</Button>
</form> </form>
{:else if data.is_deleted && data.token}
<p>Your account is pending deletion since {data.deleted_at}.</p>
<p>If you wish to cancel deletion, press the button below.</p>
<p>
<Button color="primary" on:click={cancelDelete} disabled={deleteCancelled}
>Cancel account deletion</Button
>
</p>
{#if deleteCancelled}
<Alert color="secondary" fade={false}>
Account deletion cancelled! You can now <a href="/auth/login">log in</a> again.
</Alert>
{/if}
{#if deleteError}
<Alert color="danger" fade={false}>
<h4 class="alert-heading">An error occurred</h4>
<b>{deleteError.code}</b>: {deleteError.message}
</Alert>
{/if}
{:else} {:else}
Loading... Loading...
{/if} {/if}

View File

@ -17,7 +17,7 @@ export const load = (async () => {
data.invitesEnabled = false; data.invitesEnabled = false;
data.invites = []; data.invites = [];
} else { } else {
throw error(500, (e as APIError).message); throw error((e as APIError).code, (e as APIError).message);
} }
} }