From 73b8528c47a2de289b795c08b2f25586f8a2fa53 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:49:09 -0700 Subject: [PATCH 01/38] feat(lookup): add the FuzzySearch reverse image search client (SONA-156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-only client for api.fuzzysearch.net, plus the pure helpers the lookup endpoint and the upload UI need: match normalization and banding, post and profile URL building, source-URL folding, and local-artist matching through the existing handlesOverlap predicate. The key resolves like the registry fork key — a FUZZYSEARCH_API_KEY deploy secret wins, otherwise the D1 raw setting. Neither the key nor the response body is ever logged. --- src/app.d.ts | 7 + src/lib/server/fuzzysearch.test.ts | 315 ++++++++++++++++++++++++++++ src/lib/server/fuzzysearch.ts | 317 +++++++++++++++++++++++++++++ wrangler.toml.example | 3 + 4 files changed, 642 insertions(+) create mode 100644 src/lib/server/fuzzysearch.test.ts create mode 100644 src/lib/server/fuzzysearch.ts 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/server/fuzzysearch.test.ts b/src/lib/server/fuzzysearch.test.ts new file mode 100644 index 00000000..b8d15e3f --- /dev/null +++ b/src/lib/server/fuzzysearch.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect } from 'vitest'; +import { + FUZZYSEARCH_ENDPOINT, + FUZZYSEARCH_TIMEOUT_MS, + searchImage, + normalizeMatches, + pickPrefillMatch, + strictestRating, + normalizeSourceUrl, + handleProfileUrl, + findLocalArtists, + findArtistsByName, + 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' } + }); +} + +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([]); + }); +}); + +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); + 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); + }); + + it('accepts an ArrayBuffer as well as a Blob', async () => { + const { fn, calls } = fakeFetch(jsonResponse([])); + const result = await searchImage(new Uint8Array([9, 9]).buffer, 'k', fn); + expect(result).toEqual({ ok: true, matches: [] }); + expect((calls[0].init.body as FormData).get('image')).toBeInstanceOf(File); + }); + + it('refuses without a key rather than calling out', async () => { + const { fn, calls } = fakeFetch(jsonResponse([])); + expect(await searchImage(new Blob(['x']), '', fn)).toEqual({ ok: false, reason: 'no_key' }); + expect(calls).toHaveLength(0); + }); +}); + +describe('searchImage — failure mapping', () => { + const cases: Array<[number, string]> = [ + [401, 'key_refused'], + [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 }); + }); + } + + 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' + }); + }); +}); + +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); + }); + + it('keeps path case, since some sites are case-sensitive there', () => { + expect(normalizeSourceUrl('https://twitter.com/Kuttoya/status/160')).toBe( + 'twitter.com/Kuttoya/status/160' + ); + }); + + it('is empty for blank input', () => { + expect(normalizeSourceUrl('')).toBe(''); + expect(normalizeSourceUrl(null)).toBe(''); + expect(normalizeSourceUrl(undefined)).toBe(''); + }); +}); + +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'); + }); + + 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]); + }); + + 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([]); + }); +}); diff --git a/src/lib/server/fuzzysearch.ts b/src/lib/server/fuzzysearch.ts new file mode 100644 index 00000000..0e87f332 --- /dev/null +++ b/src/lib/server/fuzzysearch.ts @@ -0,0 +1,317 @@ +// 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 { MAX_REMOTE_BUFFER_BYTES } from './storage/buffer'; +import { getRawSetting } from './settings'; +import { handlesOverlap, SOCIAL_KEY_TO_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'; +/** ISO date-time of the last 401 from FuzzySearch, or '' once a call succeeds. */ +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; +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 = + | 'no_key' + | '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 +}; + +/** + * 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. + */ +export async function resolveFuzzysearchKey( + db: Database, + env: Env | undefined +): Promise { + const fromEnv = env?.FUZZYSEARCH_API_KEY?.trim(); + if (fromEnv) return fromEnv; + const stored = (await getRawSetting(db, FUZZYSEARCH_API_KEY_SETTING))?.trim(); + return stored || null; +} + +/** 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'; +} + +/** 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': { + const handle = 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 = handle.trim().replace(/^@+/, ''); + if (!h) return null; + if (site === 'FurAffinity') return `https://www.furaffinity.net/user/${h}/`; + if (site === 'Twitter') return `https://twitter.com/${h}`; + return null; +} + +/** + * Normalize a source-post URL for equality checks: lowercase host, no scheme, + * no `www.`, no query, no fragment, no trailing slash. Comparing raw strings + * would miss `http` vs `https` and the trailing slash FurAffinity adds. + */ +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('/'); + const host = (slash === -1 ? rest : rest.slice(0, slash)).toLowerCase().replace(/^www\./, ''); + const path = slash === -1 ? '' : rest.slice(slash); + 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); +} + +/** + * 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 | ArrayBuffer, + key: string, + fetchFn: typeof fetch = fetch +): Promise { + if (!key) return { ok: false, reason: 'no_key' }; + + const blob = bytes instanceof Blob ? bytes : new Blob([bytes]); + const form = new FormData(); + form.append('image', blob, 'image'); + + let res: Response; + try { + res = await fetchFn(FUZZYSEARCH_ENDPOINT, { + method: 'POST', + headers: { 'x-api-key': key }, + body: form, + 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' }; + } + + if (res.status === 401) return { ok: false, reason: 'key_refused' }; + if (res.status === 429) return { ok: false, reason: 'rate_limited' }; + if (res.status === 413) return { ok: false, reason: 'too_large' }; + if (res.status === 400) { + // The one 400 worth distinguishing: FuzzySearch says the image is over its + // own limit. The body is inspected for that single token and discarded. + const body = await res.text().catch(() => ''); + return { ok: false, reason: body.includes('too_large') ? 'too_large' : 'invalid_image' }; + } + if (!res.ok) return { ok: false, reason: 'unavailable' }; + + const payload = await res.json().catch(() => null); + if (payload === null) 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 artist row key that holds a site's profile URL. */ +const SITE_SOCIAL_KEY: Partial> = { + FurAffinity: 'furAffinityUrl', + Twitter: 'twitterUrl' +}; + +/** + * Local artists whose stored socials point at one of a match's handles. + * Uses `handlesOverlap`, the app's canonical "same artist" predicate, 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 key = SITE_SOCIAL_KEY[match.site]; + if (!key || !(key in SOCIAL_KEY_TO_PLATFORM)) return []; + const probes = match.handles + .map((h) => handleProfileUrl(match.site, h)) + .filter((url): url is string => url !== null) + .map((url) => ({ [key]: url })); + if (probes.length === 0) return []; + return rows.filter((row) => probes.some((probe) => handlesOverlap(row, probe))); +} + +/** + * 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 = handle.trim().replace(/^@+/, '').toLowerCase(); + if (!needle) return []; + return rows.filter((row) => row.name.trim().toLowerCase() === needle); +} diff --git a/wrangler.toml.example b/wrangler.toml.example index 75fdad41..f3532f9f 100644 --- a/wrangler.toml.example +++ b/wrangler.toml.example @@ -33,6 +33,9 @@ keep_vars = true # submit/sync). Get one via POST /v1/forks on the registry. # REGISTRY_URL — optional; override the registry base URL (defaults to the # public registry, https://registry.sona.fast). +# FUZZYSEARCH_API_KEY — optional; enables "Look up artist" (reverse image search +# against FuzzySearch). Overrides the key saved in +# Settings → Connections. Free key: api.fuzzysearch.net/selfserve. # RESEND_API_KEY — optional; enables the admin "Forgot password" reset email # (via Resend). Without it, recovery is `npm run reset-password`. # RESEND_FROM — optional; reset-email sender, format "Name ". From f36d77f0f4f8d46b6338a793871bbe16d9cb523d Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:51:24 -0700 Subject: [PATCH 02/38] feat(lookup): add POST /api/admin/artist-lookup (SONA-156) Takes either an uploaded file or a stored image id and returns the normalized FuzzySearch matches, the local artists those matches point at, and any image already credited to the same source post. The id shape resolves the URL in D1 and fetches it through proxyStoredImage, so a caller-supplied URL is never contacted. A refused key is recorded in site_settings so Settings can say so; the next success clears the marker. --- src/routes/api/admin/artist-lookup/+server.ts | 227 +++++++++++ .../api/admin/artist-lookup/server.test.ts | 364 ++++++++++++++++++ 2 files changed, 591 insertions(+) create mode 100644 src/routes/api/admin/artist-lookup/+server.ts create mode 100644 src/routes/api/admin/artist-lookup/server.test.ts diff --git a/src/routes/api/admin/artist-lookup/+server.ts b/src/routes/api/admin/artist-lookup/+server.ts new file mode 100644 index 00000000..262a0824 --- /dev/null +++ b/src/routes/api/admin/artist-lookup/+server.ts @@ -0,0 +1,227 @@ +import { json, error } from '@sveltejs/kit'; +import { eq, isNotNull } from 'drizzle-orm'; +import { getDb } from '$lib/server/db'; +import { images, artists } from '$lib/server/db/schema'; +import { proxyStoredImage } from '$lib/server/image-proxy'; +import { bufferStream, MaxBytesExceededError } from '$lib/server/storage/buffer'; +import { getRawSetting, setRawSetting } from '$lib/server/settings'; +import { + FUZZYSEARCH_KEY_REFUSED_SETTING, + FUZZYSEARCH_MAX_BYTES, + findArtistsByName, + findLocalArtists, + normalizeSourceUrl, + pickPrefillMatch, + resolveFuzzysearchKey, + searchImage, + type LookupFailure, + type LookupMatch +} from '$lib/server/fuzzysearch'; +import type { RequestHandler } from './$types'; + +// POST /api/admin/artist-lookup (admin-only via hooks — everything under /api +// except /api/cron/ requires the admin session). +// +// Reverse image search for "who drew this" (SONA-156). Two request shapes: +// +// multipart/form-data with `file` — the upload page, where the bytes are +// still in the browser. +// application/json { imageId } — the edit page, where they are not: the +// CSP's connect-src blocks the browser +// from reading a stored image's bytes at +// all (docs/reading-image-bytes.md). +// +// The imageId shape looks the URL up in D1 and fetches it server-side through +// proxyStoredImage, which is what keeps this from being an SSRF hole. A URL +// from the caller is never accepted, in either shape. +// +// Nothing leaves this app until an operator clicks: there is no cron, no +// render path, and no background call into this endpoint. FuzzySearch's own +// response body is never logged, stored, or echoed — only the normalized +// match list below. + +/** What the UI gets back for a failed lookup, and the status carrying it. */ +const FAILURE_STATUS: Record = { + no_key: 400, + key_refused: 401, + rate_limited: 429, + too_large: 413, + invalid_image: 422, + unavailable: 502 +}; + +/** Multipart framing (boundary lines + part headers) on top of the file cap, + * so a file exactly at the cap still passes the declared-length pre-check and + * is judged precisely by the exact size check. Mirrors /api/upload. */ +const MULTIPART_SLACK_BYTES = 64 * 1024; + +function failure(reason: LookupFailure) { + return json({ enabled: true, error: reason }, { status: FAILURE_STATUS[reason] }); +} + +/** Artists whose stored socials or display name point at a match, per match. */ +interface ArtistHit { + matchIndex: number; + artists: Array<{ id: number; name: string }>; +} + +export const POST: RequestHandler = async ({ request, platform, fetch }) => { + const db = getDb(platform!.env.DB); + + // No key configured: the integration is off, not broken. The UI hides the + // button on this answer instead of showing an error (the registry shape). + const key = await resolveFuzzysearchKey(db, platform?.env); + if (!key) return json({ enabled: false }); + + const contentType = request.headers.get('content-type') ?? ''; + + let bytes: Blob; + /** Set on the imageId shape: the image being looked up, excluded from its + * own source-URL clash check. */ + let selfImage: { id: number; parentImageId: number | null } | null = null; + + if (contentType.includes('multipart/form-data')) { + // Layer 1 of the size cap: reject a body the client DECLARES oversized + // before formData() materializes it. Absent or unparseable header falls + // through to the exact check below. + const declaredLength = Number(request.headers.get('content-length') ?? NaN); + if (Number.isFinite(declaredLength) && declaredLength > FUZZYSEARCH_MAX_BYTES + MULTIPART_SLACK_BYTES) { + return failure('too_large'); + } + const form = await request.formData(); + const file = form.get('file'); + if (!(file instanceof File)) error(400, 'No file provided'); + // Layer 2: the exact check, on the file's real size. + if (file.size > FUZZYSEARCH_MAX_BYTES) return failure('too_large'); + bytes = file; + } else { + const body = (await request.json().catch(() => null)) as { imageId?: unknown } | null; + const imageId = Number(body?.imageId); + if (!Number.isInteger(imageId) || imageId <= 0) error(400, 'Invalid image id'); + + const row = await db + .select({ id: images.id, imageUrl: images.imageUrl, parentImageId: images.parentImageId }) + .from(images) + .where(eq(images.id, imageId)) + .get(); + if (!row) error(404, 'Image not found'); + selfImage = { id: row.id, parentImageId: row.parentImageId }; + + // Server-side fetch of a URL the SERVER looked up, with the shared + // hardening: private and link-local hosts refused, redirects not + // followed, image/* content types only. + const stored = await proxyStoredImage(row.imageUrl, fetch); + if (!stored?.body) return failure('unavailable'); + if (!(stored.headers.get('content-type') ?? '').startsWith('image/')) { + return failure('unavailable'); + } + try { + const buffered = await bufferStream(stored.body, FUZZYSEARCH_MAX_BYTES); + // bufferStream allocates an exact-size array, so its backing buffer is + // the payload with nothing else in it. + bytes = new Blob([buffered.buffer as ArrayBuffer]); + } catch (e) { + if (e instanceof MaxBytesExceededError) return failure('too_large'); + return failure('unavailable'); + } + } + + const result = await searchImage(bytes, key, fetch); + + if (!result.ok) { + // A refused key is the one failure worth remembering: the settings page + // tells the operator their key stopped working instead of leaving the + // button failing silently. + if (result.reason === 'key_refused') { + await setRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING, new Date().toISOString()); + } + return failure(result.reason); + } + + // The key works — clear a stale refusal marker (only when one is set, so the + // happy path costs one read rather than a write). + if (await getRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING)) { + await setRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING, ''); + } + + const matches = result.matches; + const artistRows = await db + .select({ + id: artists.id, + name: artists.name, + twitterUrl: artists.twitterUrl, + furAffinityUrl: artists.furAffinityUrl + }) + .from(artists); + + const localArtists: ArtistHit[] = []; + const nameMatches: ArtistHit[] = []; + matches.forEach((match, matchIndex) => { + const byHandle = findLocalArtists(artistRows, match); + if (byHandle.length) { + localArtists.push({ matchIndex, artists: byHandle.map((a) => ({ id: a.id, name: a.name })) }); + } + // Weaker evidence, kept separate: a name collision is a "you may already + // have this artist" prompt, never an automatic link. + const byName = new Map(); + for (const handle of match.handles) { + for (const a of findArtistsByName(artistRows, handle)) byName.set(a.id, { id: a.id, name: a.name }); + } + if (byName.size) nameMatches.push({ matchIndex, artists: [...byName.values()] }); + }); + + return json({ + enabled: true, + matches, + localArtists, + nameMatches, + sourceClash: await findSourceClash(db, matches, selfImage) + }); +}; + +/** The image (or variant set) already credited to the same source post, so the + * UI can warn before the operator uploads a duplicate. */ +async function findSourceClash( + db: ReturnType, + matches: LookupMatch[], + selfImage: { id: number; parentImageId: number | null } | null +) { + const prefill = pickPrefillMatch(matches); + if (!prefill) return null; + const target = normalizeSourceUrl(prefill.postUrl); + if (!target) return null; + + const rows = await db + .select({ + id: images.id, + title: images.title, + parentImageId: images.parentImageId, + sourcePostUrl: images.sourcePostUrl + }) + .from(images) + .where(isNotNull(images.sourcePostUrl)); + + // The image being looked up is not a clash with itself, and neither are its + // own variants — they legitimately share one source post. + const selfRoot = selfImage ? (selfImage.parentImageId ?? selfImage.id) : null; + const clashing = rows + .filter((r) => normalizeSourceUrl(r.sourcePostUrl) === target) + .filter((r) => selfRoot === null || (r.parentImageId ?? r.id) !== selfRoot) + .sort((a, b) => a.id - b.id); + if (clashing.length === 0) return null; + + const first = clashing[0]; + const rootId = first.parentImageId ?? first.id; + const root = + first.parentImageId === null + ? { title: first.title } + : await db.select({ title: images.title }).from(images).where(eq(images.id, rootId)).get(); + + return { + imageId: rootId, + title: root?.title ?? first.title, + isVariant: first.parentImageId !== null, + parentImageId: first.parentImageId, + variantCount: clashing.length - 1 + }; +} diff --git a/src/routes/api/admin/artist-lookup/server.test.ts b/src/routes/api/admin/artist-lookup/server.test.ts new file mode 100644 index 00000000..1981a3aa --- /dev/null +++ b/src/routes/api/admin/artist-lookup/server.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +// better-sqlite3 ships no bundled types and is a dev-only test dependency here. +// @ts-expect-error - no declaration file for 'better-sqlite3' +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/d1'; +import { isHttpError } from '@sveltejs/kit'; +import * as schema from '$lib/server/db/schema'; +import { setRawSetting, getRawSetting } from '$lib/server/settings'; +import { + FUZZYSEARCH_API_KEY_SETTING, + FUZZYSEARCH_KEY_REFUSED_SETTING, + FUZZYSEARCH_MAX_BYTES, + type LookupResult +} from '$lib/server/fuzzysearch'; +import { makeD1 } from '$lib/server/test/d1'; +import { POST } from './+server'; + +// Only the outbound call is stubbed: normalization, banding, URL building and +// the local-artist matching stay real, so the endpoint's own wiring is what +// these tests exercise. +const searchImage = vi.hoisted(() => + vi.fn( + async ( + _bytes: Blob | ArrayBuffer, + _key: string, + _fetchFn?: typeof fetch + ): Promise => ({ ok: true, matches: [] }) + ) +); +vi.mock('$lib/server/fuzzysearch', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, searchImage }; +}); + +const DDL = `CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE images (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, slug TEXT, + image_url TEXT NOT NULL, thumbnail_url TEXT, width INTEGER, height INTEGER, file_size INTEGER, + md5hash TEXT, nsfw INTEGER NOT NULL DEFAULT 0, published INTEGER NOT NULL DEFAULT 1, + source_post_url TEXT, artist_id INTEGER, collection_id INTEGER, commissioned_at TEXT, + parent_image_id INTEGER, variant_label TEXT, featured INTEGER NOT NULL DEFAULT 0, + featured_order INTEGER, created_at TEXT); + CREATE TABLE artists (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, avatar_url TEXT, + twitter_url TEXT, bluesky_url TEXT, telegram_url TEXT, furaffinity_url TEXT, + deviantart_url TEXT, patreon_url TEXT, instagram_url TEXT, global_id TEXT UNIQUE, + registry_version INTEGER, registry_synced_at TEXT, aliases TEXT, + avatar_resolved_at TEXT, created_at TEXT NOT NULL);`; + +/** A 1×1-ish PNG stand-in — the endpoint never decodes, it only forwards. */ +const IMAGE_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]); + +function makeEnv(env: Record = {}) { + const sqlite = new Database(':memory:'); + sqlite.exec(DDL); + const d1 = makeD1(sqlite); + return { + sqlite, + db: drizzle(d1, { schema }), + platform: { env: { DB: d1, ...env } } as unknown as App.Platform + }; +} + +/** event.fetch, answering with stored image bytes unless told otherwise. */ +function imageFetch(response?: Response) { + const calls: string[] = []; + const fn = (async (url: string) => { + calls.push(String(url)); + return ( + response ?? + new Response(IMAGE_BYTES, { status: 200, headers: { 'content-type': 'image/png' } }) + ); + }) as unknown as typeof fetch; + return { fn, calls }; +} + +function multipartEvent( + platform: App.Platform, + file: File, + fetchFn: typeof fetch = imageFetch().fn +) { + const form = new FormData(); + form.append('file', file); + const request = new Request('http://localhost/api/admin/artist-lookup', { + method: 'POST', + body: form + }); + return { request, platform, fetch: fetchFn } as never; +} + +function jsonEvent(platform: App.Platform, body: unknown, fetchFn: typeof fetch = imageFetch().fn) { + const request = new Request('http://localhost/api/admin/artist-lookup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); + return { request, platform, fetch: fetchFn } as never; +} + +function pngFile(size = 32) { + const bytes = new Uint8Array(size); + bytes.set(IMAGE_BYTES.slice(0, 4)); + return new File([bytes], 'a.png', { type: 'image/png' }); +} + +async function statusOf(fn: () => unknown): Promise { + try { + await fn(); + return 200; + } catch (e) { + if (isHttpError(e)) return e.status; + throw e; + } +} + +const FA_EXACT = { + site: 'FurAffinity' as const, + siteId: '12345', + handles: ['kuttoya'], + distance: 0, + band: 'exact' as const, + postedAt: null, + rating: 'general' as const, + postUrl: 'https://www.furaffinity.net/view/12345/' +}; + +beforeEach(() => { + searchImage.mockReset(); + searchImage.mockResolvedValue({ ok: true, matches: [] }); +}); + +describe('artist-lookup — configuration', () => { + it('answers enabled:false with no key, and never calls out', async () => { + const { platform } = makeEnv(); + const res = await POST(multipartEvent(platform, pngFile())); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ enabled: false }); + expect(searchImage).not.toHaveBeenCalled(); + }); + + it('prefers the deploy secret over the saved setting', async () => { + const { db, platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'from-env' }); + await setRawSetting(db, FUZZYSEARCH_API_KEY_SETTING, 'from-settings'); + + await POST(multipartEvent(platform, pngFile())); + + expect(searchImage.mock.calls[0][1]).toBe('from-env'); + }); + + it('falls back to the saved setting when no secret is deployed', async () => { + const { db, platform } = makeEnv(); + await setRawSetting(db, FUZZYSEARCH_API_KEY_SETTING, 'from-settings'); + + await POST(multipartEvent(platform, pngFile())); + + expect(searchImage.mock.calls[0][1]).toBe('from-settings'); + }); +}); + +describe('artist-lookup — uploaded file', () => { + it('forwards the file and returns normalized matches with local artists', async () => { + const { sqlite, platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + sqlite.exec( + `INSERT INTO artists (name, furaffinity_url, created_at) + VALUES ('Kuttoya', 'https://www.furaffinity.net/user/KUTTOYA/', '2026-01-01'); + INSERT INTO artists (name, created_at) VALUES ('kuttoya', '2026-01-01');` + ); + searchImage.mockResolvedValue({ ok: true, matches: [FA_EXACT] }); + + const res = await POST(multipartEvent(platform, pngFile())); + const body = (await res.json()) as { + enabled: boolean; + matches: unknown[]; + localArtists: Array<{ matchIndex: number; artists: Array<{ id: number; name: string }> }>; + nameMatches: Array<{ matchIndex: number; artists: Array<{ id: number }> }>; + sourceClash: unknown; + }; + + expect(res.status).toBe(200); + expect(body.enabled).toBe(true); + expect(body.matches).toEqual([FA_EXACT]); + expect(body.localArtists).toEqual([{ matchIndex: 0, artists: [{ id: 1, name: 'Kuttoya' }] }]); + // Both rows are named for the handle; the name-only one is the weak hit. + expect(body.nameMatches[0].artists.map((a) => a.id)).toEqual([1, 2]); + expect(body.sourceClash).toBeNull(); + expect(searchImage.mock.calls[0][0]).toBeInstanceOf(File); + }); + + it('refuses a file over the remote-body cap on its exact size', async () => { + const { platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + const res = await POST(multipartEvent(platform, pngFile(FUZZYSEARCH_MAX_BYTES + 1))); + expect(res.status).toBe(413); + expect(await res.json()).toEqual({ enabled: true, error: 'too_large' }); + expect(searchImage).not.toHaveBeenCalled(); + }); + + it('refuses a declared-oversized body before reading it', async () => { + const { platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + const request = new Request('http://localhost/api/admin/artist-lookup', { + method: 'POST', + headers: { + 'content-type': 'multipart/form-data; boundary=x', + 'content-length': String(FUZZYSEARCH_MAX_BYTES + 1024 * 1024) + }, + body: '--x--' + }); + const res = await POST({ request, platform, fetch: imageFetch().fn } as never); + expect(res.status).toBe(413); + expect(searchImage).not.toHaveBeenCalled(); + }); + + it('rejects a multipart body with no file', async () => { + const { platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + const request = new Request('http://localhost/api/admin/artist-lookup', { + method: 'POST', + body: new FormData() + }); + expect(await statusOf(() => POST({ request, platform, fetch: imageFetch().fn } as never))).toBe( + 400 + ); + }); +}); + +describe('artist-lookup — stored image by id', () => { + it('fetches the URL the server looked up, not one the caller sent', async () => { + const { sqlite, platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + sqlite.exec( + `INSERT INTO images (id, title, slug, image_url, created_at) + VALUES (1, 'Ref', 'ref', 'https://cdn.example.com/stored.png', '2026-01-01');` + ); + const fetcher = imageFetch(); + + const res = await POST( + jsonEvent(platform, { imageId: 1, imageUrl: 'http://169.254.169.254/latest' }, fetcher.fn) + ); + + expect(res.status).toBe(200); + // The attacker-supplied URL is never contacted; the stored one is. + expect(fetcher.calls).toEqual(['https://cdn.example.com/stored.png']); + expect(searchImage.mock.calls[0][0]).toBeInstanceOf(Blob); + }); + + it('rejects a body that carries only a URL', async () => { + const { platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + const fetcher = imageFetch(); + expect( + await statusOf(() => + POST(jsonEvent(platform, { imageUrl: 'https://evil.example/x.png' }, fetcher.fn)) + ) + ).toBe(400); + expect(fetcher.calls).toEqual([]); + expect(searchImage).not.toHaveBeenCalled(); + }); + + it('404s an unknown id', async () => { + const { platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + expect(await statusOf(() => POST(jsonEvent(platform, { imageId: 404 })))).toBe(404); + }); + + it('reports unavailable when the stored image is not an image', async () => { + const { sqlite, platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + sqlite.exec( + `INSERT INTO images (id, title, slug, image_url, created_at) + VALUES (1, 'Ref', 'ref', 'https://cdn.example.com/stored.png', '2026-01-01');` + ); + const html = new Response('', { + status: 200, + headers: { 'content-type': 'text/html' } + }); + + const res = await POST(jsonEvent(platform, { imageId: 1 }, imageFetch(html).fn)); + + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ enabled: true, error: 'unavailable' }); + expect(searchImage).not.toHaveBeenCalled(); + }); +}); + +describe('artist-lookup — failure mapping and the refused marker', () => { + it('records the refusal on a 401 and clears it on the next success', async () => { + const { db, platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + searchImage.mockResolvedValue({ ok: false, reason: 'key_refused' }); + + const refused = await POST(multipartEvent(platform, pngFile())); + expect(refused.status).toBe(401); + expect(await refused.json()).toEqual({ enabled: true, error: 'key_refused' }); + expect(await getRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING)).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + searchImage.mockResolvedValue({ ok: true, matches: [] }); + const ok = await POST(multipartEvent(platform, pngFile())); + expect(ok.status).toBe(200); + expect(await getRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING)).toBe(''); + }); + + it('maps each remaining failure to its status without echoing a body', async () => { + const { platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + const cases = [ + ['rate_limited', 429], + ['too_large', 413], + ['invalid_image', 422], + ['unavailable', 502] + ] as const; + for (const [reason, status] of cases) { + searchImage.mockResolvedValue({ ok: false, reason }); + const res = await POST(multipartEvent(platform, pngFile())); + expect(res.status, reason).toBe(status); + expect(await res.json()).toEqual({ enabled: true, error: reason }); + } + }); +}); + +describe('artist-lookup — source-post clash', () => { + const clashSetup = (extra = '') => { + const env = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + env.sqlite.exec( + `INSERT INTO images (id, title, slug, image_url, source_post_url, parent_image_id, created_at) + VALUES (1, 'Sparky at the beach', 'beach', 'https://cdn/1.png', + 'http://FurAffinity.net/view/12345?full=1', NULL, '2026-01-01'), + (2, 'Beach variant', 'beach-v', 'https://cdn/2.png', + 'https://www.furaffinity.net/view/12345/', 1, '2026-01-02'), + (3, 'Unrelated', 'other', 'https://cdn/3.png', + 'https://www.furaffinity.net/view/999/', NULL, '2026-01-03'); + ${extra}` + ); + searchImage.mockResolvedValue({ ok: true, matches: [FA_EXACT] }); + return env; + }; + + it('reports the parent of the set that already carries the source URL', async () => { + const { platform } = clashSetup(); + const res = await POST(multipartEvent(platform, pngFile())); + const body = (await res.json()) as { sourceClash: Record }; + + expect(body.sourceClash).toEqual({ + imageId: 1, + title: 'Sparky at the beach', + isVariant: false, + parentImageId: null, + variantCount: 1 + }); + }); + + it('does not report an image clashing with its own variant set', async () => { + const { platform } = clashSetup(); + const res = await POST(jsonEvent(platform, { imageId: 2 })); + const body = (await res.json()) as { sourceClash: unknown }; + expect(body.sourceClash).toBeNull(); + }); + + it('is null when nothing shares the URL, or when no match is confident', async () => { + const { platform } = clashSetup(); + searchImage.mockResolvedValue({ + ok: true, + matches: [{ ...FA_EXACT, distance: 5, band: 'possible' }] + }); + const loose = await POST(multipartEvent(platform, pngFile())); + expect(((await loose.json()) as { sourceClash: unknown }).sourceClash).toBeNull(); + + searchImage.mockResolvedValue({ + ok: true, + matches: [{ ...FA_EXACT, siteId: '55555', postUrl: 'https://www.furaffinity.net/view/55555/' }] + }); + const none = await POST(multipartEvent(platform, pngFile())); + expect(((await none.json()) as { sourceClash: unknown }).sourceClash).toBeNull(); + }); +}); From 536786fca2d95e449dacc59ecb01d48b0b4ad719 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:57:37 -0700 Subject: [PATCH 03/38] feat(settings): add the Artist lookup section to Connections (SONA-156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four states: not connected, connected, confirming removal, and key refused. The section leads with what leaves the site — Sona sends the image file to FuzzySearch, an independent service that may keep a copy — so an operator reads that before pasting a key rather than after. The key is a raw site_settings row. Only a mask reaches the client, and a key that came from the FUZZYSEARCH_API_KEY deploy secret sends nothing derived from itself at all. --- messages/en.json | 24 +++ messages/ja.json | 24 +++ src/lib/server/fuzzysearch.ts | 11 ++ src/routes/admin/settings/+page.server.ts | 53 ++++++ src/routes/admin/settings/+page.svelte | 161 ++++++++++++++++++ src/routes/admin/settings/page.server.test.ts | 124 ++++++++++++++ 6 files changed, 397 insertions(+) diff --git a/messages/en.json b/messages/en.json index 9d792723..713f0d4e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1182,6 +1182,30 @@ "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_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_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..c47cb66a 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -902,6 +902,30 @@ "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_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_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/lib/server/fuzzysearch.ts b/src/lib/server/fuzzysearch.ts index 0e87f332..c276f83f 100644 --- a/src/lib/server/fuzzysearch.ts +++ b/src/lib/server/fuzzysearch.ts @@ -88,6 +88,17 @@ export async function resolveFuzzysearchKey( return stored || null; } +/** + * Masked record of a stored key for the settings card: bullets for everything + * but the last four characters, never fewer than eight bullets, so the mask + * says nothing about a short 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(Math.max(8, key.length - tail.length)) + 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; diff --git a/src/routes/admin/settings/+page.server.ts b/src/routes/admin/settings/+page.server.ts index 9c4c1949..ef72bf5b 100644 --- a/src/routes/admin/settings/+page.server.ts +++ b/src/routes/admin/settings/+page.server.ts @@ -54,6 +54,11 @@ import { REGISTRY_API_KEY_SETTING, REGISTRY_URL_SETTING } from '$lib/server/registry'; +import { + FUZZYSEARCH_API_KEY_SETTING, + FUZZYSEARCH_KEY_REFUSED_SETTING, + fuzzysearchKeyDisplayRecord +} from '$lib/server/fuzzysearch'; import { syncArtists } from '$lib/server/artist-sync'; import { resolveRefImage, @@ -266,6 +271,16 @@ 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(); + const fuzzysearchStoredKey = (await getRawSetting(db, FUZZYSEARCH_API_KEY_SETTING))?.trim() ?? ''; + const fuzzysearchRefusedAt = (await getRawSetting(db, 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 +324,15 @@ export const load: PageServerLoad = async ({ platform, url, locals }) => { storageStatus, registryEnabled: isRegistryEnabled(renv), registryHasSecret: !!platform?.env?.REGISTRY_API_KEY, + fuzzysearchKeySet: fuzzysearchKeyFromEnv || !!fuzzysearchStoredKey, + fuzzysearchKeyFromEnv, + fuzzysearchKeyRecord: 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. + fuzzysearchKeyRefusedAt: + fuzzysearchStoredKey && fuzzysearchRefusedAt ? formatDate(fuzzysearchRefusedAt) : 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 +710,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..a84a5b28 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -176,6 +176,11 @@ let savingRecoveryEmail = $state(false); let savingSupporterKey = $state(false); let removingSupporterKey = $state(false); + let savingFuzzysearchKey = $state(false); + let removingFuzzysearchKey = $state(false); + // Purely client state: the Remove key button swaps the action row for a + // confirmation block rather than opening a dialog over the section. + let confirmingFuzzysearchRemove = $state(false); // Localized "in early access right now" list, joined for the status line. Empty // until a pilot feature is registered, in which case the "nothing" line shows. @@ -1388,6 +1393,109 @@ {/if} + +
+

{m.admin_settings_lookup_heading()}

+ {#if data.fuzzysearchKeyRefusedAt} +
{m.admin_settings_lookup_refused_eyebrow()}
+ {:else if data.fuzzysearchKeySet} +
{m.admin_settings_lookup_connected_eyebrow()}
+ {/if} + +

{m.admin_settings_lookup_explainer_1()}

+

{m.admin_settings_lookup_explainer_2()}

+ + {#if data.fuzzysearchKeyFromEnv} +

{m.admin_settings_lookup_secret_pre()}FUZZYSEARCH_API_KEY{m.admin_settings_lookup_secret_post()}

+ {:else if data.fuzzysearchKeyRefusedAt} +

{m.admin_settings_lookup_refused_line({ date: data.fuzzysearchKeyRefusedAt })}

+
+
{m.admin_settings_lookup_refused_key_label()}
+
{data.fuzzysearchKeyRecord}
+
+ {:else if data.fuzzysearchKeySet} +
+
{m.admin_settings_lookup_saved_key_label()}
+
{data.fuzzysearchKeyRecord}
+
+

{m.admin_settings_lookup_replace()}

+ {#if confirmingFuzzysearchRemove} +
+

{m.admin_settings_lookup_confirm()}

+
+
{ + removingFuzzysearchKey = true; + return async ({ result, update }) => { + await update({ reset: false }); + removingFuzzysearchKey = false; + confirmingFuzzysearchRemove = false; + if (result.type === 'success') toast.success(m.admin_settings_lookup_removed()); + }; + }}> + +
+ + +
+
+ {:else} +
+ +
+ {/if} + {/if} + + {#if !data.fuzzysearchKeyFromEnv && (!data.fuzzysearchKeySet || data.fuzzysearchKeyRefusedAt)} +
{ + savingFuzzysearchKey = true; + return async ({ result, update }) => { + await update({ reset: false }); + savingFuzzysearchKey = false; + if (result.type === 'success') toast.success(m.admin_settings_lookup_saved()); + }; + }}> + + {#if form?.fuzzysearchKeyError} + + {/if} +
+ +
+ {#if !data.fuzzysearchKeyRefusedAt} +

{m.admin_settings_lookup_hint_pre()}api.fuzzysearch.net/selfserve{' '}{m.link_opens_new_tab()}{m.admin_settings_lookup_hint_post()}

+ {/if} +
+ {/if} +
+

{m.admin_settings_danger_zone()}

@@ -2354,6 +2462,59 @@ .key-actions { margin-top: 14px; } + + /* ── Artist lookup / FuzzySearch (SONA-156) ───────────────── */ + /* The state colour rides the eyebrow, the section's only status surface — + the same convention the supporter card uses for its countdown. */ + .lookup-section .key-eyebrow.connected { + color: var(--status-ok); + } + .lookup-section .key-eyebrow.refused { + color: var(--status-warn); + } + /* The
exists for the screen-reader label on the mask; it must not add + spacing of its own on top of .key-record. */ + .lookup-section .key-dl { + margin: 14px 0 0; + } + .lookup-section .key-dl dd { + margin: 0; + } + .lookup-section .replace-line { + margin-top: 14px; + margin-bottom: 0; + } + /* Bordered pill, destructive text: removing the key is reversible (paste a + new one) so it doesn't earn a filled destructive button here — the filled + one is on the confirmation, where the action actually happens. */ + .lookup-section .btn-remove { + background: none; + border: 1px solid var(--border); + color: var(--destructive); + } + .lookup-section .btn-remove:hover { + border-color: var(--destructive); + } + .lookup-section .remove-confirm { + margin-top: 14px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-s); + background: var(--secondary); + } + .lookup-section .remove-confirm p { + margin: 0 0 14px; + font-size: 14px; + color: var(--foreground); + line-height: 1.55; + max-width: 62ch; + } + .lookup-section .confirm-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + } .save-row { margin-top: 20px; } diff --git a/src/routes/admin/settings/page.server.test.ts b/src/routes/admin/settings/page.server.test.ts index 96b524f6..39f848bc 100644 --- a/src/routes/admin/settings/page.server.test.ts +++ b/src/routes/admin/settings/page.server.test.ts @@ -7,6 +7,11 @@ import { drizzle } from 'drizzle-orm/d1'; import * as schema from '$lib/server/db/schema'; import { siteSettings } from '$lib/server/db/schema'; import { REGISTRY_API_KEY_SETTING } from '$lib/server/registry'; +import { + FUZZYSEARCH_API_KEY_SETTING, + FUZZYSEARCH_KEY_REFUSED_SETTING, + fuzzysearchKeyDisplayRecord +} from '$lib/server/fuzzysearch'; import { getRawSetting, setRawSetting, @@ -1900,3 +1905,122 @@ describe('settings load — storage breakdown (SONA-192)', () => { } }); }); + +// Artist lookup (SONA-156). The FuzzySearch key is a raw setting, so the load +// exposes a MASK and a presence flag, never the key — and the deploy secret, +// when set, sends nothing derived from itself at all. +describe('settings — FuzzySearch key', () => { + function keyEvent(platform: App.Platform, fields: Record) { + const body = new FormData(); + for (const [k, v] of Object.entries(fields)) body.append(k, v); + return { + platform, + url: LOAD_URL, + request: new Request('https://taro.surf/admin/settings?/saveFuzzysearchKey', { + method: 'POST', + body + }) + } as never; + } + + it('saves a well-formed key and clears any standing refusal', async () => { + const { db, platform } = makeLoadDb(); + await setRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING, '2026-09-01T00:00:00.000Z'); + + const result = await actions.saveFuzzysearchKey( + keyEvent(platform, { fuzzysearchApiKey: ' fs-live-abcdef3k9q ' }) + ); + + expect(result).toEqual({ fuzzysearchKeySaved: true }); + expect(await getRawSetting(db, FUZZYSEARCH_API_KEY_SETTING)).toBe('fs-live-abcdef3k9q'); + expect(await getRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING)).toBe(''); + }); + + it('refuses a key that is too short, too long, or not printable ASCII', async () => { + const { db, platform } = makeLoadDb(); + for (const bad of ['short12', 'x'.repeat(201), 'has space here', 'smart“quote”key']) { + const result = (await actions.saveFuzzysearchKey( + keyEvent(platform, { fuzzysearchApiKey: bad }) + )) as unknown as { status: number; data: { fuzzysearchKeyError: string } }; + expect(result.status, bad).toBe(400); + expect(result.data.fuzzysearchKeyError).toBe('invalid'); + } + expect(await getRawSetting(db, FUZZYSEARCH_API_KEY_SETTING)).toBeNull(); + }); + + it('removes the key and the refusal marker together', async () => { + const { db, platform } = makeLoadDb(); + await setRawSetting(db, FUZZYSEARCH_API_KEY_SETTING, 'fs-live-abcdef3k9q'); + await setRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING, '2026-09-01T00:00:00.000Z'); + + expect(await actions.removeFuzzysearchKey({ platform } as never)).toEqual({ + fuzzysearchKeyRemoved: true + }); + expect(await getRawSetting(db, FUZZYSEARCH_API_KEY_SETTING)).toBe(''); + expect(await getRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING)).toBe(''); + }); + + it('exposes a mask and presence, never the key itself', async () => { + const { db, platform } = makeLoadDb(); + await setRawSetting(db, FUZZYSEARCH_API_KEY_SETTING, 'fs-live-abcdef3k9q'); + + const result = (await load(loadEvent(platform))) as unknown as Record; + + expect(result.fuzzysearchKeySet).toBe(true); + expect(result.fuzzysearchKeyFromEnv).toBe(false); + expect(result.fuzzysearchKeyRecord).toBe('••••••••••••••3k9q'); + expect(JSON.stringify(result)).not.toContain('fs-live-abcdef3k9q'); + }); + + it('reports the deploy secret without deriving anything from it', async () => { + const { platform } = makeLoadDb({ FUZZYSEARCH_API_KEY: 'fs-live-fromdeploy' }); + + const result = (await load(loadEvent(platform))) as unknown as Record; + + expect(result.fuzzysearchKeySet).toBe(true); + expect(result.fuzzysearchKeyFromEnv).toBe(true); + expect(result.fuzzysearchKeyRecord).toBeNull(); + expect(JSON.stringify(result)).not.toContain('fromdeploy'); + }); + + it('surfaces a formatted refusal date only while a key is saved', async () => { + const { db, platform } = makeLoadDb(); + await setRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING, '2026-09-01T10:20:30.000Z'); + + const orphan = (await load(loadEvent(platform))) as unknown as Record; + expect(orphan.fuzzysearchKeyRefusedAt).toBeNull(); + + await setRawSetting(db, FUZZYSEARCH_API_KEY_SETTING, 'fs-live-abcdef3k9q'); + const refused = (await load(loadEvent(platform))) as unknown as Record; + expect(refused.fuzzysearchKeyRefusedAt).toBe('2026.09.01'); + }); + + it('masks a short key to at least eight bullets', () => { + expect(fuzzysearchKeyDisplayRecord('abcd1234')).toBe('••••••••1234'); + expect(fuzzysearchKeyDisplayRecord('abc')).toBe('••••••••'); + }); +}); + +// Source pin: the disclosure copy is the point of this section — an operator +// has to read what leaves their site before they paste a key. Nothing renders +// Svelte under the pure-TS vitest setup, so grep the file (the #182 pattern). +describe('artist lookup section markup (SONA-156)', () => { + const src = readFileSync(new URL('./+page.svelte', import.meta.url), 'utf8'); + + it('renders both disclosure paragraphs', () => { + expect(src).toContain('m.admin_settings_lookup_explainer_1()'); + expect(src).toContain('m.admin_settings_lookup_explainer_2()'); + }); + + it('links the self-serve key page as a safe external link', () => { + expect(src).toContain('https://api.fuzzysearch.net/selfserve'); + const link = src.slice(src.indexOf('https://api.fuzzysearch.net/selfserve'), src.indexOf('https://api.fuzzysearch.net/selfserve') + 200); + expect(link).toContain('rel="noopener noreferrer"'); + }); + + it('takes the key in a password field and never renders a stored key', () => { + expect(src).toContain('name="fuzzysearchApiKey"'); + expect(src).toContain('data.fuzzysearchKeyRecord'); + expect(src).not.toContain('data.fuzzysearchApiKey'); + }); +}); From 3d7f369feedda659417cef1fd344e24a25b2a72a Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:57:40 -0700 Subject: [PATCH 04/38] docs: name FuzzySearch in the architecture diagram and README (SONA-156) Adds the FuzzySearch node and its Admin edge to the external-services subgraph, extends the optional-integrations sentence, and gives the feature a README bullet naming the secret that gates it. --- README.md | 4 ++++ docs/architecture.md | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) 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` From 9d76e87f25f2ff1a23a28b90897bef4f22f5e9c1 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:25:49 -0700 Subject: [PATCH 05/38] fix(lookup): tighten the FuzzySearch client and endpoint (SONA-156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 200 whose body isn't the documented array is now `unavailable` rather than an empty match list — reported as no matches it reads as "your art isn't indexed", which is a different and wrong answer. Source URLs fold known host aliases (x.com, sfw.furaffinity.net) and lowercase the path on the two hosts that ignore its case, so a clash warning survives the operator having saved the same post under its other name. The variant count only counts the reported set: two unrelated images sharing a source post are separate clashes. Also: handles are percent-encoded into profile URLs, the stored-image path attaches the validated content type to the part it sends, local-artist matching compares normalized handles instead of round-tripping through a synthetic URL, and the unreachable `no_key` failure is gone along with searchImage's ArrayBuffer arm. --- src/lib/server/fuzzysearch.test.ts | 115 ++++++++++++++--- src/lib/server/fuzzysearch.ts | 77 +++++++---- src/routes/api/admin/artist-lookup/+server.ts | 22 +++- .../api/admin/artist-lookup/server.test.ts | 120 ++++++++++++++++++ 4 files changed, 285 insertions(+), 49 deletions(-) diff --git a/src/lib/server/fuzzysearch.test.ts b/src/lib/server/fuzzysearch.test.ts index b8d15e3f..4b9bb377 100644 --- a/src/lib/server/fuzzysearch.test.ts +++ b/src/lib/server/fuzzysearch.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { FUZZYSEARCH_ENDPOINT, + FUZZYSEARCH_MAX_DISTANCE, FUZZYSEARCH_TIMEOUT_MS, searchImage, normalizeMatches, @@ -98,6 +99,57 @@ describe('normalizeMatches', () => { 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', () => { @@ -119,19 +171,6 @@ describe('searchImage — request shape', () => { // The bound the signal was built with, pinned so it can't silently grow. expect(FUZZYSEARCH_TIMEOUT_MS).toBe(8000); }); - - it('accepts an ArrayBuffer as well as a Blob', async () => { - const { fn, calls } = fakeFetch(jsonResponse([])); - const result = await searchImage(new Uint8Array([9, 9]).buffer, 'k', fn); - expect(result).toEqual({ ok: true, matches: [] }); - expect((calls[0].init.body as FormData).get('image')).toBeInstanceOf(File); - }); - - it('refuses without a key rather than calling out', async () => { - const { fn, calls } = fakeFetch(jsonResponse([])); - expect(await searchImage(new Blob(['x']), '', fn)).toEqual({ ok: false, reason: 'no_key' }); - expect(calls).toHaveLength(0); - }); }); describe('searchImage — failure mapping', () => { @@ -179,6 +218,19 @@ describe('searchImage — failure mapping', () => { 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' + }); + } + }); }); function match(over: Partial): LookupMatch { @@ -248,9 +300,33 @@ describe('normalizeSourceUrl', () => { expect(normalizeSourceUrl(' https://WWW.furaffinity.net/view/12345// ')).toBe(canonical); }); - it('keeps path case, since some sites are case-sensitive there', () => { + // 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/kuttoya/status/160' + ); + expect(normalizeSourceUrl('https://mobile.twitter.com/kuttoya/status/160')).toBe( + 'twitter.com/kuttoya/status/160' + ); + expect(normalizeSourceUrl('https://sfw.furaffinity.net/view/12345/')).toBe( + 'furaffinity.net/view/12345' + ); + }); + + it('lowercases the path on the hosts that treat it case-insensitively', () => { expect(normalizeSourceUrl('https://twitter.com/Kuttoya/status/160')).toBe( - 'twitter.com/Kuttoya/status/160' + 'twitter.com/kuttoya/status/160' + ); + expect(normalizeSourceUrl('https://www.furaffinity.net/View/12345/')).toBe( + 'furaffinity.net/view/12345' + ); + }); + + it('keeps path case elsewhere, since most sites are case-sensitive there', () => { + expect(normalizeSourceUrl('https://www.weasyl.com/submission/5150/Some-Title')).toBe( + 'weasyl.com/submission/5150/Some-Title' ); }); @@ -269,6 +345,15 @@ describe('handleProfileUrl', () => { 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(); diff --git a/src/lib/server/fuzzysearch.ts b/src/lib/server/fuzzysearch.ts index c276f83f..679a7b97 100644 --- a/src/lib/server/fuzzysearch.ts +++ b/src/lib/server/fuzzysearch.ts @@ -13,7 +13,7 @@ import { MAX_REMOTE_BUFFER_BYTES } from './storage/buffer'; import { getRawSetting } from './settings'; -import { handlesOverlap, SOCIAL_KEY_TO_PLATFORM } from './handle-normalize'; +import { normalizeHandle, socialsToHandles, type Platform } from './handle-normalize'; import type { Database } from './db'; type Env = App.Platform['env']; @@ -49,7 +49,6 @@ export interface LookupMatch { } export type LookupFailure = - | 'no_key' | 'key_refused' | 'rate_limited' | 'too_large' @@ -133,15 +132,33 @@ export function postUrlFor(site: LookupSite, siteId: string, handles: string[]): export function handleProfileUrl(site: LookupSite, handle: string): string | null { const h = handle.trim().replace(/^@+/, ''); if (!h) return null; - if (site === 'FurAffinity') return `https://www.furaffinity.net/user/${h}/`; - if (site === 'Twitter') return `https://twitter.com/${h}`; + // 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. */ +const HOST_ALIASES: Record = { + 'x.com': 'twitter.com', + 'mobile.twitter.com': 'twitter.com', + 'sfw.furaffinity.net': 'furaffinity.net', + 'd.furaffinity.net': 'furaffinity.net' +}; + +/** Hosts whose paths are case-insensitive, so `/View/12345` and `/view/12345` + * are one post. Left alone elsewhere — most sites' paths are case-sensitive. */ +const CASE_INSENSITIVE_PATH_HOSTS = new Set(['twitter.com', 'furaffinity.net']); + /** * Normalize a source-post URL for equality checks: lowercase host, no scheme, - * no `www.`, no query, no fragment, no trailing slash. Comparing raw strings - * would miss `http` vs `https` and the trailing slash FurAffinity adds. + * 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(); @@ -150,8 +167,10 @@ export function normalizeSourceUrl(url: string | null | undefined): string { rest = rest.replace(/[?#].*$/, ''); rest = rest.replace(/\/+$/, ''); const slash = rest.indexOf('/'); - const host = (slash === -1 ? rest : rest.slice(0, slash)).toLowerCase().replace(/^www\./, ''); - const path = slash === -1 ? '' : rest.slice(slash); + let host = (slash === -1 ? rest : rest.slice(0, slash)).toLowerCase().replace(/^www\./, ''); + host = HOST_ALIASES[host] ?? host; + let path = slash === -1 ? '' : rest.slice(slash); + if (CASE_INSENSITIVE_PATH_HOSTS.has(host)) path = path.toLowerCase(); return host + path; } @@ -218,15 +237,12 @@ export function normalizeMatches(payload: unknown): LookupMatch[] { * status and a localized line, and the remote body never travels with them. */ export async function searchImage( - bytes: Blob | ArrayBuffer, + bytes: Blob, key: string, fetchFn: typeof fetch = fetch ): Promise { - if (!key) return { ok: false, reason: 'no_key' }; - - const blob = bytes instanceof Blob ? bytes : new Blob([bytes]); const form = new FormData(); - form.append('image', blob, 'image'); + form.append('image', bytes, 'image'); let res: Response; try { @@ -254,7 +270,10 @@ export async function searchImage( if (!res.ok) return { ok: false, reason: 'unavailable' }; const payload = await res.json().catch(() => null); - if (payload === null) return { ok: false, reason: 'unavailable' }; + // 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) }; } @@ -290,30 +309,32 @@ export function strictestRating( return { rating: best, sites }; } -/** The artist row key that holds a site's profile URL. */ -const SITE_SOCIAL_KEY: Partial> = { - FurAffinity: 'furAffinityUrl', - Twitter: 'twitterUrl' +/** 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. - * Uses `handlesOverlap`, the app's canonical "same artist" predicate, so this - * agrees with the registry import and the artists API on what a match is. + * 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 key = SITE_SOCIAL_KEY[match.site]; - if (!key || !(key in SOCIAL_KEY_TO_PLATFORM)) return []; - const probes = match.handles - .map((h) => handleProfileUrl(match.site, h)) - .filter((url): url is string => url !== null) - .map((url) => ({ [key]: url })); - if (probes.length === 0) return []; - return rows.filter((row) => probes.some((probe) => handlesOverlap(row, probe))); + 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)) + ); } /** diff --git a/src/routes/api/admin/artist-lookup/+server.ts b/src/routes/api/admin/artist-lookup/+server.ts index 262a0824..f76f980f 100644 --- a/src/routes/api/admin/artist-lookup/+server.ts +++ b/src/routes/api/admin/artist-lookup/+server.ts @@ -42,7 +42,6 @@ import type { RequestHandler } from './$types'; /** What the UI gets back for a failed lookup, and the status carrying it. */ const FAILURE_STATUS: Record = { - no_key: 400, key_refused: 401, rate_limited: 429, too_large: 413, @@ -83,7 +82,12 @@ export const POST: RequestHandler = async ({ request, platform, fetch }) => { if (contentType.includes('multipart/form-data')) { // Layer 1 of the size cap: reject a body the client DECLARES oversized // before formData() materializes it. Absent or unparseable header falls - // through to the exact check below. + // through to the exact check below — a chunked body carries no + // content-length, so for that shape the exact check on file.size is the + // only cap, and formData() buffers the part first. Accepted: this is an + // admin-only route behind the session, so a buffered oversized part costs + // the operator's own memory, and pre-counting the stream would mean + // re-implementing multipart parsing for a caller we already trust. const declaredLength = Number(request.headers.get('content-length') ?? NaN); if (Number.isFinite(declaredLength) && declaredLength > FUZZYSEARCH_MAX_BYTES + MULTIPART_SLACK_BYTES) { return failure('too_large'); @@ -112,14 +116,17 @@ export const POST: RequestHandler = async ({ request, platform, fetch }) => { // followed, image/* content types only. const stored = await proxyStoredImage(row.imageUrl, fetch); if (!stored?.body) return failure('unavailable'); - if (!(stored.headers.get('content-type') ?? '').startsWith('image/')) { + const storedType = (stored.headers.get('content-type') ?? '').split(';')[0].trim(); + if (!storedType.startsWith('image/')) { return failure('unavailable'); } try { const buffered = await bufferStream(stored.body, FUZZYSEARCH_MAX_BYTES); // bufferStream allocates an exact-size array, so its backing buffer is - // the payload with nothing else in it. - bytes = new Blob([buffered.buffer as ArrayBuffer]); + // the payload with nothing else in it. The validated type rides along so + // the multipart part FuzzySearch receives from the edit page looks like + // the one the upload page sends (a File carries its own type). + bytes = new Blob([buffered.buffer as ArrayBuffer], { type: storedType }); } catch (e) { if (e instanceof MaxBytesExceededError) return failure('too_large'); return failure('unavailable'); @@ -222,6 +229,9 @@ async function findSourceClash( title: root?.title ?? first.title, isVariant: first.parentImageId !== null, parentImageId: first.parentImageId, - variantCount: clashing.length - 1 + // Only the reported set's own rows. Two unrelated images that happen to + // carry the same source URL are separate clashes, and counting them here + // would tell the operator this one image has variants it doesn't have. + variantCount: clashing.filter((r) => (r.parentImageId ?? r.id) === rootId).length - 1 }; } diff --git a/src/routes/api/admin/artist-lookup/server.test.ts b/src/routes/api/admin/artist-lookup/server.test.ts index 1981a3aa..892f98fe 100644 --- a/src/routes/api/admin/artist-lookup/server.test.ts +++ b/src/routes/api/admin/artist-lookup/server.test.ts @@ -255,6 +255,73 @@ describe('artist-lookup — stored image by id', () => { expect(await statusOf(() => POST(jsonEvent(platform, { imageId: 404 })))).toBe(404); }); + it('sends the stored image with the content type the proxy validated', async () => { + const { sqlite, platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + sqlite.exec( + `INSERT INTO images (id, title, slug, image_url, created_at) + VALUES (1, 'Ref', 'ref', 'https://cdn.example.com/stored.jpg', '2026-01-01');` + ); + const jpeg = new Response(IMAGE_BYTES, { + status: 200, + headers: { 'content-type': 'image/jpeg; charset=binary' } + }); + + await POST(jsonEvent(platform, { imageId: 1 }, imageFetch(jpeg).fn)); + + // The upload page forwards a File, which carries its own type; the edit + // page has to attach one or FuzzySearch sees an untyped part. + expect((searchImage.mock.calls[0][0] as Blob).type).toBe('image/jpeg'); + }); + + it('reports unavailable when the proxy refuses the stored URL', async () => { + const { sqlite, platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + sqlite.exec( + `INSERT INTO images (id, title, slug, image_url, created_at) + VALUES (1, 'Ref', 'ref', 'https://cdn.example.com/gone.png', '2026-01-01'), + (2, 'Internal', 'int', 'http://169.254.169.254/latest/meta-data', '2026-01-01');` + ); + + // Upstream 404: proxyStoredImage answers null, so there are no bytes. + const missing = imageFetch(new Response('nope', { status: 404 })); + const gone = await POST(jsonEvent(platform, { imageId: 1 }, missing.fn)); + expect(gone.status).toBe(502); + expect(await gone.json()).toEqual({ enabled: true, error: 'unavailable' }); + + // A link-local host stored in the row is refused before any fetch. + const internal = imageFetch(); + const blocked = await POST(jsonEvent(platform, { imageId: 2 }, internal.fn)); + expect(blocked.status).toBe(502); + expect(internal.calls).toEqual([]); + + expect(searchImage).not.toHaveBeenCalled(); + }); + + it('refuses a stored image whose body runs past the cap', async () => { + const { sqlite, platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + sqlite.exec( + `INSERT INTO images (id, title, slug, image_url, created_at) + VALUES (1, 'Huge', 'huge', 'https://cdn.example.com/huge.png', '2026-01-01');` + ); + // Streamed in 1 MiB chunks rather than allocated whole: bufferStream aborts + // mid-stream, which is the behaviour being pinned. + const chunk = new Uint8Array(1024 * 1024); + let sent = 0; + const body = new ReadableStream({ + pull(controller) { + if (sent > FUZZYSEARCH_MAX_BYTES) return controller.close(); + sent += chunk.length; + controller.enqueue(chunk); + } + }); + const huge = new Response(body, { status: 200, headers: { 'content-type': 'image/png' } }); + + const res = await POST(jsonEvent(platform, { imageId: 1 }, imageFetch(huge).fn)); + + expect(res.status).toBe(413); + expect(await res.json()).toEqual({ enabled: true, error: 'too_large' }); + expect(searchImage).not.toHaveBeenCalled(); + }); + it('reports unavailable when the stored image is not an image', async () => { const { sqlite, platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); sqlite.exec( @@ -290,6 +357,17 @@ describe('artist-lookup — failure mapping and the refused marker', () => { expect(await getRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING)).toBe(''); }); + it('writes nothing on a clean success with no marker standing', async () => { + const { db, platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + + const res = await POST(multipartEvent(platform, pngFile())); + + expect(res.status).toBe(200); + // Null, not '': the happy path costs one read, and never a write that + // would put a row in site_settings for every lookup. + expect(await getRawSetting(db, FUZZYSEARCH_KEY_REFUSED_SETTING)).toBeNull(); + }); + it('maps each remaining failure to its status without echoing a body', async () => { const { platform } = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); const cases = [ @@ -338,6 +416,48 @@ describe('artist-lookup — source-post clash', () => { }); }); + // The URL can sit on a VARIANT only — a set whose parent row carries no + // source URL. The clash is still the whole set, so the operator is pointed at + // the parent they can actually open, titled from the parent's own row. + it('reports the parent when only a variant carries the source URL', async () => { + const env = makeEnv({ FUZZYSEARCH_API_KEY: 'k' }); + env.sqlite.exec( + `INSERT INTO images (id, title, slug, image_url, source_post_url, parent_image_id, created_at) + VALUES (10, 'Sparky at the beach', 'beach', 'https://cdn/10.png', NULL, NULL, '2026-01-01'), + (11, 'Beach variant', 'beach-v', 'https://cdn/11.png', + 'https://www.furaffinity.net/view/12345/', 10, '2026-01-02');` + ); + searchImage.mockResolvedValue({ ok: true, matches: [FA_EXACT] }); + + const res = await POST(multipartEvent(env.platform, pngFile())); + const body = (await res.json()) as { sourceClash: Record }; + + expect(body.sourceClash).toEqual({ + imageId: 10, + title: 'Sparky at the beach', + isVariant: true, + parentImageId: 10, + // One row in the set carries the URL, and it is the row being reported. + variantCount: 0 + }); + }); + + // Two unrelated images can carry the same source post (a two-piece + // commission, say). They are separate clashes, not variants of the one + // reported — counting them would claim variants this image does not have. + it('counts only the reported set when unrelated images share the URL', async () => { + const env = clashSetup( + `INSERT INTO images (id, title, slug, image_url, source_post_url, parent_image_id, created_at) + VALUES (4, 'Same post, different piece', 'other-piece', 'https://cdn/4.png', + 'https://www.furaffinity.net/view/12345/', NULL, '2026-01-04');` + ); + + const res = await POST(multipartEvent(env.platform, pngFile())); + const body = (await res.json()) as { sourceClash: Record }; + + expect(body.sourceClash).toMatchObject({ imageId: 1, variantCount: 1 }); + }); + it('does not report an image clashing with its own variant set', async () => { const { platform } = clashSetup(); const res = await POST(jsonEvent(platform, { imageId: 2 })); From bca3ef062ad2f018e8501dd01804f7cab365eb03 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:25:57 -0700 Subject: [PATCH 06/38] fix(settings): make the Artist lookup section reachable and readable (SONA-156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focus is moved by hand across the removal confirmation: into Keep when it opens, back to Remove key when Keep closes it, and to the key field once the key is gone. Every button involved unmounts as the state changes, so a bare `autofocus` left a keyboard user at the top of the page. Keep also gets a visible boundary — .btn-secondary's fill is the confirmation panel's own background, so it had no edge at all. The section had no vertical rhythm: the two disclosure paragraphs and the field label ran together as one block. The refusal line moves up under the eyebrow in the lapsed-line voice, the masked record gets a visible "Refused key" label there, and the refused state offers removal instead of only overwriting. The key hint moves above the Save button, and the replace line steps aside while the confirmation is open. A refusal recorded against the deploy secret no longer surfaces: there is no key on this page to remove or replace, so the state would be a dead end. Adds tests/e2e/fuzzysearch-key.spec.ts for the states and the two client-side transitions. It submits its own save and remove and leaves the key removed. --- messages/en.json | 5 +- messages/ja.json | 1 + src/routes/admin/settings/+page.server.ts | 10 +- src/routes/admin/settings/+page.svelte | 100 ++++++++++++---- src/routes/admin/settings/page.server.test.ts | 55 +++++++++ tests/e2e/fuzzysearch-key.spec.ts | 111 ++++++++++++++++++ 6 files changed, 258 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/fuzzysearch-key.spec.ts diff --git a/messages/en.json b/messages/en.json index 713f0d4e..5da8bfd4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1192,7 +1192,8 @@ "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_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", @@ -1200,7 +1201,7 @@ "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_confirm_keep": "Keep", - "admin_settings_lookup_refused_eyebrow": "KEY REFUSED", + "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", diff --git a/messages/ja.json b/messages/ja.json index c47cb66a..a77c17d3 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -913,6 +913,7 @@ "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": "キーを削除", diff --git a/src/routes/admin/settings/+page.server.ts b/src/routes/admin/settings/+page.server.ts index ef72bf5b..312758fe 100644 --- a/src/routes/admin/settings/+page.server.ts +++ b/src/routes/admin/settings/+page.server.ts @@ -330,9 +330,15 @@ export const load: PageServerLoad = async ({ platform, url, locals }) => { ? 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. + // one date string identically on SSR and after hydration. Only for a key + // saved HERE: a refusal recorded against the deploy secret has no remedy + // on this page (no key to remove, no date to trust), so the refused state + // would be a dead end. The marker still gets written — it costs nothing + // and becomes meaningful again if the secret is ever dropped. fuzzysearchKeyRefusedAt: - fuzzysearchStoredKey && fuzzysearchRefusedAt ? formatDate(fuzzysearchRefusedAt) : null, + !fuzzysearchKeyFromEnv && fuzzysearchStoredKey && fuzzysearchRefusedAt + ? formatDate(fuzzysearchRefusedAt) + : 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, diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte index a84a5b28..2d03b70b 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -1,4 +1,5 @@