From 9b825bfe1dff260c753166b121078bc32be2a405 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 16:20:04 +0200 Subject: [PATCH] External profile links (max 5 per user) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can attach up to 5 labelled URLs to their profile — social handles, blog, anything. They're shown on //list (the opt-in public list), behind target="_blank" rel="noopener noreferrer ugc" anchors so the destination tab can't script back into our window. Schema: - user_links(id, user_id, label, url, position, created_at) UNIQUE(user_id, position) to keep ordering stable, ON DELETE CASCADE so user deletion sweeps links. Wire: - UserLink type (id/label/url) - MeResponse.links: UserLink[] - PublicListResponse.links: UserLink[] - ProfileUpdateRequest.links?: { label, url }[] — bulk replace - USER_LINK_LIMITS exported so frontend constraints match server Validation (server/auth.ts): - Label 1-40 chars, trimmed - URL parseable + http:// or https:// only (no javascript:, data:, mailto:, etc.) - URL ≤ 500 chars - Max 5 links per user Bulk replace semantics with up-front validation, then DELETE + INSERT inside the same transaction as the user UPDATE. A username UNIQUE violation rolls back the link changes too — no half-applied state. Empty rows in the request are silently dropped so users can leave half-typed entries without a server rejection. Frontend Profile gets an "Eksterne lenker" section between the FriendsPanel and the Eksporter section. Five label+URL row pairs with add/remove buttons, save button, error → Bokmål mapping (link_label_required, link_url_bad_protocol, etc.). 93 tests still pass; typecheck clean; build ok. --- frontend/src/components/Profile.svelte | 116 +++++++++++++++++++++- frontend/src/components/PublicList.svelte | 19 +++- server/auth.ts | 97 ++++++++++++++++-- server/db.ts | 13 +++ server/users.ts | 2 + shared/types.ts | 22 +++- 6 files changed, 258 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte index 99eac75..66038cd 100644 --- a/frontend/src/components/Profile.svelte +++ b/frontend/src/components/Profile.svelte @@ -5,7 +5,8 @@ import { session } from '../lib/session.svelte'; import { downloadExport } from '../lib/export'; import FriendsPanel from './FriendsPanel.svelte'; - import type { InviteEntry } from '../../../shared/types'; + import type { InviteEntry, UserLink } from '../../../shared/types'; + import { USER_LINK_LIMITS } from '../../../shared/types'; interface Props { onDone: () => void; @@ -61,6 +62,71 @@ let pwSaving = $state(false); let pwChanged = $state(false); + // --- External profile links ---------------------------------------------- + // svelte-ignore state_referenced_locally + let linkRows: { label: string; url: string }[] = $state( + session.user?.links?.map((l) => ({ label: l.label, url: l.url })) ?? [], + ); + let linksSaving = $state(false); + let linksError: string | null = $state(null); + let linksSaved = $state(false); + + function addLinkRow() { + if (linkRows.length >= USER_LINK_LIMITS.maxPerUser) return; + linkRows = [...linkRows, { label: '', url: '' }]; + } + function removeLinkRow(i: number) { + linkRows = linkRows.filter((_, idx) => idx !== i); + } + + /** Map a server error code from the links endpoint to a friendly message. */ + function linkErrorMessage(code: string): string { + switch (code) { + case 'too_many_links': + return `Du kan ha maks ${USER_LINK_LIMITS.maxPerUser} lenker.`; + case 'link_label_required': + return 'Hver lenke trenger en kort etikett.'; + case 'link_label_too_long': + return `Etikett kan være maks ${USER_LINK_LIMITS.labelMax} tegn.`; + case 'link_url_required': + return 'Hver lenke trenger en adresse.'; + case 'link_url_too_long': + return 'Adressen er for lang.'; + case 'link_url_malformed': + return 'En av adressene er ikke en gyldig URL.'; + case 'link_url_bad_protocol': + return 'Adresser må starte med http:// eller https://.'; + default: + return 'Lagring feilet.'; + } + } + + async function saveLinks() { + linksError = null; + linksSaved = false; + linksSaving = true; + try { + // Drop empty rows so users can leave half-typed entries without a + // server rejection — they were probably about to delete that row anyway. + const cleaned = linkRows + .map((l) => ({ label: l.label.trim(), url: l.url.trim() })) + .filter((l) => l.label || l.url); + const next = await api.updateProfile({ links: cleaned }); + if (session.user) session.user.links = next.links; + linkRows = next.links.map((l: UserLink) => ({ label: l.label, url: l.url })); + linksSaved = true; + } catch (err) { + if (err instanceof ApiError && err.status === 400) { + const detail = err.detail as { error?: string } | null; + linksError = linkErrorMessage(detail?.error ?? 'generic'); + } else { + linksError = 'Lagring feilet.'; + } + } finally { + linksSaving = false; + } + } + // --- Invites ------------------------------------------------------------- let invites: InviteEntry[] = $state([]); let invitesError: string | null = $state(null); @@ -219,6 +285,54 @@ +
+ +

+ Legg til opptil {USER_LINK_LIMITS.maxPerUser} lenker — sosiale profiler, + blogg, hva som helst. De vises på den offentlige listen din (når du har + skrudd den på). +

+ + {#each linkRows as link, i (i)} +
+ + + +
+ {/each} + + {#if linkRows.length < USER_LINK_LIMITS.maxPerUser} + + {/if} + + {#if linksError}{/if} + {#if linksSaved}

Lagret.

{/if} + +
+ +
+
+

Eksporter

diff --git a/frontend/src/components/PublicList.svelte b/frontend/src/components/PublicList.svelte index 2d79382..2165f0d 100644 --- a/frontend/src/components/PublicList.svelte +++ b/frontend/src/components/PublicList.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { api, ApiError } from '../lib/api'; import ActivityRow from './ActivityRow.svelte'; - import type { ActivityPublic } from '../../../shared/types'; + import type { ActivityPublic, UserLink } from '../../../shared/types'; interface Props { username: string; @@ -11,6 +11,7 @@ let { username, onBack }: Props = $props(); let displayName: string | null = $state(null); + let links: UserLink[] = $state([]); let activities: ActivityPublic[] = $state([]); let loading = $state(true); let notFound = $state(false); @@ -20,6 +21,7 @@ try { const res = await api.publicList(username); displayName = res.display_name; + links = res.links; activities = res.activities; } catch (err) { if (err instanceof ApiError && err.status === 404) notFound = true; @@ -51,6 +53,21 @@ {:else}

{displayName?.trim() || username}

Offentlige vinteraktiviteter

+ + {#if links.length} + +

+ {#each links as link (link.id)} + + {link.label} ↗ + + {/each} +

+ {/if} + {#if !activities.length}

Ingen offentlige aktiviteter ennå.

{/if} diff --git a/server/auth.ts b/server/auth.ts index b616355..d5e8eaf 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -15,7 +15,9 @@ import type { RecoveryCompleteRequest, MeResponse, ProfileUpdateRequest, + UserLink, } from '../shared/types'; +import { USER_LINK_LIMITS } from '../shared/types'; const MAX_DISPLAY_NAME = 50; const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{1,30}$/; @@ -50,9 +52,59 @@ function loadMe(userId: string): MeResponse | null { is_admin: row.is_admin === 1, username: row.username, public_list_enabled: row.public_list_enabled === 1, + links: loadUserLinks(userId), }; } +export function loadUserLinks(userId: string): UserLink[] { + return getDb() + .prepare(` + SELECT id, label, url FROM user_links + WHERE user_id = ? + ORDER BY position ASC + `) + .all(userId) as UserLink[]; +} + +/** + * Parse + validate a link. Returns null with an error code if invalid. + * Two reasons URLs need explicit validation here: + * - The browser will follow these to third-party sites; we should be sure + * the protocol is http/https (not javascript:, data:, mailto:, etc.). + * - A misformatted URL would render as broken-looking text on the public + * profile page. + */ +function validateLink(raw: unknown): { ok: true; label: string; url: string } | { ok: false; error: string } { + if (!raw || typeof raw !== 'object') return { ok: false, error: 'invalid_link' }; + const r = raw as Record; + const label = typeof r.label === 'string' ? r.label.trim() : ''; + const url = typeof r.url === 'string' ? r.url.trim() : ''; + if (!label) return { ok: false, error: 'link_label_required' }; + if (label.length > USER_LINK_LIMITS.labelMax) return { ok: false, error: 'link_label_too_long' }; + if (!url) return { ok: false, error: 'link_url_required' }; + if (url.length > USER_LINK_LIMITS.urlMax) return { ok: false, error: 'link_url_too_long' }; + let parsed: URL; + try { parsed = new URL(url); } catch { return { ok: false, error: 'link_url_malformed' }; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, error: 'link_url_bad_protocol' }; + } + return { ok: true, label, url: parsed.toString() }; +} + +/** Pure validation of a link array — used by PATCH /profile pre-write. */ +function validateLinkList(raw: unknown[]): + | { links: { label: string; url: string }[] } + | { error: string } +{ + const out: { label: string; url: string }[] = []; + for (const entry of raw) { + const v = validateLink(entry); + if (!v.ok) return { error: v.error }; + out.push({ label: v.label, url: v.url }); + } + return { links: out }; +} + function normaliseDisplayName(raw: unknown): string | null { if (raw === null) return null; if (typeof raw !== 'string') return null; @@ -328,21 +380,50 @@ authRoutes.patch('/profile', requireAuth, async (c) => { params.push(body.public_list_enabled ? 1 : 0); } - if (updates.length === 0) { - // No-op update is fine — return current state so the client stays in sync. + // Validate links up front so we can fail without touching state. The + // actual replace happens inside the transaction below so a username + // collision (UNIQUE violation) on the UPDATE doesn't leave links rewritten + // for an otherwise-failed PATCH. + let validatedLinks: { label: string; url: string }[] | null = null; + if ('links' in body) { + if (!Array.isArray(body.links)) return c.json({ error: 'links_must_be_array' }, 400); + if (body.links.length > USER_LINK_LIMITS.maxPerUser) { + return c.json({ error: 'too_many_links' }, 400); + } + const v = validateLinkList(body.links); + if ('error' in v) return c.json({ error: v.error }, 400); + validatedLinks = v.links; + } + + if (updates.length === 0 && validatedLinks === null) { + // No-op update — return current state so the client stays in sync. const me = loadMe(userId); if (!me) return c.json({ error: 'internal_error' }, 500); return c.json(me); } - params.push(userId); try { - db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params); + db.transaction(() => { + if (updates.length > 0) { + db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`) + .run(...params, userId); + } + if (validatedLinks !== null) { + db.prepare('DELETE FROM user_links WHERE user_id = ?').run(userId); + const insert = db.prepare( + 'INSERT INTO user_links (id, user_id, label, url, position, created_at) VALUES (?, ?, ?, ?, ?, ?)', + ); + const now = Date.now(); + for (let i = 0; i < validatedLinks.length; i++) { + const { label, url } = validatedLinks[i]!; + insert.run(crypto.randomUUID(), userId, label, url, i, now); + } + } + })(); } catch (err) { - // SQLite throws an SqliteError with a message containing "UNIQUE - // constraint failed: users.username" (column-level constraint) or - // ".users_username_idx" (the partial unique index used by migrated DBs). - // Either way the user-facing meaning is the same — slug taken. + // See PATCH-profile race-proofing notes: SQLITE_CONSTRAINT_UNIQUE on + // users.username (or the partial unique index on migrated DBs) bubbles + // up here. Convert to 409 so the client gets a clean error. if (err instanceof Error && /UNIQUE constraint failed.*username/i.test(err.message)) { return c.json({ error: 'username_taken' }, 409); } diff --git a/server/db.ts b/server/db.ts index 97b30ca..d6aa986 100644 --- a/server/db.ts +++ b/server/db.ts @@ -173,6 +173,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [ CHECK (blocker_id <> blocked_id) )`, `CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx ON user_blocks(blocked_id)`, + // External profile links: a small ordered list (max enforced at the app + // layer) the user can attach to their profile. Shown on //list. + // UNIQUE(user_id, position) keeps ordering stable across rewrites. + `CREATE TABLE IF NOT EXISTS user_links ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label TEXT NOT NULL, + url TEXT NOT NULL, + position INTEGER NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (user_id, position) + )`, + `CREATE INDEX IF NOT EXISTS user_links_user_idx ON user_links(user_id, position)`, ]; const PRAGMAS: readonly string[] = [ diff --git a/server/users.ts b/server/users.ts index 4fa410a..831d04a 100644 --- a/server/users.ts +++ b/server/users.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { getDb } from './db'; import { tagsFor } from './tags'; +import { loadUserLinks } from './auth'; import type { PublicListResponse, ActivityPublic } from '../shared/types'; /** @@ -84,6 +85,7 @@ usersRoutes.get('/:username/list', (c) => { const resp: PublicListResponse = { username, display_name: user.display_name, + links: loadUserLinks(user.id), activities, }; return c.json(resp); diff --git a/shared/types.ts b/shared/types.ts index c048884..652dd4e 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -94,6 +94,20 @@ export interface RecoveryCompleteRequest { dek_pw_nonce: string; } +/** A user's external profile link — social, blog, anything they want. */ +export interface UserLink { + id: string; + label: string; + url: string; +} + +/** Limits enforced server-side; surfaced so the client UI matches. */ +export const USER_LINK_LIMITS = { + maxPerUser: 5, + labelMax: 40, + urlMax: 500, +} as const; + export interface MeResponse { id: string; email: string; @@ -102,6 +116,7 @@ export interface MeResponse { is_admin: boolean; username: string | null; public_list_enabled: boolean; + links: UserLink[]; } // --- Admin ----------------------------------------------------------------- @@ -124,16 +139,21 @@ export interface AdminRoleUpdate { } export interface ProfileUpdateRequest { - // All optional — omit a field to leave it alone. Pass `null` to clear. + // All optional — omit a field to leave it alone. Pass `null` to clear + // display_name or username. The `links` field, when present, replaces + // the user's entire link list (bulk replace semantics — simpler than + // tracking per-link ids on the client). display_name?: string | null; username?: string | null; public_list_enabled?: boolean; + links?: { label: string; url: string }[]; } /** Response shape for GET /api/users/:username/list (opt-in public list). */ export interface PublicListResponse { username: string; display_name: string | null; + links: UserLink[]; activities: ActivityPublic[]; }