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}
+ error id {errorId}
+ {#if copied} {:else} {/if}
+ {/if}
+
+
+ {#if suggestions.length}
+ did you mean
+
+ {/if}
+
+ go somewhere else
+
+ {#if showSignIn}
+
sign in
+ {/if}
+
home
+
trackers
+
projects
+
go back
+ {#if isOwner}
+
{#if report === 'sending'}reporting…{:else if typeof report === 'number'}added as task #{report}{:else if report === 'failed'}could
+ not report, retry{:else}report it{/if}
+ {/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 };
};