External profile links (max 5 per user)

Users can attach up to 5 labelled URLs to their profile — social
handles, blog, anything. They're shown on /<username>/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.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 16:20:04 +02:00
commit 9b825bfe1d
6 changed files with 258 additions and 11 deletions

View file

@ -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<string, unknown>;
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);
}

View file

@ -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 /<username>/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[] = [

View file

@ -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);