Attic is a cozy space with lofty ambitions. attic.social

display name record

dbushell.com 2cbb4fa4 ac1b222b

verified
+196 -81
+2 -2
package.json
··· 4 4 "version": "0.0.1", 5 5 "type": "module", 6 6 "scripts": { 7 - "lex": "lex-cli generate", 8 7 "dev": "vite dev", 9 8 "build": "vite build", 10 9 "preview": "vite preview", 11 10 "prepare": "svelte-kit sync || echo ''", 12 11 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 13 - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 12 + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 + "lex:generate": "lex-cli generate" 14 14 }, 15 15 "dependencies": { 16 16 "@atcute/atproto": "^3.1.10",
+11 -1
src/css/main.css
··· 16 16 .avatar { 17 17 align-items: center; 18 18 display: grid; 19 + column-gap: 10px; 19 20 grid-template-columns: 50px auto; 20 - gap: 10px; 21 21 22 22 & img { 23 23 border-radius: calc(1px * infinity); 24 24 block-size: 50px; 25 25 inline-size: 50px; 26 + grid-column: 1; 27 + grid-row: 1 / 5; 26 28 } 27 29 28 30 & p { 29 31 font-weight: 700; 32 + grid-column: 2; 33 + grid-row: 2; 34 + line-height: 1.25; 35 + 36 + & + & { 37 + font-weight: 400; 38 + grid-row: 3; 39 + } 30 40 } 31 41 }
+9 -1
src/hooks.server.ts
··· 1 1 import { dev } from "$app/environment"; 2 - import { restoreSession } from "$lib/server/session.ts"; 3 2 import type { Handle } from "@sveltejs/kit"; 4 3 import { sequence } from "@sveltejs/kit/hooks"; 4 + import { restoreSession } from "./lib/server/session.ts"; 5 5 6 6 /** 7 7 * {@link https://svelte.dev/docs/cli/devtools-json} ··· 15 15 }; 16 16 17 17 export const defaultHandle: Handle = async ({ event, resolve }) => { 18 + // [TODO] necessary? 19 + // if ( 20 + // event.url.searchParams.has("session") || 21 + // event.request.headers.has("x-session") || 22 + // event.request.headers.get("sec-fetch-mode") === "navigate" || 23 + // // event.request.headers.get("sec-fetch-dest") === "empty" 24 + // ) { 18 25 await restoreSession(event); 26 + // } 19 27 return resolve(event); 20 28 }; 21 29
-1
src/lexicons/index.ts
··· 1 - export * as SocialAtticActorProfile from "./types/social/attic/actor/profile.ts";
-32
src/lexicons/types/social/attic/actor/profile.ts
··· 1 - import type {} from "@atcute/lexicons"; 2 - import * as v from "@atcute/lexicons/validations"; 3 - import type {} from "@atcute/lexicons/ambient"; 4 - 5 - const _mainSchema = /*#__PURE__*/ v.record( 6 - /*#__PURE__*/ v.literal("self"), 7 - /*#__PURE__*/ v.object({ 8 - $type: /*#__PURE__*/ v.literal("social.attic.actor.profile"), 9 - /** 10 - * @maxLength 640 11 - * @maxGraphemes 64 12 - */ 13 - displayName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 14 - /*#__PURE__*/ v.stringLength(0, 640), 15 - /*#__PURE__*/ v.stringGraphemes(0, 64), 16 - ]), 17 - }), 18 - ); 19 - 20 - type main$schematype = typeof _mainSchema; 21 - 22 - export interface mainSchema extends main$schematype {} 23 - 24 - export const mainSchema = _mainSchema as mainSchema; 25 - 26 - export interface Main extends v.InferInput<typeof mainSchema> {} 27 - 28 - declare module "@atcute/lexicons/ambient" { 29 - interface Records { 30 - "social.attic.actor.profile": mainSchema; 31 - } 32 - }
+10 -2
src/lib/server/oauth.ts
··· 1 1 import { dev } from "$app/environment"; 2 + import type {} from "$lexicons/index.ts"; 2 3 import { 3 4 OAUTH_COOKIE_PREFIX, 4 5 OAUTH_MAX_AGE, ··· 115 116 }); 116 117 117 118 const redirect = new URL("/oauth/callback", event.platform.env.ORIGIN); 118 - const scopes = [scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" })]; 119 - let metadata: OAuthClientOptions["metadata"]; 119 + 120 + const scopes = [ 121 + scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" }), 122 + scope.repo({ 123 + collection: ["social.attic.actor.profile"], 124 + }), 125 + ]; 126 + 120 127 let keyset: ClientAssertionPrivateJwk[] | undefined; 121 128 129 + let metadata: OAuthClientOptions["metadata"]; 122 130 if (dev) { 123 131 metadata = { 124 132 redirect_uris: [redirect.href],
+31 -2
src/lib/server/session.ts
··· 3 3 HANDLE_COOKIE, 4 4 OAUTH_MAX_AGE, 5 5 SESSION_COOKIE, 6 + SESSION_MAX_AGE, 6 7 } from "$lib/server/constants.ts"; 7 - import { decryptText } from "$lib/server/crypto.ts"; 8 + import { decryptText, encryptText } from "$lib/server/crypto.ts"; 8 9 import { createOAuthClient } from "$lib/server/oauth.ts"; 9 10 import { parsePublicUser, type PublicUserData } from "$lib/valibot.ts"; 10 11 import { Client } from "@atcute/client"; ··· 31 32 }; 32 33 33 34 /** 34 - * Login 35 + * Begin auth flow 35 36 * @returns {URL} OAuth redirect 36 37 */ 37 38 export const startSession = async ( ··· 58 59 }, 59 60 ); 60 61 return url; 62 + }; 63 + 64 + /** 65 + * Store the logged in user data 66 + */ 67 + export const updateSession = async ( 68 + event: RequestEvent, 69 + user: PublicUserData, 70 + ) => { 71 + const { cookies, platform } = event; 72 + if (platform?.env === undefined) { 73 + throw new Error(); 74 + } 75 + const encrypted = await encryptText( 76 + JSON.stringify(user), 77 + platform.env.PRIVATE_COOKIE_KEY, 78 + ); 79 + cookies.set( 80 + SESSION_COOKIE, 81 + encrypted, 82 + { 83 + httpOnly: true, 84 + maxAge: SESSION_MAX_AGE, 85 + path: "/", 86 + sameSite: "lax", 87 + secure: !dev, 88 + }, 89 + ); 61 90 }; 62 91 63 92 /**
+18 -1
src/lib/valibot.ts
··· 4 4 import { OAuthSession } from "@atcute/oauth-node-client"; 5 5 import * as v from "valibot"; 6 6 7 + const DisplayNameSchema = v.pipe( 8 + v.string(), 9 + v.trim(), 10 + v.minLength(1), 11 + v.maxLength(640), 12 + v.maxGraphemes(64), 13 + ); 14 + 7 15 const UserSchema = { 8 16 did: v.custom<Did>(isDid, "invalid did"), 9 17 handle: v.custom<Handle>(isHandle, "invalid handle"), 10 - displayName: v.string(), 18 + displayName: DisplayNameSchema, 11 19 }; 12 20 13 21 export const PublicUserSchema = v.object(UserSchema); ··· 27 35 export function parsePrivateUser(data: unknown): PrivateUserData { 28 36 return v.parse(PrivateUserSchema, data); 29 37 } 38 + 39 + const ActorProfileSchema = v.object({ 40 + displayName: DisplayNameSchema, 41 + }); 42 + export type ActorProfileData = v.InferOutput<typeof ActorProfileSchema>; 43 + 44 + export function parseActorProfile(data: unknown): ActorProfileData { 45 + return v.parse(ActorProfileSchema, data); 46 + }
+56 -1
src/routes/+page.server.ts
··· 1 1 import { HANDLE_COOKIE } from "$lib/server/constants.ts"; 2 - import { destroySession, startSession } from "$lib/server/session.ts"; 2 + import { 3 + destroySession, 4 + startSession, 5 + updateSession, 6 + } from "$lib/server/session.ts"; 7 + import { Client } from "@atcute/client"; 3 8 import { type Actions, fail, redirect } from "@sveltejs/kit"; 9 + import { parseActorProfile } from "../lib/valibot.ts"; 10 + 4 11 export const actions = { 5 12 logout: async (event) => { 6 13 await destroySession(event); ··· 18 25 return fail(400, { handle, action: "login", error: "Invalid handle." }); 19 26 } 20 27 redirect(303, url); 28 + }, 29 + displayName: async (event) => { 30 + if (event.locals.user === undefined) return; 31 + const { user } = event.locals; 32 + try { 33 + const formData = await event.request.formData(); 34 + const record = parseActorProfile({ 35 + displayName: formData.get("displayName"), 36 + }); 37 + const rpc = new Client({ handler: user.session }); 38 + const result = await rpc.post("com.atproto.repo.putRecord", { 39 + input: { 40 + repo: user.did, 41 + collection: "social.attic.actor.profile", 42 + rkey: "self", 43 + record, 44 + }, 45 + }); 46 + if (result.ok === false) { 47 + throw new Error(); 48 + } 49 + event.locals.user.displayName = record.displayName; 50 + await updateSession(event, { 51 + did: user.did, 52 + handle: user.handle, 53 + displayName: record.displayName, 54 + }); 55 + return { success: true }; 56 + } catch { 57 + return fail(400, { action: "displayName", error: "Failed to update." }); 58 + } 59 + }, 60 + purge: async (event) => { 61 + const { user } = event.locals; 62 + if (user === undefined) return; 63 + const rpc = new Client({ handler: user.session }); 64 + const result = await rpc.post("com.atproto.repo.deleteRecord", { 65 + input: { 66 + repo: user.did, 67 + collection: "social.attic.actor.profile", 68 + rkey: "self", 69 + }, 70 + }); 71 + if (result.ok) { 72 + await destroySession(event); 73 + redirect(303, "/"); 74 + } 75 + return fail(400, { action: "purge", error: "Failed to purge." }); 21 76 }, 22 77 } satisfies Actions;
+19 -2
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { PageProps } from "./$types.d.ts"; 3 3 let { data, form }: PageProps = $props(); 4 + 5 + const confirmPurge = (ev: SubmitEvent) => { 6 + if (confirm("Are you sure?")) { 7 + return; 8 + } 9 + ev.preventDefault(); 10 + }; 4 11 </script> 5 12 6 13 {#if data.user} ··· 8 15 <div class="avatar"> 9 16 <img alt="avatar" src="/avatar/{data.user.did}" width="50" height="50" /> 10 17 <p>{data.user.displayName}</p> 18 + <p><small>@{data.user.handle}</small></p> 11 19 </div> 12 - 13 20 <form method="POST" action="?/displayName"> 14 21 <h2>Attic settings</h2> 22 + {#if form?.action === "displayName" && form?.error} 23 + <p class="error">{form.error}</p> 24 + {/if} 15 25 <label for="displayName">Display name</label> 16 26 <input 17 27 type="text" ··· 21 31 /> 22 32 <button type="submit">Update</button> 23 33 </form> 24 - 25 34 <form method="POST" action="?/logout"> 26 35 <h2>Bye!</h2> 27 36 <button type="submit">Sign out</button> 37 + </form> 38 + <form method="POST" action="?/purge" onsubmit={confirmPurge}> 39 + <h2>Purge data</h2> 40 + <p>Delete all Attic records and sign out.</p> 41 + {#if form?.action === "purge" && form?.error} 42 + <p class="error">{form.error}</p> 43 + {/if} 44 + <button type="submit">Confirm</button> 28 45 </form> 29 46 {:else} 30 47 <form method="POST" action="?/login">
+39 -35
src/routes/oauth/callback/+server.ts
··· 1 - import { dev } from "$app/environment"; 2 - import { 3 - HANDLE_COOKIE, 4 - SESSION_COOKIE, 5 - SESSION_MAX_AGE, 6 - } from "$lib/server/constants.ts"; 7 - import { encryptText } from "$lib/server/crypto.ts"; 1 + import { HANDLE_COOKIE } from "$lib/server/constants.ts"; 8 2 import { createOAuthClient } from "$lib/server/oauth.ts"; 9 - import type { PublicUserData } from "$lib/valibot.ts"; 3 + import { parseActorProfile, type PublicUserData } from "$lib/valibot.ts"; 10 4 import { AppBskyActorGetProfile } from "@atcute/bluesky"; 11 5 import { Client, ok } from "@atcute/client"; 12 6 import { isHandle } from "@atcute/lexicons/syntax"; 13 7 import type { OAuthSession } from "@atcute/oauth-node-client"; 14 8 import { redirect } from "@sveltejs/kit"; 9 + import { updateSession } from "../../../lib/server/session.ts"; 15 10 import type { RequestHandler } from "./$types.d.ts"; 16 11 17 12 export const GET: RequestHandler = async (event) => { ··· 35 30 redirect(303, "/?error=session"); 36 31 } 37 32 38 - const data: PublicUserData = { 33 + const user: PublicUserData = { 39 34 handle, 40 35 did: session.did, 41 36 displayName: handle, 42 37 }; 38 + 39 + const rpc = new Client({ handler: session }); 43 40 44 41 try { 45 - const rpc = new Client({ handler: session }); 46 - const profile = await ok( 47 - rpc.call(AppBskyActorGetProfile, { 48 - params: { actor: session.did }, 49 - }), 50 - ); 51 - if (profile.displayName) { 52 - data.displayName = profile.displayName; 53 - } 42 + const result = await ok(rpc.get("com.atproto.repo.getRecord", { 43 + params: { 44 + repo: user.did, 45 + collection: "social.attic.actor.profile", 46 + rkey: "self", 47 + }, 48 + })); 49 + const profile = parseActorProfile(result.value); 50 + user.displayName = profile.displayName; 54 51 } catch { 55 - // No Bluesky account? 52 + try { 53 + await ok(rpc.call(AppBskyActorGetProfile, { 54 + params: { actor: user.did }, 55 + })).then((result) => { 56 + if (result.displayName) { 57 + user.displayName = result.displayName; 58 + } 59 + }).catch(() => {/* No Bluesky */}); 60 + const create = await rpc.post("com.atproto.repo.putRecord", { 61 + input: { 62 + repo: user.did, 63 + collection: "social.attic.actor.profile", 64 + rkey: "self", 65 + record: { displayName: user.displayName }, 66 + }, 67 + }); 68 + if (create.ok === false) { 69 + throw new Error(); 70 + } 71 + } catch (err) { 72 + console.log(err); 73 + redirect(303, "/?fail"); 74 + } 56 75 } 57 76 58 - const encrypted = await encryptText( 59 - JSON.stringify(data), 60 - platform.env.PRIVATE_COOKIE_KEY, 61 - ); 62 - 63 - cookies.set( 64 - SESSION_COOKIE, 65 - encrypted, 66 - { 67 - httpOnly: true, 68 - maxAge: SESSION_MAX_AGE, 69 - path: "/", 70 - sameSite: "lax", 71 - secure: !dev, 72 - }, 73 - ); 77 + await updateSession(event, user); 74 78 75 79 redirect(303, "/"); 76 80 };
+1 -1
tsconfig.json
··· 11 11 "sourceMap": true, 12 12 "strict": true, 13 13 "moduleResolution": "bundler", 14 - "types": [], 14 + "types": ["@atcute/atproto", "@atcute/bluesky"], 15 15 }, 16 16 }