diff --git a/README.md b/README.md index 108fac0d..aeac4aeb 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ original deployment it grew out of). The project home is - **Sticker packs** — mirror Telegram sticker sets or upload your own; static, animated (.tgs→Lottie), and video stickers, with per-sticker artist credit and emoji search. *(Telegram import gated by `TELEGRAM_BOT_TOKEN`.)* +- **Artist lookup** — reverse image search an upload against FuzzySearch to + find who drew it, then credit them without leaving the form. *(Optional; + keyed off a `FUZZYSEARCH_API_KEY` secret or a key saved in Settings → + Connections. Nothing is sent until you click.)* - **Conventions** — track the cons you're attending (picked from the [cons.fyi](https://cons.fyi) feed, synced from your Bluesky "going" labels, or entered manually); upcoming ones show on the About page. diff --git a/docs/architecture.md b/docs/architecture.md index 0765f1b9..89342be6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,6 +41,7 @@ graph TB subgraph "External services" TG[🤖 Telegram Bot API] FurTrack[📸 FurTrack] + FuzzySearch[🔍 FuzzySearch — reverse image search] Resend[✉️ Resend] Turnstile[🧩 Cloudflare Turnstile] ConsFYI[📅 cons.fyi] @@ -86,6 +87,7 @@ graph TB Importers -->|sticker sets| TG Importers -->|photo import| FurTrack + Admin -->|artist lookup| FuzzySearch Auth -->|reset email| Resend RateLimit --> Turnstile Public -->|convention dates| ConsFYI @@ -129,8 +131,8 @@ graph TB - The cons.fyi feed supplies each convention's IANA timezone as well as its dates, which is what lets `/connect` decide "here now" in the event's own zone rather than the reader's or UTC. -- Telegram, FurTrack, Resend, and Turnstile are optional integrations, keyed - off secrets or settings (see `wrangler.toml.example` for the full list). +- Telegram, FurTrack, FuzzySearch, Resend, and Turnstile are optional + integrations, keyed off secrets or settings (see `wrangler.toml.example` for the full list). - GitHub Actions is part of the runtime, not just delivery: the scheduled workflows (`sticker-resync` daily 06:00 UTC, `artist-sync` 06:30, `avatar-refresh` 07:00, `cleanup-orphans` weekly, `backfill-animated` diff --git a/messages/en.json b/messages/en.json index 9d792723..73c0b657 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1182,6 +1182,32 @@ "admin_settings_disconnecting": "Disconnecting…", "admin_settings_connect_registry": "Connect to registry", "admin_settings_forkkey_hint": "The fork key is stored in your site's database (not a deploy secret). It's a low-privilege, revocable, submit-only key.", + "admin_settings_lookup_heading": "Artist lookup", + "admin_settings_lookup_explainer_1": "Find who drew a piece when the post doesn't say. When you click Look up artist on an upload, Sona sends that image file to FuzzySearch. FuzzySearch matches it against its index of FurAffinity, Weasyl, e621, and the Twitter accounts it tracks. Sona sends nothing until you click, and keeps only the values you apply to the form.", + "admin_settings_lookup_explainer_2": "FuzzySearch is an independent service, not part of Sona. It may keep a copy or a hash of what you send. Sona has no agreement with it and can't delete anything on your behalf.", + "admin_settings_lookup_key_label": "FuzzySearch API key", + "admin_settings_lookup_key_placeholder": "Paste your key", + "admin_settings_lookup_hint_pre": "You can get a free key at ", + "admin_settings_lookup_hint_post": ".", + "admin_settings_lookup_save": "Save key", + "admin_settings_lookup_saved": "Key saved.", + "admin_settings_lookup_error_invalid": "That doesn't look like a key. Paste it exactly as FuzzySearch gave it to you.", + "admin_settings_lookup_connected_eyebrow": "Connected", + "admin_settings_lookup_key_ending": "ending {tail}", + "admin_settings_lookup_saved_key_label": "Saved key", + "admin_settings_lookup_replace": "To use a different key, remove this one and save the new one.", + "admin_settings_lookup_remove": "Remove key", + "admin_settings_lookup_removed": "Key removed.", + "admin_settings_lookup_confirm": "Remove the key? Look up artist disappears from upload and edit until you save a new one.", + "admin_settings_lookup_confirm_remove": "Remove", + "admin_settings_lookup_removing": "Removing…", + "admin_settings_lookup_confirm_keep": "Keep", + "admin_settings_lookup_refused_eyebrow": "Key refused", + "admin_settings_lookup_refused_line": "FuzzySearch didn't accept this key on the last lookup ({date}).", + "admin_settings_lookup_refused_key_label": "Refused key", + "admin_settings_lookup_new_key_label": "New FuzzySearch API key", + "admin_settings_lookup_secret_pre": "Set by the ", + "admin_settings_lookup_secret_post": " deploy secret. Manage it wherever you set your deploy secrets.", "admin_settings_danger_zone": "Danger Zone", "admin_settings_export_title": "Export data", "admin_settings_export_desc": "Download a full backup of all images, metadata, collections, and tags as a JSON file.", diff --git a/messages/ja.json b/messages/ja.json index aee3c372..93b82767 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -902,6 +902,32 @@ "admin_settings_disconnecting": "切断中…", "admin_settings_connect_registry": "レジストリに接続", "admin_settings_forkkey_hint": "フォークキーはサイトのデータベースに保存されます(デプロイシークレットではありません)。低権限・失効可能・申請専用のキーです。", + "admin_settings_lookup_heading": "アーティスト検索", + "admin_settings_lookup_explainer_1": "投稿元に記載がない作品でも、描いた人を探せます。アップロードした画像で「アーティストを検索」を押すと、Sonaはその画像ファイルをFuzzySearchに送信します。FuzzySearchは、FurAffinity・Weasyl・e621と、収集対象のTwitterアカウントのインデックスと照合します。押すまでは何も送信されず、フォームに反映した値だけが保存されます。", + "admin_settings_lookup_explainer_2": "FuzzySearchはSonaとは無関係の外部サービスです。送信した画像のコピーやハッシュが保管される場合があります。Sonaは同サービスと契約しておらず、あなたに代わってデータを削除することはできません。", + "admin_settings_lookup_key_label": "FuzzySearch APIキー", + "admin_settings_lookup_key_placeholder": "キーを貼り付け", + "admin_settings_lookup_hint_pre": "無料のキーは ", + "admin_settings_lookup_hint_post": " で取得できます。", + "admin_settings_lookup_save": "キーを保存", + "admin_settings_lookup_saved": "キーを保存しました。", + "admin_settings_lookup_error_invalid": "キーの形式が違うようです。FuzzySearchから受け取ったとおりに貼り付けてください。", + "admin_settings_lookup_connected_eyebrow": "接続済み", + "admin_settings_lookup_key_ending": "末尾 {tail}", + "admin_settings_lookup_saved_key_label": "保存済みのキー", + "admin_settings_lookup_replace": "別のキーを使うには、このキーを削除してから新しいキーを保存してください。", + "admin_settings_lookup_remove": "キーを削除", + "admin_settings_lookup_removed": "キーを削除しました。", + "admin_settings_lookup_confirm": "キーを削除しますか?新しいキーを保存するまで、アップロード画面と編集画面から「アーティストを検索」がなくなります。", + "admin_settings_lookup_confirm_remove": "削除", + "admin_settings_lookup_removing": "削除中…", + "admin_settings_lookup_confirm_keep": "そのままにする", + "admin_settings_lookup_refused_eyebrow": "キーが拒否されました", + "admin_settings_lookup_refused_line": "前回の検索で、FuzzySearchはこのキーを受け付けませんでした({date})。", + "admin_settings_lookup_refused_key_label": "拒否されたキー", + "admin_settings_lookup_new_key_label": "新しいFuzzySearch APIキー", + "admin_settings_lookup_secret_pre": "デプロイシークレット ", + "admin_settings_lookup_secret_post": " で設定されています。デプロイシークレットを設定している場所で管理してください。", "admin_settings_danger_zone": "危険な操作", "admin_settings_export_title": "データをエクスポート", "admin_settings_export_desc": "すべての画像・メタデータ・コレクション・タグの完全なバックアップをJSONファイルとしてダウンロードします。", diff --git a/src/app.d.ts b/src/app.d.ts index f83ec198..da7c7d4f 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -96,6 +96,13 @@ declare global { * the site runs entirely on its local artists table. */ REGISTRY_API_KEY?: string; + /** + * FuzzySearch API key, enabling "Look up artist" (reverse image search). + * Optional: without it — and without the key saved in Settings → + * Connections, which this secret overrides — the lookup endpoint answers + * `{ enabled: false }` and the button never appears. + */ + FUZZYSEARCH_API_KEY?: string; /** * Resend API key. Gates the admin "Forgot password" flow: when unset, * /admin/forgot silently no-ops (still returns the generic response) and diff --git a/src/lib/components/ConCard.svelte b/src/lib/components/ConCard.svelte index 94501815..aa72d6e3 100644 --- a/src/lib/components/ConCard.svelte +++ b/src/lib/components/ConCard.svelte @@ -6,6 +6,7 @@ conCardFaceSvg, conCardPrintSheetSvg, conCardFileBase, + isEmbeddableAvatarType, CON_CARD_WIDTH, CON_CARD_HEIGHT, type ConCardColor, @@ -173,6 +174,14 @@ try { const response = await fetch(avatarSrc); if (!response.ok) throw new Error(`avatar ${response.status}`); + // The proxy answers octet-stream for anything outside the raster + // allowlist, and a data URI made from that draws nothing. Treated as a + // failed avatar so the card falls back to the initial in the ring and + // says so, rather than saving a blank one. A response with no + // content-type at all is a direct same-origin avatar, not a refusal, so + // it still embeds. + const type = response.headers.get('content-type'); + if (!isEmbeddableAvatarType(type)) throw new Error(`avatar type ${type}`); const blob = await response.blob(); avatarData = await new Promise((resolve, reject) => { const reader = new FileReader(); diff --git a/src/lib/components/con-card-markup.test.ts b/src/lib/components/con-card-markup.test.ts index 9b405f13..8322571a 100644 --- a/src/lib/components/con-card-markup.test.ts +++ b/src/lib/components/con-card-markup.test.ts @@ -67,6 +67,19 @@ describe('ConCard download paths', () => { expect(source).toMatch(/avatarFailed = true;[\s\S]*?return null;/); }); + it('checks the proxy answered an image before it builds the data URI', () => { + // isEmbeddableAvatarType is unit-tested in con-card.test.ts; what has to be + // pinned here is that the fetch path actually calls it, on the response's + // own content-type, and throws instead of reaching the FileReader — a data + // URI made from an octet-stream body draws nothing and would be saved as a + // blank avatar with no message. Only the order matters — the header read, + // then the guard, then the reader — so renaming the local or moving a + // comment between them doesn't fail this. + expect(source).toMatch( + /embedAvatar\(\)[\s\S]*?headers\.get\(['"]content-type['"]\)[\s\S]*?isEmbeddableAvatarType\([\s\S]*?throw[\s\S]*?readAsDataURL/ + ); + }); + it('separates "saved without your avatar" from "nothing saved"', () => { // avatarFailed is the embed path's alone; a raster failure must never claim // a file was saved. Both save paths route their catch to rasterFailed. diff --git a/src/lib/con-card.test.ts b/src/lib/con-card.test.ts index 01f683f1..3beee855 100644 --- a/src/lib/con-card.test.ts +++ b/src/lib/con-card.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { qrSvg } from './qr'; import { SOCIAL_ICON_ART, type SocialIconArt } from './social-icon-paths'; import { SOCIAL_PLATFORM_NAMES, type SocialPlatform } from './social-label'; @@ -6,6 +6,7 @@ import { conCardFaceSvg, conCardPrintSheetSvg, conCardFileBase, + isEmbeddableAvatarType, CON_CARD_WIDTH, CON_CARD_HEIGHT, CON_CARD_SHEET_WIDTH, @@ -618,3 +619,55 @@ describe('conCardFileBase', () => { expect(conCardFileBase('')).toBe('con-card'); }); }); + +// The card embeds the avatar as a data URI, and the byte proxy in front of it +// hands anything outside the stored raster allowlist back as a download. A URI +// built from one of those draws nothing, so the type decides whether the card +// keeps the face or falls back to the initial in the ring. +describe('isEmbeddableAvatarType', () => { + it('takes the raster types the gallery stores, whatever their spelling', () => { + for (const type of ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif']) { + expect(isEmbeddableAvatarType(type), type).toBe(true); + } + expect(isEmbeddableAvatarType('Image/PNG; charset=binary')).toBe(true); + }); + + it('refuses the proxy download type and svg', () => { + const refused = ['application/octet-stream', 'image/svg+xml', 'text/html']; + for (const type of refused) { + expect(isEmbeddableAvatarType(type), String(type)).toBe(false); + } + }); + + // The guard also runs on the direct same-origin avatar URL, and the proxy + // always sets a content-type. Only a PRESENT, non-raster type is a refusal — + // treating a missing header as one drops a perfectly good avatar to the + // initial. + it('takes a response with no content-type at all', () => { + for (const type of ['', null, undefined]) { + expect(isEmbeddableAvatarType(type), String(type)).toBe(true); + } + }); + + // GALLERY_ACCEPT has no spaces today, and an accept list is just as valid + // written with them. Read literally, a single space would drop the avatar to + // the initial for a type the server's own allowlist stores. + it('reads an accept list written with spaces after the commas', async () => { + vi.resetModules(); + vi.doMock('$lib/config', async () => { + const actual = await vi.importActual('./config'); + return { ...actual, GALLERY_ACCEPT: 'image/jpeg, image/png, image/webp' }; + }); + try { + const spaced = await import('./con-card'); + expect(spaced.isEmbeddableAvatarType('image/png')).toBe(true); + expect(spaced.isEmbeddableAvatarType('image/webp')).toBe(true); + // Not in the stubbed list, so a false here also proves the stub is the + // list being read rather than the real constant. + expect(spaced.isEmbeddableAvatarType('image/gif')).toBe(false); + } finally { + vi.doUnmock('$lib/config'); + vi.resetModules(); + } + }); +}); diff --git a/src/lib/con-card.ts b/src/lib/con-card.ts index ff8965d7..98c64239 100644 --- a/src/lib/con-card.ts +++ b/src/lib/con-card.ts @@ -1,3 +1,4 @@ +import { GALLERY_ACCEPT } from '$lib/config'; import { qrSvg } from '$lib/qr'; import { SOCIAL_ICON_ART, type SocialIconArt } from '$lib/social-icon-paths'; import { SOCIAL_PLATFORM_NAMES, type SocialPlatform } from '$lib/social-label'; @@ -620,6 +621,27 @@ export function conCardPrintSheetSvg(opts: Omit): str .join(''); } +/** + * Whether a fetched avatar response can be embedded in a saved card. The + * same-origin byte proxy hands anything outside the stored raster allowlist back + * as `application/octet-stream` with a download disposition, and a data URI + * built from that never draws: the card would save with an empty ring and no + * word to the operator. Only a PRESENT, non-raster type is a failure — the + * proxy always sets a content-type, so a response without one is a direct + * same-origin avatar and stays embeddable. Read off GALLERY_ACCEPT so this and + * the server's allowlist can't drift apart. + */ +export function isEmbeddableAvatarType(contentType: string | null | undefined): boolean { + if (!contentType) return true; + const type = contentType.split(';')[0].trim().toLowerCase(); + // Tokens are trimmed: an accept list written with a space after the comma is + // the same list to an , and reading it literally here would + // refuse a type the server's allowlist takes. + return GALLERY_ACCEPT.split(',') + .map((t) => t.trim()) + .includes(type); +} + /** Filename stem for a downloaded card: `taro-con-card`. */ export function conCardFileBase(name: string): string { const slug = name diff --git a/src/lib/server/fuzzysearch.test.ts b/src/lib/server/fuzzysearch.test.ts new file mode 100644 index 00000000..656b53d4 --- /dev/null +++ b/src/lib/server/fuzzysearch.test.ts @@ -0,0 +1,671 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + FUZZYSEARCH_ENDPOINT, + FUZZYSEARCH_MAX_DISTANCE, + FUZZYSEARCH_TIMEOUT_MS, + FUZZYSEARCH_ERROR_BODY_BYTES, + FUZZYSEARCH_RESPONSE_BYTES, + searchImage, + normalizeMatches, + pickPrefillMatch, + strictestRating, + normalizeSourceUrl, + postUrlFor, + handleProfileUrl, + findLocalArtists, + findArtistsByName, + fuzzysearchKeyDisplayRecord, + fuzzysearchRefusedMarker, + parseFuzzysearchRefusedMarker, + type LookupMatch +} from './fuzzysearch'; + +// A fetch stand-in that records what the client sent and answers with a fixed +// response. Injected rather than stubbed globally (the furtrack.test.ts shape). +function fakeFetch(response: Response | (() => Promise)) { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fn = (async (url: string, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return typeof response === 'function' ? await response() : response; + }) as unknown as typeof fetch; + return { fn, calls }; +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +// A response whose body arrives chunk by chunk, recording how much was pulled +// and whether the rest was cancelled — the way to see that a body past the byte +// cap is never read to the end. +function chunkedResponse(chunks: string[], status = 200) { + const state = { pulled: 0, cancelled: false }; + const encoder = new TextEncoder(); + const body = new ReadableStream({ + pull(controller) { + if (state.pulled >= chunks.length) return controller.close(); + controller.enqueue(encoder.encode(chunks[state.pulled++])); + }, + cancel() { + state.cancelled = true; + } + }); + return { response: new Response(body, { status }), state }; +} + +const MIXED_PAYLOAD = [ + // Dropped: too far away to be a lead. + { site: 'FurAffinity', site_id_str: '999', artists: ['faraway'], distance: 9 }, + { site: 'Weasyl', site_id_str: '5150', artists: ['kuttoya'], distance: 2, rating: 'mature' }, + { + site: 'FurAffinity', + site_id_str: '12345', + artists: ['kuttoya'], + distance: 0, + rating: 'general', + posted_at: '2026-01-02T03:04:05Z' + }, + // Dropped: a site with no post-URL shape and nothing to link to. + { site: 'Unknown', site_id_str: '1', artists: [], distance: 0 }, + { site: 'e621', site_id_str: '777', artists: ['kuttoya'], distance: null, rating: 'adult' }, + { site: 'Twitter', site_id_str: '160', artists: ['kuttoya'], distance: 2, rating: 'adult' }, + // Twitter with no artist handle — the status still has a canonical URL. + { site: 'Twitter', site_id_str: '161', artists: [], distance: 5 } +]; + +describe('normalizeMatches', () => { + const matches = normalizeMatches(MIXED_PAYLOAD); + + it('drops Unknown sites and anything past the distance cap', () => { + expect(matches.map((m) => m.siteId)).not.toContain('999'); + expect(matches.some((m) => (m.site as string) === 'Unknown')).toBe(false); + expect(matches).toHaveLength(5); + }); + + it('sorts closest first, ties by site, nulls last', () => { + expect(matches.map((m) => `${m.site}:${m.siteId}`)).toEqual([ + 'FurAffinity:12345', + 'Twitter:160', + 'Weasyl:5150', + 'Twitter:161', + 'e621:777' + ]); + }); + + it('bands the distances', () => { + expect(matches[0].band).toBe('exact'); // 0 + expect(matches[1].band).toBe('strong'); // 2 + expect(matches[3].band).toBe('possible'); // 5 + expect(matches[4].band).toBeNull(); // unknown distance + expect(matches[4].distance).toBeNull(); + }); + + it('builds a post URL for every site', () => { + const byId = Object.fromEntries(matches.map((m) => [m.siteId, m.postUrl])); + expect(byId['12345']).toBe('https://www.furaffinity.net/view/12345/'); + expect(byId['5150']).toBe('https://www.weasyl.com/submission/5150'); + expect(byId['777']).toBe('https://e621.net/posts/777'); + expect(byId['160']).toBe('https://twitter.com/kuttoya/status/160'); + expect(byId['161']).toBe('https://twitter.com/i/status/161'); + }); + + it('keeps the raw handles, the posted date, and a known rating', () => { + expect(matches[0].handles).toEqual(['kuttoya']); + expect(matches[0].postedAt).toBe('2026-01-02T03:04:05Z'); + expect(matches[0].rating).toBe('general'); + expect(matches[3].rating).toBeNull(); + }); + + it('returns nothing for a payload that is not a list', () => { + expect(normalizeMatches({ matches: [] })).toEqual([]); + expect(normalizeMatches(null)).toEqual([]); + }); + + // The cap is the line between a lead and noise, so it is pinned rather than + // left to whatever the constant happens to be. + it('keeps a match at the distance cap and drops the one past it', () => { + const at = normalizeMatches([ + { site: 'FurAffinity', site_id_str: '7', artists: [], distance: FUZZYSEARCH_MAX_DISTANCE } + ]); + expect(at.map((m) => m.siteId)).toEqual(['7']); + expect(at[0].band).toBe('possible'); + expect( + normalizeMatches([ + { site: 'FurAffinity', site_id_str: '8', artists: [], distance: FUZZYSEARCH_MAX_DISTANCE + 1 } + ]) + ).toEqual([]); + }); + + // Third-party JSON: every field can be the wrong shape, and none of it may + // throw or reach the operator as a half-built match. + it('survives junk entries — dropping or nulling each without throwing', () => { + const junk = normalizeMatches([ + // Negative distance: not a real Hamming distance, so not a lead. + { site: 'FurAffinity', site_id_str: '1', distance: -1 }, + // Non-numeric distances read as "unknown", which is allowed. + { site: 'FurAffinity', site_id_str: '2', distance: 'close' }, + { site: 'FurAffinity', site_id_str: '3', distance: Number.NaN }, + // No usable id — nothing to link to. + { site: 'FurAffinity', distance: 0 }, + { site: 'FurAffinity', site_id_str: 12345, distance: 0 }, + // Non-object entries. + null, + 'FurAffinity', + 42, + [{ site: 'FurAffinity', site_id_str: '9' }] + ]); + + expect(junk.map((m) => m.siteId)).toEqual(['2', '3']); + expect(junk.every((m) => m.distance === null && m.band === null)).toBe(true); + }); + + it('keeps only usable handles, and still builds a Twitter URL without one', () => { + const [match] = normalizeMatches([ + { site: 'Twitter', site_id_str: '160', artists: ['ok', '', ' ', 42, null], distance: 0 } + ]); + expect(match.handles).toEqual(['ok']); + + const [junkOnly] = normalizeMatches([ + { site: 'Twitter', site_id_str: '161', artists: [null, 7], distance: 0 } + ]); + expect(junkOnly.handles).toEqual([]); + expect(junkOnly.postUrl).toBe('https://twitter.com/i/status/161'); + }); +}); + +describe('searchImage — request shape', () => { + it('posts the bytes as multipart with the key header and a timeout signal', async () => { + const { fn, calls } = fakeFetch(jsonResponse(MIXED_PAYLOAD)); + const result = await searchImage(new Blob([new Uint8Array([1, 2, 3])]), 'secret-key', fn); + + expect(result).toEqual({ ok: true, matches: normalizeMatches(MIXED_PAYLOAD) }); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe(FUZZYSEARCH_ENDPOINT); + expect(calls[0].init.method).toBe('POST'); + expect((calls[0].init.headers as Record)['x-api-key']).toBe('secret-key'); + expect(calls[0].init.signal).toBeInstanceOf(AbortSignal); + // Following a redirect would replay the key header at the Location host. + expect(calls[0].init.redirect).toBe('manual'); + const body = calls[0].init.body as FormData; + expect(body).toBeInstanceOf(FormData); + const sent = body.get('image'); + expect(sent).toBeInstanceOf(File); + expect((sent as File).name).toBe('image'); + // The bound the signal was built with, pinned so it can't silently grow. + expect(FUZZYSEARCH_TIMEOUT_MS).toBe(8000); + }); + + // `instanceof AbortSignal` passes for a signal that never fires, so the one + // handed to fetch is driven on fake timers: still live a tick before the + // bound, aborted a tick after. AbortSignal.timeout stands in as a + // controller on a plain setTimeout, which fake timers can advance. + it('hands fetch a signal that aborts at the timeout, not one that never fires', async () => { + vi.useFakeTimers(); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms: number) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(new DOMException('timed out', 'TimeoutError')), ms); + return controller.signal; + }); + try { + const { fn, calls } = fakeFetch(jsonResponse([])); + await searchImage(new Blob(['x']), 'k', fn); + + expect(timeout).toHaveBeenCalledWith(FUZZYSEARCH_TIMEOUT_MS); + const signal = calls[0].init.signal as AbortSignal; + vi.advanceTimersByTime(FUZZYSEARCH_TIMEOUT_MS - 1); + expect(signal.aborted).toBe(false); + vi.advanceTimersByTime(2); + expect(signal.aborted).toBe(true); + } finally { + timeout.mockRestore(); + vi.useRealTimers(); + } + }); +}); + +describe('searchImage — failure mapping', () => { + const cases: Array<[number, string]> = [ + [401, 'key_refused'], + // A revoked or suspended key answers 403, not 401; both are the refused + // state the settings card can act on. + [403, 'key_refused'], + // Nothing follows the redirect, so the 3xx itself is the answer. + [302, 'unavailable'], + [429, 'rate_limited'], + [413, 'too_large'], + [500, 'unavailable'], + [404, 'unavailable'] + ]; + for (const [status, reason] of cases) { + it(`maps ${status} to ${reason}`, async () => { + const { fn } = fakeFetch(new Response('nope', { status })); + expect(await searchImage(new Blob(['x']), 'k', fn)).toEqual({ ok: false, reason }); + }); + } + + // The body of a failure is never read, and a subrequest stream left unread + // holds the connection open until the runtime reaps it. + it('cancels the unread body on every failure status', async () => { + for (const status of [...cases.map(([s]) => s), 400]) { + const response = new Response('nope', { status }); + const cancel = vi.spyOn(response.body as ReadableStream, 'cancel'); + const { fn } = fakeFetch(response); + await searchImage(new Blob(['x']), 'k', fn); + expect(cancel, String(status)).toHaveBeenCalled(); + } + }); + + // The ok path reads the payload instead — cancelling it would throw away the + // matches the operator asked for. + it('does not cancel the body of a 200 it is about to read', async () => { + const response = jsonResponse([]); + const cancel = vi.spyOn(response.body as ReadableStream, 'cancel'); + const { fn } = fakeFetch(response); + expect(await searchImage(new Blob(['x']), 'k', fn)).toEqual({ ok: true, matches: [] }); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('splits 400 into too_large and invalid_image by body', async () => { + const big = fakeFetch(new Response('{"error":"too_large"}', { status: 400 })); + expect(await searchImage(new Blob(['x']), 'k', big.fn)).toEqual({ + ok: false, + reason: 'too_large' + }); + const bad = fakeFetch(new Response('{"error":"could not decode"}', { status: 400 })); + expect(await searchImage(new Blob(['x']), 'k', bad.fn)).toEqual({ + ok: false, + reason: 'invalid_image' + }); + }); + + it('maps a network error or an aborted request to unavailable', async () => { + const { fn } = fakeFetch(async () => { + throw new DOMException('The operation was aborted.', 'TimeoutError'); + }); + expect(await searchImage(new Blob(['x']), 'k', fn)).toEqual({ + ok: false, + reason: 'unavailable' + }); + }); + + it('maps an unparseable 200 body to unavailable', async () => { + const { fn } = fakeFetch(new Response('', { status: 200 })); + expect(await searchImage(new Blob(['x']), 'k', fn)).toEqual({ + ok: false, + reason: 'unavailable' + }); + }); + + // A 200 carrying something other than the documented array is a broken + // upstream. Reported as no matches it would read as "your art isn't indexed", + // which is a different — and wrong — answer. + it('maps a 200 whose JSON is not an array to unavailable, not an empty list', async () => { + for (const body of [{ matches: [] }, 'ok', 42, null]) { + const { fn } = fakeFetch(jsonResponse(body)); + expect(await searchImage(new Blob(['x']), 'k', fn), JSON.stringify(body)).toEqual({ + ok: false, + reason: 'unavailable' + }); + } + }); +}); + +// FuzzySearch is a third party: how many bytes of its answer this isolate holds +// is our decision, not theirs. +describe('searchImage — bounded response reads', () => { + it('stops reading a 400 body at the cap and reports invalid_image', async () => { + // The token sits in the last chunk, well past the cap it would have to be + // inside to count. + const chunks = [...Array(9).fill('a'.repeat(1024)), '{"error":"too_large"}']; + const { response, state } = chunkedResponse(chunks, 400); + const { fn } = fakeFetch(response); + expect(await searchImage(new Blob(['x']), 'k', fn)).toEqual({ + ok: false, + reason: 'invalid_image' + }); + expect(state.cancelled).toBe(true); + // Enough chunks to cross the cap plus a little read-ahead (how much the + // stream pulls ahead is the platform's choice, so leave it room), nowhere + // near the whole body. + expect(state.pulled).toBeLessThanOrEqual(FUZZYSEARCH_ERROR_BODY_BYTES / 1024 + 4); + expect(state.pulled).toBeLessThan(chunks.length); + }); + + it('still finds the too_large token inside the cap', async () => { + const { response } = chunkedResponse(['{"error":"too_large"}'], 400); + const { fn } = fakeFetch(response); + expect(await searchImage(new Blob(['x']), 'k', fn)).toEqual({ + ok: false, + reason: 'too_large' + }); + }); + + it('maps a 200 body past the cap to unavailable without reading it all', async () => { + const chunk = 'x'.repeat(64 * 1024); + const chunks = Array(Math.ceil(FUZZYSEARCH_RESPONSE_BYTES / chunk.length) + 4).fill(chunk); + const { response, state } = chunkedResponse(chunks); + const { fn } = fakeFetch(response); + expect(await searchImage(new Blob(['x']), 'k', fn)).toEqual({ + ok: false, + reason: 'unavailable' + }); + expect(state.cancelled).toBe(true); + expect(state.pulled).toBeLessThan(chunks.length); + }); + + it('reads a 200 payload that fits, chunked', async () => { + const { response } = chunkedResponse(['[{"site":"FurAffinity",', '"site_id_str":"12345"}]']); + const { fn } = fakeFetch(response); + expect(await searchImage(new Blob(['x']), 'k', fn)).toEqual({ + ok: true, + matches: [normalizeMatches([{ site: 'FurAffinity', site_id_str: '12345' }])[0]] + }); + }); + + // Some responses (and some test doubles) carry no stream at all; text() is + // then the only way in. + it('falls back to text() when the response has no body stream', async () => { + const bodiless = { + status: 200, + ok: true, + body: null, + text: async () => '[]' + } as unknown as Response; + const { fn } = fakeFetch(bodiless); + expect(await searchImage(new Blob(['x']), 'k', fn)).toEqual({ ok: true, matches: [] }); + }); +}); + +function match(over: Partial): LookupMatch { + return { + site: 'FurAffinity', + siteId: '1', + handles: [], + distance: 0, + band: 'exact', + postedAt: null, + rating: null, + postUrl: 'https://www.furaffinity.net/view/1/', + ...over + }; +} + +describe('pickPrefillMatch', () => { + it('takes the first exact or strong match in sorted order', () => { + const picked = pickPrefillMatch([ + match({ siteId: 'a', distance: 1, band: 'strong' }), + match({ siteId: 'b', distance: 0, band: 'exact' }) + ]); + expect(picked?.siteId).toBe('a'); + }); + + it('is null when nothing is closer than possible', () => { + expect(pickPrefillMatch([match({ distance: 4, band: 'possible' })])).toBeNull(); + expect(pickPrefillMatch([match({ distance: null, band: null })])).toBeNull(); + expect(pickPrefillMatch([])).toBeNull(); + }); +}); + +describe('strictestRating', () => { + it('takes the strictest rating across confident matches and names its sites', () => { + expect( + strictestRating([ + match({ site: 'FurAffinity', rating: 'general' }), + match({ site: 'e621', band: 'strong', distance: 2, rating: 'adult' }), + match({ site: 'Twitter', band: 'strong', distance: 1, rating: 'adult' }), + match({ site: 'Weasyl', rating: 'mature' }) + ]) + ).toEqual({ rating: 'adult', sites: ['e621', 'Twitter'] }); + }); + + it('ignores possible and unknown-distance matches', () => { + expect( + strictestRating([ + match({ rating: 'general' }), + match({ site: 'e621', band: 'possible', distance: 6, rating: 'adult' }), + match({ site: 'Weasyl', band: null, distance: null, rating: 'adult' }) + ]) + ).toEqual({ rating: 'general', sites: ['FurAffinity'] }); + }); + + it('is null when no confident match carries a rating', () => { + expect(strictestRating([match({ rating: null })])).toBeNull(); + expect(strictestRating([])).toBeNull(); + }); +}); + +describe('normalizeSourceUrl', () => { + it('folds scheme, host case, www, query, fragment, and trailing slash', () => { + const canonical = 'furaffinity.net/view/12345'; + expect(normalizeSourceUrl('https://www.furaffinity.net/view/12345/')).toBe(canonical); + expect(normalizeSourceUrl('http://FurAffinity.NET/view/12345')).toBe(canonical); + expect(normalizeSourceUrl('https://www.furaffinity.net/view/12345?full=1#c')).toBe(canonical); + expect(normalizeSourceUrl(' https://WWW.furaffinity.net/view/12345// ')).toBe(canonical); + }); + + // The same post under the site's other name. Without this fold, an operator + // who saved the x.com link gets no clash warning for the twitter.com URL + // this client builds. + it('folds known host aliases onto one canonical host', () => { + expect(normalizeSourceUrl('https://x.com/kuttoya/status/160')).toBe( + 'twitter.com/i/status/160' + ); + expect(normalizeSourceUrl('https://mobile.twitter.com/kuttoya/status/160')).toBe( + 'twitter.com/i/status/160' + ); + expect(normalizeSourceUrl('https://sfw.furaffinity.net/view/12345/')).toBe( + 'furaffinity.net/view/12345' + ); + }); + + // A match with no artist handle gets the /i/ URL this client builds, and the + // operator's saved link carries the handle. Same tweet, so both must reduce + // to the same string or the duplicate-source warning never fires. + it('reduces a tweet to its status id, with or without the handle', () => { + const canonical = 'twitter.com/i/status/160'; + expect(normalizeSourceUrl('https://twitter.com/i/status/160')).toBe(canonical); + expect(normalizeSourceUrl('https://twitter.com/kuttoya/status/160')).toBe(canonical); + expect(normalizeSourceUrl('https://x.com/kuttoya/status/160')).toBe(canonical); + // The address Twitter's UI hands out for a tweet's image. + expect(normalizeSourceUrl('https://x.com/kuttoya/status/160/photo/1')).toBe(canonical); + }); + + // A host that names an inherited Object member must be a lookup miss, not + // whatever the prototype carries under that key. + it('does not read host aliases through Object.prototype', () => { + expect(normalizeSourceUrl('http://constructor/view/1')).toBe('constructor/view/1'); + expect(normalizeSourceUrl('http://__proto__/view/1')).toBe('__proto__/view/1'); + }); + + it('lowercases the path on the hosts that treat it case-insensitively', () => { + expect(normalizeSourceUrl('https://twitter.com/Kuttoya')).toBe('twitter.com/kuttoya'); + expect(normalizeSourceUrl('https://www.furaffinity.net/View/12345/')).toBe( + 'furaffinity.net/view/12345' + ); + expect(normalizeSourceUrl('https://www.weasyl.com/~Kuttoya')).toBe('weasyl.com/~kuttoya'); + }); + + it('keeps path case elsewhere, since most sites are case-sensitive there', () => { + expect(normalizeSourceUrl('https://e621.net/users/Kuttoya')).toBe('e621.net/users/Kuttoya'); + }); + + // Each site's other spelling of one submission: FurAffinity's full-size view + // and e621's old post path both name the post this client builds a canonical + // URL for. + it('folds the alternate post paths on FurAffinity and e621', () => { + expect(normalizeSourceUrl('https://www.furaffinity.net/full/12345/')).toBe( + 'furaffinity.net/view/12345' + ); + expect(normalizeSourceUrl('https://e621.net/post/show/160')).toBe('e621.net/posts/160'); + // e621's old path carried the tag string after the id, and that is still + // the same post — folded like a tweet's trailing segment. + expect(normalizeSourceUrl('https://e621.net/post/show/160/canine%20solo')).toBe( + 'e621.net/posts/160' + ); + // Only the bare id form folds on FurAffinity: a deeper path is a different + // page there. + expect(normalizeSourceUrl('https://www.furaffinity.net/full/12345/extra')).toBe( + 'furaffinity.net/full/12345/extra' + ); + }); + + // Weasyl hangs the title slug off the submission path, and this client builds + // the bare spelling — both are the one submission, so an operator who saved + // the slugged link still gets the clash warning. + it('folds a Weasyl title slug off the submission path', () => { + const canonical = 'weasyl.com/submission/5150'; + expect(normalizeSourceUrl(postUrlFor('Weasyl', '5150', []))).toBe(canonical); + expect(normalizeSourceUrl('https://www.weasyl.com/submission/5150')).toBe(canonical); + expect(normalizeSourceUrl('https://www.weasyl.com/submission/5150/Some-Title')).toBe(canonical); + expect(normalizeSourceUrl('https://www.weasyl.com/submission/5150/some-title/')).toBe( + canonical + ); + // Weasyl's own permalink names the artist ahead of the id, with the slug + // optional and the username's case free to differ. + expect(normalizeSourceUrl('https://www.weasyl.com/~kuttoya/submissions/5150')).toBe(canonical); + expect(normalizeSourceUrl('https://www.weasyl.com/~Kuttoya/submissions/5150/some-title')).toBe( + canonical + ); + // A different Weasyl page, not a submission under another spelling. + expect(normalizeSourceUrl('https://www.weasyl.com/submissions/5150')).toBe( + 'weasyl.com/submissions/5150' + ); + }); + + it('is empty for blank input', () => { + expect(normalizeSourceUrl('')).toBe(''); + expect(normalizeSourceUrl(null)).toBe(''); + expect(normalizeSourceUrl(undefined)).toBe(''); + }); +}); + +describe('postUrlFor', () => { + // FuzzySearch hands Twitter handles back spelled either way, and the '@' is + // not part of the path — left in, it builds a 404 for every tweet. + it('strips a leading @ from the Twitter handle', () => { + expect(postUrlFor('Twitter', '160', ['@kuttoya'])).toBe( + 'https://twitter.com/kuttoya/status/160' + ); + expect(postUrlFor('Twitter', '160', ['kuttoya'])).toBe( + 'https://twitter.com/kuttoya/status/160' + ); + }); + + // Nothing left after stripping is the same as no handle at all. + it('falls back to the /i/ spelling when the handle is only decoration', () => { + expect(postUrlFor('Twitter', '160', ['@'])).toBe('https://twitter.com/i/status/160'); + expect(postUrlFor('Twitter', '160', [' '])).toBe('https://twitter.com/i/status/160'); + expect(postUrlFor('Twitter', '160', [])).toBe('https://twitter.com/i/status/160'); + }); +}); + +describe('handleProfileUrl', () => { + it('builds profile URLs for the sites we hold a column for', () => { + expect(handleProfileUrl('FurAffinity', 'kuttoya')).toBe( + 'https://www.furaffinity.net/user/kuttoya/' + ); + expect(handleProfileUrl('Twitter', '@kuttoya')).toBe('https://twitter.com/kuttoya'); + }); + + // A handle is third-party text: unescaped, a slash or a '?' in it re-points + // the URL at a page the operator did not ask for. + it('percent-encodes a handle carrying URL syntax', () => { + expect(handleProfileUrl('FurAffinity', 'evil/../../news')).toBe( + 'https://www.furaffinity.net/user/evil%2F..%2F..%2Fnews/' + ); + expect(handleProfileUrl('Twitter', 'a?b#c')).toBe('https://twitter.com/a%3Fb%23c'); + }); + + it('returns null for sites with no artist column yet, and for a blank handle', () => { + expect(handleProfileUrl('Weasyl', 'kuttoya')).toBeNull(); + expect(handleProfileUrl('e621', 'kuttoya')).toBeNull(); + expect(handleProfileUrl('FurAffinity', ' ')).toBeNull(); + }); +}); + +describe('findLocalArtists', () => { + const rows = [ + { id: 1, name: 'Kuttoya', furAffinityUrl: 'https://www.furaffinity.net/user/KUTTOYA/' }, + { id: 2, name: 'Someone Else', twitterUrl: 'https://x.com/kuttoya' }, + { id: 3, name: 'Nobody', furAffinityUrl: '' } + ]; + + it('matches a FurAffinity handle case-insensitively', () => { + const found = findLocalArtists(rows, { site: 'FurAffinity', handles: ['kuttoya'] }); + expect(found.map((r) => r.id)).toEqual([1]); + }); + + it('matches a Twitter handle across host spellings', () => { + const found = findLocalArtists(rows, { site: 'Twitter', handles: ['Kuttoya'] }); + expect(found.map((r) => r.id)).toEqual([2]); + }); + + // Twitter is where an @-prefixed handle actually arrives, and the stripping + // moved into normalizeHandle when this matcher was rewritten. + it('matches a Twitter handle that arrives with its @', () => { + const found = findLocalArtists(rows, { site: 'Twitter', handles: ['@Kuttoya'] }); + expect(found.map((r) => r.id)).toEqual([2]); + }); + + it('returns nothing for sites with no artist column, or with no handles', () => { + expect(findLocalArtists(rows, { site: 'Weasyl', handles: ['kuttoya'] })).toEqual([]); + expect(findLocalArtists(rows, { site: 'e621', handles: ['kuttoya'] })).toEqual([]); + expect(findLocalArtists(rows, { site: 'FurAffinity', handles: [] })).toEqual([]); + }); +}); + +describe('findArtistsByName', () => { + const rows = [{ id: 1, name: 'Kuttoya' }, { id: 2, name: 'kuttoya ' }, { id: 3, name: 'Other' }]; + + it('matches exactly, ignoring case, surrounding space, and a leading @', () => { + expect(findArtistsByName(rows, 'KUTTOYA').map((r) => r.id)).toEqual([1, 2]); + expect(findArtistsByName(rows, '@kuttoya').map((r) => r.id)).toEqual([1, 2]); + }); + + it('does not match on a substring, or on nothing', () => { + expect(findArtistsByName(rows, 'kutt')).toEqual([]); + expect(findArtistsByName(rows, ' ')).toEqual([]); + }); +}); + +describe('fuzzysearchKeyDisplayRecord', () => { + // Always eight bullets: a run sized to the key would tell anyone reading the + // settings card exactly how long the saved key is. + it('masks with a fixed bullet run whatever the key length', () => { + expect(fuzzysearchKeyDisplayRecord('abcdefgh1234')).toBe('••••••••1234'); + expect(fuzzysearchKeyDisplayRecord('a'.repeat(64) + 'wxyz')).toBe('••••••••wxyz'); + expect(fuzzysearchKeyDisplayRecord('abcde')).toBe('••••••••bcde'); + }); + + it('shows no tail for a key too short to have one', () => { + expect(fuzzysearchKeyDisplayRecord('abcd')).toBe('••••••••'); + }); +}); + +describe('the refused marker', () => { + it('round-trips the date and the key source', () => { + const marker = fuzzysearchRefusedMarker('env', new Date('2026-09-01T10:00:00.000Z')); + expect(marker).toBe('2026-09-01T10:00:00.000Z|env'); + expect(parseFuzzysearchRefusedMarker(marker)).toEqual({ + at: '2026-09-01T10:00:00.000Z', + source: 'env' + }); + expect(parseFuzzysearchRefusedMarker(fuzzysearchRefusedMarker('stored'))?.source).toBe('stored'); + }); + + it('reads a cleared or missing marker as no refusal', () => { + expect(parseFuzzysearchRefusedMarker('')).toBeNull(); + expect(parseFuzzysearchRefusedMarker(null)).toBeNull(); + expect(parseFuzzysearchRefusedMarker(undefined)).toBeNull(); + }); + + it('reads a marker with no source as a refusal of the stored key', () => { + expect(parseFuzzysearchRefusedMarker('2026-09-01T10:00:00.000Z')).toEqual({ + at: '2026-09-01T10:00:00.000Z', + source: 'stored' + }); + }); +}); diff --git a/src/lib/server/fuzzysearch.ts b/src/lib/server/fuzzysearch.ts new file mode 100644 index 00000000..2580289f --- /dev/null +++ b/src/lib/server/fuzzysearch.ts @@ -0,0 +1,484 @@ +// Server-only client for FuzzySearch (https://fuzzysearch.net), the reverse +// image search behind "Look up artist" (SONA-156). +// +// Server-only for two reasons: the API key must never reach the browser, and +// the operator's artwork leaves this app only from a place we control. Every +// call is operator-initiated — nothing here runs on a render path, and nothing +// is sent until the operator clicks. +// +// The response body is TREATED AS SECRET-ADJACENT: it is never logged, never +// stored, and never echoed anywhere but the normalized shape below. A 4xx body +// can carry the key back, and the match list is third-party data about the +// operator's own art. + +import { bufferStream, MAX_REMOTE_BUFFER_BYTES } from './storage/buffer'; +import { getRawSetting } from './settings'; +import { normalizeHandle, socialsToHandles, type Platform } from './handle-normalize'; +import type { Database } from './db'; + +type Env = App.Platform['env']; + +/** site_settings keys. Raw rows, like the registry fork key: kept out of the + * SiteSettings interface so the key never serializes to the browser. */ +export const FUZZYSEARCH_API_KEY_SETTING = 'fuzzysearchApiKey'; +/** `|` for the last refusal from FuzzySearch, or '' + * once a call succeeds. See `fuzzysearchRefusedMarker`. */ +export const FUZZYSEARCH_KEY_REFUSED_SETTING = 'fuzzysearchKeyRefusedAt'; + +export const FUZZYSEARCH_ENDPOINT = 'https://api.fuzzysearch.net/v1/image'; +/** Same 10 MiB bound every other third-party body gets (storage/buffer.ts). */ +export const FUZZYSEARCH_MAX_BYTES = MAX_REMOTE_BUFFER_BYTES; +/** How much of a 4xx body the 400 branch reads before giving up on it: the + * token it looks for sits in a short JSON error object, and nothing past 4 KiB + * is evidence. */ +export const FUZZYSEARCH_ERROR_BODY_BYTES = 4096; +/** How much of a 200 body is read: a match list runs to a few KB, so a MiB is + * already generous, and past it the response is a broken or hostile upstream + * rather than a search result. */ +export const FUZZYSEARCH_RESPONSE_BYTES = 1024 * 1024; +export const FUZZYSEARCH_TIMEOUT_MS = 8000; +/** Hamming distance past which a match is noise rather than a lead. */ +export const FUZZYSEARCH_MAX_DISTANCE = 7; + +export type LookupSite = 'FurAffinity' | 'Weasyl' | 'e621' | 'Twitter'; +export type LookupRating = 'general' | 'mature' | 'adult'; +/** 0 → exact, 1-2 → strong, 3-7 → possible, unknown distance → null. */ +export type MatchBand = 'exact' | 'strong' | 'possible' | null; + +export interface LookupMatch { + site: LookupSite; + siteId: string; + /** Raw handles as the source site knows them (not normalized). */ + handles: string[]; + distance: number | null; + band: MatchBand; + postedAt: string | null; + rating: LookupRating | null; + postUrl: string; +} + +export type LookupFailure = + | 'key_refused' + | 'rate_limited' + | 'too_large' + | 'invalid_image' + | 'unavailable'; + +export type LookupResult = + | { ok: true; matches: LookupMatch[] } + | { ok: false; reason: LookupFailure }; + +const SITES: readonly LookupSite[] = ['FurAffinity', 'Weasyl', 'e621', 'Twitter']; +const RATINGS: readonly LookupRating[] = ['general', 'mature', 'adult']; + +/** Display order when distances tie: the sites whose matches are most likely to + * name an artist we can link locally come first. */ +const SITE_ORDER: Record = { + FurAffinity: 0, + Twitter: 1, + Weasyl: 2, + e621: 3 +}; + +/** Where the key in use came from: the deploy secret, or the admin settings. */ +export type FuzzysearchKeySource = 'env' | 'stored'; + +/** + * Resolve the FuzzySearch key: a deploy-time `FUZZYSEARCH_API_KEY` secret wins + * and short-circuits the DB read, otherwise the D1 raw setting. Same precedence + * as the registry fork key, so a fork can connect from the admin UI without a + * deploy. Returns null when the integration is not configured. The source + * travels with the key so a refusal can be recorded against the key it refused. + */ +export async function resolveFuzzysearchKey( + db: Database, + env: Env | undefined +): Promise<{ key: string; source: FuzzysearchKeySource } | null> { + const fromEnv = env?.FUZZYSEARCH_API_KEY?.trim(); + if (fromEnv) return { key: fromEnv, source: 'env' }; + const stored = (await getRawSetting(db, FUZZYSEARCH_API_KEY_SETTING))?.trim(); + return stored ? { key: stored, source: 'stored' } : null; +} + +/** + * The value written to FUZZYSEARCH_KEY_REFUSED_SETTING: when the refusal + * happened and WHICH key was refused. The source has to be stored alongside + * the date because the settings card only offers a remedy for a key saved + * there — without it, a refusal recorded while the deploy secret was in use + * would later be shown against a stored key that was never refused. + */ +export function fuzzysearchRefusedMarker( + source: FuzzysearchKeySource, + at: Date = new Date() +): string { + return `${at.toISOString()}|${source}`; +} + +/** Read a refusal marker back. '' (the cleared value) and anything without a + * date come back null; a value carrying no source reads as 'stored', the state + * the settings card can act on. */ +export function parseFuzzysearchRefusedMarker( + raw: string | null | undefined +): { at: string; source: FuzzysearchKeySource } | null { + const [at, source] = (raw ?? '').trim().split('|'); + if (!at) return null; + return { at, source: source === 'env' ? 'env' : 'stored' }; +} + +/** + * Masked record of a stored key for the settings card: exactly eight bullets + * plus the last four characters, so the mask says nothing about the key's + * length. The operator only needs to recognize which key is saved — the value + * itself never leaves the server. + */ +export function fuzzysearchKeyDisplayRecord(key: string): string { + const tail = key.length > 4 ? key.slice(-4) : ''; + return '•'.repeat(8) + tail; +} + +/** Band for a distance, matching the wording the UI uses about confidence. */ +export function distanceBand(distance: number | null): MatchBand { + if (distance === null) return null; + if (distance === 0) return 'exact'; + if (distance <= 2) return 'strong'; + return 'possible'; +} + +/** A handle as the rest of this file wants it: trimmed, with the '@' decoration + * off. Handles arrive spelled either way, and one that was nothing but + * decoration comes back empty for the caller to fall back on. */ +function cleanHandle(handle: string): string { + return handle.trim().replace(/^@+/, ''); +} + +/** Public post URL for a match, by site. Built here rather than trusted from + * the response so a hostile payload cannot hand the operator an arbitrary link. */ +export function postUrlFor(site: LookupSite, siteId: string, handles: string[]): string { + const id = encodeURIComponent(siteId); + switch (site) { + case 'FurAffinity': + return `https://www.furaffinity.net/view/${id}/`; + case 'Weasyl': + return `https://www.weasyl.com/submission/${id}`; + case 'e621': + return `https://e621.net/posts/${id}`; + case 'Twitter': { + // The '@' is not part of the path, and a handle that was nothing but + // decoration falls through to the handle-less spelling. + const handle = cleanHandle(handles[0] ?? ''); + // Without a handle Twitter still resolves the status through /i/. + return handle + ? `https://twitter.com/${encodeURIComponent(handle)}/status/${id}` + : `https://twitter.com/i/status/${id}`; + } + } +} + +/** Canonical profile URL for a handle on a site we hold an artist column for. + * Weasyl and e621 have no column yet (SONA-219), so they resolve to null. */ +export function handleProfileUrl(site: LookupSite, handle: string): string | null { + const h = cleanHandle(handle); + if (!h) return null; + // Percent-encoded like postUrlFor's ids: a handle is third-party text, and a + // slash or a '?' in it would otherwise re-point the URL at another page. + const safe = encodeURIComponent(h); + if (site === 'FurAffinity') return `https://www.furaffinity.net/user/${safe}/`; + if (site === 'Twitter') return `https://twitter.com/${safe}`; + return null; +} + +/** Hosts that are the same site under two names. Without folding these, an + * operator who saved an `x.com` link gets no clash warning for the `twitter.com` + * URL this client builds. A Map, not an object literal: a plain lookup answers + * `constructor` and `__proto__` with an inherited member rather than a miss. */ +const HOST_ALIASES = new Map([ + ['x.com', 'twitter.com'], + ['mobile.twitter.com', 'twitter.com'], + ['sfw.furaffinity.net', 'furaffinity.net'] +]); + +/** Hosts whose paths are case-insensitive, so `/View/12345` and `/view/12345` + * are one post. Weasyl is here for the username in its permalinks: `/~User/` and + * `/~user/` are the same artist, and what the fold below leaves is numeric. Left + * alone elsewhere — most sites' paths are case-sensitive. */ +const CASE_INSENSITIVE_PATH_HOSTS = new Set(['twitter.com', 'furaffinity.net', 'weasyl.com']); + +/** + * Normalize a source-post URL for equality checks: lowercase host, no scheme, + * no `www.`, no query, no fragment, no trailing slash, and known host aliases + * folded together. Comparing raw strings would miss `http` vs `https`, the + * trailing slash FurAffinity adds, and `x.com` against `twitter.com`. + */ +export function normalizeSourceUrl(url: string | null | undefined): string { + const raw = (url ?? '').trim(); + if (!raw) return ''; + let rest = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); + rest = rest.replace(/[?#].*$/, ''); + rest = rest.replace(/\/+$/, ''); + const slash = rest.indexOf('/'); + let host = (slash === -1 ? rest : rest.slice(0, slash)).toLowerCase().replace(/^www\./, ''); + host = HOST_ALIASES.get(host) ?? host; + let path = slash === -1 ? '' : rest.slice(slash); + if (CASE_INSENSITIVE_PATH_HOSTS.has(host)) path = path.toLowerCase(); + // A tweet is identified by its status id alone: `/kuttoya/status/160` and the + // handle-less `/i/status/160` this client builds for a match with no artist + // are the same post, so both reduce to the `/i/` spelling before comparison. + // Anything after the id goes too, so the `/photo/1` permalink Twitter's own + // UI hands out compares equal to the bare tweet. + if (host === 'twitter.com') path = path.replace(/^\/[^/]+\/status\/(\d+).*$/, '/i/status/$1'); + // The same submission under each site's other spelling: FurAffinity's + // full-size view (`/full/12345`) is the post this client builds as + // `/view/12345`, and e621's old post path (`/post/show/160`) is today's + // `/posts/160`. An operator who saved either gets no clash warning without + // the fold. e621's old path also carried the tag string as a trailing + // segment (`/post/show/160/canine`), and Weasyl hangs the title slug off the + // submission path (`/submission/5150/some-title`) — both still the same post; + // FurAffinity's stays anchored, since nothing follows the id there. Weasyl's + // own permalink names the artist as well (`/~kuttoya/submissions/5150/title`), + // and that is the submission this client builds as `/submission/5150`; a + // `/submissions/5150` with no user segment is another page, so it stays put. + if (host === 'furaffinity.net') path = path.replace(/^\/full\/(\d+)$/, '/view/$1'); + if (host === 'e621.net') path = path.replace(/^\/post\/show\/(\d+)(?:\/.*)?$/, '/posts/$1'); + if (host === 'weasyl.com') + path = path + .replace(/^\/submission\/(\d+)(?:\/.*)?$/, '/submission/$1') + .replace(/^\/~[^/]+\/submissions\/(\d+)(?:\/.*)?$/, '/submission/$1'); + return host + path; +} + +interface RawMatch { + site?: unknown; + site_id_str?: unknown; + artists?: unknown; + distance?: unknown; + posted_at?: unknown; + rating?: unknown; +} + +function normalizeMatch(raw: RawMatch): LookupMatch | null { + const site = SITES.find((s) => s === raw.site); + // 'Unknown' (and anything else we have no post-URL shape for) is dropped: + // a match we cannot link to is not a lead the operator can act on. + if (!site) return null; + const siteId = typeof raw.site_id_str === 'string' ? raw.site_id_str : ''; + if (!siteId) return null; + + const distance = + typeof raw.distance === 'number' && Number.isFinite(raw.distance) ? raw.distance : null; + if (distance !== null && (distance < 0 || distance > FUZZYSEARCH_MAX_DISTANCE)) return null; + + const handles = Array.isArray(raw.artists) + ? raw.artists.filter((a): a is string => typeof a === 'string' && a.trim() !== '') + : []; + const rating = RATINGS.find((r) => r === raw.rating) ?? null; + + return { + site, + siteId, + handles, + distance, + band: distanceBand(distance), + postedAt: typeof raw.posted_at === 'string' ? raw.posted_at : null, + rating, + postUrl: postUrlFor(site, siteId, handles) + }; +} + +/** Closest first; an unknown distance sorts last; ties break on site order. */ +function compareMatches(a: LookupMatch, b: LookupMatch): number { + const ad = a.distance ?? Number.POSITIVE_INFINITY; + const bd = b.distance ?? Number.POSITIVE_INFINITY; + if (ad !== bd) return ad - bd; + return SITE_ORDER[a.site] - SITE_ORDER[b.site]; +} + +/** Normalize + filter + sort a raw v1/image payload. Exported for tests. */ +export function normalizeMatches(payload: unknown): LookupMatch[] { + if (!Array.isArray(payload)) return []; + return payload + .map((entry) => (entry && typeof entry === 'object' ? normalizeMatch(entry as RawMatch) : null)) + .filter((m): m is LookupMatch => m !== null) + .sort(compareMatches); +} + +/** + * Drop the body of a response nobody is going to read. An unread stream holds + * the subrequest open until the runtime reaps it, and on a failure path there + * is nothing in the body worth keeping. Cancelling one that a `text()` already + * drained throws, which is as harmless as the cancel itself. + */ +async function discardBody(res: Response): Promise { + try { + await res.body?.cancel(); + } catch { + // already read, already cancelled, or never had a body + } +} + +/** + * Read at most `max` bytes of a response body as text. A body that runs past + * the cap is cancelled and comes back null: a third party does not get to + * decide how much of this isolate's memory its answer occupies, and neither the + * 400 token nor the match list is anywhere near the caps above. An unreadable + * body reads as null the same way. `res.body` is null on some responses (and + * some test doubles), where `text()` is the only way in. + */ +async function readBounded(res: Response, max: number): Promise { + if (!res.body) return await res.text().catch(() => null); + try { + return new TextDecoder().decode(await bufferStream(res.body, max)); + } catch { + // Over the cap (bufferStream cancelled the rest), or the stream errored. + return null; + } +} + +/** + * POST the bytes to FuzzySearch and return normalized matches. + * + * `fetchFn` is injected so tests drive this without globals. Failures are + * returned as typed reasons rather than thrown: the caller maps each to a + * status and a localized line, and the remote body never travels with them. + */ +export async function searchImage( + bytes: Blob, + key: string, + fetchFn: typeof fetch = fetch +): Promise { + const form = new FormData(); + form.append('image', bytes, 'image'); + + let res: Response; + try { + res = await fetchFn(FUZZYSEARCH_ENDPOINT, { + method: 'POST', + headers: { 'x-api-key': key }, + body: form, + // Never follow a redirect: fetch would replay the key header at + // whatever host the Location points to. A 3xx falls through to + // `!res.ok` and reads as unavailable. + redirect: 'manual', + signal: AbortSignal.timeout(FUZZYSEARCH_TIMEOUT_MS) + }); + } catch { + // Network error or the timeout firing. Deliberately no logging: the error + // can carry the request, and the request carries the key header. + return { ok: false, reason: 'unavailable' }; + } + + // 403 alongside 401, the pair the registry client already treats as an auth + // failure: a revoked or suspended key answers 403, and without it the + // operator would never see the refused state for the one case they can fix. + if (res.status === 401 || res.status === 403) { + await discardBody(res); + return { ok: false, reason: 'key_refused' }; + } + if (res.status === 429) { + await discardBody(res); + return { ok: false, reason: 'rate_limited' }; + } + if (res.status === 413) { + await discardBody(res); + return { ok: false, reason: 'too_large' }; + } + if (res.status === 400) { + // The one 400 worth distinguishing: FuzzySearch says the image is over its + // own limit. Only the first FUZZYSEARCH_ERROR_BODY_BYTES are inspected for + // that single token, and the body is discarded; one that runs past the cap + // is not evidence of anything, so it falls through to invalid_image. + const body = await readBounded(res, FUZZYSEARCH_ERROR_BODY_BYTES); + await discardBody(res); + return { ok: false, reason: body?.includes('too_large') ? 'too_large' : 'invalid_image' }; + } + if (!res.ok) { + await discardBody(res); + return { ok: false, reason: 'unavailable' }; + } + + // Bounded like the error body above: `res.json()` would buffer whatever the + // remote sends. Over the cap, unreadable, or unparsable all land on the + // non-array path below. + const text = await readBounded(res, FUZZYSEARCH_RESPONSE_BYTES); + let payload: unknown = null; + try { + if (text !== null) payload = JSON.parse(text); + } catch { + payload = null; + } + // A 200 that isn't the documented array is a broken upstream, not a search + // with no hits — reporting it as "no matches" would tell the operator their + // art is unindexed when nobody actually looked. + if (!Array.isArray(payload)) return { ok: false, reason: 'unavailable' }; + return { ok: true, matches: normalizeMatches(payload) }; +} + +/** + * The match worth prefilling the form from: the closest exact or strong one. + * `normalizeMatches` already sorted by distance then site, so the first + * qualifying entry is the best one. + */ +export function pickPrefillMatch(matches: LookupMatch[]): LookupMatch | null { + return matches.find((m) => m.band === 'exact' || m.band === 'strong') ?? null; +} + +/** + * The strictest rating carried by the confident matches, with the sites that + * carried it — so the UI can say where an NSFW suggestion came from. Possible + * and unknown-distance matches are excluded: a loose match must not flip the + * operator's NSFW flag. + */ +export function strictestRating( + matches: LookupMatch[] +): { rating: LookupRating; sites: LookupSite[] } | null { + const confident = matches.filter((m) => m.band === 'exact' || m.band === 'strong'); + let best: LookupRating | null = null; + for (const m of confident) { + if (!m.rating) continue; + if (best === null || RATINGS.indexOf(m.rating) > RATINGS.indexOf(best)) best = m.rating; + } + if (!best) return null; + const sites: LookupSite[] = []; + for (const m of confident) { + if (m.rating === best && !sites.includes(m.site)) sites.push(m.site); + } + return { rating: best, sites }; +} + +/** The platform a site's handles live on, for the sites we hold a column for. */ +const SITE_PLATFORM: Partial> = { + FurAffinity: 'furaffinity', + Twitter: 'twitter' +}; + +/** + * Local artists whose stored socials point at one of a match's handles. + * Compares through `normalizeHandle` and `socialsToHandles`, the same pair + * `handlesOverlap` is built on, so this agrees with the registry import and the + * artists API on what a match is. + * A full scan, like every other handle matcher here — there is no handle index. + */ +export function findLocalArtists>( + rows: T[], + match: Pick +): T[] { + const platform = SITE_PLATFORM[match.site]; + if (!platform) return []; + const wanted = new Set( + match.handles.map((h) => normalizeHandle(platform, h)).filter((h) => h !== '') + ); + if (wanted.size === 0) return []; + return rows.filter((row) => + socialsToHandles(row).some((h) => h.platform === platform && wanted.has(h.handleNorm)) + ); +} + +/** + * Local artists whose display name equals a handle, case-insensitively. Weaker + * evidence than a handle match (names collide), so it feeds the dialog's + * "you may already have this artist" guard rather than an automatic link. + */ +export function findArtistsByName(rows: T[], handle: string): T[] { + const needle = cleanHandle(handle).toLowerCase(); + if (!needle) return []; + return rows.filter((row) => row.name.trim().toLowerCase() === needle); +} diff --git a/src/lib/server/image-proxy.test.ts b/src/lib/server/image-proxy.test.ts index 7647335c..aa15e21c 100644 --- a/src/lib/server/image-proxy.test.ts +++ b/src/lib/server/image-proxy.test.ts @@ -1,5 +1,10 @@ -import { describe, it, expect } from 'vitest'; -import { isPrivateHost, isSameOriginUrl } from './image-proxy'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + isPrivateHost, + isSameOriginUrl, + proxyStoredImage, + PROXY_HEADERS_TIMEOUT_MS +} from './image-proxy'; // The guard both byte proxies rely on. Driven directly rather than through a // route, because the boundaries are the whole point and a route test only ever @@ -91,3 +96,122 @@ describe('isSameOriginUrl', () => { expect(isSameOriginUrl('http://fork.example/img/x.png', origin)).toBe(false); }); }); + +// The response the proxy builds. Three callers share this file (artist-lookup, +// avatar, ref-image) and only one of them pins the content-type fold, so a +// regression here would silently turn the other two's images into downloads. +describe('proxyStoredImage', () => { + function fetcherAnswering(contentType: string): typeof fetch { + return (async () => + new Response('bytes', { headers: { 'content-type': contentType } })) as unknown as typeof fetch; + } + + async function proxy(contentType: string): Promise { + const res = await proxyStoredImage('https://cdn.example/img.png', fetcherAnswering(contentType)); + if (!res) throw new Error('proxyStoredImage returned null'); + return res; + } + + // Media types are case-insensitive, so the upstream's own spelling is echoed + // back rather than demoted to a download. + it('passes an image content type through whatever its case', async () => { + expect((await proxy('Image/PNG')).headers.get('content-type')).toBe('Image/PNG'); + expect((await proxy('image/png')).headers.get('content-type')).toBe('image/png'); + }); + + // The type parameters an upstream may attach are not part of the media type. + it('passes an allowed type through with its parameters', async () => { + const res = await proxy('Image/JPEG; charset=binary'); + expect(res.headers.get('content-type')).toBe('Image/JPEG; charset=binary'); + expect(res.headers.get('content-disposition')).toBe('inline'); + }); + + // Narrower than image/*: SVG is an image type that carries script, so it is + // demoted exactly like a text/html payload wearing an image URL. + it('demotes svg and non-image types to a download', async () => { + for (const type of ['image/svg+xml', 'text/html']) { + const res = await proxy(type); + expect(res.headers.get('content-type'), type).toBe('application/octet-stream'); + expect(res.headers.get('content-disposition'), type).toBe('attachment'); + } + }); + + it('serves inline and never caches', async () => { + const res = await proxy('image/png'); + expect(res.headers.get('content-disposition')).toBe('inline'); + expect(res.headers.get('cache-control')).toBe('private, no-store'); + }); + + // A second, redundant layer: even a response a browser decided to render + // gets an opaque origin with scripts off. + it('sandboxes the response', async () => { + expect((await proxy('image/png')).headers.get('content-security-policy')).toBe('sandbox'); + expect((await proxy('text/html')).headers.get('content-security-policy')).toBe('sandbox'); + }); + + it('refuses a private host without fetching', async () => { + let called = false; + const fetcher = (async () => { + called = true; + return new Response('bytes', { headers: { 'content-type': 'image/png' } }); + }) as unknown as typeof fetch; + expect(await proxyStoredImage('http://127.0.0.1/img.png', fetcher)).toBeNull(); + expect(called).toBe(false); + }); + + // A fetch that REJECTS rather than answering — DNS failure, reset connection, + // TLS error. Handled here rather than in each route, so all three callers + // report the stored image as unreachable instead of throwing a 500. + it('answers null when the fetch rejects', async () => { + const rejecting = (async () => { + throw new TypeError('fetch failed'); + }) as unknown as typeof fetch; + expect(await proxyStoredImage('https://cdn.example/img.png', rejecting)).toBeNull(); + }); + + // The bound is on the HEADERS only. A host that accepts the connection and + // then says nothing would otherwise hold the operator's request open until + // the platform kills it; a big image that answers promptly must still be + // allowed to stream for as long as it takes. + describe('the wait for upstream headers', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('gives up on a fetcher that never answers', async () => { + let signal: AbortSignal | undefined; + const silent = ((_url: string, init?: RequestInit) => { + signal = init?.signal ?? undefined; + // Rejects the way a real fetch does when its signal aborts. + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')) + ); + }); + }) as unknown as typeof fetch; + + const pending = proxyStoredImage('https://cdn.example/img.png', silent); + await vi.advanceTimersByTimeAsync(PROXY_HEADERS_TIMEOUT_MS - 1); + expect(signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(2); + expect(signal?.aborted).toBe(true); + expect(await pending).toBeNull(); + }); + + it('clears the timer once the headers land, so the body streams unbounded', async () => { + let signal: AbortSignal | undefined; + const answering = ((_url: string, init?: RequestInit) => { + signal = init?.signal ?? undefined; + return Promise.resolve( + new Response('bytes', { headers: { 'content-type': 'image/png' } }) + ); + }) as unknown as typeof fetch; + + const res = await proxyStoredImage('https://cdn.example/img.png', answering); + expect(res?.headers.get('content-type')).toBe('image/png'); + // Long past the bound: nothing is left to fire at a body still arriving. + await vi.advanceTimersByTimeAsync(PROXY_HEADERS_TIMEOUT_MS * 10); + expect(signal?.aborted).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); + }); +}); diff --git a/src/lib/server/image-proxy.ts b/src/lib/server/image-proxy.ts index bda0dd8b..b4a79184 100644 --- a/src/lib/server/image-proxy.ts +++ b/src/lib/server/image-proxy.ts @@ -12,9 +12,21 @@ * Callers pass a URL the SERVER looked up (a stored row, a setting), never one * the client supplied, which is what keeps this from being an SSRF hole. On top * of that: private and link-local hosts are refused, redirects are not - * followed, and only image/* content types are echoed back. + * followed, and only the raster types the storage layer itself accepts are + * echoed back inline. */ +import { isAllowedImageType } from './storage/allowlist'; + +/** + * How long the upstream has to answer with HEADERS. The bound covers the wait + * for the response only — once the headers land the timer is cleared, so a + * large image streams for as long as it needs. Without it a host that accepts + * the connection and then says nothing holds the request open until the + * platform kills it, and the operator sees a spinner with no end. + */ +export const PROXY_HEADERS_TIMEOUT_MS = 10_000; + // Loopback / unspecified / RFC1918 / link-local / ULA hosts a stored URL must // never point the server-side fetch at. export function isPrivateHost(hostname: string): boolean { @@ -86,14 +98,38 @@ export async function proxyStoredImage( // A storage host answering with a redirect is unexpected — treat it as an // upstream error rather than following it to an arbitrary location. - const upstream = await fetcher(imageUrl, { redirect: 'manual' }); + let upstream: Response; + const controller = new AbortController(); + const headersTimer = setTimeout(() => controller.abort(), PROXY_HEADERS_TIMEOUT_MS); + try { + upstream = await fetcher(imageUrl, { redirect: 'manual', signal: controller.signal }); + } catch { + // A DNS failure, a reset connection or a TLS error rejects rather than + // answering, and so does the abort above. The stored image is as + // unreachable as it is on a non-ok response, so it reports the same way — + // every caller already handles null, and none of them has to answer 500 to + // a network blip. + return null; + } finally { + // The headers are in (or the fetch is over): the body streams unbounded + // from here, so the timer must not fire mid-download. + clearTimeout(headersTimer); + } if (!upstream.ok || !upstream.body) return null; const contentType = upstream.headers.get('content-type') ?? ''; + // The same raster allowlist stored uploads pass, rather than the whole of + // `image/*`: SVG is an image type that carries script, and an upstream is + // free to label anything it likes. isAllowedImageType is case-insensitive + // and ignores parameters, so `Image/JPEG; charset=binary` still passes. + // Anything else is handed back as an opaque download that no browser will + // render, with a sandbox CSP as a second, redundant layer in case one does. + const isImage = isAllowedImageType(contentType); return new Response(upstream.body, { headers: { - 'Content-Type': contentType.startsWith('image/') ? contentType : 'application/octet-stream', - 'Content-Disposition': 'inline', + 'Content-Type': isImage ? contentType : 'application/octet-stream', + 'Content-Disposition': isImage ? 'inline' : 'attachment', + 'Content-Security-Policy': 'sandbox', 'Cache-Control': 'private, no-store' } }); diff --git a/src/lib/theme-contrast.test.ts b/src/lib/theme-contrast.test.ts index 63502fdd..20f1aa28 100644 --- a/src/lib/theme-contrast.test.ts +++ b/src/lib/theme-contrast.test.ts @@ -916,3 +916,77 @@ describe('no accent-color override on form controls (SONA-172)', () => { } }); }); + +// The Artist lookup "Remove key" button rides .btn-outline but overrides its +// label to --destructive. .btn-outline's hover fill mixes --background 88% +// toward white or black, and --destructive on that mix falls to 3.6-4.4:1 on +// five of the six themes, so the component pins the fill back to --background +// and lets the border carry the hover signal (SONA-156). Asserted the way the +// #103 hover tests are: resolve the rule's own fill in each theme block and +// measure the label against it, so any fill that clears AA passes and any that +// doesn't fails, whatever it is spelled as. +describe('SONA-156 Remove-key label on its hover fill', () => { + /** The body of a `.lookup-section .btn-remove` rule, as written. */ + function removeRule(suffix: string): string { + const source = readFileSync( + fileURLToPath(new URL('../routes/admin/settings/+page.svelte', import.meta.url)), + 'utf8' + ); + const body = source.match( + new RegExp(`^\\s*\\.lookup-section \\.btn-remove${suffix}\\s*\\{([^}]*)\\}`, 'm') + )?.[1]; + if (!body) throw new Error(`.lookup-section .btn-remove${suffix} rule not found`); + return body; + } + + /** The `background-color` the hovered button actually paints, as written. */ + function removeHoverFill(): string { + const fill = removeRule(':hover') + .match(/background-color:\s*([^;]+);/)?.[1] + .trim(); + // No fill of its own means the button inherits .btn-outline's hover mix, + // which is the pairing this block exists to keep it away from. + if (!fill) throw new Error('.lookup-section .btn-remove:hover sets no background-color'); + return fill; + } + + /** + * The label color the hovered button paints. The hover rule may set its own + * `color`; when it doesn't, the base rule's `color` still applies, so the + * measurement follows whichever one the button actually wears. + */ + function removeHoverColor(): string { + const declared = (body: string) => + body + .match(/(?:^|[;{\s])color:\s*([^;]+);/)?.[1] + .trim(); + const color = declared(removeRule(':hover')) ?? declared(removeRule('')); + if (!color) throw new Error('.lookup-section .btn-remove declares no color'); + return color; + } + + // A token, or a srgb color-mix of one toward black, white or another token — + // the two shapes the app's hover fills and labels use. Anything else throws + // rather than passing unmeasured. + function resolveColor(value: string, sel: string): string { + const token = value.match(/^var\(--([\w-]+)\)$/); + if (token) return blockToken(sel, token[1]); + const mixed = value.match( + /^color-mix\(in srgb,\s*var\(--([\w-]+)\)\s*(\d+)%,\s*(black|white|var\(--[\w-]+\))\)$/ + ); + if (!mixed) throw new Error(`cannot resolve "${value}" to a color`); + const base = blockToken(sel, mixed[1]); + const pct = Number(mixed[2]); + const toward = mixed[3]; + if (toward === 'black' || toward === 'white') return mixSrgb(base, pct, toward); + return mix2(base, pct, blockToken(sel, toward.slice('var(--'.length, -1))); + } + + for (const { name, sel } of THEME_BLOCKS) { + it(`${name}: the label on the hovered fill meets 4.5:1`, () => { + const fill = resolveColor(removeHoverFill(), sel); + const label = resolveColor(removeHoverColor(), sel); + expect(contrast(label, fill)).toBeGreaterThanOrEqual(4.5); + }); + } +}); diff --git a/src/routes/admin/settings/+page.server.ts b/src/routes/admin/settings/+page.server.ts index 9c4c1949..9ad54af2 100644 --- a/src/routes/admin/settings/+page.server.ts +++ b/src/routes/admin/settings/+page.server.ts @@ -6,6 +6,7 @@ import { getSettings, saveSettings, getRawSetting, + getRawSettings, setRawSetting, clearSettingsCache, clearSupporterKeyStatusCache, @@ -54,6 +55,12 @@ import { REGISTRY_API_KEY_SETTING, REGISTRY_URL_SETTING } from '$lib/server/registry'; +import { + FUZZYSEARCH_API_KEY_SETTING, + FUZZYSEARCH_KEY_REFUSED_SETTING, + fuzzysearchKeyDisplayRecord, + parseFuzzysearchRefusedMarker +} from '$lib/server/fuzzysearch'; import { syncArtists } from '$lib/server/artist-sync'; import { resolveRefImage, @@ -266,6 +273,24 @@ export const load: PageServerLoad = async ({ platform, url, locals }) => { // for display. Empty until the first pilot feature is registered. const earlyAccess = earlyAccessActive(now).map((e) => ({ flag: e.flag, gaDate: formatDate(e.gaDate) })); + // FuzzySearch key (SONA-156) — a raw setting like the registry fork key, so + // it never rides along in the client-exposed SiteSettings. Only the MASK + // travels, and only for a key saved here: a key that came from the deploy + // secret sends nothing derived from it at all. The mask is attached as its + // own field rather than folded into a status object, so a later spread + // cannot pick the raw key up by accident (the supporter-key precedent). + const fuzzysearchKeyFromEnv = !!platform?.env?.FUZZYSEARCH_API_KEY?.trim(); + // Both rows in one query: load already spends its subrequest budget on the + // D1 reads above, and these two keys are always read together. + const fuzzysearchRaw = await getRawSettings(db, [ + FUZZYSEARCH_API_KEY_SETTING, + FUZZYSEARCH_KEY_REFUSED_SETTING + ]); + const fuzzysearchStoredKey = fuzzysearchRaw[FUZZYSEARCH_API_KEY_SETTING]?.trim() ?? ''; + const fuzzysearchRefused = parseFuzzysearchRefusedMarker( + fuzzysearchRaw[FUZZYSEARCH_KEY_REFUSED_SETTING] + ); + // Per-content-type usage (SONA-192) — R2 only: derived from listing the // bucket, so it also counts files D1 never tracked. Reduced to counts and // sums here; raw object keys never leave the server or reach a log line. @@ -309,6 +334,26 @@ export const load: PageServerLoad = async ({ platform, url, locals }) => { storageStatus, registryEnabled: isRegistryEnabled(renv), registryHasSecret: !!platform?.env?.REGISTRY_API_KEY, + fuzzysearchKeySet: fuzzysearchKeyFromEnv || !!fuzzysearchStoredKey, + fuzzysearchKeyFromEnv, + // The deploy secret wins, and while it does the card shows the secret's own + // line instead of a mask — so the stored key's last four have no reader and + // no business in the payload. Gated here rather than in the markup: a field + // the page never renders still ships in the SSR data blob. + fuzzysearchKeyRecord: + !fuzzysearchKeyFromEnv && fuzzysearchStoredKey + ? fuzzysearchKeyDisplayRecord(fuzzysearchStoredKey) + : null, + // Pre-formatted here, like the early-access GA dates, so the card renders + // one date string identically on SSR and after hydration. Only for a key + // saved HERE, and only when THAT key is the one that was refused: a + // refusal recorded against the deploy secret has no remedy on this page + // (no key to remove), and showing it after the secret is dropped would + // blame a stored key that FuzzySearch never turned away. + fuzzysearchKeyRefusedAt: + !fuzzysearchKeyFromEnv && fuzzysearchStoredKey && fuzzysearchRefused?.source === 'stored' + ? formatDate(fuzzysearchRefused.at) + : null, // Presence-only flags for the password-reset setup guide. The secret VALUES // are deploy-time env and must never reach the client — only whether they exist. resendKeySet: !!platform?.env?.RESEND_API_KEY, @@ -686,6 +731,35 @@ export const actions = { return { success: true, registryMessage: 'Disconnected from the shared registry.' }; }, + // FuzzySearch key (SONA-156). A raw setting, like the registry fork key, so + // the key stays out of the public client payload — and the action returns + // only a flag, never the value it just stored. + saveFuzzysearchKey: async ({ request, platform }) => { + const db = getDb(platform!.env.DB); + const data = await request.formData(); + const raw = data.get('fuzzysearchApiKey'); + const key = typeof raw === 'string' ? raw.trim() : ''; + // Shape check only — whether the key WORKS is answered by the first + // lookup, which records a refusal the section then surfaces. Printable + // ASCII: an API key with a space or a smart quote in it is a bad paste, + // and would ride into a request header. + if (key.length < 8 || key.length > 200 || !/^[\x21-\x7e]+$/.test(key)) { + return fail(400, { fuzzysearchKeyError: 'invalid' }); + } + await setRawSetting(db, FUZZYSEARCH_API_KEY_SETTING, key); + // A new key deserves a clean slate: the old key's refusal says nothing + // about this one. + await setRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING, ''); + return { fuzzysearchKeySaved: true }; + }, + + removeFuzzysearchKey: async ({ platform }) => { + const db = getDb(platform!.env.DB); + await setRawSetting(db, FUZZYSEARCH_API_KEY_SETTING, ''); + await setRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING, ''); + return { fuzzysearchKeyRemoved: true }; + }, + saveSecurityEmail: async ({ request, platform }) => { const db = getDb(platform!.env.DB); const data = await request.formData(); diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte index 1ee414a7..e5c6a7c0 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -1,4 +1,5 @@