From 92d6da959d12a025d65323e6a938b1be9b7c073b Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sun, 25 Oct 2026 13:11:59 +0000 Subject: [PATCH] daisy-hook: accept a full "Name: value" header setting, redact the key from logs, retry once; start: brand icons on link and service tiles (trimmed simple-icons + saved favicons, monogram fallback) --- scripts/link-icons.ts | 16 ++++ src/lib/link-icons.json | 41 +++++++++ src/lib/link-icons.test.ts | 41 +++++++++ src/lib/link-icons.ts | 126 ++++++++++++++++++++++++++ src/lib/server/daisy-hook.test.ts | 34 ++++++- src/lib/server/daisy-hook.ts | 81 ++++++++++++----- src/routes/start/+page.svelte | 68 +++++++++++++- static/link-icons/belastingdienst.png | Bin 0 -> 488 bytes static/link-icons/buienradar.png | Bin 0 -> 7819 bytes static/link-icons/funda.png | Bin 0 -> 599 bytes static/link-icons/grok.png | Bin 0 -> 1006 bytes static/link-icons/megekko.png | Bin 0 -> 672 bytes static/link-icons/mijndak.png | Bin 0 -> 598 bytes static/link-icons/moneybird.png | Bin 0 -> 552 bytes static/link-icons/ns.png | Bin 0 -> 1417 bytes static/link-icons/pararius.png | Bin 0 -> 831 bytes static/link-icons/stuhee.svg | 1 + static/link-icons/tweakers.png | Bin 0 -> 968 bytes static/link-icons/vash.ico | Bin 0 -> 15406 bytes 19 files changed, 379 insertions(+), 29 deletions(-) create mode 100644 scripts/link-icons.ts create mode 100644 src/lib/link-icons.json create mode 100644 src/lib/link-icons.test.ts create mode 100644 src/lib/link-icons.ts create mode 100644 static/link-icons/belastingdienst.png create mode 100644 static/link-icons/buienradar.png create mode 100644 static/link-icons/funda.png create mode 100644 static/link-icons/grok.png create mode 100644 static/link-icons/megekko.png create mode 100644 static/link-icons/mijndak.png create mode 100644 static/link-icons/moneybird.png create mode 100644 static/link-icons/ns.png create mode 100644 static/link-icons/pararius.png create mode 100644 static/link-icons/stuhee.svg create mode 100644 static/link-icons/tweakers.png create mode 100644 static/link-icons/vash.ico 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)}
      P)%W{Jt6oBC*qy@pFAeR`7Lck>N|AsYf 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[@5M+m1kTO_uafM4KH3X^Q<=H(r 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 literal 0 HcmV?d00001 diff --git a/static/link-icons/grok.png b/static/link-icons/grok.png new file mode 100644 index 0000000000000000000000000000000000000000..be3670f835af07a5f6fe4be896459b88ce2eb3d9 GIT binary patch literal 1006 zcmV|5rBzED+SLH#42q$~lBQNz;5qh^A>j-8_!N(D(gPNZ${`FphKgX~W7& z@XvN0mV1Ho$1t}S6dwM7X5uOM_FdZIf23=sj6zJ!i|wNlA~6Ud#<%zd#>6oF0gneB znyLO1%N!WI{odmZ5}+mhAw>HJyfWTreJi{I?+*aR2A|Iee^vlUD*PJ%1FyoZ z0tD5&){F#<+|S1uZEzF+2r+isRw>ubDV{JVi`i&(kJb@ku!FsIi0Ds|GQ zZ9dm{D?K!clv~hSubBeN^0^EGzst%5wLk{Y;rlDyl?_3~?d2(60Rq~; zAK`~JTI*fe)x$l0LoS=Ub-W-jGQiyqaK=G&0^(PxN&@ahJSE|`o%URo?6Z+iauc8* z83DjStByMUU<=gxnLyp*k7$~Lpl^D%Ea-oMchR%u5h?<*pfvY*_Ofr-La%2LY6(vP z!M(7Q1i=NRlJvg~{wFuudjcR6q6PW>m>JS%)5UA1fvqAX;whD4j1(=1*?>&IPT=v5 z+!8{ee9w+Ru~ImiQcIo1fHpAq1l~$42)tRoTaq)M2`Gw6{E^NaZ1g|?l?DdAmL7j< z5Nwd`Xrw6$a&2Vh#&Q9etuScxf_@+<;sA^*lL~gPU}!>4G6Qt1t0*$x2qlU9(c+*Bk4OZF5%b^<^>0oZWO`ziP?n{o)di zCzzUPiYjP#yX;QD-`B-NDxl+D-s5p>iX+!Fz8l#{ zV4TjOi5Ol>!V|;WsRQPz92~{V!Ko8dF}vd9i_sV6mf`6XKZftG_~Cov_&_0^#{d31 c{)-&`4;d7o#NB%!BLDyZ07*qoM6N<$f)=jVK>z>% literal 0 HcmV?d00001 diff --git a/static/link-icons/megekko.png b/static/link-icons/megekko.png new file mode 100644 index 0000000000000000000000000000000000000000..b05cba5e07c23ed7d098d0f6ad0bb36059ddb0ce GIT binary patch literal 672 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!3<7*YT-^(NdaiO4k8Y*kfO^VwH3wRF<{FfcF)c)B=-M6|vQ^PF^8fyabtWAgw1+ndj- zO6D%;)1tWVZIiG2!M-8tw*DQ}1;renHtsKUZ!z|lY>*eXu=%{bBISXx z?1L2*B26K^4z_`foo)&@Y{j!X6%U&_=DXf4Z`A0ilW#Ozes$l12}+{6Q&xH3=cp;0 zptj^tSENXAPvypY(g)`?f2yf)deHIA^46~tOZQ~N-kIWPy?X9)Hn#^yGR`zk+`K4q zV&RW+-wlCf^Slp+)SL>v=2EUUnN7BKsz%tM>G!R-3J5nYJHE^&G?XD|xp0-z4Ue1A zEK?fX9;Dab+O_A?WswfH*FU%UwprCq6#49biS^OZx`bu>l#icHdd?QM`Q*o$=9@Xa zBrKFY-`mbM-2QOp0tV$Py-$lueJ&XGUST(^spY$PcI}3#xhqU!Hj6u~zJ6-$#AhAT z*Vgr>dmrck0;7=7kp8Z`+jA|S|9s^-&tz+?)V<} zAmngMgOc3$m#vP%hgFipnyu6{E=)W6c;E2_yBlupII)x8t9AF&H%}w4Z)5e)=POka z=Tx4pxY4M&Z+F73Olhl?_Qr}<3ELx{1up-7;gR;sV|f>r&bO#DIr&8OW4B_<*Xsvj lyFPn=-T9l}{*Uq>Mt&Rnm}#qK2(lkxYNK)~rpXntuRl+Le`z z(W+FwqiC_7blR;*;jlj?cf^^Nh~=_4?X)XCe7yI#6~d%sM2OSD)b6gw>DA)%09nOc zlh&2E<9ImQ)BpehY)M2xR9J=WRoilcFc1v^t?M=+prUQPlwPP%wY66N|I-Q*i=C1X z^`+Cvd4ZYDIlFsy34~Z!SeU?LcnO`cu-E6_xpd?M3INfNpP3#lG~Pb-!S>E*L98YuFq zG!J_F#!)1t9T3e7ykx z1R&Av69!op=xi2<`c*)7*$q^{b6sUj6RP;{>kmZ{MaZB6$8k38lS5UOZhp#X)rPK> zAuC`7{;0rby9TYTk-$DpryeE(ByhCoG<}Vvz19kJn}3h)M#JAV7Esi%-CfmV7Z_Y= kWPd9vaPfs}VexnLaO?v@VeIncQRs2F4AZE{-7)t#8Bb&T3WQ;Bh?iz5e0XDM1h4 z=RLo@`q{ckf^D8Rw~6cTV@&+s(~zvv&+p;nqmb}Zo85)gpj%;=-Ygao-VP2cjyD^X zHhc^=uv6&}krPtkT4~}SuVv`QlfhZmD9n98ihr)G<*gN#mM((lA39H+F#Y}9h1%xY zjI*CB&f#x$4F2$2d5z$UU#F*4*j#6O>Da@?cP-l@=|E0pheP(s-^Q%@SrZ&zG$t|F z>?-^vn3Hb(b9(KZwT!vx?2>N+XErPdd3e2xBUSusQ(9v2D~CNA!EWFBxmP>XNTSBTB^@$aiH*Rzr|Cd_1?VSYFq#4AIn;P>BLOw&zk~jH8d{-v?cwT79sOI zRAlY#>@PRgUq1aI)$=#Qkcvw(t zFT}an_3=8{Xw@r>d)wSIdZc%<5Qhj0E|lW>n6y;>{}%`aMCIyr@Hs)_Wn khXM0?4(Z4J;jUw_a8tUy`I50JFhUtTUHx3vIVCg!0P`j7IRF3v literal 0 HcmV?d00001 diff --git a/static/link-icons/ns.png b/static/link-icons/ns.png new file mode 100644 index 0000000000000000000000000000000000000000..a3d9c934625bbce422e08c547c5cac13b1214882 GIT binary patch literal 1417 zcmb7+eK6Yx9L9gYUtWon5HZq-mqaMelG2b9@lqsMz16#_m(rnBl&-9&w-VKAXjx_ni?1^K^v>3g(c$kbqFRTGcywsnM|>?x1m_jX=Ji3 z+m`OYWU*N0)|{OjM-O`^78BIe)Wm9GjkL9mm{c;A`M;&=25@M=6>x=tH~_+dFdV3Q z2oM1P25$Nv!I3Z!fr8W{T?YW+Ac%xRn7?NbfM9S04ykKOcZs zmYM|NU^svSNMqvL@up~Zor3=c!N5dsU>}rY3dhOR#s~yu(oe(hS1E1jZd+}PE|6#F zk*&8Aaqf)SsHt_$;nsYjBezN7D>T+orJfs}`7bJ@FwuDY)t z&F0xmidr7tucbA|-fWDF3M#5J$OtKXn%TOP_1b6U z5o>Q^hy~%`nT^~WgiN--(f7nha@sD`^I<*Okn&RirdCPo<>%!x7maU{GowVS)Ze4?W z5eVDht!u3q3=U|;ux@t3} z8x7*XnfDwKM%FlbzO~TGYpa1bUMP}Oi2H+@MYYsa{%!76dy|zVLR-V*&_Em777SzO zII->3@ec?3P&1c1l2#KA87ZnrO$+RjE8a`e9VN9BZCt@8ig9PBNmF{tbZB%(V%;e5 zr%Bs$)ug(RhaxVEd&Kyu?|0A%;m|S z9t&vKWWLPn^2n~DDW<9RP{*apZH2vWz1$_vhhN2@Xt0`(rmiV38Wg|qF?=$^3ayQ^aap`o>3?DLz06j6t%V$<`%7xbU?5);x#8vQAC<#C6|$e{X# zv4XGg`R-4Zlwqgp!q^QJFxm5Gz@t9)t>(Eq<3eGTcqyxVP-eF^K1(1|{yLnU$&z)G zvmoz`x%!H++5Te3-MTI{O4hwk|%s%V;IFRWqIZGQdK_NHhu{jGQ?&K$vxX|R{?(;qV=Isr9l{**r$+|1yXz7b}s7c{eYZ=tXb z8K2U6JbcemSdxrzDKBC*#A50eV=TTXSx+eIPEoMSzKJID?a9c8JUS)xir)m z&bFMl^p#qyTKe{v<({!IF3T;i>v5G@1=yXaiMgZkjlze`C1~@cKEwIXOM?m|&kqI( OHXB@`j<#c}@xK74Lq3rJ literal 0 HcmV?d00001 diff --git a/static/link-icons/pararius.png b/static/link-icons/pararius.png new file mode 100644 index 0000000000000000000000000000000000000000..2f0dbd1417ebdc49d52091cbaf8c45a52598a3dd GIT binary patch literal 831 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!5(0ceT-^(Ne*OLTp9mmk9t~8* zs2j?l8u0q<2Z2R@_>Chr?K)cAH0|uG|MORFyL988eOPf+PRpT_7Y)6$t2^g(OO&XMF20f=zhH)cwlSXy&OQ+An6pk{N9RxDUknUP4W2HJArY-h!*3@wEAWWE-uiY2 z@9kAv|Nh@Ury;C`<*kjs^i!G16Iv#yaI6Zh)$}r3q@!0FmH0bhNxE}s;I)@=WmDPT zcq-pdb`MQAh*e%~u3FS|xz|N*X?(Xu?BX46cKdy=TO|JZY-g98KI8x9>E~}eRhYkV zesj%%w!8n%ulO%oFTQ&7j=J;dXU?|E{uEwx<7q>U+4-=c8zyZj{zcmoE4JkResLs3s)1d#u4kJJt9Y$)Jgx(%B#J8UoKJ7 zU!yVlplHNw-S-EtSzQX*dLZj-dCPpx%q44oGwo-~o_*@Z(MOzj)*iUkZBw@Ot-y@@ z>mQ0(SvVdXvU&DH&7*pGwc(C-wghIjOrAf7)xZC?YB@J$OZE}Pl(Xe-bLx2`Q%p;k zzh4&HGx7I}1v4FbRoN2m*jeS&r!rf-s~2oP@c;d}rUm}<9{20zx;(kMVxp^E{>G0V z?ktk-O!kj&-eULZ$GkTlpNj+g-le1#p8NRxzkEv6{!`vJVugXJl)=;0&t;ucLK6U* CCzkdA literal 0 HcmV?d00001 diff --git a/static/link-icons/stuhee.svg b/static/link-icons/stuhee.svg new file mode 100644 index 0000000..e0bbf80 --- /dev/null +++ b/static/link-icons/stuhee.svg @@ -0,0 +1 @@ + \ 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 0000000000000000000000000000000000000000..979e86871a4392614ec7965fedd5bb3f8576e97b GIT binary patch literal 968 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 QqcQ&f`~RPTf7J~9FZj=DC;$Ke literal 0 HcmV?d00001