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:
parent
7964d499e2
commit
9b825bfe1d
6 changed files with 258 additions and 11 deletions
|
|
@ -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 @@
|
|||
|
||||
<FriendsPanel />
|
||||
|
||||
<section class="card" aria-labelledby="links-h">
|
||||
<h3 id="links-h">Eksterne lenker</h3>
|
||||
<p class="muted">
|
||||
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å).
|
||||
</p>
|
||||
|
||||
{#each linkRows as link, i (i)}
|
||||
<div class="row" style="margin-bottom: 0.5rem; align-items: stretch;">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Etikett (f.eks. «Mastodon»)"
|
||||
maxlength={USER_LINK_LIMITS.labelMax}
|
||||
bind:value={linkRows[i].label}
|
||||
style="flex: 1 1 35%; min-width: 9rem;"
|
||||
aria-label={`Etikett for lenke ${i + 1}`}
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
maxlength={USER_LINK_LIMITS.urlMax}
|
||||
bind:value={linkRows[i].url}
|
||||
style="flex: 2 1 50%; min-width: 12rem;"
|
||||
aria-label={`URL for lenke ${i + 1}`}
|
||||
/>
|
||||
<button class="danger" type="button"
|
||||
onclick={() => removeLinkRow(i)}
|
||||
aria-label={`Fjern lenke ${i + 1}`}>
|
||||
Fjern
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if linkRows.length < USER_LINK_LIMITS.maxPerUser}
|
||||
<button type="button" onclick={addLinkRow}>+ Legg til lenke</button>
|
||||
{/if}
|
||||
|
||||
{#if linksError}<p class="error" role="alert">{linksError}</p>{/if}
|
||||
{#if linksSaved}<p class="muted" role="status">Lagret.</p>{/if}
|
||||
|
||||
<div class="row" style="margin-top: 0.75rem;">
|
||||
<button class="primary" type="button" onclick={saveLinks} disabled={linksSaving}>
|
||||
{linksSaving ? 'Lagrer …' : 'Lagre lenker'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="exp-h">
|
||||
<h3 id="exp-h">Eksporter</h3>
|
||||
<p class="muted">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<h2 style="font-size: 1.75rem; margin-top: 0.5rem;">{displayName?.trim() || username}</h2>
|
||||
<p class="muted">Offentlige vinteraktiviteter</p>
|
||||
|
||||
{#if links.length}
|
||||
<!-- rel="noopener" because we don't want the opened tab to be able to
|
||||
script into our window via opener; "ugc" tells search engines this
|
||||
is user-generated content (mild SEO hygiene + spam signal). -->
|
||||
<p class="row" style="gap: 0.5rem; margin: 0.5rem 0 1rem;">
|
||||
{#each links as link (link.id)}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer ugc"
|
||||
class="tag" style="text-decoration: none; color: inherit;">
|
||||
{link.label} ↗
|
||||
</a>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !activities.length}
|
||||
<p class="muted">Ingen offentlige aktiviteter ennå.</p>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
13
server/db.ts
13
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 /<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[] = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue