diff --git a/scripts/link-icons.ts b/scripts/link-icons.ts new file mode 100644 index 0000000..ba8e6ce --- /dev/null +++ b/scripts/link-icons.ts @@ -0,0 +1,16 @@ +// Trims the simple-icons marks used by src/lib/link-icons.ts into src/lib/link-icons.json, +// so /start ships a few KB of SVG paths instead of the whole icon set. Run after editing RULES. +import { writeFileSync } from 'node:fs'; +import all from '@iconify-json/simple-icons/icons.json' with { type: 'json' }; +import { RULES } from '../src/lib/link-icons.ts'; + +const icons = (all as { icons: Record }).icons; +const out: Record = {}; +for (const r of RULES) { + if (!r.si) continue; + const icon = icons[r.si]; + if (!icon) throw new Error(`simple-icons has no "${r.si}"`); + out[r.si] = icon.body; +} +writeFileSync('src/lib/link-icons.json', JSON.stringify(out, null, '\t') + '\n'); +console.log(`wrote ${Object.keys(out).length} marks`); diff --git a/src/lib/link-icons.json b/src/lib/link-icons.json new file mode 100644 index 0000000..da0b00c --- /dev/null +++ b/src/lib/link-icons.json @@ -0,0 +1,41 @@ +{ + "gmail": "", + "googlecalendar": "", + "googledrive": "", + "googlephotos": "", + "googlemaps": "", + "whatsapp": "", + "discord": "", + "x": "", + "ticktick": "", + "spotify": "", + "lastdotfm": "", + "youtube": "", + "twitch": "", + "osu": "", + "steam": "", + "reddit": "", + "linkedin": "", + "claude": "", + "revolut": "", + "gitea": "", + "immich": "", + "umami": "", + "postgresql": "", + "docker": "", + "svelte": "", + "netdata": "", + "portainer": "", + "ntfy": "", + "github": "", + "cursor": "", + "wix": "", + "drizzle": "", + "cloudflare": "", + "ovh": "", + "hetzner": "", + "tailscale": "", + "backblaze": "", + "1password": "", + "squarespace": "" +} diff --git a/src/lib/link-icons.test.ts b/src/lib/link-icons.test.ts new file mode 100644 index 0000000..f68c94d --- /dev/null +++ b/src/lib/link-icons.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { existsSync } from 'node:fs'; +import marks from './link-icons.json'; +import { RULES, iconFile, linkIcon } from './link-icons'; + +describe('link icons', () => { + it('picks brand marks by host, port and path', () => { + expect(linkIcon('https://mail.google.com/mail/u/0/')).toMatchObject({ + kind: 'svg', + color: '#EA4335' + }); + expect(linkIcon('http://stan-ovh.tail665403.ts.net:19999')).toMatchObject({ color: '#00AB44' }); + expect(linkIcon('https://stan-ovh.tail665403.ts.net:9443')).toMatchObject({ color: '#13BEF9' }); + expect(linkIcon('https://www.google.com/maps')).toMatchObject({ color: '#34A853' }); + expect(linkIcon('https://github.com/')).toMatchObject({ kind: 'svg', color: 'currentColor' }); + }); + + it('uses saved favicons and exact hosts', () => { + expect(linkIcon('https://www.funda.nl/')).toEqual({ + kind: 'img', + src: '/link-icons/funda.png' + }); + expect(linkIcon('https://stanrunge.dev')).toEqual({ kind: 'img', src: '/favicon-32x32.png' }); + expect(linkIcon('https://notstanrunge.dev').kind).toBe('letter'); + }); + + it('falls back to a monogram', () => { + expect(linkIcon('https://example.org', 'example')).toMatchObject({ + kind: 'letter', + letter: 'E' + }); + expect(linkIcon('not a url', 'x').kind).toBe('letter'); + }); + + it('has every mark and favicon a rule points at', () => { + for (const r of RULES) { + if (r.si) expect(marks, r.si).toHaveProperty(r.si); + if (r.img) expect(existsSync(`static${iconFile(r.img)}`), r.img).toBe(true); + } + }); +}); diff --git a/src/lib/link-icons.ts b/src/lib/link-icons.ts new file mode 100644 index 0000000..b4faf67 --- /dev/null +++ b/src/lib/link-icons.ts @@ -0,0 +1,126 @@ +// Icons for /start link and service tiles, picked from the URL (no hotlinking): +// - brand marks from simple-icons, trimmed into link-icons.json by scripts/link-icons.ts +// (run `bun scripts/link-icons.ts` after adding a `si` rule below), +// - favicons saved under static/link-icons/ for brands simple-icons doesn't have, +// - otherwise a coloured monogram. +import marks from './link-icons.json'; + +export type LinkIcon = + | { kind: 'svg'; body: string; color: string } + | { kind: 'img'; src: string } + | { kind: 'letter'; letter: string; color: string }; + +type Rule = { + /** Host suffix ("google.com" matches mail.google.com), or exact host with a leading "=". */ + host: string; + port?: string; + path?: string; + si?: string; + color?: string; // brand colour; omitted = currentColor (black/white marks) + img?: string; +}; + +// First match wins, so specific rules go before broad ones. +export const RULES: Rule[] = [ + { host: 'mail.google.com', si: 'gmail', color: '#EA4335' }, + { host: 'calendar.google.com', si: 'googlecalendar', color: '#4285F4' }, + { host: 'drive.google.com', si: 'googledrive', color: '#1FA463' }, + { host: 'photos.google.com', si: 'googlephotos', color: '#FBBC04' }, + { host: 'maps.google.com', si: 'googlemaps', color: '#34A853' }, + { host: 'google.com', path: '/maps', si: 'googlemaps', color: '#34A853' }, + { host: 'whatsapp.com', si: 'whatsapp', color: '#25D366' }, + { host: 'discord.com', si: 'discord', color: '#5865F2' }, + { host: 'x.com', si: 'x' }, + { host: 'twitter.com', si: 'x' }, + { host: 'grok.com', img: 'grok.png' }, + { host: 'ticktick.com', si: 'ticktick', color: '#4772FA' }, + { host: 'spotify.com', si: 'spotify', color: '#1ED760' }, + { host: 'last.fm', si: 'lastdotfm', color: '#D51007' }, + { host: 'youtube.com', si: 'youtube', color: '#FF0000' }, + { host: 'twitch.tv', si: 'twitch', color: '#9146FF' }, + { host: 'osu.ppy.sh', si: 'osu', color: '#FF66AA' }, + { host: 'steampowered.com', si: 'steam' }, + { host: 'steamcommunity.com', si: 'steam' }, + { host: 'reddit.com', si: 'reddit', color: '#FF4500' }, + { host: 'linkedin.com', si: 'linkedin', color: '#0A66C2' }, + { host: 'claude.ai', si: 'claude', color: '#D97757' }, + { host: 'revolut.com', si: 'revolut' }, + { host: 'moneybird.com', img: 'moneybird.png' }, + { host: 'belastingdienst.nl', img: 'belastingdienst.png' }, + { host: 'megekko.nl', img: 'megekko.png' }, + { host: 'tweakers.net', img: 'tweakers.png' }, + { host: 'funda.nl', img: 'funda.png' }, + { host: 'pararius.nl', img: 'pararius.png' }, + { host: 'mijndak.nl', img: 'mijndak.png' }, + { host: 'ns.nl', img: 'ns.png' }, + { host: 'buienradar.nl', img: 'buienradar.png' }, + // Self-hosted on stan-ovh + { host: 'git.stanrunge.dev', si: 'gitea', color: '#609926' }, + { host: 'photos.stanrunge.dev', si: 'immich', color: '#4250AF' }, + { host: 'analytics.stanrunge.dev', si: 'umami' }, + { host: 'pgadmin.stanrunge.dev', si: 'postgresql', color: '#4169E1' }, + { host: 'logs.vash.software', si: 'docker', color: '#2496ED' }, + { host: 'dev.stanrunge.dev', si: 'svelte', color: '#FF3E00' }, + { host: 'stuhee.stanrunge.dev', img: 'stuhee.svg' }, + { host: 'vash.software', img: 'vash.ico' }, + { host: '=stanrunge.dev', img: '/favicon-32x32.png' }, + { host: 'ts.net', port: '19999', si: 'netdata', color: '#00AB44' }, + { host: 'ts.net', port: '9443', si: 'portainer', color: '#13BEF9' }, + { host: 'ntfy.sh', si: 'ntfy', color: '#3CB4A0' }, + // Dev + infra + { host: 'github.com', si: 'github' }, + { host: 'cursor.com', si: 'cursor' }, + { host: 'wix.com', si: 'wix' }, + { host: 'svelte.dev', si: 'svelte', color: '#FF3E00' }, + { host: 'drizzle.team', si: 'drizzle', color: '#C5F74F' }, + { host: 'cloudflare.com', si: 'cloudflare', color: '#F38020' }, + { host: 'ovh.com', si: 'ovh', color: '#4F8CF7' }, + { host: 'ovhcloud.com', si: 'ovh', color: '#4F8CF7' }, + { host: 'hetzner.com', si: 'hetzner', color: '#D50C2D' }, + { host: 'hetzner.cloud', si: 'hetzner', color: '#D50C2D' }, + { host: 'tailscale.com', si: 'tailscale' }, + { host: 'backblaze.com', si: 'backblaze', color: '#E21E29' }, + { host: '1password.com', si: '1password', color: '#3B66BC' }, + { host: 'squarespace.com', si: 'squarespace' } +]; + +export const iconFile = (img: string) => (img.startsWith('/') ? img : `/link-icons/${img}`); + +function parse(url: string) { + try { + const u = new URL(url); + return { host: u.hostname.replace(/^www\./, '').toLowerCase(), port: u.port, path: u.pathname }; + } catch { + return null; + } +} + +export function ruleFor(url: string): Rule | null { + const u = parse(url); + if (!u) return null; + return ( + RULES.find((r) => { + const hostOk = r.host.startsWith('=') + ? u.host === r.host.slice(1) + : u.host === r.host || u.host.endsWith(`.${r.host}`); + return hostOk && (!r.port || r.port === u.port) && (!r.path || u.path.startsWith(r.path)); + }) ?? null + ); +} + +const marksBySlug = marks as Record; + +export function linkIcon(url: string, title = ''): LinkIcon { + const r = ruleFor(url); + if (r?.si && marksBySlug[r.si]) + return { kind: 'svg', body: marksBySlug[r.si], color: r.color ?? 'currentColor' }; + if (r?.img) return { kind: 'img', src: iconFile(r.img) }; + const host = parse(url)?.host ?? url; + let h = 0; + for (const c of host) h = (h * 31 + c.charCodeAt(0)) >>> 0; + return { + kind: 'letter', + letter: (title.trim()[0] ?? host[0] ?? '?').toUpperCase(), + color: `hsl(${h % 360} 70% 55%)` + }; +} diff --git a/src/lib/server/daisy-hook.test.ts b/src/lib/server/daisy-hook.test.ts index d23d5e8..fcfefd9 100644 --- a/src/lib/server/daisy-hook.test.ts +++ b/src/lib/server/daisy-hook.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { daisyHookConfig, notifyDaisy } from './daisy-hook'; +import { daisyHookConfig, notifyDaisy, redact } from './daisy-hook'; describe('daisy task webhook', () => { it('is off until both URL and key are set', () => { @@ -49,3 +49,35 @@ describe('daisy task webhook', () => { expect(bad?.headers.Authorization).toBe('Bearer k1'); }); }); + +describe('daisy task webhook hardening', () => { + it('accepts a full "Authorization: Bearer " line as the header setting', () => { + const config = daisyHookConfig({ + DAISY_TASK_WEBHOOK_URL: 'https://x/hook', + DAISY_TASK_WEBHOOK_KEY: 'k9', + DAISY_TASK_WEBHOOK_HEADER: 'Authorization: Bearer k9' + }); + expect(Object.keys(config?.headers ?? {})).toEqual(['content-type', 'Authorization']); + expect(config?.headers.Authorization).toBe('Bearer k9'); + }); + + it('never logs the key', () => { + expect(redact("Invalid header 'Authorization: Bearer crsr_abc123'", ['k'])).not.toContain( + 'crsr_abc123' + ); + expect(redact('failed for supersecret', ['supersecret'])).toBe('failed for [redacted]'); + }); + + it('retries once after a failure', async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 502 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + const config = daisyHookConfig({ + DAISY_TASK_WEBHOOK_URL: 'https://x', + DAISY_TASK_WEBHOOK_KEY: 'k' + }); + notifyDaisy({ id: 1, title: 't' }, config, fetcher as unknown as typeof fetch, 0); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/src/lib/server/daisy-hook.ts b/src/lib/server/daisy-hook.ts index f611500..93f1512 100644 --- a/src/lib/server/daisy-hook.ts +++ b/src/lib/server/daisy-hook.ts @@ -3,45 +3,76 @@ // DAISY_TASK_WEBHOOK_URL where to POST {task_id, title} // DAISY_TASK_WEBHOOK_KEY secret sent with it // DAISY_TASK_WEBHOOK_HEADER header name (default Authorization, sent as "Bearer "; -// any other header gets the bare key) +// any other header gets the bare key). A full "Name: value" +// line is also accepted (only the name is used). // Unset URL or key: nothing is sent and the task is still saved. -export type DaisyHookConfig = { url: string; headers: Record }; +export type DaisyHookConfig = { url: string; headers: Record; secrets: string[] }; + +const HEADER_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/; export function daisyHookConfig(env: Record = process.env) { const url = env.DAISY_TASK_WEBHOOK_URL?.trim(); const key = env.DAISY_TASK_WEBHOOK_KEY?.trim(); if (!url || !key) return null; - // 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'; + // The header once got set to a whole "Authorization: Bearer " line: keep only the name. + let header = (env.DAISY_TASK_WEBHOOK_HEADER ?? '').split(':')[0].trim() || 'Authorization'; + if (!HEADER_NAME.test(header)) { + console.error( + '[daisy-hook] DAISY_TASK_WEBHOOK_HEADER is not a valid header name, using Authorization' + ); + header = 'Authorization'; + } const value = header.toLowerCase() === 'authorization' ? `Bearer ${key}` : key; - return { url, headers: { 'content-type': 'application/json', [header]: value } }; + return { + url, + headers: { 'content-type': 'application/json', [header]: value }, + secrets: [key, url] + } satisfies DaisyHookConfig; } -/** Fire and forget: never awaited by the request, 3 s timeout, failures only logged. */ +/** Strip the key and URL (it may carry a token) from anything that gets logged. */ +export function redact(message: string, secrets: string[] = []) { + let out = message; + for (const s of secrets) if (s) out = out.split(s).join('[redacted]'); + return out + .replace(/crsr_[A-Za-z0-9]+/g, '[redacted]') + .replace(/Bearer\s+[^\s'"]+/gi, 'Bearer [redacted]'); +} + +async function post(config: DaisyHookConfig, body: string, fetcher: typeof fetch) { + try { + const res = await fetcher(config.url, { + method: 'POST', + headers: config.headers, + body, + signal: AbortSignal.timeout(5000) + }); + return res.ok ? null : `HTTP ${res.status}`; + } 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 } }; + return `${e?.name ?? 'Error'} ${e?.cause?.code ?? ''}`.trim(); + } +} + +/** Fire and forget: never awaited by the request; one retry after 2 s; failures logged redacted. */ export function notifyDaisy( task: { id: number; title: string }, config: DaisyHookConfig | null = daisyHookConfig(), - fetcher: typeof fetch = fetch + fetcher: typeof fetch = fetch, + retryDelayMs = 2000 ): 'sent' | 'not-configured' { if (!config) return 'not-configured'; - fetcher(config.url, { - method: 'POST', - headers: config.headers, - body: JSON.stringify({ task_id: task.id, title: task.title }), - signal: AbortSignal.timeout(3000) - }) - .then((res) => { - if (!res.ok) console.error(`[daisy-hook] task ${task.id}: HTTP ${res.status}`); - }) - .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() - ); - }); + const body = JSON.stringify({ task_id: task.id, title: task.title }); + void (async () => { + let failure = await post(config, body, fetcher); + if (failure) { + await new Promise((r) => setTimeout(r, retryDelayMs)); + failure = await post(config, body, fetcher); + } + if (failure) console.error(`[daisy-hook] task ${task.id}: ${redact(failure, config.secrets)}`); + else console.log(`[daisy-hook] task ${task.id}: delivered`); + })(); return 'sent'; } diff --git a/src/routes/start/+page.svelte b/src/routes/start/+page.svelte index 0f94878..6fb8200 100644 --- a/src/routes/start/+page.svelte +++ b/src/routes/start/+page.svelte @@ -2,6 +2,7 @@ import { enhance } from '$app/forms'; import { invalidate } from '$app/navigation'; import { ago, bytes, duration, timerStatus, type Container } from '#lib/home.js'; + import { linkIcon } from '#lib/link-icons.js'; let { data, form } = $props(); @@ -358,7 +359,8 @@ href={s.url} title="{host_(s.url)} · {s.code ?? '?'}{s.ms != null ? ` · ${s.ms} ms` : ''}" > - {s.name} + {@render icon(s.url, s.name)}{s.name} + {/each} @@ -383,7 +385,7 @@ {#if form?.message}{/if} {#each sections as [section, links] (section)} {#if sections.length > 1}

{section}

{/if} -
    +
      {#each links as l (l.id)}
    • {#if editing && editId === l.id} @@ -406,7 +408,9 @@ {:else} - {l.title} + {@render icon(l.url, l.title)}{l.title} {/if}
    • {/each} @@ -434,6 +438,20 @@ {/snippet} +{#snippet icon(url: string, title: string)} + {@const ic = linkIcon(url, title)} + {#if ic.kind === 'svg'} + + {:else if ic.kind === 'img'} + + {:else} + + {/if} +{/snippet} + {#snippet linkForm(l: { id: number; title: string; url: string; section: string } | null)}
      \ No newline at end of file diff --git a/static/link-icons/tweakers.png b/static/link-icons/tweakers.png new file mode 100644 index 0000000..979e868 Binary files /dev/null and b/static/link-icons/tweakers.png differ diff --git a/static/link-icons/vash.ico b/static/link-icons/vash.ico new file mode 100644 index 0000000..2c9e68f Binary files /dev/null and b/static/link-icons/vash.ico differ