feat: cancel user deletion
This commit is contained in:
		
							parent
							
								
									1e6eb66168
								
							
						
					
					
						commit
						9bfabcc1f1
					
				
					 9 changed files with 169 additions and 9 deletions
				
			
		|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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)) | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										70
									
								
								backend/routes/auth/undelete.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								backend/routes/auth/undelete.go
									
										
									
									
									
										Normal 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 | ||||
| } | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -3,13 +3,18 @@ import { PUBLIC_BASE_URL } from "$env/static/public"; | |||
| 
 | ||||
| export async function apiFetch<T>( | ||||
|   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}`, { | ||||
|     method: method || "GET", | ||||
|     headers: { | ||||
|       ...(token ? { Authorization: token } : {}), | ||||
|       ...(headers ? headers : {}), | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|     body: body ? JSON.stringify(body) : null, | ||||
|  |  | |||
|  | @ -30,4 +30,7 @@ interface CallbackResponse { | |||
|   discord?: string; | ||||
|   ticket?: string; | ||||
|   require_invite: boolean; | ||||
| 
 | ||||
|   is_deleted: boolean; | ||||
|   deleted_at?: Date; | ||||
| } | ||||
|  |  | |||
|  | @ -1,6 +1,6 @@ | |||
| <script lang="ts"> | ||||
|   import { onMount } from "svelte"; | ||||
|   import { Alert, Icon } from "sveltestrap"; | ||||
|   import { Alert, Button, Icon } from "sveltestrap"; | ||||
| 
 | ||||
|   import { goto } from "$app/navigation"; | ||||
|   import type { APIError, MeUser } from "$lib/api/entities"; | ||||
|  | @ -16,7 +16,7 @@ | |||
|   export let data: PageData; | ||||
| 
 | ||||
|   onMount(() => { | ||||
|     if (data.token && data.user) { | ||||
|     if (!data.is_deleted && data.token && data.user) { | ||||
|       localStorage.setItem("pronouns-token", data.token); | ||||
|       localStorage.setItem("pronouns-user", JSON.stringify(data.user)); | ||||
|       userStore.set(data.user); | ||||
|  | @ -46,6 +46,25 @@ | |||
|       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> | ||||
| 
 | ||||
| <svelte:head> | ||||
|  | @ -91,8 +110,27 @@ | |||
|       By signing up, you agree to the <a href="/page/tos">terms of service</a> and the | ||||
|       <a href="/page/privacy">privacy policy</a>. | ||||
|     </div> | ||||
|     <button type="submit" class="btn btn-primary">Sign up</button> | ||||
|     <Button type="submit" color="primary">Sign up</Button> | ||||
|   </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} | ||||
|   Loading... | ||||
| {/if} | ||||
|  |  | |||
|  | @ -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); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue