Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
73b8528
feat(lookup): add the FuzzySearch reverse image search client (SONA-156)
sparkyfen Sep 7, 2026
f36d77f
feat(lookup): add POST /api/admin/artist-lookup (SONA-156)
sparkyfen Sep 7, 2026
536786f
feat(settings): add the Artist lookup section to Connections (SONA-156)
sparkyfen Sep 7, 2026
3d7f369
docs: name FuzzySearch in the architecture diagram and README (SONA-156)
sparkyfen Sep 7, 2026
9d76e87
fix(lookup): tighten the FuzzySearch client and endpoint (SONA-156)
sparkyfen Sep 7, 2026
bca3ef0
fix(settings): make the Artist lookup section reachable and readable …
sparkyfen Sep 7, 2026
02fd958
fix(settings): keep focus and contrast through the lookup key states …
sparkyfen Sep 7, 2026
6df227d
fix(lookup): compare tweets by status id and read host aliases safely…
sparkyfen Sep 7, 2026
1b44be0
fix(lookup): restore the mobile.twitter.com alias and fold tweet perm…
sparkyfen Sep 7, 2026
a08c3cf
fix(settings): hold Remove key's hover contrast and pin the pending c…
sparkyfen Sep 7, 2026
13fa96b
fix(settings): hold the lookup block steady when the confirmation ope…
sparkyfen Sep 7, 2026
6a07c13
test(settings): pin the cancel() double-submit guard and the live reg…
sparkyfen Sep 7, 2026
e2e3a6e
test(settings): pin the lookup confirmation's layout and guards by co…
sparkyfen Sep 7, 2026
931b1aa
fix(lookup): report a rejected stored fetch and fold more source URLs…
sparkyfen Sep 8, 2026
e6b09b7
fix(image-proxy): pass through only the stored raster types (SONA-156)
sparkyfen Sep 8, 2026
bb47b6d
refactor(lookup): fold the rejected fetch into the proxy and share on…
sparkyfen Sep 8, 2026
bf3f2f2
fix(con-card): fall back when the avatar proxy answers a non-image ty…
sparkyfen Sep 8, 2026
aba70b3
test(con-card): pin the avatar proxy type guard at its call site (SON…
sparkyfen Sep 8, 2026
1365544
fix(artist-lookup): treat 403 as a refused key and gate uploads on ra…
sparkyfen Sep 8, 2026
b721d12
fix(artist-lookup): clear the refusal marker per key source and separ…
sparkyfen Sep 8, 2026
3b04fdf
test(e2e): make the artist-lookup key save step survive a pre-hydrati…
sparkyfen Sep 8, 2026
79f6d49
test(e2e): start each artist-lookup key save attempt from a fresh set…
sparkyfen Sep 8, 2026
8608dcb
fix(fuzzysearch): fold Weasyl title slugs and pin the timeout signal …
sparkyfen Sep 8, 2026
423286d
fix(settings): ignore a reflex second click on the artist-lookup remo…
sparkyfen Sep 8, 2026
227bbd2
fix(fuzzysearch): fold Weasyl user permalinks and compare their paths…
sparkyfen Sep 8, 2026
fadd84a
fix(settings): scope the remove-confirmation reflex guard to pointer …
sparkyfen Sep 8, 2026
559c9b2
fix(settings): only swallow a reflex click near where Remove key was …
sparkyfen Sep 8, 2026
e4aebf3
fix(settings): widen the reflex box for coarse pointers (SONA-156)
sparkyfen Sep 8, 2026
237afc0
test(e2e): note that the artist-lookup key spec cannot be stress-run …
sparkyfen Sep 8, 2026
207499a
fix(artist-lookup): bound the proxy header wait, cancel failed upstre…
sparkyfen Sep 8, 2026
5218219
fix(fuzzysearch): bound how much of a FuzzySearch response is read (S…
sparkyfen Sep 8, 2026
b7da9c4
test(artist-lookup): re-arm the settings spy before each case (SONA-156)
sparkyfen Sep 8, 2026
b2b6ead
test(fuzzysearch): give the bounded-read chunk allowance room for str…
sparkyfen Sep 8, 2026
ea3e8fa
fix(fuzzysearch): never follow a redirect with the API key, and pin t…
sparkyfen Sep 8, 2026
f65010f
fix(artist-lookup): answer a refused key with 424 so it stays out of …
sparkyfen Sep 8, 2026
11ca467
test(theme-contrast): measure the lookup remove hover against the rul…
sparkyfen Sep 8, 2026
f20ad6b
fix(artist-lookup): sniff stored image bytes before sending them to F…
sparkyfen Sep 8, 2026
4f47d71
fix(settings): keep the stored key's mask off the page while a deploy…
sparkyfen Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
keyed off a `FUZZYSEARCH_API_KEY` secret or a key saved in Settings →
Connections. Nothing is sent until you click.)*
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **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.
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
26 changes: 26 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,32 @@
"admin_settings_disconnecting": "Disconnecting…",
"admin_settings_connect_registry": "Connect to registry",
"admin_settings_forkkey_hint": "The fork key is stored in your site's database (not a deploy secret). It's a low-privilege, revocable, submit-only key.",
"admin_settings_lookup_heading": "Artist lookup",
"admin_settings_lookup_explainer_1": "Find who drew a piece when the post doesn't say. When you click Look up artist on an upload, Sona sends that image file to FuzzySearch. FuzzySearch matches it against its index of FurAffinity, Weasyl, e621, and the Twitter accounts it tracks. Sona sends nothing until you click, and keeps only the values you apply to the form.",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"admin_settings_lookup_explainer_2": "FuzzySearch is an independent service, not part of Sona. It may keep a copy or a hash of what you send. Sona has no agreement with it and can't delete anything on your behalf.",
"admin_settings_lookup_key_label": "FuzzySearch API key",
"admin_settings_lookup_key_placeholder": "Paste your key",
"admin_settings_lookup_hint_pre": "You can get a free key at ",
"admin_settings_lookup_hint_post": ".",
"admin_settings_lookup_save": "Save key",
"admin_settings_lookup_saved": "Key saved.",
"admin_settings_lookup_error_invalid": "That doesn't look like a key. Paste it exactly as FuzzySearch gave it to you.",
"admin_settings_lookup_connected_eyebrow": "Connected",
"admin_settings_lookup_key_ending": "ending {tail}",
"admin_settings_lookup_saved_key_label": "Saved key",
"admin_settings_lookup_replace": "To use a different key, remove this one and save the new one.",
"admin_settings_lookup_remove": "Remove key",
"admin_settings_lookup_removed": "Key removed.",
"admin_settings_lookup_confirm": "Remove the key? Look up artist disappears from upload and edit until you save a new one.",
"admin_settings_lookup_confirm_remove": "Remove",
"admin_settings_lookup_removing": "Removing…",
"admin_settings_lookup_confirm_keep": "Keep",
"admin_settings_lookup_refused_eyebrow": "Key refused",
"admin_settings_lookup_refused_line": "FuzzySearch didn't accept this key on the last lookup ({date}).",
"admin_settings_lookup_refused_key_label": "Refused key",
"admin_settings_lookup_new_key_label": "New FuzzySearch API key",
"admin_settings_lookup_secret_pre": "Set by the ",
"admin_settings_lookup_secret_post": " deploy secret. Manage it wherever you set your deploy secrets.",
"admin_settings_danger_zone": "Danger Zone",
"admin_settings_export_title": "Export data",
"admin_settings_export_desc": "Download a full backup of all images, metadata, collections, and tags as a JSON file.",
Expand Down
26 changes: 26 additions & 0 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,32 @@
"admin_settings_disconnecting": "切断中…",
"admin_settings_connect_registry": "レジストリに接続",
"admin_settings_forkkey_hint": "フォークキーはサイトのデータベースに保存されます(デプロイシークレットではありません)。低権限・失効可能・申請専用のキーです。",
"admin_settings_lookup_heading": "アーティスト検索",
"admin_settings_lookup_explainer_1": "投稿元に記載がない作品でも、描いた人を探せます。アップロードした画像で「アーティストを検索」を押すと、Sonaはその画像ファイルをFuzzySearchに送信します。FuzzySearchは、FurAffinity・Weasyl・e621と、収集対象のTwitterアカウントのインデックスと照合します。押すまでは何も送信されず、フォームに反映した値だけが保存されます。",
"admin_settings_lookup_explainer_2": "FuzzySearchはSonaとは無関係の外部サービスです。送信した画像のコピーやハッシュが保管される場合があります。Sonaは同サービスと契約しておらず、あなたに代わってデータを削除することはできません。",
"admin_settings_lookup_key_label": "FuzzySearch APIキー",
"admin_settings_lookup_key_placeholder": "キーを貼り付け",
"admin_settings_lookup_hint_pre": "無料のキーは ",
"admin_settings_lookup_hint_post": " で取得できます。",
"admin_settings_lookup_save": "キーを保存",
"admin_settings_lookup_saved": "キーを保存しました。",
"admin_settings_lookup_error_invalid": "キーの形式が違うようです。FuzzySearchから受け取ったとおりに貼り付けてください。",
"admin_settings_lookup_connected_eyebrow": "接続済み",
"admin_settings_lookup_key_ending": "末尾 {tail}",
"admin_settings_lookup_saved_key_label": "保存済みのキー",
"admin_settings_lookup_replace": "別のキーを使うには、このキーを削除してから新しいキーを保存してください。",
"admin_settings_lookup_remove": "キーを削除",
"admin_settings_lookup_removed": "キーを削除しました。",
"admin_settings_lookup_confirm": "キーを削除しますか?新しいキーを保存するまで、アップロード画面と編集画面から「アーティストを検索」がなくなります。",
"admin_settings_lookup_confirm_remove": "削除",
"admin_settings_lookup_removing": "削除中…",
"admin_settings_lookup_confirm_keep": "そのままにする",
"admin_settings_lookup_refused_eyebrow": "キーが拒否されました",
"admin_settings_lookup_refused_line": "前回の検索で、FuzzySearchはこのキーを受け付けませんでした({date})。",
"admin_settings_lookup_refused_key_label": "拒否されたキー",
"admin_settings_lookup_new_key_label": "新しいFuzzySearch APIキー",
"admin_settings_lookup_secret_pre": "デプロイシークレット ",
"admin_settings_lookup_secret_post": " で設定されています。デプロイシークレットを設定している場所で管理してください。",
"admin_settings_danger_zone": "危険な操作",
"admin_settings_export_title": "データをエクスポート",
"admin_settings_export_desc": "すべての画像・メタデータ・コレクション・タグの完全なバックアップをJSONファイルとしてダウンロードします。",
Expand Down
7 changes: 7 additions & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/lib/components/ConCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
conCardFaceSvg,
conCardPrintSheetSvg,
conCardFileBase,
isEmbeddableAvatarType,
CON_CARD_WIDTH,
CON_CARD_HEIGHT,
type ConCardColor,
Expand Down Expand Up @@ -173,6 +174,14 @@
try {
const response = await fetch(avatarSrc);
if (!response.ok) throw new Error(`avatar ${response.status}`);
// The proxy answers octet-stream for anything outside the raster
// allowlist, and a data URI made from that draws nothing. Treated as a
// failed avatar so the card falls back to the initial in the ring and
// says so, rather than saving a blank one. A response with no
// content-type at all is a direct same-origin avatar, not a refusal, so
// it still embeds.
const type = response.headers.get('content-type');
if (!isEmbeddableAvatarType(type)) throw new Error(`avatar type ${type}`);
const blob = await response.blob();
avatarData = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
Expand Down
13 changes: 13 additions & 0 deletions src/lib/components/con-card-markup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ describe('ConCard download paths', () => {
expect(source).toMatch(/avatarFailed = true;[\s\S]*?return null;/);
});

it('checks the proxy answered an image before it builds the data URI', () => {
// isEmbeddableAvatarType is unit-tested in con-card.test.ts; what has to be
// pinned here is that the fetch path actually calls it, on the response's
// own content-type, and throws instead of reaching the FileReader — a data
// URI made from an octet-stream body draws nothing and would be saved as a
// blank avatar with no message. Only the order matters — the header read,
// then the guard, then the reader — so renaming the local or moving a
// comment between them doesn't fail this.
expect(source).toMatch(
/embedAvatar\(\)[\s\S]*?headers\.get\(['"]content-type['"]\)[\s\S]*?isEmbeddableAvatarType\([\s\S]*?throw[\s\S]*?readAsDataURL/
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('separates "saved without your avatar" from "nothing saved"', () => {
// avatarFailed is the embed path's alone; a raster failure must never claim
// a file was saved. Both save paths route their catch to rasterFailed.
Expand Down
55 changes: 54 additions & 1 deletion src/lib/con-card.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { qrSvg } from './qr';
import { SOCIAL_ICON_ART, type SocialIconArt } from './social-icon-paths';
import { SOCIAL_PLATFORM_NAMES, type SocialPlatform } from './social-label';
import {
conCardFaceSvg,
conCardPrintSheetSvg,
conCardFileBase,
isEmbeddableAvatarType,
CON_CARD_WIDTH,
CON_CARD_HEIGHT,
CON_CARD_SHEET_WIDTH,
Expand Down Expand Up @@ -618,3 +619,55 @@ describe('conCardFileBase', () => {
expect(conCardFileBase('')).toBe('con-card');
});
});

// The card embeds the avatar as a data URI, and the byte proxy in front of it
// hands anything outside the stored raster allowlist back as a download. A URI
// built from one of those draws nothing, so the type decides whether the card
// keeps the face or falls back to the initial in the ring.
describe('isEmbeddableAvatarType', () => {
it('takes the raster types the gallery stores, whatever their spelling', () => {
for (const type of ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif']) {
expect(isEmbeddableAvatarType(type), type).toBe(true);
}
expect(isEmbeddableAvatarType('Image/PNG; charset=binary')).toBe(true);
});

it('refuses the proxy download type and svg', () => {
const refused = ['application/octet-stream', 'image/svg+xml', 'text/html'];
for (const type of refused) {
expect(isEmbeddableAvatarType(type), String(type)).toBe(false);
}
});

// The guard also runs on the direct same-origin avatar URL, and the proxy
// always sets a content-type. Only a PRESENT, non-raster type is a refusal —
// treating a missing header as one drops a perfectly good avatar to the
// initial.
it('takes a response with no content-type at all', () => {
for (const type of ['', null, undefined]) {
expect(isEmbeddableAvatarType(type), String(type)).toBe(true);
}
});

// GALLERY_ACCEPT has no spaces today, and an accept list is just as valid
// written with them. Read literally, a single space would drop the avatar to
// the initial for a type the server's own allowlist stores.
it('reads an accept list written with spaces after the commas', async () => {
vi.resetModules();
vi.doMock('$lib/config', async () => {
const actual = await vi.importActual<typeof import('./config')>('./config');
return { ...actual, GALLERY_ACCEPT: 'image/jpeg, image/png, image/webp' };
});
try {
const spaced = await import('./con-card');
expect(spaced.isEmbeddableAvatarType('image/png')).toBe(true);
expect(spaced.isEmbeddableAvatarType('image/webp')).toBe(true);
// Not in the stubbed list, so a false here also proves the stub is the
// list being read rather than the real constant.
expect(spaced.isEmbeddableAvatarType('image/gif')).toBe(false);
} finally {
vi.doUnmock('$lib/config');
vi.resetModules();
}
});
});
22 changes: 22 additions & 0 deletions src/lib/con-card.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GALLERY_ACCEPT } from '$lib/config';
import { qrSvg } from '$lib/qr';
import { SOCIAL_ICON_ART, type SocialIconArt } from '$lib/social-icon-paths';
import { SOCIAL_PLATFORM_NAMES, type SocialPlatform } from '$lib/social-label';
Expand Down Expand Up @@ -620,6 +621,27 @@ export function conCardPrintSheetSvg(opts: Omit<ConCardOptions, 'variant'>): str
.join('');
}

/**
* Whether a fetched avatar response can be embedded in a saved card. The
* same-origin byte proxy hands anything outside the stored raster allowlist back
* as `application/octet-stream` with a download disposition, and a data URI
* built from that never draws: the card would save with an empty ring and no
* word to the operator. Only a PRESENT, non-raster type is a failure — the
* proxy always sets a content-type, so a response without one is a direct
* same-origin avatar and stays embeddable. Read off GALLERY_ACCEPT so this and
* the server's allowlist can't drift apart.
*/
export function isEmbeddableAvatarType(contentType: string | null | undefined): boolean {
if (!contentType) return true;
const type = contentType.split(';')[0].trim().toLowerCase();
// Tokens are trimmed: an accept list written with a space after the comma is
// the same list to an <input accept>, and reading it literally here would
// refuse a type the server's allowlist takes.
return GALLERY_ACCEPT.split(',')
.map((t) => t.trim())
.includes(type);
}

/** Filename stem for a downloaded card: `taro-con-card`. */
export function conCardFileBase(name: string): string {
const slug = name
Expand Down
Loading
Loading