From 1d8443d797e1cf7c2f543fbbc05dbd0410fb2caa Mon Sep 17 00:00:00 2001 From: sovITxyz Date: Sun, 19 Jul 2026 06:55:44 -0600 Subject: [PATCH 1/2] feat: configurable Nostr profile (kind 0) from the dashboard (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /dashboard/profile: edit name, display_name, about, picture, banner, website, nip05, lud16, lud06; prefilled from the stored kind 0 (columns + raw content JSON), unknown content keys preserved through config.extra - public/js/profile.js: build + sign client-side via NbreadSigner, mirror to /api/mirror, NIP-42 broadcast to editorRelays, NIP-55 redirect/resume, Blossom uploads for picture/banner - /api/mirror now accepts kind 0 (own-key only; same rate limit + blocked gates; single replaceable slot so no storage growth) - profiles.lud16 column (migration 0006) + upsertProfile parsing — the NIP-57 zaps prerequisite - suggested nip05 @nbread.lol; surfaced the settings-about override --- migrations/0006_profile_lud16.sql | 5 + public/js/profile.js | 453 ++++++++++++++++++++++++++++++ src/routes/api.ts | 15 +- src/routes/dashboard.ts | 31 ++ src/services/profiles.ts | 98 ++++++- src/views/main/dashboard.tsx | 8 + src/views/main/profile.tsx | 273 ++++++++++++++++++ test/helpers.ts | 47 ++++ test/integration/editor.spec.ts | 17 +- test/integration/profile.spec.ts | 266 ++++++++++++++++++ 10 files changed, 1201 insertions(+), 12 deletions(-) create mode 100644 migrations/0006_profile_lud16.sql create mode 100644 public/js/profile.js create mode 100644 src/views/main/profile.tsx create mode 100644 test/integration/profile.spec.ts diff --git a/migrations/0006_profile_lud16.sql b/migrations/0006_profile_lud16.sql new file mode 100644 index 0000000..d9b0255 --- /dev/null +++ b/migrations/0006_profile_lud16.sql @@ -0,0 +1,5 @@ +-- Migration number: 0006 profile lud16 +-- Configurable profile (#11): dedicated column for the lightning address so +-- the render path can read it without parsing profiles.raw per request. +-- lud16 is the prerequisite NIP-57 zaps (#12) consume server-side. +ALTER TABLE profiles ADD COLUMN lud16 TEXT; diff --git a/public/js/profile.js b/public/js/profile.js new file mode 100644 index 0000000..3a26ccf --- /dev/null +++ b/public/js/profile.js @@ -0,0 +1,453 @@ +// Profile glue: build the user's kind 0 metadata event from the dashboard +// profile form, sign it through the NbreadSigner abstraction (NIP-07 +// extension, NIP-46 remote bunker, NIP-55/Amber redirect, or a stored local +// key), broadcast it to the user's relays client-side (best-effort, with +// NIP-42 AUTH support), and POST the signed event to /api/mirror so the +// stored profile updates immediately. Field values the form does not edit +// (cfg.extra — custom keys other clients published) are merged back into the +// content so a save never erases them. No secret key ever enters this file. +// NIP-55 signing round-trips through a full page redirect — the resume block +// below picks the flow back up when Amber sends the user back. +(function () { + "use strict"; + + var cfgEl = document.getElementById("profile-config"); + var form = document.getElementById("profile-form"); + if (!cfgEl || !form) return; + + var cfg; + try { + cfg = JSON.parse(cfgEl.textContent || "{}"); + } catch (e) { + return; + } + + // Form field ids, keyed by the kind 0 content key they edit. + var FIELD_IDS = { + name: "profile-name", + display_name: "profile-display-name", + about: "profile-about", + picture: "profile-picture", + banner: "profile-banner", + website: "profile-website", + nip05: "profile-nip05", + lud16: "profile-lud16", + lud06: "profile-lud06", + }; + + var statusEl = document.getElementById("profile-status"); + var publishBtn = document.getElementById("profile-publish"); + + function say(message) { + if (statusEl) statusEl.textContent = message; + } + + // A signer must be configured AND belong to the identity this dashboard + // session is signed in as (same guard as editor.js — see the NIP-55 note + // there: ready() only reports ok with a stored pubkey, so getPublicKey() + // never redirects from here). + async function ensureSigner() { + var r; + try { + r = await NbreadSigner.ready(); + } catch (e) { + r = { ok: false }; + } + if (!r || !r.ok) { + say( + "No signer configured in this browser — open " + + location.origin + + "/login, choose a signing method, then come back and retry.", + ); + return false; + } + var pk; + try { + pk = await NbreadSigner.getPublicKey(); + } catch (e) { + say(String((e && e.message) || e)); + return false; + } + if (pk !== cfg.pubkey) { + say( + "This browser's signer is a different Nostr identity than the one signed in. Sign out and back in, or switch signer on the login page.", + ); + return false; + } + return true; + } + + function nowSeconds() { + return Math.floor(Date.now() / 1000); + } + + // An edit must WIN the replaceable (pubkey, 0, '') slot: created_at + // strictly greater than the stored version's (ties break on id and can + // lose). + function nextCreatedAt() { + var prev = typeof cfg.prevCreatedAt === "number" ? cfg.prevCreatedAt : 0; + return Math.max(nowSeconds(), prev + 1); + } + + // Metadata object for the kind 0 content: the preserved non-form keys + // first, then every non-empty form field on top. A field the user cleared + // is simply absent (kind 0 is a full replacement, absence deletes it). + function buildMetadata() { + var out = {}; + var extra = cfg.extra; + if (extra !== null && typeof extra === "object" && !Array.isArray(extra)) { + for (var k in extra) { + if (Object.prototype.hasOwnProperty.call(extra, k)) out[k] = extra[k]; + } + } + for (var key in FIELD_IDS) { + if (!Object.prototype.hasOwnProperty.call(FIELD_IDS, key)) continue; + var el = document.getElementById(FIELD_IDS[key]); + var value = el && el.value ? el.value : ""; + value = value.replace(/\r\n?/g, "\n").trim(); + if (value !== "") out[key] = value; + } + return out; + } + + // Best-effort broadcast with NIP-42 AUTH — same contract as editor.js + // (resolves once every relay attempt finishes, never rejects; redirect + // signers skip AUTH because signing would navigate away mid-broadcast). + function broadcast(event) { + var relays = Array.isArray(cfg.relays) ? cfg.relays : []; + var message = JSON.stringify(["EVENT", event]); + var attempts = relays.map(function (url) { + return new Promise(function (resolve) { + var ws = null; + var done = false; + var timer = null; + var startedAt = Date.now(); + var authTried = false; // at most one AUTH round per connection + var authEventId = null; // our kind-22242 id, to match its OK frame + var eventResent = false; // EVENT resent after an accepted AUTH + function finish() { + if (done) return; + done = true; + if (timer !== null) clearTimeout(timer); + try { + if (ws) ws.close(); + } catch (e) { + /* already closed */ + } + resolve(); + } + // (Re)arm the deadline as "totalMs after the connection started", + // so an AUTH extension is a total budget, not a fresh window. + function deadline(totalMs) { + if (done) return; + if (timer !== null) clearTimeout(timer); + timer = setTimeout(finish, Math.max(0, startedAt + totalMs - Date.now())); + } + function sendEvent() { + try { + ws.send(message); + } catch (e) { + finish(); + } + } + function handleAuthChallenge(challenge) { + if (NbreadSigner.isRedirectSigner()) return; + deadline(8000); // signing + the extra round-trip needs headroom + var unsignedAuth = { + kind: 22242, + created_at: nowSeconds(), + tags: [ + ["relay", url], + ["challenge", challenge], + ], + content: "", + }; + NbreadSigner.signEvent(unsignedAuth).then( + function (signedAuth) { + if (done) return; + if (!signedAuth || typeof signedAuth.id !== "string") return; + authEventId = signedAuth.id; + try { + ws.send(JSON.stringify(["AUTH", signedAuth])); + } catch (e) { + finish(); + } + }, + function () { + /* signing declined/failed — just wait out the deadline */ + }, + ); + } + function handleFrame(frame) { + if (!Array.isArray(frame)) return; + if (frame[0] === "OK") { + if (frame[1] === event.id) { + // NIP-42 pre-auth rejection (see editor.js): an OK false + // "auth-required:" must not end the attempt while our AUTH + // round can still complete and resend the EVENT. + var authRequired = + frame[2] === false && + typeof frame[3] === "string" && + frame[3].indexOf("auth-required:") === 0; + if (authRequired && !eventResent && !NbreadSigner.isRedirectSigner()) { + return; + } + finish(); // the relay answered for OUR event — done either way + } else if (authEventId !== null && frame[1] === authEventId) { + if (frame[2] === true) { + eventResent = true; + sendEvent(); + } else { + finish(); + } + } + return; + } + if (frame[0] === "AUTH" && typeof frame[1] === "string" && !authTried) { + authTried = true; + handleAuthChallenge(frame[1]); + } + // NOTICE / CLOSED / anything else: keep waiting for the deadline. + } + try { + ws = new WebSocket(url); + } catch (e) { + resolve(); + return; + } + deadline(3000); + ws.onopen = function () { + sendEvent(); + }; + ws.onmessage = function (m) { + try { + handleFrame(JSON.parse(m.data)); + } catch (e) { + finish(); // unparseable frame — treat like "any reply ends it" + } + }; + ws.onerror = finish; + ws.onclose = finish; + }); + }); + return Promise.all(attempts); + } + + // POST the signed event to the server mirror (updates the stored profile + // immediately). Throws with the server's error message on rejection. + async function postMirror(event) { + var res = await fetch("/api/mirror", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + }); + var data = {}; + try { + data = await res.json(); + } catch (e) { + /* non-JSON error body */ + } + if (!res.ok) { + throw new Error(data.error || data.result || "mirror failed (" + res.status + ")"); + } + return data.result; + } + + function leaveSaved() { + window.location.href = "/dashboard/profile?published=1"; + } + + async function signMirrorBroadcast(unsigned) { + if (NbreadSigner.isRedirectSigner()) { + // NIP-55 (Amber): signer.js completes the unsigned event, stashes the + // pending record (kind 0 buckets as "publish"), and NAVIGATES to the + // Amber intent — the promise never settles; the resume block below + // picks the flow back up after the redirect. + say("Handing off to your signing app…"); + await NbreadSigner.signEvent(unsigned); + return; // unreachable with nip55; defensive for future redirect backends + } + say("Waiting for your signature…"); + var signed = await NbreadSigner.signEvent(unsigned); + say("Publishing your profile…"); + var result = await postMirror(signed); + if (result !== "stored") { + throw new Error("unexpected mirror result: " + result); + } + say("Broadcasting to your relays…"); + await broadcast(signed); + leaveSaved(); + } + + // --- NIP-55 resume + no-resign retry -------------------------------------- + // Same shape as editor.js: a resumed signed event that fails at the mirror + // stays armed in these closure variables (and re-stashed under signer.js's + // pending key) so the next click retries WITHOUT re-signing. + var resumedSigned = null; // verified signed kind 0 awaiting publish retry + + function restashResumed() { + try { + var record = globalThis.NbreadSignerCore.makePendingRecord({ + kind: "publish", + unsigned: resumedSigned, + returnTo: location.href, + nowSec: nowSeconds(), + }); + localStorage.setItem("nbread:nip55:pending", JSON.stringify(record)); + } catch (e) { + /* quota/private mode — in-memory retry still available this load */ + } + } + + function discardResumed() { + resumedSigned = null; + try { + localStorage.removeItem("nbread:nip55:pending"); + } catch (e) { + /* nothing stashed */ + } + } + + async function publishResumed() { + var signed = resumedSigned; + say("Publishing your profile…"); + var result = await postMirror(signed); + if (result !== "stored") { + throw new Error("unexpected mirror result: " + result); + } + resumedSigned = null; + try { + localStorage.removeItem("nbread:nip55:pending"); + } catch (e) { + /* nothing stashed */ + } + say("Broadcasting to your relays…"); + await broadcast(signed); + leaveSaved(); + } + + async function retryResumedOrDiscard() { + try { + await publishResumed(); + } catch (e) { + discardResumed(); + throw new Error( + String((e && e.message) || e) + + " — the saved signature was discarded; press the button again to sign a new version.", + ); + } + } + + // Consume a NIP-55 callback exactly once, before the button handler is + // wired. Only kind 0 publish records belong to this page (the pending + // record's returnTo pins the callback to the URL that stashed it). + (function resumeNip55() { + var pending = null; + try { + pending = NbreadSigner.resumePending(); + } catch (e) { + return; // malformed callback — nothing to resume + } + if (!pending) return; + if (pending.error) { + if (pending.kind !== "login") { + say("Signing cancelled or failed: " + pending.error); + } + return; + } + if (pending.kind !== "publish") return; + if (!pending.signed || pending.signed.kind !== 0) return; + resumedSigned = pending.signed; + say("Resuming…"); + if (publishBtn) publishBtn.disabled = true; + publishResumed() + .catch(function (e) { + restashResumed(); + say( + String((e && e.message) || e) + + ' — press "Sign & publish profile" again to retry without re-signing.', + ); + }) + .then(function () { + if (publishBtn) publishBtn.disabled = false; + }); + })(); + + // Publish goes through the button, never a native form submit. + form.addEventListener("submit", function (e) { + e.preventDefault(); + }); + + if (publishBtn) { + publishBtn.addEventListener("click", async function () { + publishBtn.disabled = true; + try { + // Retry path for a resumed NIP-55 publish whose mirror POST failed. + if (resumedSigned) { + await retryResumedOrDiscard(); + return; + } + if (!(await ensureSigner())) return; + var metadata = buildMetadata(); + if ( + Object.keys(metadata).length === 0 && + !window.confirm( + "All fields are empty — publish an empty profile? This clears your name, picture, and bio everywhere.", + ) + ) { + return; + } + await signMirrorBroadcast({ + kind: 0, + created_at: nextCreatedAt(), + tags: [], + content: JSON.stringify(metadata), + }); + return; + } catch (e) { + say(String((e && e.message) || e)); + } finally { + publishBtn.disabled = false; + } + }); + } + + // --- Blossom upload wiring (picture / banner) ----------------------------- + // Each "Upload image" button opens its hidden file input; a chosen file is + // uploaded direct-from-browser (public/js/blossom.js) and the returned URL + // dropped into the matching text field. Redirect signers are rejected by + // uploadBlob itself with a paste-a-URL message. + var uploadButtons = document.querySelectorAll("button[data-upload-target]"); + Array.prototype.forEach.call(uploadButtons, function (btn) { + var targetId = btn.getAttribute("data-upload-target"); + var fileInput = document.querySelector( + 'input[type="file"][data-upload-for="' + targetId + '"]', + ); + var urlInput = document.getElementById(targetId); + if (!fileInput || !urlInput) return; + + btn.addEventListener("click", function () { + fileInput.click(); + }); + + fileInput.addEventListener("change", async function () { + var file = fileInput.files && fileInput.files[0]; + fileInput.value = ""; // allow re-picking the same file after a failure + if (!file) return; + if (!(await ensureSigner())) return; + btn.disabled = true; + say("Uploading image…"); + try { + var result = await NbreadBlossom.uploadBlob(file, { + signer: NbreadSigner, + }); + urlInput.value = result.url; + say("Image uploaded — remember to publish your profile."); + } catch (e) { + say(String((e && e.message) || e)); + } finally { + btn.disabled = false; + } + }); + }); +})(); diff --git a/src/routes/api.ts b/src/routes/api.ts index b671cc8..3b76899 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -105,11 +105,18 @@ apiRoutes.post("/mirror", async (c) => { return c.json({ error: "event pubkey does not match the signed-in key" }, 403); } - // Only long-form posts and deletes flow through the editor; profiles and - // everything else arrive via the relay sync paths (cron refresh). - if (ev.kind !== 30023 && ev.kind !== 5) { + // Long-form posts and deletes flow through the editor; kind 0 through the + // dashboard profile form (#11) — mirrorEvent already routes it to + // upsertProfile. Everything else arrives via the relay sync paths (cron + // refresh). The MAX_POSTS_PER_PUBKEY cap below is 30023-only by design: + // kind 0 occupies a single replaceable (pubkey, 0, '') slot, so it cannot + // grow storage per save. + if (ev.kind !== 30023 && ev.kind !== 5 && ev.kind !== 0) { return c.json( - { error: "only kind 30023 (post) and kind 5 (delete) are accepted" }, + { + error: + "only kind 30023 (post), kind 5 (delete), and kind 0 (profile) are accepted", + }, 400, ); } diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 543b9d6..64e87c7 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -13,6 +13,7 @@ import { } from "../services/users"; import { rateLimitAllows } from "../services/ratelimit"; import { getPost, listPostsByPubkey, rowToEvent } from "../services/events"; +import { getProfile, storedProfileContent } from "../services/profiles"; import { bumpGen } from "../services/mirror"; import { renderPost } from "../markdown"; import { sanitizeCss, MAX_THEME_CSS_LENGTH } from "../markdown/css-sanitize"; @@ -21,6 +22,7 @@ import { relayList } from "../cron/refresh"; import { selfRelayUrl } from "../relay/url"; import { DashboardPage, type DashboardPost } from "../views/main/dashboard"; import { EditorPage } from "../views/main/editor"; +import { ProfilePage } from "../views/main/profile"; /** * Dashboard (apex only, session required): handle claim (P4), the signed-in @@ -399,6 +401,35 @@ dashboardRoutes.post("/settings", async (c) => { return c.redirect("/dashboard?saved=1", 303); }); +// --- GET /dashboard/profile — kind 0 profile editor (#11) ----------------------- +// Prefill comes from the stored profiles row: profileContentFields parses the +// raw kind 0 event so the fields WITHOUT a dedicated column (display_name, +// banner, website, lud06) round-trip too. prevCreatedAt is the stored event's +// created_at — the client must strictly exceed it to win the replaceable +// (pubkey, 0, '') slot, exactly like the post editor. Signing + publishing is +// entirely client-side (public/js/profile.js → /api/mirror + relay broadcast). +dashboardRoutes.get("/profile", async (c) => { + const sess = c.var.session; + if (!sess) return c.redirect("/login", 302); + const user = await getUserByPubkey(c.env, sess.pubkey); + const profile = await getProfile(c.env, sess.pubkey); + const settings = readBlogSettings(user?.settings ?? "{}"); + const { fields, extra } = storedProfileContent(profile?.raw ?? ""); + return c.html( + ProfilePage({ + pubkey: sess.pubkey, + handle: user?.handle ?? null, + mainHost: c.env.MAIN_HOST.toLowerCase(), + fields, + extra, + prevCreatedAt: profile?.updated_at ?? null, + relays: editorRelays(c.env, user), + settingsAboutSet: settings.about.trim() !== "", + published: c.req.query("published") === "1", + }), + ); +}); + // --- Editor pages ---------------------------------------------------------------- dashboardRoutes.get("/posts/new", async (c) => { diff --git a/src/services/profiles.ts b/src/services/profiles.ts index afd0ea7..2754f1a 100644 --- a/src/services/profiles.ts +++ b/src/services/profiles.ts @@ -7,16 +7,33 @@ export type ProfileRow = { picture: string | null; about: string | null; nip05: string | null; + lud16: string | null; raw: string; updated_at: number; }; // Field caps: kind 0 content is untrusted relay data; keep stored fields // bounded (views escape on output, but there is no reason to persist blobs). -const MAX_NAME = 200; -const MAX_PICTURE = 1_000; -const MAX_ABOUT = 2_000; -const MAX_NIP05 = 320; +// Exported for the dashboard profile form, whose maxlength attributes must +// match what upsertProfile persists (fields without a column — display_name, +// banner, website, lud06 — only live in the event content and use the same +// caps as their column-backed siblings). +export const PROFILE_FIELD_MAX = { + name: 200, + display_name: 200, + picture: 1_000, + banner: 1_000, + website: 1_000, + about: 2_000, + nip05: 320, + lud16: 320, // user@domain, same shape family as nip05 + lud06: 1_000, // bech32 LNURL strings run long +} as const; +const MAX_NAME = PROFILE_FIELD_MAX.name; +const MAX_PICTURE = PROFILE_FIELD_MAX.picture; +const MAX_ABOUT = PROFILE_FIELD_MAX.about; +const MAX_NIP05 = PROFILE_FIELD_MAX.nip05; +const MAX_LUD16 = PROFILE_FIELD_MAX.lud16; /** Trimmed, length-capped string field, or null when absent/not a string. */ function strField(value: unknown, max: number): string | null { @@ -41,6 +58,7 @@ export async function upsertProfile(env: Env, ev: NostrEvent): Promise { let picture: string | null = null; let about: string | null = null; let nip05: string | null = null; + let lud16: string | null = null; try { const data: unknown = JSON.parse(ev.content); if (data !== null && typeof data === "object" && !Array.isArray(data)) { @@ -49,17 +67,19 @@ export async function upsertProfile(env: Env, ev: NostrEvent): Promise { picture = strField(d.picture, MAX_PICTURE); about = strField(d.about, MAX_ABOUT); nip05 = strField(d.nip05, MAX_NIP05); + lud16 = strField(d.lud16, MAX_LUD16); } } catch { // malformed kind 0 content → all-null profile fields } await env.DB.prepare( - `INSERT INTO profiles (pubkey, name, picture, about, nip05, raw, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + `INSERT INTO profiles (pubkey, name, picture, about, nip05, lud16, raw, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(pubkey) DO UPDATE SET name = excluded.name, picture = excluded.picture, about = excluded.about, nip05 = excluded.nip05, + lud16 = excluded.lud16, raw = excluded.raw, updated_at = excluded.updated_at WHERE excluded.updated_at >= profiles.updated_at`, ) @@ -69,6 +89,7 @@ export async function upsertProfile(env: Env, ev: NostrEvent): Promise { picture, about, nip05, + lud16, JSON.stringify(pickEventFields(ev)), ev.created_at, ) @@ -85,3 +106,68 @@ export async function getProfile( .first(); return row ?? null; } + +/** The kind 0 content fields the dashboard profile form edits. */ +export type ProfileContentFields = { + name: string; + display_name: string; + about: string; + picture: string; + banner: string; + website: string; + nip05: string; + lud16: string; + lud06: string; +}; + +/** + * Parse a stored `profiles.raw` event back into the editable content fields + * (the columns only cover a subset — display_name/banner/website/lud06 live + * solely in the event content) plus every OTHER content key verbatim. The + * profile form republishes the whole kind 0, so unknown fields (custom app + * metadata, deprecated aliases) must ride along or a save would silently + * erase them. Defensive on both JSON layers: malformed relay data prefills + * as empty, never throws. Fields get the same trim + caps as upsertProfile + * so the form shows exactly what a re-publish would persist. + */ +export function storedProfileContent(raw: string): { + fields: ProfileContentFields; + extra: Record; +} { + const fields: ProfileContentFields = { + name: "", + display_name: "", + about: "", + picture: "", + banner: "", + website: "", + nip05: "", + lud16: "", + lud06: "", + }; + const extra: Record = {}; + try { + const ev: unknown = JSON.parse(raw); + if (ev === null || typeof ev !== "object" || Array.isArray(ev)) { + return { fields, extra }; + } + const content = (ev as Record).content; + if (typeof content !== "string") return { fields, extra }; + const data: unknown = JSON.parse(content); + if (data === null || typeof data !== "object" || Array.isArray(data)) { + return { fields, extra }; + } + const d = data as Record; + for (const key of Object.keys(d)) { + if (Object.hasOwn(fields, key)) { + const k = key as keyof ProfileContentFields; + fields[k] = strField(d[k], PROFILE_FIELD_MAX[k]) ?? ""; + } else { + extra[key] = d[key]; + } + } + } catch { + // malformed raw/content JSON → empty prefill + } + return { fields, extra }; +} diff --git a/src/views/main/dashboard.tsx b/src/views/main/dashboard.tsx index 3f5636a..528cdd6 100644 --- a/src/views/main/dashboard.tsx +++ b/src/views/main/dashboard.tsx @@ -140,6 +140,14 @@ export function DashboardPage(props: { )} +
+

Your profile

+

+ Name, avatar, bio, lightning address — your public Nostr profile + (kind 0). Edit profile +

+
+

Blog settings

diff --git a/src/views/main/profile.tsx b/src/views/main/profile.tsx new file mode 100644 index 0000000..9394cda --- /dev/null +++ b/src/views/main/profile.tsx @@ -0,0 +1,273 @@ +import { Layout } from "../layout"; +import { SiteHeader, SiteFooter } from "./chrome"; +import { + PROFILE_FIELD_MAX, + type ProfileContentFields, +} from "../../services/profiles"; + +/** + * Profile editor page (apex, session required): edit and publish the user's + * Nostr kind 0 metadata. Exactly like the post editor, all signing happens + * client-side (public/js/profile.js through NbreadSigner); this page ships + * the form prefilled from the stored profile plus a JSON config blob. The + * picture/banner fields get a Blossom upload button (public/js/blossom.js). + * + * XSS notes: every prefill value renders through hono/jsx auto-escaping + * (profile fields are relay-sourced and hostile by assumption). The config + * JSON is embedded in a non-executable script tag with every `<` escaped so + * a crafted relay URL can never break out with ``. + */ +export function ProfilePage(props: { + pubkey: string; + handle: string | null; + mainHost: string; + fields: ProfileContentFields; + extra: Record; // non-form kind 0 keys, preserved on publish + prevCreatedAt: number | null; // stored kind 0's created_at (edit must exceed it) + relays: string[]; + settingsAboutSet: boolean; // dashboard "About" setting shadows the kind-0 about + published: boolean; +}) { + const config = { + pubkey: props.pubkey, + relays: props.relays, + prevCreatedAt: props.prevCreatedAt, + extra: props.extra, + }; + const configJson = JSON.stringify(config).replace(/ + +
+

Edit profile

+

+ ← Dashboard +

+

+ Your public Nostr profile (a kind 0 event). Publishing + signs it with your key and broadcasts it to your relays — it updates + your blog header here and everywhere else on Nostr. +

+ + {props.published ? ( +

+ Profile published. +

+ ) : null} + + +

+ +

+

+ +

+

+ + {props.settingsAboutSet ? ( + <> +
+ + Note: the “About” text in your blog settings currently + overrides this bio in your blog header. Clear it there if you + want this one shown. + + + ) : null} +

+

+ {" "} + + +

+

+ {" "} + + +

+

+ +

+

+ + {suggestedNip05 ? ( + <> +
+ + {suggestedNip05} is already verified for your + handle here — keep it, or point it anywhere else you verify. + + + ) : null} +

+

+ +
+ Lets readers send you sats (zaps). +

+

+ +

+ +

+ +

+ +

+ + + + + + + + +
+ + + ); +} diff --git a/test/helpers.ts b/test/helpers.ts index 11015de..24db8ac 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -188,6 +188,53 @@ export function signPostEvent(opts: { ) as NostrEvent; } +/** + * Sign a kind 0 profile with a committed fixture key. `content` may be the + * metadata object (stringified here, like public/js/profile.js does) or a + * raw string for malformed-content tests. + */ +export function signProfileEvent(opts: { + sk?: string; + content: string | Record; + created_at: number; + tags?: string[][]; +}): NostrEvent { + return finalizeEvent( + { + kind: 0, + created_at: opts.created_at, + tags: opts.tags ?? [], + content: + typeof opts.content === "string" + ? opts.content + : JSON.stringify(opts.content), + }, + hexToBytes(opts.sk ?? ALICE_SK), + ) as NostrEvent; +} + +/** + * Sign an event of an arbitrary kind with a fixture key — rejection-path + * tests (kinds the mirror/relay must refuse) build their probes with this. + */ +export function signRawEvent(opts: { + sk?: string; + kind: number; + created_at: number; + tags?: string[][]; + content?: string; +}): NostrEvent { + return finalizeEvent( + { + kind: opts.kind, + created_at: opts.created_at, + tags: opts.tags ?? [], + content: opts.content ?? "", + }, + hexToBytes(opts.sk ?? ALICE_SK), + ) as NostrEvent; +} + /** * Sign a kind 5 delete with a committed fixture key. Mirrors editor.js: * e-tag the stored event id, a-tag the replaceable address. diff --git a/test/integration/editor.spec.ts b/test/integration/editor.spec.ts index 67f6696..03d7aa7 100644 --- a/test/integration/editor.spec.ts +++ b/test/integration/editor.spec.ts @@ -16,6 +16,7 @@ import { sessionCookieFor, signDeleteEvent, signPostEvent, + signRawEvent, findXssVectors, } from "../helpers"; import type { NostrEvent } from "../../src/nostr/event"; @@ -110,12 +111,24 @@ describe("POST /api/mirror — auth gates", () => { expect(row).toBeNull(); }); - it("rejects kinds other than 30023 and 5 (400)", async () => { + it("rejects kinds other than 30023, 5, and 0 (400)", async () => { const cookie = await sessionCookieFor(ALICE_PK); - const res = await postMirror(aliceProfile, { Cookie: cookie }); + const note = signRawEvent({ + kind: 1, + created_at: 1_700_000_500, + content: "a short text note", + }); + const res = await postMirror(note, { Cookie: cookie }); expect(res.status).toBe(400); }); + it("accepts a kind 0 profile for the OWN pubkey (#11)", async () => { + const cookie = await sessionCookieFor(ALICE_PK); + const res = await postMirror(aliceProfile, { Cookie: cookie }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ result: "stored" }); + }); + it("rejects non-event JSON (400)", async () => { const cookie = await sessionCookieFor(ALICE_PK); const res = await postMirror({ not: "an event" }, { Cookie: cookie }); diff --git a/test/integration/profile.spec.ts b/test/integration/profile.spec.ts new file mode 100644 index 0000000..f42e4fb --- /dev/null +++ b/test/integration/profile.spec.ts @@ -0,0 +1,266 @@ +// #11: configurable Nostr profile — the /dashboard/profile editor page +// (prefill from the stored kind 0, config JSON, XSS discipline) and the +// /api/mirror kind 0 path (tenant isolation, lud16 persistence, replaceable +// newest-wins semantics). +import { SELF, env } from "cloudflare:test"; +import { beforeEach, describe, expect, it } from "vitest"; +import fixtures from "../fixtures/events.json"; +import { + ALICE_PK, + BOB_SK, + resetMirrorState, + resetRateLimits, + seedAlice, + sessionCookieFor, + signProfileEvent, +} from "../helpers"; +import type { NostrEvent } from "../../src/nostr/event"; +import { mirrorEvent } from "../../src/services/mirror"; +import { + getProfile, + PROFILE_FIELD_MAX, + storedProfileContent, +} from "../../src/services/profiles"; + +const aliceProfile = fixtures.profiles.alice as NostrEvent; + +function getProfilePage(headers: Record = {}): Promise { + return SELF.fetch("https://nbread.lol/dashboard/profile", { + headers: { "CF-Connecting-IP": "203.0.113.77", ...headers }, + redirect: "manual", + }); +} + +function postMirror( + event: unknown, + headers: Record = {}, +): Promise { + return SELF.fetch("https://nbread.lol/api/mirror", { + method: "POST", + headers: { + "Content-Type": "application/json", + "CF-Connecting-IP": "203.0.113.77", + ...headers, + }, + body: JSON.stringify(event), + }); +} + +/** The embedded profile-config JSON blob of a rendered page. */ +function extractConfig(html: string): { + pubkey: string; + relays: string[]; + prevCreatedAt: number | null; + extra: Record; +} { + const m = + /'; + const ev = signProfileEvent({ + created_at: 1_700_000_100, + content: { + name: '">', + about: hostile, + picture: "javascript:alert(1)", + // Unknown key → lands raw in the config JSON blob; the `<` escaping + // must keep it from closing the non-executable script tag. + evil: hostile, + }, + }); + expect(await mirrorEvent(env, ev)).toBe("stored"); + + const cookie = await sessionCookieFor(ALICE_PK); + const html = await (await getProfilePage({ Cookie: cookie })).text(); + // Attribute/text prefills render escaped — the raw payloads never appear + // as markup ("onerror=" as inert TEXT inside a quoted, entity-escaped + // value attribute is fine; the `"` that would close the attribute and the + // `<` that would open a tag are both escaped). + expect(html).not.toContain("'; const ev = signProfileEvent({ @@ -232,21 +247,62 @@ describe("POST /api/mirror — kind 0 (#11)", () => { }); describe("storedProfileContent", () => { - it("splits known fields from extra keys with caps applied", () => { + it("splits known fields from extra keys, prefilled faithfully (no trim/caps)", () => { + // Republishing must not silently rewrite over-cap or padded values the + // user published elsewhere — prefill is verbatim; only upsertProfile's + // COLUMN copies are capped. + const longAbout = "x".repeat(PROFILE_FIELD_MAX.about + 500); const raw = JSON.stringify({ content: JSON.stringify({ name: " padded ", - about: "x".repeat(PROFILE_FIELD_MAX.about + 50), + about: longAbout, custom: 42, }), }); const { fields, extra } = storedProfileContent(raw); - expect(fields.name).toBe("padded"); - expect(fields.about).toHaveLength(PROFILE_FIELD_MAX.about); + expect(fields.name).toBe(" padded "); + expect(fields.about).toBe(longAbout); expect(fields.lud16).toBe(""); expect(extra).toEqual({ custom: 42 }); }); + it("folds NIP-24 deprecated aliases into the canonical fields and drops them", () => { + const raw = JSON.stringify({ + content: JSON.stringify({ displayName: "Old Name", username: "olduser" }), + }); + const { fields, extra } = storedProfileContent(raw); + expect(fields.display_name).toBe("Old Name"); + expect(fields.name).toBe("olduser"); + // NOT preserved as extra — a republish removes the alias (NIP-24) so it + // can never fight a later edit of the canonical field. + expect(extra).toEqual({}); + }); + + it("canonical fields beat their deprecated aliases regardless of key order", () => { + const raw = JSON.stringify({ + content: JSON.stringify({ displayName: "Stale", display_name: "Current" }), + }); + const { fields, extra } = storedProfileContent(raw); + expect(fields.display_name).toBe("Current"); + expect(extra).toEqual({}); + }); + + it('preserves a literal "__proto__" content key as extra DATA', () => { + // A plain-object extra would lose the key to the inherited setter (and + // take the relay-controlled value as its prototype). + const raw = JSON.stringify({ + content: '{"name":"a","__proto__":{"custom":1},"other":"kept"}', + }); + const { extra } = storedProfileContent(raw); + expect(Object.getOwnPropertyDescriptor(extra, "__proto__")?.value).toEqual({ + custom: 1, + }); + expect(Object.getPrototypeOf(extra)).toBeNull(); + const json = JSON.stringify(extra); + expect(json).toContain('"__proto__":{"custom":1}'); + expect(json).toContain('"other":"kept"'); + }); + it("returns empty prefill on malformed raw or content JSON", () => { for (const raw of ["", "not json", "[]", '{"content":"not json"}', '{"content":"[1]"}']) { const { fields, extra } = storedProfileContent(raw);