form.ts (3441B)
1 import { AuthSessionUseCase } from "#core/application/authn/session.ts"; 2 import { AEAD } from "#core/domain/crypto.ts"; 3 import { deleteCookie, getCookies, setCookie } from "$std/http/cookie.ts"; 4 import * as V from "$valita"; 5 6 const aead = new AEAD(); 7 8 export interface FormContexts { 9 "/connect": { back: Link }; 10 "/register/email": { email: string; back: Link }; 11 "/verify/email": { uuid: string; back: Link }; 12 "/login": { uuid: string; back: Link }; 13 "/logout": { back: Link }; 14 "/register/phone": { back: Link; conflict?: true }; 15 "/verify/sms": { phoneNumber: string; back: Link }; 16 "/register/id-document": { 17 side: "doc-front" | "doc-back" | "face-left" | "face-front" | "face-right"; 18 back: Link; 19 }; 20 "/verify/id-document": { cursor: number }; 21 "/oauth2/callback": { flowId: string; clientId: string }; 22 } 23 24 export type Link = 25 | string 26 | { 27 [K in keyof FormContexts]: { form: K; context: FormContexts[K] }; 28 }[keyof FormContexts]; 29 30 export class Forms { 31 constructor( 32 readonly base: URL, 33 readonly session: { 34 token: string; 35 uuid: string; 36 } | null, 37 ) {} 38 39 static async parse(request: Request, authenticate: AuthSessionUseCase) { 40 const url = new URL(request.url); 41 const cookies = getCookies(request.headers); 42 if (!("session" in cookies)) { 43 return new this(url, null); 44 } 45 46 const sessionToken = cookies.session; 47 const result = await authenticate.execute({ sessionToken }); 48 49 if (result.status === "expired") { 50 return new this(url, null); 51 } 52 53 return new this(url, { 54 token: sessionToken, 55 uuid: result.uuid!, 56 }); 57 } 58 59 link(link: Link, session: string | boolean = true) { 60 if (typeof link === "string") { 61 return new URL(link, this.base); 62 } 63 64 const url = new URL(link.form, this.base); 65 url.searchParams.set( 66 "state", 67 aead.encrypt( 68 JSON.stringify(link.context), 69 `${link.form}#${ 70 session 71 ? (typeof session === "string" 72 ? session 73 : (this.session?.token ?? "")) 74 : "" 75 }`, 76 ), 77 ); 78 return url; 79 } 80 81 redirect(link: Link) { 82 return Response.redirect(this.link(link), 303); 83 } 84 85 redirectWithSession(link: Link, sessionToken: string) { 86 const url = this.link(link, sessionToken); 87 const headers = new Headers(); 88 headers.set("location", url.href); 89 setCookie(headers, { 90 name: "session", 91 value: sessionToken, 92 path: "/", 93 httpOnly: true, 94 sameSite: "Lax", 95 }); 96 return new Response(null, { headers, status: 303 }); 97 } 98 99 redirectWithoutSession(link: Link) { 100 const url = this.link(link); 101 const headers = new Headers(); 102 headers.set("location", url.href); 103 deleteCookie(headers, "session", { path: "/" }); 104 return new Response(null, { headers, status: 303 }); 105 } 106 107 context<F extends keyof FormContexts>(form: F): FormContexts[F] | null { 108 try { 109 return JSON.parse(aead.decrypt( 110 `${this.base.searchParams.get("state") ?? ""}`, 111 `${form}#${this.session?.token ?? ""}`, 112 )) as FormContexts[F]; 113 } catch { 114 return null; 115 } 116 } 117 118 async inputs<T>(request: Request, type: V.Type<T>) { 119 const formData = await request.formData(); 120 return type.parse( 121 Object.fromEntries( 122 Array.from(formData.entries()) 123 .map(([k, v]) => [k, `${v}`]), 124 ), 125 { mode: "strip" }, 126 ); 127 } 128 }