From a89a187346767529d11d41b2695f038f2f37e6f2 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sun, 25 Oct 2026 13:10:50 +0000 Subject: [PATCH] Global error page: ErrorView with per-status copy, did-you-mean suggestions, error ids from handleError, static error.html; 404 instead of 500 for bad progress ids; tolerate full header line in DAISY_TASK_WEBHOOK_HEADER and stop logging raw fetch errors --- src/app.d.ts | 6 +- src/error.html | 67 ++++ src/hooks.client.ts | 10 + src/hooks.server.ts | 18 +- src/lib/components/ErrorView.svelte | 315 ++++++++++++++++++ src/lib/errors.test.ts | 34 ++ src/lib/errors.ts | 133 ++++++++ src/lib/server/daisy-hook.test.ts | 14 + src/lib/server/daisy-hook.ts | 13 +- src/routes/+error.svelte | 18 + src/routes/+layout.svelte | 9 +- .../projects/progress/[id]/+page.server.ts | 29 +- .../progress/topics/[id]/+page.server.ts | 29 +- 13 files changed, 665 insertions(+), 30 deletions(-) create mode 100644 src/error.html create mode 100644 src/hooks.client.ts create mode 100644 src/lib/components/ErrorView.svelte create mode 100644 src/lib/errors.test.ts create mode 100644 src/lib/errors.ts create mode 100644 src/routes/+error.svelte diff --git a/src/app.d.ts b/src/app.d.ts index 6a21d25..b549e44 100755 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -4,7 +4,11 @@ // for information about these interfaces declare global { namespace App { - // interface Error {} + interface Error { + message: string; + /** Short id of an unexpected (5xx) error, also in the server log line. */ + id?: string; + } interface Locals { user: typeof import('#lib/server/auth.js').auth.$Infer.Session.user | null; session: typeof import('#lib/server/auth.js').auth.$Infer.Session.session | null; diff --git a/src/error.html b/src/error.html new file mode 100644 index 0000000..084c274 --- /dev/null +++ b/src/error.html @@ -0,0 +1,67 @@ + + + + + + + %sveltekit.status% · Stan + + + +
+

%sveltekit.status%

+

Something went wrong

+

%sveltekit.error.message%. Try again in a moment.

+ go home +
+ + diff --git a/src/hooks.client.ts b/src/hooks.client.ts new file mode 100644 index 0000000..27bb49a --- /dev/null +++ b/src/hooks.client.ts @@ -0,0 +1,10 @@ +import type { HandleClientError } from '@sveltejs/kit'; + +// Errors thrown while rendering/loading in the browser: same shape as the server (short id, +// generic message) so the error page looks the same either way. +export const handleError: HandleClientError = ({ error, status, message }) => { + if (status === 404) return { message: 'Not Found' }; + const id = crypto.randomUUID().slice(0, 8); + console.error(`[error ${id}] ${status}`, error); + return { message: status >= 500 ? 'Internal Error' : message, id }; +}; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 8e973b9..bbaa8a1 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,4 +1,4 @@ -import type { Handle, ServerInit } from '@sveltejs/kit/hooks'; +import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit/hooks'; import { startMusicPoller } from '#lib/server/music/poller.js'; import { building } from '$app/env'; import { auth } from '#lib/server/auth.js'; @@ -41,3 +41,19 @@ export const handle: Handle = async ({ event, resolve }) => { } return response; }; + +// Unexpected errors (anything not thrown with error()) and unknown routes. 5xx get a short id +// that the error page shows and the log line starts with ("[error ab12cd34]"), so a screenshot +// or report can be matched to the stack trace. The client only gets a generic message: no +// stack, no internals. Query strings are left out of the log (they can carry tokens). +export const handleError: HandleServerError = ({ error, event, status, message }) => { + const route = event.route.id ?? '(no route)'; + const where = `${event.request.method} ${event.url.pathname} route=${route}`; + if (status === 404) { + console.warn(`[404] ${where}`); + return { message: 'Not Found' }; + } + const id = crypto.randomUUID().slice(0, 8); + console.error(`[error ${id}] ${status} ${where}`, error); + return { message: status >= 500 ? 'Internal Error' : message, id }; +}; diff --git a/src/lib/components/ErrorView.svelte b/src/lib/components/ErrorView.svelte new file mode 100644 index 0000000..e644268 --- /dev/null +++ b/src/lib/components/ErrorView.svelte @@ -0,0 +1,315 @@ + + +
+

+ {status} +

+

+ {copy.title} +

+

+ {copy.message} + {#if detail}{detail}{/if} +

+ +
+ {path} + {#if errorId} + + {/if} +
+ + {#if suggestions.length} +

did you mean

+ + {/if} + +

go somewhere else

+
+ {#if showSignIn} + sign in + {/if} + home + trackers + projects + + {#if isOwner} + + {/if} +
+
+ + diff --git a/src/lib/errors.test.ts b/src/lib/errors.test.ts new file mode 100644 index 0000000..f312852 --- /dev/null +++ b/src/lib/errors.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { errorCopy, suggestPages } from './errors'; + +const hrefs = (path: string, owner = false) => suggestPages(path, owner).map((s) => s.href); + +describe('errorCopy', () => { + it('has copy per status family', () => { + expect(errorCopy(404).tone).toBe('missing'); + expect(errorCopy(403).title).toBe('Owner only'); + expect(errorCopy(500).tone).toBe('broken'); + expect(errorCopy(503).tone).toBe('down'); + }); +}); + +describe('suggestPages', () => { + it('suggests the parent tracker for a missing detail page', () => { + expect(hrefs('/trackers/cars/does-not-exist')[0]).toBe('/trackers/cars'); + }); + it('fixes typos in tracker names', () => { + expect(hrefs('/trackers/bokos')).toContain('/trackers/books'); + expect(hrefs('/trakcers/trians')).toContain('/trackers/trains'); + }); + it('finds a tracker by name under the wrong section', () => { + expect(hrefs('/projects/planes')).toContain('/trackers/planes'); + }); + it('never suggests private trackers to visitors', () => { + expect(hrefs('/trackers/helth')).not.toContain('/trackers/health'); + expect(hrefs('/trackers/helth', true)).toContain('/trackers/health'); + }); + it('returns nothing for the home page or noise', () => { + expect(hrefs('/')).toEqual([]); + expect(hrefs('/zzqxj')).toEqual([]); + }); +}); diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..ca48394 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,133 @@ +// Copy and "did you mean" suggestions for the global error page (src/routes/+error.svelte, +// rendered by #lib/components/ErrorView.svelte). Pure functions, unit tested. +import { projects, visibleTrackers } from '#lib/projects.js'; + +export type ErrorTone = 'missing' | 'locked' | 'broken' | 'down'; +export type ErrorCopy = { title: string; message: string; tone: ErrorTone }; + +export function errorCopy(status: number): ErrorCopy { + if (status === 404 || status === 410) + return { + title: 'Page not found', + message: 'This page does not exist (anymore), or the link has a typo.', + tone: 'missing' + }; + if (status === 401) + return { + title: 'Sign in to continue', + message: 'This page is only for signed-in visitors.', + tone: 'locked' + }; + if (status === 403) + return { + title: 'Owner only', + message: 'This part of the site is private.', + tone: 'locked' + }; + if (status === 405) + return { + title: 'Not allowed', + message: 'This address does not accept that kind of request.', + tone: 'missing' + }; + if (status === 429) + return { + title: 'Slow down a bit', + message: 'Too many requests in a short time. Try again in a minute.', + tone: 'down' + }; + if (status === 502 || status === 503 || status === 504) + return { + title: 'Temporarily unavailable', + message: + 'Something this page depends on is not responding right now. It is usually back within a minute.', + tone: 'down' + }; + if (status >= 500) + return { + title: 'Something broke', + message: 'An unexpected error happened on the server. It has been logged.', + tone: 'broken' + }; + return { + title: 'That did not work', + message: 'The request could not be completed.', + tone: 'missing' + }; +} + +export type Suggestion = { + name: string; + href: string; + kind: 'tracker' | 'project' | 'page'; + subtitle: string; +}; + +const pages: Suggestion[] = [ + { name: 'home', href: '/', kind: 'page', subtitle: 'the front page' }, + { name: 'trackers', href: '/trackers', kind: 'page', subtitle: 'every tracker' }, + { name: 'projects', href: '/projects', kind: 'page', subtitle: 'tools and experiments' }, + { name: 'blog', href: '/blog', kind: 'page', subtitle: 'articles and notes' } +]; + +/** Every internal destination the error page can suggest (private trackers only for the owner). */ +export function destinations(isOwner: boolean): Suggestion[] { + const out: Suggestion[] = [...pages]; + for (const p of projects) + if (!p.external) + out.push({ name: p.name, href: p.href, kind: 'project', subtitle: p.subtitle }); + for (const t of visibleTrackers(isOwner)) { + out.push({ name: t.name, href: t.href, kind: 'tracker', subtitle: t.subtitle }); + for (const c of t.children ?? []) + out.push({ name: c.name, href: c.href, kind: 'tracker', subtitle: c.subtitle }); + } + const seen = new Set(); + return out.filter((d) => !seen.has(d.href) && seen.add(d.href)); +} + +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const cur = [i]; + for (let j = 1; j <= b.length; j++) + cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); + prev = cur; + } + return prev[b.length]; +} + +const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, ''); +function similarity(a: string, b: string): number { + if (!a || !b) return 0; + if (a === b) return 1; + if (a.length >= 3 && b.length >= 3 && (a.includes(b) || b.includes(a))) return 0.85; + return 1 - levenshtein(a, b) / Math.max(a.length, b.length); +} + +/** + * Up to `limit` destinations that look like what `pathname` was meant to be: the closest + * existing parent section (/trackers/cars for /trackers/cars/nope), then fuzzy matches of + * any path segment against tracker/project names and URL slugs ("/trakcers/bokos" → books). + */ +export function suggestPages(pathname: string, isOwner: boolean, limit = 3): Suggestion[] { + const path = pathname.replace(/\/+$/, '') || '/'; + const segments = path.split('/').map(norm).filter(Boolean); + if (!segments.length) return []; + const scored = destinations(isOwner) + .filter((d) => d.href !== path) + .map((d) => { + const slug = norm(d.href.split('/').filter(Boolean).at(-1) ?? ''); + const name = norm(d.name.replace(/\(.*\)/, '')); + let score = 0; + for (const s of segments) score = Math.max(score, similarity(s, name), similarity(s, slug)); + // Generic section names only count when spelled out: they match too much otherwise. + if (d.kind === 'page') score *= 0.8; + if (d.href !== '/' && path.startsWith(`${d.href}/`)) + score = Math.max(score, 0.9 + d.href.length / 1000); + return { d, score }; + }) + .filter((x) => x.score >= 0.6) + .sort((a, b) => b.score - a.score); + return scored.slice(0, limit).map((x) => x.d); +} diff --git a/src/lib/server/daisy-hook.test.ts b/src/lib/server/daisy-hook.test.ts index 85fc814..d23d5e8 100644 --- a/src/lib/server/daisy-hook.test.ts +++ b/src/lib/server/daisy-hook.test.ts @@ -34,4 +34,18 @@ describe('daisy task webhook', () => { ); expect(fetcher).toHaveBeenCalledTimes(1); }); + it('takes only the name from a full "Name: value" header setting', () => { + const base = { + DAISY_TASK_WEBHOOK_URL: 'https://example.test/hook', + DAISY_TASK_WEBHOOK_KEY: 'k1' + }; + const cfg = daisyHookConfig({ + ...base, + DAISY_TASK_WEBHOOK_HEADER: 'Authorization: Bearer old' + }); + expect(cfg?.headers.Authorization).toBe('Bearer k1'); + expect(Object.keys(cfg?.headers ?? {})).toEqual(['content-type', 'Authorization']); + const bad = daisyHookConfig({ ...base, DAISY_TASK_WEBHOOK_HEADER: 'bad header' }); + expect(bad?.headers.Authorization).toBe('Bearer k1'); + }); }); diff --git a/src/lib/server/daisy-hook.ts b/src/lib/server/daisy-hook.ts index 3cd2bb2..f611500 100644 --- a/src/lib/server/daisy-hook.ts +++ b/src/lib/server/daisy-hook.ts @@ -12,7 +12,10 @@ export function daisyHookConfig(env: Record = proces const url = env.DAISY_TASK_WEBHOOK_URL?.trim(); const key = env.DAISY_TASK_WEBHOOK_KEY?.trim(); if (!url || !key) return null; - const header = env.DAISY_TASK_WEBHOOK_HEADER?.trim() || 'Authorization'; + // Header name only. Tolerate a full "Name: value" line (the value part is ignored; the key + // comes from DAISY_TASK_WEBHOOK_KEY) and fall back to Authorization for anything invalid. + const rawHeader = env.DAISY_TASK_WEBHOOK_HEADER?.split(':')[0].trim() ?? ''; + const header = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(rawHeader) ? rawHeader : 'Authorization'; const value = header.toLowerCase() === 'authorization' ? `Bearer ${key}` : key; return { url, headers: { 'content-type': 'application/json', [header]: value } }; } @@ -33,6 +36,12 @@ export function notifyDaisy( .then((res) => { if (!res.ok) console.error(`[daisy-hook] task ${task.id}: HTTP ${res.status}`); }) - .catch((err) => console.error(`[daisy-hook] task ${task.id}: ${String(err)}`)); + .catch((err: unknown) => { + // Name/code only: some fetch errors echo request headers (and so the key). + const e = err as { name?: string; cause?: { code?: string } }; + console.error( + `[daisy-hook] task ${task.id}: ${e?.name ?? 'Error'} ${e?.cause?.code ?? ''}`.trim() + ); + }); return 'sent'; } diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..5afc81d --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 17eb040..4d17f56 100755 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,12 +2,19 @@ import '../app.css'; import { dev } from '$app/env'; import { page } from '$app/state'; + import { errorCopy } from '#lib/errors.js'; import { getSeo } from '#lib/seo.js'; + import { formatTitle } from '#lib/title.js'; // The header/footer are in #lib/components/SiteChrome.svelte, which +layout.ts loads only // for pages that show them (not the full-screen book reader). let { data, children } = $props(); - const seo = $derived(getSeo(page.url.pathname, page.data, data.environment)); + const seo = $derived.by(() => { + const seo = getSeo(page.url.pathname, page.data, data.environment); + if (!page.error) return seo; + const title = `${page.status} ${errorCopy(page.status).title}`; + return { ...seo, title: formatTitle(title, data.environment), indexable: false }; + }); const Chrome = $derived(data.Chrome); diff --git a/src/routes/projects/progress/[id]/+page.server.ts b/src/routes/projects/progress/[id]/+page.server.ts index a56e4f6..18f824d 100755 --- a/src/routes/projects/progress/[id]/+page.server.ts +++ b/src/routes/projects/progress/[id]/+page.server.ts @@ -3,22 +3,27 @@ import type { Actions, PageServerLoad } from './$types'; import { db } from '#lib/server/db/index.js'; import { tasks, tasksToTopics, topics } from '#lib/server/db/schema/index.js'; import { eq, and } from 'drizzle-orm'; +import { error } from '@sveltejs/kit'; export const load: PageServerLoad = async (event) => { - const { params } = event; - return { - task: await db.query.tasks.findFirst({ - with: { - tasksToTopics: { - with: { - topic: true - } + const id = Number(event.params.id); + // Non-numeric ids used to reach Postgres as NaN and 500. + if (!Number.isSafeInteger(id) || id < 1) error(404, 'Not Found'); + const task = await db.query.tasks.findFirst({ + with: { + tasksToTopics: { + with: { + topic: true } - }, - where: { - id: Number(params.id) } - }), + }, + where: { + id + } + }); + if (!task) error(404, 'Not Found'); + return { + task, topics: await db.query.topics.findMany(), editable: isAdmin(event) }; diff --git a/src/routes/projects/progress/topics/[id]/+page.server.ts b/src/routes/projects/progress/topics/[id]/+page.server.ts index 4e297d4..7c3a122 100644 --- a/src/routes/projects/progress/topics/[id]/+page.server.ts +++ b/src/routes/projects/progress/topics/[id]/+page.server.ts @@ -1,18 +1,21 @@ +import { error } from '@sveltejs/kit'; import { db } from '#lib/server/db/index.js'; +import type { PageServerLoad } from './$types'; -export const load = async ({ params }) => { - return { - topic: await db.query.topics.findFirst({ - with: { - topicsToTasks: { - with: { - task: true - } +export const load: PageServerLoad = async ({ params }) => { + const id = Number(params.id); + // Non-numeric ids used to reach Postgres as NaN and 500. + if (!Number.isSafeInteger(id) || id < 1) error(404, 'Not Found'); + const topic = await db.query.topics.findFirst({ + with: { + topicsToTasks: { + with: { + task: true } - }, - where: { - id: Number(params.id) } - }) - }; + }, + where: { id } + }); + if (!topic) error(404, 'Not Found'); + return { topic }; };