+
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[];
}