diff --git a/AI_POLICY.md b/AI_POLICY.md index 3666e8ef..a76d9f62 100644 --- a/AI_POLICY.md +++ b/AI_POLICY.md @@ -24,6 +24,14 @@ second AI model reads the first one's work, and CodeRabbit, a third-party service, reviews the diff. A human approves every merge and clicks every deploy. There is no review team here, just one maintainer and a set of tools. +The running site makes one AI call of its own. When the operator asks for tag +suggestions on a piece of artwork, the site sends entail.dev, an image +classifier, a public link to the artwork's source post or to the picture in +that post. In normal operation, nothing a visitor does reaches an AI service. +The one exception is the diagnostic case the /ai page describes: when the +maintainer is debugging, logs that can include visitor IP addresses and page +URLs may pass through the development tools. + ## Rules the agents work under These rules predate this file, and every agent session on this project works diff --git a/UPDATING.md b/UPDATING.md index 24ac1773..e59c2494 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -48,6 +48,31 @@ you sync — or to catch up on what shipped since you last did — read the the merged changes since the previous one. `git log --oneline ..upstream/main` after a fetch gives the same view from a clone. +## Read before upgrading: tag suggestions call entail.dev (SONA-220) + +This release adds an admin-only tag suggestion lookup for artwork. Only you can +start it, and nothing runs on its own. When you do, the site sends a public +link to entail.dev, an image classifier, and shows you the tags it returns. For a +Bluesky post that link is the post itself. For an X post the site first asks X's +own API which picture the post carries, then sends the picture link X hands back. +Nothing from either reply is stored; the suggestions are yours to accept or drop. + +The built-in privacy policy and the `/ai` page describe this call already. If +you pasted your own privacy text or your own `/ai` text in Settings, **neither +was updated**, and your pages will not mention services your site now contacts. +In your privacy policy's list of feature integrations, replace your X and +Bluesky entry with the first line below and add the second: + +- `X (formerly Twitter) and Bluesky (fetching the profile pictures shown on this site; X is also asked which picture a post carries)` +- `entail.dev (an image classifier that suggests tags for artwork from its source post or the picture in it)` + +And add this to your `/ai` text: "The site calls an AI service in one place. When +the site owner asks for tag suggestions on a piece of artwork, the site sends a +public link to entail.dev, an image classifier. That link points at the artwork's +source post, or at the picture in that post. For a post on X, the site first asks +X's own service which picture the post carries. Only the site owner can start +that, so nothing you do is sent to an AI service as you browse." + ## One-time backfill: sticker animation flags (SONA-123) The release that adds the per-sticker download-format menu also adds a diff --git a/docs/architecture.md b/docs/architecture.md index 89342be6..a2f5b2a9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,7 +45,8 @@ graph TB Resend[✉️ Resend] Turnstile[🧩 Cloudflare Turnstile] ConsFYI[📅 cons.fyi] - Avatars[🖼️ Bluesky + X — profile picture sources] + Avatars[🖼️ Bluesky + X — profile pictures, tweet media] + Entail[🏷️ entail.dev — image tag classifier] UT[☁️ UploadThing — optional] end @@ -91,6 +92,7 @@ graph TB Auth -->|reset email| Resend RateLimit --> Turnstile Public -->|convention dates| ConsFYI + API -->|tag suggestions for a source post| Entail RegClient -->|search / pull / submit| RegWorker RegWorker --> RegD1 @@ -98,7 +100,7 @@ graph TB CI --> Deploy Deploy -->|wrangler pages deploy| Hooks CronWF -->|POST /api/cron/* with CRON_SECRET| API - API -->|fetch profile pictures to re-host| Avatars + API -->|fetch profile pictures to re-host, resolve tweet media| Avatars Admin -->|fetch profile pictures to re-host| Avatars Release -.->|pull tagged releases| Forks ``` @@ -133,6 +135,11 @@ graph TB zone rather than the reader's or UTC. - Telegram, FurTrack, FuzzySearch, Resend, and Turnstile are optional integrations, keyed off secrets or settings (see `wrangler.toml.example` for the full list). +- entail.dev needs no key or secret. The app calls it only when an operator + asks for tag suggestions on an image whose source post is on Bluesky or X, + and never on a render path or a schedule. An X post takes one extra hop: + X's own API resolves the post to its image, and that image URL is what + entail.dev classifies. - GitHub Actions is part of the runtime, not just delivery: the scheduled workflows (`sticker-resync` daily 06:00 UTC, `artist-sync` 06:30, `avatar-refresh` 07:00, `cleanup-orphans` weekly, `backfill-animated` diff --git a/src/lib/ai-disclosure.test.ts b/src/lib/ai-disclosure.test.ts index 12a5ca2c..f2319c27 100644 --- a/src/lib/ai-disclosure.test.ts +++ b/src/lib/ai-disclosure.test.ts @@ -33,7 +33,14 @@ describe('defaultAiDisclosure', () => { }); it('states the runtime boundary and the dev-time access plainly', () => { - expect(all).toMatch(/never calls an AI service, so nothing you do is sent to one as you browse/); + // SONA-220 added the one runtime call; the disclosure names it and keeps + // the browsing-time claim scoped so it stays true. + expect(all).toMatch(/calls an AI service in one place/); + expect(all).toContain('entail.dev'); + // Both paths: the post link (Bluesky) and the picture link X hands back. + expect(all).toMatch(/points at the artwork's source post, or at the picture in that post/); + expect(all).toMatch(/the site first asks X's own service which picture the post carries/); + expect(all).toMatch(/Only the site owner can start that, so nothing you do is sent to an AI service as you browse/); expect(all).toMatch(/logs and database/); expect(all).toContain('CodeRabbit'); // Honesty about what dev-time log access can expose: no "your data never diff --git a/src/lib/ai-disclosure.ts b/src/lib/ai-disclosure.ts index e27c6f84..8dd8fe93 100644 --- a/src/lib/ai-disclosure.ts +++ b/src/lib/ai-disclosure.ts @@ -2,8 +2,9 @@ // // Wording approved by the operator 2026-08-12 after a peer review that set the // register: short, disclosure-only, no persuasion. Facts only — every claim -// here is backed by the codebase (no runtime AI calls anywhere), the repo -// policy (AI_POLICY.md), or the operator's own practice. An owner whose +// here is backed by the codebase (the one runtime AI call is the operator's +// tag-suggestion lookup against entail.dev, SONA-220), the repo policy +// (AI_POLICY.md), or the operator's own practice. An owner whose // practice differs (or who wants their own words) overrides the text via // Settings, or turns the page off entirely with the aiPageEnabled toggle. // @@ -70,7 +71,7 @@ export function defaultAiDisclosure(): AiDisclosure { }, { lead: 'Your data.', - body: "The running site never calls an AI service, so nothing you do is sent to one as you browse. When the software is being worked on, the developer's tools can read this site's logs and database, as any developer's could, and those logs can include visitors' IP addresses and the pages they requested. Code goes to Anthropic and to CodeRabbit, a review service. Model training is switched off on the accounts used, and CodeRabbit states that the data from its reviews is never used for training. The privacy policy has the details." + body: "The site calls an AI service in one place. When the site owner asks for tag suggestions on a piece of artwork, the site sends a public link to entail.dev, an image classifier. That link points at the artwork's source post, or at the picture in that post. For a post on X, the site first asks X's own service which picture the post carries. Only the site owner can start that, so nothing you do is sent to an AI service as you browse. When the software is being worked on, the developer's tools can read this site's logs and database, as any developer's could, and those logs can include visitors' IP addresses and the pages they requested. Code goes to Anthropic and to CodeRabbit, a review service. Model training is switched off on the accounts used, and CodeRabbit states that the data from its reviews is never used for training. The privacy policy has the details." }, { lead: 'The model.', diff --git a/src/lib/legal.test.ts b/src/lib/legal.test.ts index 4ccb7c9b..7bfa2731 100644 --- a/src/lib/legal.test.ts +++ b/src/lib/legal.test.ts @@ -104,7 +104,7 @@ describe('defaultPrivacyPolicy', () => { // The runtime boundary, honestly scoped: nothing browsing-time goes to the // tools, but shared diagnostic logs can carry request data — both halves // must stay, or the paragraph overclaims again. - expect(text).toMatch(/nothing you do here is sent to them as you browse/); + expect(text).toMatch(/nothing you do here is sent to those tools as you browse/); expect(text).toMatch(/can contain request data such as IP addresses/); }); @@ -180,7 +180,10 @@ describe('defaultPrivacyPolicy', () => { expect(text).toContain('cons.fyi'); // The integrations list reads exhaustive, so it must actually be: every // remote service a feature calls out to is named (SONA-167 round 1). - expect(text).toContain('Bluesky'); + // Picture lookup is an X-only ask; Bluesky posts go to entail.dev whole. + expect(text).toMatch(/X \(formerly Twitter\) and Bluesky \(fetching the profile pictures shown on this site; X is also asked which picture a post carries\)/); + // SONA-220: the tag-suggestion lookup sends a post or picture URL to entail.dev. + expect(text).toMatch(/entail.dev \(an image classifier that suggests tags for artwork from its source post or the picture in it\)/); expect(text).toContain('FurTrack'); expect(text).toContain('FuzzySearch'); // The lookup sends a copy of the image, and an unpublished one too; naming @@ -297,8 +300,8 @@ describe('LEGAL_DEFAULTS_UPDATED tracks the default text', () => { // privacy page would show a "Last updated" line older than its own text. // Deliberately two assertions, not a diff — the point is to force the date // bump, not to review the prose. - const RECORDED_TEXT_HASH = '3a1187cb2970888de509560a9e0c028b3340d5ba39f393da83e0c943b407a780'; - const RECORDED_UPDATED = '2026-09-14'; + const RECORDED_TEXT_HASH = 'b2c656867b76ec288a85b230ccce8279bdff6cd963edea4e90e1b31f0c7351fa'; + const RECORDED_UPDATED = '2026-09-15'; function defaultsText(): string { // Fixed opts so the hash depends on the prose alone, not the caller. Both diff --git a/src/lib/legal.ts b/src/lib/legal.ts index 9827ea04..17a96274 100644 --- a/src/lib/legal.ts +++ b/src/lib/legal.ts @@ -27,7 +27,7 @@ export interface LegalSection { // on every fork by construction (a build/deploy date would falsely advance on a // redeploy that didn't touch the text). Bump this whenever you edit // defaultPrivacyPolicy or defaultTerms. -export const LEGAL_DEFAULTS_UPDATED = '2026-09-14'; +export const LEGAL_DEFAULTS_UPDATED = '2026-09-15'; /** * Resolve the "Last updated" date to show on a legal page from a *stable* source @@ -150,13 +150,13 @@ export function defaultPrivacyPolicy(opts: LegalOptions): LegalSection[] { // paragraph with the toggle would delete a real processor // disclosure. Only the vendor NAMES follow the affirmation, since // those are the part a declining owner has not stood behind. - 'Sites running this software are typically built and maintained with AI development tools, which do not run as part of the site itself, so nothing you do here is sent to them as you browse. When the site owner or their developer is diagnosing a problem, the operational data they share with development or code-review tools can include server logs and database records, and those logs can contain request data such as IP addresses, page URLs, and browser user-agent strings.', + 'Sites running this software are typically built and maintained with AI development tools, which do not run as part of the site itself, so nothing you do here is sent to those tools as you browse. When the site owner or their developer is diagnosing a problem, the operational data they share with development or code-review tools can include server logs and database records, and those logs can contain request data such as IP addresses, page URLs, and browser user-agent strings.', ...(opts.aiToolsDisclosed === false ? [] : [ "For this site those tools are Anthropic's Claude, which writes and debugs code under the developer's direction, and CodeRabbit, a code review service that reads proposed changes." ]), - "For specific features the site also talks to Cloudflare Turnstile (bot protection on the sign-in page), Telegram (importing sticker packs), cons.fyi (convention listings), X (formerly Twitter) and Bluesky (fetching the profile pictures shown on this site), FurTrack (importing fursuit photos), FuzzySearch (finding where a piece was posted; when the site owner asks for a lookup, a copy of that image is sent, including images not published on this site), and the shared artist registry (syncing artist credits; the registry receives this site's name and hostname as part of the sync). The site contacts these services to run the feature; they are not used to track visitors." + "For specific features the site also talks to Cloudflare Turnstile (bot protection on the sign-in page), Telegram (importing sticker packs), cons.fyi (convention listings), X (formerly Twitter) and Bluesky (fetching the profile pictures shown on this site; X is also asked which picture a post carries), entail.dev (an image classifier that suggests tags for artwork from its source post or the picture in it), FurTrack (importing fursuit photos), FuzzySearch (finding where a piece was posted; when the site owner asks for a lookup, a copy of that image is sent, including images not published on this site), and the shared artist registry (syncing artist credits; the registry receives this site's name and hostname as part of the sync). The site contacts these services to run the feature; they are not used to track visitors." ] }, { diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts new file mode 100644 index 00000000..1ba1e959 --- /dev/null +++ b/src/lib/server/entail.test.ts @@ -0,0 +1,675 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + MAX_RAW_ENTRIES, + MAX_SORTED_ENTRIES, + MAX_SUGGESTED_TAGS, + POLL_TIMEOUT_MS, + POST_TIMEOUT_MS, + classifySourceUrl, + classifyMediaUrl, + lookupBlueskySource, + suggestionsFromResult, + translateTag +} from './entail'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +describe('classifySourceUrl', () => { + it('accepts the bluesky post shapes', () => { + expect(classifySourceUrl('https://bsky.app/profile/example.bsky.social/post/3abc')).toEqual({ + kind: 'bluesky', + url: 'https://bsky.app/profile/example.bsky.social/post/3abc' + }); + expect(classifySourceUrl('https://www.bsky.app/profile/did:plc:aaaa/post/3abc/')).toEqual({ + kind: 'bluesky', + url: 'https://bsky.app/profile/did:plc:aaaa/post/3abc' + }); + expect(classifySourceUrl('https://bsky.app/profile/did%3Aplc%3Aaaaa/post/3abc?ref=x')).toEqual({ + kind: 'bluesky', + url: 'https://bsky.app/profile/did:plc:aaaa/post/3abc' + }); + }); + + it('rejects a malformed or slash-smuggling percent-encoded bluesky actor', () => { + // A bad percent sequence used to throw URIError out of the classifier. + expect(classifySourceUrl('https://bsky.app/profile/100%/post/3abc')).toBeNull(); + // An encoded slash passes the raw-actor regex but decodes into a path + // separator inside the canonical URL. + expect(classifySourceUrl('https://bsky.app/profile/a%2f..%2fx/post/3abc')).toBeNull(); + // A double-encoded actor decodes to one that still carries a `%`; the + // endpoint used to decode that again downstream. + expect(classifySourceUrl('https://bsky.app/profile/a%252Fb/post/3abc')).toBeNull(); + expect(classifySourceUrl('https://bsky.app/profile/foo%252ebar/post/3abc')).toBeNull(); + }); + + it('accepts the x/twitter status shapes and canonicalises them', () => { + const canonical = { kind: 'x', url: 'https://x.com/examplefox/status/1234567890', id: '1234567890' }; + expect(classifySourceUrl('https://x.com/examplefox/status/1234567890')).toEqual(canonical); + expect(classifySourceUrl('https://twitter.com/examplefox/status/1234567890?s=21')).toEqual(canonical); + expect(classifySourceUrl('https://x.com/examplefox/status/1234567890/photo/1')).toEqual(canonical); + expect(classifySourceUrl('https://mobile.twitter.com/examplefox/statuses/1234567890')).toEqual( + canonical + ); + expect(classifySourceUrl('https://mobile.x.com/examplefox/status/1234567890')).toEqual(canonical); + expect(classifySourceUrl('https://x.com/i/status/1234567890')).toEqual({ + kind: 'x', + url: 'https://x.com/i/status/1234567890', + id: '1234567890' + }); + // The share-sheet permalink form. + expect(classifySourceUrl('https://x.com/i/web/status/1234567890')).toEqual({ + kind: 'x', + url: 'https://x.com/i/status/1234567890', + id: '1234567890' + }); + expect(classifySourceUrl('https://twitter.com/i/web/status/1234567890?s=20')).toEqual({ + kind: 'x', + url: 'https://x.com/i/status/1234567890', + id: '1234567890' + }); + // `web` only means something after `i`. + expect(classifySourceUrl('https://x.com/web/status/1234567890')).toEqual({ + kind: 'x', + url: 'https://x.com/web/status/1234567890', + id: '1234567890' + }); + expect(classifySourceUrl('https://x.com/i/web/1234567890')).toBeNull(); + }); + + it('rejects anything else', () => { + expect(classifySourceUrl('https://bsky.app/profile/example.bsky.social')).toBeNull(); + expect(classifySourceUrl('https://bsky.app/profile/example/feed/3abc')).toBeNull(); + expect(classifySourceUrl('https://x.com/examplefox')).toBeNull(); + expect(classifySourceUrl('https://x.com/examplefox/status/notanid')).toBeNull(); + expect(classifySourceUrl('https://example.com/x.com/user/status/1')).toBeNull(); + expect(classifySourceUrl('https://pbs.twimg.com/media/abc.jpg')).toBeNull(); + expect(classifySourceUrl('javascript:alert(1)')).toBeNull(); + expect(classifySourceUrl('not a url')).toBeNull(); + }); +}); + +describe('translateTag', () => { + it('drops the trailing qualifier and hyphenates', () => { + expect(translateTag('digital_media_(artwork)')).toBe('digital-media'); + expect(translateTag('two_tone_fur')).toBe('two-tone-fur'); + expect(translateTag('Mammal')).toBe('mammal'); + }); + + it('leaves an inner parenthetical alone', () => { + expect(translateTag('a_(b)_c')).toBe('a-b-c'); + }); + + it('runs the result through the tag sanitizer', () => { + expect(translateTag('50%_fur')).toBe('50-fur'); + expect(translateTag(' Pink_Hair ')).toBe('pink-hair'); + }); + + it('hyphenates a slash or colon instead of gluing the words together', () => { + // The sanitizer drops anything outside [a-z0-9 -], so without this + // `male/female` would collapse into `malefemale`. + expect(translateTag('male/female')).toBe('male-female'); + expect(translateTag('canine/wolf_(species)')).toBe('canine-wolf'); + // Still needs a letter: `2:1` is a ratio tag, not a word. + expect(translateTag('2:1')).toBeNull(); + }); + + it('returns null when nothing survives', () => { + expect(translateTag('(artwork)')).toBeNull(); + expect(translateTag('!!!')).toBeNull(); + expect(translateTag('')).toBeNull(); + }); + + it('leaves no doubled or trailing hyphen when the cap cuts a hyphen run', () => { + // The sanitizer slices at 50 characters. A run of hyphens straddling the + // cut would otherwise survive as a doubled or trailing hyphen. + const tag = translateTag(`${'a'.repeat(48)}___bbb`); + expect(tag).toBe('a'.repeat(48)); + expect(tag).not.toMatch(/--|-$/); + }); + + it('returns quickly and null for a very long underscore run', () => { + // The qualifier-stripping regex backtracks quadratically on `_` runs; the + // input is cut to 200 characters before it runs, so 100k stays cheap. + const started = performance.now(); + expect(translateTag('_'.repeat(100_000))).toBeNull(); + expect(performance.now() - started).toBeLessThan(100); + }); + + it('drops emoticon tags instead of leaving their debris', () => { + // e621 carries symbol-only tags whose sanitized remains ("3", "-", "---") + // would otherwise be suggested as if they were words. + expect(translateTag('<3')).toBeNull(); + expect(translateTag('^_^')).toBeNull(); + expect(translateTag('-_-')).toBeNull(); + expect(translateTag(':3')).toBeNull(); + expect(translateTag('digital_media_(artwork)')).toBe('digital-media'); + }); +}); + +describe('suggestionsFromResult', () => { + it('keeps tags at or above the floor, in confidence order', () => { + expect( + suggestionsFromResult({ + rating: 'safe', + tags: [ + { name: 'mammal', confidence: 0.92 }, + { name: 'pink_hair', confidence: 0.8 }, + { name: 'canine', confidence: 0.79 } + ] + }) + ).toEqual({ tags: ['mammal', 'pink-hair'], rating: 'safe' }); + }); + + it('dedupes tags that translate to the same name', () => { + expect( + suggestionsFromResult({ + tags: [ + { name: 'digital_media_(artwork)', confidence: 0.95 }, + { name: 'digital media', confidence: 0.9 } + ] + }).tags + ).toEqual(['digital-media']); + }); + + it('caps the list after dedupe', () => { + const tags = Array.from({ length: MAX_SUGGESTED_TAGS + 5 }, (_, i) => ({ + name: `tag_${i}`, + confidence: 0.99 + })); + // A duplicate ahead of the cap must not count against it. + tags.unshift({ name: 'tag_0', confidence: 0.999 }); + const result = suggestionsFromResult({ tags }).tags; + expect(result).toHaveLength(MAX_SUGGESTED_TAGS); + expect(result[0]).toBe('tag-0'); + expect(new Set(result).size).toBe(MAX_SUGGESTED_TAGS); + }); + + it('keeps the most confident entries when a hostile array exceeds the cap', () => { + // The classify path sends no confidence floor, so a body can list its + // tags in any order. The cap has to drop the least confident, not the + // last-listed: a qualifying tag deep past the cap survives, a lower one + // inside it does not. + const low = { name: 'noise', confidence: 0.1 }; + const tags: unknown[] = Array.from({ length: MAX_RAW_ENTRIES + 100 }, () => ({ ...low })); + tags[0] = { name: 'canine', confidence: 0.85 }; + tags[MAX_RAW_ENTRIES + 50] = { name: 'mammal', confidence: 0.99 }; + // Entries with no usable confidence sort last rather than throwing. + tags[5] = { name: 'junk', confidence: 'high' }; + tags[6] = null; + tags[7] = { name: 'nan', confidence: Number.NaN }; + expect(suggestionsFromResult({ tags }).tags).toEqual(['mammal', 'canine']); + // Ties keep the API's order (a stable sort). + const tied = [ + { name: 'zebra', confidence: 0.9 }, + { name: 'ant', confidence: 0.9 } + ]; + expect(suggestionsFromResult({ tags: tied }).tags).toEqual(['zebra', 'ant']); + // Past the cap, everything that remains is below what was kept. + const many = Array.from({ length: MAX_RAW_ENTRIES + 1 }, (_, i) => ({ + name: `tag_${i}`, + confidence: i === MAX_RAW_ENTRIES ? 0.5 : 0.9 + })); + expect(suggestionsFromResult({ tags: many }).tags).not.toContain(`tag-${MAX_RAW_ENTRIES}`); + }); + + it('sorts only the first MAX_SORTED_ENTRIES of a hostile array', () => { + // The sort itself is bounded, not just the walk after it: a body far + // past what entail.dev ever returns is cut before it is copied and + // sorted, so a qualifying tag in the dropped tail is never seen, while + // one inside the bound but past MAX_RAW_ENTRIES still sorts to the top. + const low = { name: 'noise', confidence: 0.1 }; + const tags: unknown[] = Array.from({ length: 5000 }, () => ({ ...low })); + tags[250] = { name: 'canine', confidence: 0.99 }; + tags[4999] = { name: 'mammal', confidence: 0.99 }; + expect(MAX_RAW_ENTRIES).toBeLessThan(250); + expect(MAX_SORTED_ENTRIES).toBeLessThan(4999); + expect(suggestionsFromResult({ tags }).tags).toEqual(['canine']); + }); + + it('drops junk entries and unknown ratings', () => { + expect( + suggestionsFromResult({ + rating: 'nsfw', + tags: [{ name: 42 }, { confidence: 0.99 }, { name: '(artwork)', confidence: 0.99 }] + }) + ).toEqual({ tags: [], rating: null }); + expect(suggestionsFromResult(null)).toEqual({ tags: [], rating: null }); + expect(suggestionsFromResult({ tags: 'nope' })).toEqual({ tags: [], rating: null }); + }); +}); + +describe('timeouts', () => { + it('outlast the wait=true hold the server puts on a fresh job', () => { + // About five seconds measured on 2026-09-07; anything at or above six + // clears it. The comment above the constants explains why this matters. + expect(POST_TIMEOUT_MS).toBeGreaterThanOrEqual(6000); + expect(POLL_TIMEOUT_MS).toBeGreaterThanOrEqual(6000); + }); +}); + +describe('lookupBlueskySource', () => { + const url = 'https://bsky.app/profile/did:plc:aaaa/post/3abc'; + // The endpoint validates the URL with classifySourceUrl and hands the + // result straight to lookupBlueskySource; this does the same in one step. + const lookupBlueskyPost = (postUrl: string, fetchImpl: typeof fetch, signal?: AbortSignal) => { + const source = classifySourceUrl(postUrl); + if (!source || source.kind !== 'bluesky') throw new Error(`not a bluesky post: ${postUrl}`); + return lookupBlueskySource(source, fetchImpl, signal); + }; + const post = { + uri: 'at://did:plc:aaaa/app.bsky.feed.post/3abc', + images: [ + { cid: 'one', rating: 'explicit', tags: [{ name: 'mammal', confidence: 0.99 }] }, + { cid: 'two', rating: 'safe', tags: [{ name: 'canine', confidence: 0.99 }] } + ] + }; + + it('clamps the image count to what a post can carry', async () => { + const many = { images: Array.from({ length: 9 }, () => post.images[0]) }; + const fetchImpl = vi.fn(async (_url: string | URL | Request) => json(many)); + const outcome = await lookupBlueskyPost(url, fetchImpl); + expect(outcome.ok && outcome.imageCount).toBe(4); + }); + + it('uses the first image only, and reports how many there were', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request) => json(post)); + expect(await lookupBlueskyPost(url, fetchImpl)).toEqual({ + ok: true, + suggestions: { tags: ['mammal'], rating: 'explicit' }, + imageCount: 2 + }); + const requested = String(fetchImpl.mock.calls[0]?.[0]); + expect(requested).toContain('min_confidence=0.8'); + expect(requested).toContain('wait=true'); + }); + + it('sends a validated source as-is through lookupBlueskySource', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request) => json(post)); + const source = { kind: 'bluesky' as const, url: 'https://bsky.app/profile/did:plc:aaaa/post/3abc' }; + expect((await lookupBlueskySource(source, fetchImpl)).ok).toBe(true); + expect(String(fetchImpl.mock.calls[0]?.[0])).toContain(encodeURIComponent(source.url)); + }); + + it('returns unavailable without fetching when the deadline has already passed', async () => { + const fetchImpl = vi.fn(async () => json(post)); + const source = { kind: 'bluesky' as const, url }; + expect(await lookupBlueskySource(source, fetchImpl, AbortSignal.abort())).toEqual({ + ok: false, + reason: 'unavailable' + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('names the reason a lookup produced nothing', async () => { + expect(await lookupBlueskyPost(url, vi.fn(async () => json({}, 202)))).toEqual({ + ok: false, + reason: 'not_ready' + }); + expect( + await lookupBlueskyPost(url, vi.fn(async () => new Response('slow down', { status: 429 }))) + ).toEqual({ ok: false, reason: 'rate_limited' }); + expect( + await lookupBlueskyPost(url, vi.fn(async () => new Response('boom', { status: 500 }))) + ).toEqual({ ok: false, reason: 'unavailable' }); + expect( + await lookupBlueskyPost( + url, + vi.fn(async () => { + throw new Error('TimeoutError'); + }) + ) + ).toEqual({ ok: false, reason: 'unavailable' }); + }); + + it('reports an input-rejection 4xx from /post as not_found, with the status in the log', async () => { + // entail.dev declining the input is the operator's problem, not an + // outage: the endpoint answers 404, which hooks.server.ts does not count + // as a site error the way it counts a 502. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + for (const status of [404, 400]) { + expect( + await lookupBlueskyPost(url, vi.fn(async () => new Response('no', { status }))) + ).toEqual({ ok: false, reason: 'not_found' }); + expect(warn.mock.calls.map((c) => c.join(' ')).join('\n')).toContain(`status=${status}`); + } + // A 403 is an edge block or an auth failure, not a declined post: the + // integration is broken, and that has to surface as an outage. + expect( + await lookupBlueskyPost(url, vi.fn(async () => new Response('blocked', { status: 403 }))) + ).toEqual({ ok: false, reason: 'unavailable' }); + }); + + it('logs a malformed body as a parse failure without quoting it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect( + await lookupBlueskyPost(url, vi.fn(async () => new Response('secret-body', { status: 200 }))) + ).toEqual({ ok: false, reason: 'unavailable' }); + const logged = warn.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(logged).toContain('SyntaxError'); + expect(logged).not.toContain('secret-body'); + expect(logged).not.toContain(''); + }); + + it('is unavailable on a 200 whose body has no images array', async () => { + // A bare array, or an object missing `images`, is a shape we don't know. + // It must not pass as "the classifier found nothing" (which is `images: []`). + const unavailable = { ok: false, reason: 'unavailable' }; + expect(await lookupBlueskyPost(url, vi.fn(async () => json(post.images)))).toEqual(unavailable); + expect(await lookupBlueskyPost(url, vi.fn(async () => json({ uri: 'at://x' })))).toEqual( + unavailable + ); + }); + + it('succeeds with no tags when the post has no classified images', async () => { + const fetchImpl = vi.fn(async () => json({ uri: 'at://x', images: [] })); + expect(await lookupBlueskyPost(url, fetchImpl)).toEqual({ + ok: true, + suggestions: { tags: [], rating: null }, + imageCount: 0 + }); + }); + + // `/post?wait=true` holds the connection open while the classifier works, + // the same way the classify poll does (see the matching test below). A post + // timeout shorter than that hold would abort the answer we asked to wait for. + it('waits out a post lookup that the server holds open for seconds', async () => { + const heldFor = 2500; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + await new Promise((resolve) => setTimeout(resolve, heldFor)); + init?.signal?.throwIfAborted(); + return json(post); + }); + expect(await lookupBlueskyPost(url, fetchImpl)).toEqual({ + ok: true, + suggestions: { tags: ['mammal'], rating: 'explicit' }, + imageCount: 2 + }); + }, 10_000); +}); + +describe('classifyMediaUrl', () => { + const url = 'https://pbs.twimg.com/media/abc'; + const done = { + status: 'done', + content_sha256: 'abc', + rating: 'questionable', + tags: [{ name: 'mammal', confidence: 0.99 }] + }; + const success = { + ok: true, + suggestions: { tags: ['mammal'], rating: 'questionable' }, + imageCount: 1 + }; + + it('refuses a host outside the allowlist without fetching, and says so', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetchImpl = vi.fn(async () => json(done)); + const refused = { ok: false, reason: 'unavailable' }; + const rejected = [ + 'https://example.com/a.jpg', + 'https://evil.pbs.twimg.com/a.jpg', + 'http://pbs.twimg.com/a.jpg', + 'nonsense' + ]; + for (const bad of rejected) expect(await classifyMediaUrl(bad, fetchImpl)).toEqual(refused); + expect(fetchImpl).not.toHaveBeenCalled(); + // One warning per refusal, naming the reason but never the URL: a media + // URL is third-party data. + expect(warn).toHaveBeenCalledTimes(rejected.length); + const logged = warn.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(logged).toContain('host not allowed'); + for (const bad of rejected) expect(logged).not.toContain(bad); + }); + + it('enqueues then polls until the job is done', async () => { + let polls = 0; + const mediaUrl = 'https://pbs.twimg.com/media/abc?format=jpg'; + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') { + expect(new Headers(init.headers).get('content-type')).toBe('application/json'); + expect(JSON.parse(String(init.body))).toEqual({ url: mediaUrl }); + return json({ job_id: 'job-1', status: 'enqueued' }, 202); + } + polls++; + expect(String(url)).toContain('/classify/job-1?wait=true'); + return polls === 1 ? json({ status: 'processing' }, 202) : json(done); + }); + expect(await classifyMediaUrl(mediaUrl, fetchImpl)).toEqual(success); + expect(polls).toBe(2); + }); + + it('encodes the job id into the poll path', async () => { + let polled = ''; + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: '../post' }, 202); + polled = String(url); + return json(done); + }); + await classifyMediaUrl(url, fetchImpl); + expect(polled.startsWith('https://entail.dev/api/classify/')).toBe(true); + expect(polled).toContain('%2F'); + }); + + it('accepts a 200 poll body that carries no status field', async () => { + const { status: _status, ...bare } = done; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' ? json({ job_id: 'job-8' }, 202) : json(bare) + ); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual(success); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('returns unavailable without fetching when the deadline has already passed', async () => { + const fetchImpl = vi.fn(async () => json(done)); + expect(await classifyMediaUrl(url, fetchImpl, AbortSignal.abort())).toEqual({ + ok: false, + reason: 'unavailable' + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('is unavailable when the caller\'s deadline passes during a fetch', async () => { + // The per-call timeout is joined with the caller's signal, so an abort + // from outside reaches the in-flight fetch through init.signal. + const controller = new AbortController(); + const fetchImpl = vi.fn( + (_url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }) + ); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const pending = classifyMediaUrl(url, fetchImpl, controller.signal); + controller.abort(new Error('caller deadline')); + expect(await pending).toEqual({ ok: false, reason: 'unavailable' }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + // The caller's reason, not the per-call TimeoutError, is what ended it. + expect(warn.mock.calls.map((c) => c.join(' ')).join('\n')).toContain('caller deadline'); + }); + + it('succeeds with a null rating when the poll body carries none', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' + ? json({ job_id: 'job-10' }, 202) + : json({ tags: [{ name: 'mammal', confidence: 0.99 }] }) + ); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual({ + ok: true, + suggestions: { tags: ['mammal'], rating: null }, + imageCount: 1 + }); + }); + + it('is unavailable on a 200 poll body that is not a classification entry', async () => { + // null, a string, or an error envelope must not read as "no tags found". + // An envelope whose `tags` holds strings is an unknown shape too, while + // an empty `tags` array is a real (empty) result. + const unavailable = { ok: false, reason: 'unavailable' }; + const emptyFetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' ? json({ job_id: 'job-9' }, 202) : json({ tags: [] }) + ); + expect(await classifyMediaUrl(url, emptyFetch)).toEqual({ + ok: true, + suggestions: { tags: [], rating: null }, + imageCount: 1 + }); + for (const body of [null, 'done', { detail: 'x' }, { tags: ['a'] }, { tags: [null] }]) { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' ? json({ job_id: 'job-9' }, 202) : json(body) + ); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual(unavailable); + } + }); + + it('accepts cdn.bsky.app too', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' ? json({ job_id: 'job-2' }, 202) : json(done) + ); + expect(await classifyMediaUrl('https://cdn.bsky.app/img/feed_fullsize/x.jpg', fetchImpl)).toEqual( + success + ); + }); + + it('reports a job still running at the poll cap as not_ready, not an outage', async () => { + // The same retry-later answer a queued Bluesky post gets: nothing broke, + // so the endpoint answers 202 rather than a 502 the site counts as an error. + let polls = 0; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: 'job-3' }, 202); + polls++; + return json({ status: 'processing' }, 202); + }); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual({ ok: false, reason: 'not_ready' }); + expect(polls).toBe(2); + }); + + it('loses that not_ready when the caller deadline cuts the second poll', async () => { + // The reason the endpoint's ceiling has to clear both polls and the pause + // between them: a job still running answers the first poll 202, and a + // deadline that fires during the second one aborts the fetch, so the + // operator gets a 502 instead of the 202 the poll cap is there to give. + const deadline = new AbortController(); + let polls = 0; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: 'job-4' }, 202); + polls++; + if (polls === 1) return json({ status: 'processing' }, 202); + deadline.abort(); + init?.signal?.throwIfAborted(); + return json(done); + }); + expect(await classifyMediaUrl(url, fetchImpl, deadline.signal)).toEqual({ + ok: false, + reason: 'unavailable' + }); + expect(polls).toBe(2); + }); + + it('keeps polling on a running status but gives up on a terminal one', async () => { + // `running` is pending, so it goes the full poll cap and answers + // not_ready. `failed` will never finish: the retry-later answer would only + // have the operator retry a dead job, so it is unavailable after one poll. + const pollsWith = (status: string) => { + let polls = 0; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: 'job-9' }, 202); + polls++; + return json({ status }); + }); + return { fetchImpl, polls: () => polls }; + }; + const running = pollsWith('running'); + expect(await classifyMediaUrl(url, running.fetchImpl)).toEqual({ ok: false, reason: 'not_ready' }); + expect(running.polls()).toBe(2); + + const failed = pollsWith('failed'); + expect(await classifyMediaUrl(url, failed.fetchImpl)).toEqual({ ok: false, reason: 'unavailable' }); + expect(failed.polls()).toBe(1); + + const shouting = pollsWith('ERROR'); + expect(await classifyMediaUrl(url, shouting.fetchImpl)).toEqual({ ok: false, reason: 'unavailable' }); + expect(shouting.polls()).toBe(1); + }); + + // The poll endpoint answers `wait=true` by holding the connection until the + // classifier finishes — about five seconds for a fresh job. A poll timeout + // shorter than that hold aborts the response we asked to wait for, which is + // what a 2000 ms timeout did in the first cut of this module. + it('waits out a poll that the server holds open for seconds', async () => { + const heldFor = 2500; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: 'job-5' }, 202); + await new Promise((resolve) => setTimeout(resolve, heldFor)); + init?.signal?.throwIfAborted(); + return json(done); + }); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual(success); + }, 10_000); + + it('is unavailable on a failed enqueue, a missing job id, and errors', async () => { + const unavailable = { ok: false, reason: 'unavailable' }; + expect( + await classifyMediaUrl(url, vi.fn(async () => new Response('boom', { status: 500 }))) + ).toEqual(unavailable); + expect(await classifyMediaUrl(url, vi.fn(async () => json({ status: 'enqueued' }, 202)))).toEqual( + unavailable + ); + // The spec documents `job_id` only; a bare `id` is not a job id. + expect(await classifyMediaUrl(url, vi.fn(async () => json({ id: 'job-7' }, 202)))).toEqual( + unavailable + ); + expect( + await classifyMediaUrl( + url, + vi.fn(async () => { + throw new Error('TimeoutError'); + }) + ) + ).toEqual(unavailable); + }); + + it('reports an input-rejection 4xx from the classify enqueue as not_found', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + for (const status of [404, 400]) { + expect(await classifyMediaUrl(url, vi.fn(async () => new Response('no', { status })))).toEqual({ + ok: false, + reason: 'not_found' + }); + expect(warn.mock.calls.map((c) => c.join(' ')).join('\n')).toContain(`status=${status}`); + } + expect(await classifyMediaUrl(url, vi.fn(async () => new Response('blocked', { status: 403 })))).toEqual({ + ok: false, + reason: 'unavailable' + }); + }); + + it('names a rate limit from either the enqueue or a poll', async () => { + expect( + await classifyMediaUrl(url, vi.fn(async () => new Response('slow down', { status: 429 }))) + ).toEqual({ ok: false, reason: 'rate_limited' }); + + const limitedPoll = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' + ? json({ job_id: 'job-6' }, 202) + : new Response('slow down', { status: 429 }) + ); + expect(await classifyMediaUrl(url, limitedPoll)).toEqual({ + ok: false, + reason: 'rate_limited' + }); + }); + + it('is unavailable when a poll fails outright', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' + ? json({ job_id: 'job-4' }, 202) + : new Response('gone', { status: 404 }) + ); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual({ ok: false, reason: 'unavailable' }); + }); +}); diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts new file mode 100644 index 00000000..f3ad075d --- /dev/null +++ b/src/lib/server/entail.ts @@ -0,0 +1,401 @@ +// entail.dev tag suggestions — a public, keyless classifier for furry artwork. +// Two entry points: a Bluesky post URL resolves through `/api/post` (the +// service has usually already classified it), and a raw media URL on an +// allowlisted CDN goes through the `/api/classify` enqueue-and-poll pair. +// +// Spec: https://entail.dev/api/openapi.json (docs at https://entail.dev/api/docs). +// Response shapes below were confirmed against the live API on 2026-09-07. +// Rate limiting is per client IP and there are no API keys, so a 429 is a +// normal outcome, not an error to surface. +// +// Everything here is fail-soft: any non-2xx, timeout, or unexpected shape +// resolves to a failed outcome and the caller carries on without suggestions. +// Third-party response bodies are never logged and never stored. + +import { errorLabel, timeoutSignal } from './fetch-errors'; +import { sanitizeTag } from './validate'; + +const ENTAIL_POST = 'https://entail.dev/api/post'; +const ENTAIL_CLASSIFY = 'https://entail.dev/api/classify'; + +/** The floor entail.dev's own docs recommend for Sona. */ +export const DEFAULT_CONFIDENCE_FLOOR = 0.8; + +/** Most tags a single lookup will suggest, counted after dedupe. The + * classifier can return hundreds; the UI shows a short list. */ +export const MAX_SUGGESTED_TAGS = 40; + +/** Most raw entries one classification is read for, taken from the top after + * a sort by confidence so the cap drops the least confident. */ +export const MAX_RAW_ENTRIES = 200; + +/** Most raw entries the confidence sort ever sees. A body is third-party + * input, so the array is cut to this length before it is copied and sorted; + * entail.dev returns at most a few hundred entries, anything beyond this is + * hostile, and the tail past the bound is dropped unsorted. */ +export const MAX_SORTED_ENTRIES = 2000; + +/** Bluesky allows four images per post; anything above that in a reply is a + * hostile or broken body, so the count shown to the operator is clamped. */ +export const MAX_POST_IMAGES = 4; + +/** Longest raw tag name translateTag looks at. e621 tags run well under this; + * the cut keeps the qualifier-stripping regex off a very long input. */ +const MAX_RAW_TAG_LENGTH = 200; + +// Both `wait=true` endpoints hold the connection open until the classifier +// finishes rather than answering 202 straight away. That hold was measured at +// roughly five seconds for a fresh job on 2026-09-07, so every timeout here +// has to clear it comfortably or we abort the very response we asked to wait +// for. A classify is one enqueue plus at most two polls, worst case about +// 3 + 8 + 0.25 + 8 seconds; the caller shows a pending state while it waits. +export const POST_TIMEOUT_MS = 8000; +export const CLASSIFY_TIMEOUT_MS = 3000; +export const POLL_TIMEOUT_MS = 8000; +export const POLL_PAUSE_MS = 250; +export const POLL_ATTEMPTS = 2; + +export type EntailRating = 'safe' | 'questionable' | 'explicit'; + +export type Suggestions = { + tags: string[]; + rating: EntailRating | null; +}; + +/** Why a lookup produced nothing. `not_ready` is the one worth retrying: the + * post is queued, or the job is still running past our poll cap. `not_found` + * is entail.dev refusing the input with one of DECLINED_STATUSES (an unknown + * post, or a URL it will not fetch): the operator's input, not an outage; auth + * and edge-block codes are `unavailable` instead. `rate_limited` + * is entail.dev's per-IP limit, which has no key to raise. Everything else — a + * timeout, a 5xx, an unexpected shape — is `unavailable`, an upstream failure. + * A post the classifier read and found nothing in is not a failure at all; it + * succeeds with an empty tag list. */ +export type LookupFailure = 'not_ready' | 'not_found' | 'rate_limited' | 'unavailable'; + +/** The 4xx codes that mean entail.dev will not take this input (a post it + * does not index, a URL it rejects). Auth, quota, and edge-block codes such as + * 401, 403, and 408 are not in the set: those mean the integration is broken, + * and they fall through to `unavailable` so an outage stays visible. */ +const DECLINED_STATUSES = new Set([400, 404, 410, 415, 422]); +const declined = (status: number) => DECLINED_STATUSES.has(status); +/** Poll-body statuses that mean the job will never finish. Any other non-done + * status (queued, running, processing) is still pending. */ +const TERMINAL_STATUSES = new Set(['failed', 'error', 'cancelled', 'canceled']); + +/** `imageCount` is how many images the source post carried. Suggestions come + * from the first one only, so a count above 1 tells the UI the rest went + * unread. classifyMediaUrl has no post to count, so it reports 1 and the + * endpoint substitutes the tweet's photo count on the X path. */ +export type LookupOutcome = + | { ok: true; suggestions: Suggestions; imageCount: number } + | { ok: false; reason: LookupFailure }; + +const fail = (reason: LookupFailure): LookupOutcome => ({ ok: false, reason }); + +/** One classification entry: an image inside a `/post` response, or the body + * of a finished `/classify/` poll. */ +export type ClassificationEntry = { + rating?: unknown; + tags?: unknown; +}; + +/** The `x` kind carries the status id so the tweet lookup never re-parses + * the URL. */ +export type SourceKind = { kind: 'bluesky'; url: string } | { kind: 'x'; url: string; id: string }; + +// Checked after percent-decoding, so a `%` that survives (a double-encoded +// actor) is rejected rather than decoded again downstream. +const BLUESKY_ACTOR = /^[A-Za-z0-9._:-]{1,256}$/; +const BLUESKY_RKEY = /^[A-Za-z0-9._~-]{1,64}$/; +const X_USER = /^[A-Za-z0-9_]{1,15}$/; +const STATUS_ID = /^\d{1,20}$/; + +/** + * Recognise a post URL we know how to get suggestions for, and return it in + * canonical form (no query string, no trailing slash, no `/photo/1` suffix). + * Anything else — including a bare media URL — returns null. Pure. + */ +export function classifySourceUrl(url: string): SourceKind | null { + let parsed: URL; + try { + parsed = new URL(url.trim()); + } catch { + return null; + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null; + + const host = parsed.hostname.toLowerCase().replace(/^www\./, ''); + const parts = parsed.pathname.split('/').filter(Boolean); + + if (host === 'bsky.app') { + // /profile//post/ + if (parts.length !== 4 || parts[0] !== 'profile' || parts[2] !== 'post') return null; + // Decode first, then validate the decoded actor: a malformed percent + // sequence throws, and an encoded slash would otherwise pass the regex + // and decode into a path separator in the canonical URL. + let actor: string; + try { + actor = decodeURIComponent(parts[1]); + } catch { + return null; + } + const rkey = parts[3]; + if (!BLUESKY_ACTOR.test(actor) || !BLUESKY_RKEY.test(rkey)) return null; + return { kind: 'bluesky', url: `https://bsky.app/profile/${actor}/post/${rkey}` }; + } + + if (host === 'x.com' || host === 'twitter.com' || host === 'mobile.x.com' || host === 'mobile.twitter.com') { + // //status/, /i/status/, /i/web/status/, any with a + // trailing /photo/N. The `/i/web/` permalink is the form X's own share + // sheet hands out, so it canonicalises to /i/status/ like the rest. + if (parts[0] === 'i' && parts[1] === 'web') parts.splice(1, 1); + if (parts.length < 3) return null; + const [user, keyword, id] = parts; + if (keyword !== 'status' && keyword !== 'statuses') return null; + if (!STATUS_ID.test(id)) return null; + // `i` (the /i/status form) is a valid user segment by this pattern too. + if (!X_USER.test(user)) return null; + return { kind: 'x', url: `https://x.com/${user}/status/${id}`, id }; + } + + return null; +} + +/** + * Translate one e621-vocabulary tag into a Sona tag. Drops the trailing + * qualifier e621 appends to disambiguate (`digital_media_(artwork)`), swaps + * underscores, slashes and colons (`male/female`, `2:1`) for hyphens, then + * runs the same sanitizer the tag inputs use. + * Emoticon tags (`<3`, `^_^`, `-_-`, `:3`) sanitize down to bare digits or + * hyphens, so the result also needs a letter to count. Returns null when + * nothing usable is left. Pure. + */ +export function translateTag(tag: string): string | null { + const translated = tag + .slice(0, MAX_RAW_TAG_LENGTH) + .trim() + .toLowerCase() + .replace(/[\s_]*\([^()]*\)\s*$/, '') + .replace(/[_/:]/g, '-'); + const sanitized = sanitizeTag(translated) + .replace(/-{2,}/g, '-') + .replace(/^-|-$/g, ''); + return /[a-z]/.test(sanitized) ? sanitized : null; +} + +function normalizeRating(rating: unknown): EntailRating | null { + return rating === 'safe' || rating === 'questionable' || rating === 'explicit' ? rating : null; +} + +/** Sort key for a raw tag entry: its confidence, or -Infinity when it has + * none, so junk entries sort last instead of unsettling the order. */ +function confidenceOf(entry: unknown): number { + const confidence = (entry as { confidence?: unknown } | null)?.confidence; + return typeof confidence === 'number' && !Number.isNaN(confidence) ? confidence : -Infinity; +} + +/** + * Turn one classification entry into Sona tag suggestions: keep the tags at or + * above the confidence floor, translate them, and drop duplicates, in + * confidence order (a stable sort, so ties keep the API's order), capped at + * {@link MAX_SUGGESTED_TAGS}. Sorts at most the first {@link MAX_SORTED_ENTRIES} + * entries and reads the {@link MAX_RAW_ENTRIES} most confident of those. Pure. + */ +export function suggestionsFromResult(result: ClassificationEntry | null | undefined): Suggestions { + const rating = normalizeRating(result?.rating); + const raw = Array.isArray(result?.tags) ? result.tags : []; + const seen = new Set(); + const tags: string[] = []; + const ordered = raw.slice(0, MAX_SORTED_ENTRIES).sort((a, b) => { + const ca = confidenceOf(a); + const cb = confidenceOf(b); + return ca === cb ? 0 : cb > ca ? 1 : -1; + }); + for (const entry of ordered.slice(0, MAX_RAW_ENTRIES)) { + const { name, confidence } = (entry ?? {}) as { name?: unknown; confidence?: unknown }; + if (typeof name !== 'string') continue; + if (typeof confidence !== 'number' || !(confidence >= DEFAULT_CONFIDENCE_FLOOR)) continue; + const tag = translateTag(name); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + tags.push(tag); + if (tags.length >= MAX_SUGGESTED_TAGS) break; + } + return { tags, rating }; +} + +/** `/post` answers with `{ uri, images: [...] }`. A body without an `images` + * array is a shape we don't know, so it resolves to null rather than to an + * empty list that would read as "nothing to suggest". */ +function postImages(body: unknown): ClassificationEntry[] | null { + const images = (body as { images?: unknown })?.images; + return Array.isArray(images) ? (images as ClassificationEntry[]) : null; +} + +/** + * Suggestions for a Bluesky post classifySourceUrl has already validated. + * Uses the post's first classified image; a post entail.dev has queued but + * not classified yet answers 202, which comes back as the `not_ready` failure + * for the caller to retry later. Never throws. + */ +export async function lookupBlueskySource( + source: Extract, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal +): Promise { + const endpoint = `${ENTAIL_POST}?url=${encodeURIComponent(source.url)}&min_confidence=${DEFAULT_CONFIDENCE_FLOOR}&wait=true`; + try { + signal?.throwIfAborted(); + const res = await fetchImpl(endpoint, { signal: timeoutSignal(POST_TIMEOUT_MS, signal) }); + if (res.status === 202) { + // Queued for classification. Best effort: no retry loop here — the + // caller decides whether to ask again. + console.warn('[entail] post not classified yet: status=202'); + return fail('not_ready'); + } + if (res.status === 429) { + console.warn('[entail] post lookup rate limited: status=429'); + return fail('rate_limited'); + } + if (declined(res.status)) { + console.warn(`[entail] post lookup declined: status=${res.status}`); + return fail('not_found'); + } + if (!res.ok) { + console.warn(`[entail] post lookup failed: status=${res.status}`); + return fail('unavailable'); + } + // An empty `images` array means entail.dev looked and found no furry + // artwork in the post. That is an answer, not a failure: the caller gets + // an empty tag list rather than an error it would have to explain. A body + // with no `images` array at all is neither; it is unavailable. + const images = postImages(await res.json()); + if (!images) { + console.warn('[entail] post lookup returned an unexpected shape'); + return fail('unavailable'); + } + return { + ok: true, + suggestions: suggestionsFromResult(images[0]), + imageCount: Math.min(images.length, MAX_POST_IMAGES) + }; + } catch (e) { + console.warn(`[entail] post lookup error: ${errorLabel(e)}`); + return fail('unavailable'); + } +} + +function jobIdFrom(body: unknown): string | null { + const { job_id: jobId } = (body ?? {}) as { job_id?: unknown }; + return typeof jobId === 'string' && jobId ? jobId : null; +} + +/** entail.dev fetches the URL itself, so only the two CDNs it allowlists are + * worth sending — anything else is refused there and never leaves here. */ +function isAllowedMediaHost(url: string): boolean { + try { + const { protocol, hostname } = new URL(url); + if (protocol !== 'https:') return false; + const host = hostname.toLowerCase(); + return host === 'pbs.twimg.com' || host === 'cdn.bsky.app'; + } catch { + return false; + } +} + +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** A finished `/classify/` body is an object with a `tags` array, the + * same way a `/post` body has an `images` array. The array's first entry, when + * there is one, has to be an object too: an error envelope that happens to + * carry `tags: ['...']` would otherwise read as an empty success. */ +function pollEntry(body: unknown): body is ClassificationEntry { + if (typeof body !== 'object' || body === null) return false; + const tags = (body as ClassificationEntry).tags; + if (!Array.isArray(tags)) return false; + return tags.length === 0 || (typeof tags[0] === 'object' && tags[0] !== null); +} + +/** + * Suggestions for a single image URL on an allowlisted CDN: enqueue a + * classification job, then poll it a few times. Gives up (`not_ready`) if + * the job isn't done by the attempt cap. Never throws. + */ +export async function classifyMediaUrl( + url: string, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal +): Promise { + if (!isAllowedMediaHost(url)) { + // The host, not the URL: a media URL is third-party data. + console.warn('[entail] media url host not allowed'); + return fail('unavailable'); + } + + try { + signal?.throwIfAborted(); + const enqueued = await fetchImpl(ENTAIL_CLASSIFY, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + signal: timeoutSignal(CLASSIFY_TIMEOUT_MS, signal) + }); + if (enqueued.status === 429) { + console.warn('[entail] classify enqueue rate limited: status=429'); + return fail('rate_limited'); + } + if (declined(enqueued.status)) { + console.warn(`[entail] classify enqueue declined: status=${enqueued.status}`); + return fail('not_found'); + } + if (!enqueued.ok && enqueued.status !== 202) { + console.warn(`[entail] classify enqueue failed: status=${enqueued.status}`); + return fail('unavailable'); + } + const jobId = jobIdFrom(await enqueued.json()); + if (!jobId) { + console.warn('[entail] classify enqueue returned no job id'); + return fail('unavailable'); + } + + const poll = `${ENTAIL_CLASSIFY}/${encodeURIComponent(jobId)}?wait=true`; + for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { + if (attempt > 0 && !signal?.aborted) await pause(POLL_PAUSE_MS); + const res = await fetchImpl(poll, { signal: timeoutSignal(POLL_TIMEOUT_MS, signal) }); + if (res.status === 202) continue; + if (res.status === 429) { + console.warn('[entail] classify poll rate limited: status=429'); + return fail('rate_limited'); + } + if (!res.ok) { + console.warn(`[entail] classify poll failed: status=${res.status}`); + return fail('unavailable'); + } + // The spec documents a 200 as "job done" and does not type a status + // field, so only an explicit contradiction sends us back to poll. + const body = (await res.json()) as (ClassificationEntry & { status?: unknown }) | null; + if (typeof body?.status === 'string' && TERMINAL_STATUSES.has(body.status.toLowerCase())) { + // A job that will never finish: polling on would only have the + // operator retry it. The status word is the API's, not user input. + console.warn(`[entail] classify job failed: status=${body.status}`); + return fail('unavailable'); + } + if (body?.status && body.status !== 'done') continue; + // A finished job carries a `tags` array. Anything else (null, a string, + // an error envelope) is a shape we don't know, not an empty result. + if (!pollEntry(body)) { + console.warn('[entail] classify poll returned an unexpected shape'); + return fail('unavailable'); + } + return { ok: true, suggestions: suggestionsFromResult(body), imageCount: 1 }; + } + // Still running, not broken: the same retry-later answer a queued + // Bluesky post gets. + console.warn(`[entail] classify job not ready after ${POLL_ATTEMPTS} polls`); + return fail('not_ready'); + } catch (e) { + console.warn(`[entail] classify error: ${errorLabel(e)}`); + return fail('unavailable'); + } +} diff --git a/src/lib/server/fetch-errors.test.ts b/src/lib/server/fetch-errors.test.ts new file mode 100644 index 00000000..f483d022 --- /dev/null +++ b/src/lib/server/fetch-errors.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { errorLabel, timeoutSignal } from './fetch-errors'; + +describe('errorLabel', () => { + it('reduces a parse failure to its name and keeps everything else readable', () => { + // Every fail-soft catch in entail.ts and twitter-media.ts logs through + // this. A SyntaxError's message quotes the body that failed to parse. + let parseError: unknown; + try { + JSON.parse('secret-body'); + } catch (e) { + parseError = e; + } + expect(errorLabel(parseError)).toBe('SyntaxError'); + expect(errorLabel(new Error('TimeoutError'))).toBe('TimeoutError'); + expect(errorLabel('plain string')).toBe('plain string'); + expect(errorLabel(42)).toBe('42'); + }); +}); + +describe('timeoutSignal', () => { + it('aborts when the caller\'s signal aborts, well before the timeout', () => { + const controller = new AbortController(); + const signal = timeoutSignal(60_000, controller.signal); + expect(signal.aborted).toBe(false); + controller.abort(); + expect(signal.aborted).toBe(true); + }); + + it('aborts from the timeout side while the caller\'s signal is still live', async () => { + const signal = timeoutSignal(5, new AbortController().signal); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(signal.aborted).toBe(true); + expect((signal.reason as Error).name).toBe('TimeoutError'); + }); +}); diff --git a/src/lib/server/fetch-errors.ts b/src/lib/server/fetch-errors.ts new file mode 100644 index 00000000..303600be --- /dev/null +++ b/src/lib/server/fetch-errors.ts @@ -0,0 +1,16 @@ +/** What a caught error is safe to log. A JSON parse failure's message quotes + * a fragment of the body, and third-party bodies are never logged, so a + * SyntaxError is reduced to its name. Shared by the fail-soft fetch clients + * (entail.ts, twitter-media.ts). */ +export function errorLabel(e: unknown): string { + if (e instanceof SyntaxError) return e.name; + return e instanceof Error ? e.message : String(e); +} + +/** A per-call timeout, joined with the caller's overall deadline when it has + * one, so a chain of fail-soft fetches cannot outlive the request that asked + * for them. */ +export function timeoutSignal(ms: number, signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(ms); + return signal ? AbortSignal.any([timeout, signal]) : timeout; +} diff --git a/src/lib/server/twitter-avatar.test.ts b/src/lib/server/twitter-avatar.test.ts index 35543aec..593aca01 100644 --- a/src/lib/server/twitter-avatar.test.ts +++ b/src/lib/server/twitter-avatar.test.ts @@ -3,7 +3,8 @@ import { twitterHandleFromUrl, parseUserAvatar, to400x400, - fetchTwitterAvatar + fetchTwitterAvatar, + xGraphqlHeaders } from './twitter-avatar'; describe('twitterHandleFromUrl', () => { @@ -52,6 +53,18 @@ describe('to400x400', () => { }); }); +describe('xGraphqlHeaders', () => { + it('mirrors a fresh csrf value into the cookie next to the guest id', () => { + const first = xGraphqlHeaders('gt-1'); + const csrf = first['x-csrf-token']; + expect(csrf).toMatch(/^[0-9a-f]{32}$/); + expect(first.Cookie).toContain(`ct0=${csrf}`); + expect(first.Cookie).toContain('guest_id=v1%3Agt-1'); + expect(first['x-guest-token']).toBe('gt-1'); + expect(xGraphqlHeaders('gt-1')['x-csrf-token']).not.toBe(csrf); + }); +}); + describe('fetchTwitterAvatar', () => { afterEach(() => { vi.unstubAllGlobals(); @@ -66,18 +79,17 @@ describe('fetchTwitterAvatar', () => { }; it('activates a guest token and resolves the 400x400 avatar', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async (url: string | URL) => { - if (String(url).includes('guest/activate')) { - return new Response(JSON.stringify({ guest_token: 'gt' }), { status: 200 }); - } - return new Response(JSON.stringify(userBody), { status: 200 }); - }) - ); + const fetchImpl = vi.fn(async (url: string | URL, _init?: RequestInit) => { + if (String(url).includes('guest/activate')) { + return new Response(JSON.stringify({ guest_token: 'gt' }), { status: 200 }); + } + return new Response(JSON.stringify(userBody), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchImpl); expect(await fetchTwitterAvatar('https://x.com/examplefox')).toBe( 'https://pbs.twimg.com/profile_images/9/pic_400x400.jpg' ); + expect(fetchImpl).toHaveBeenCalledTimes(2); }); it('retries once with a fresh token on 429, then succeeds', async () => { diff --git a/src/lib/server/twitter-avatar.ts b/src/lib/server/twitter-avatar.ts index b7723579..2b85eabd 100644 --- a/src/lib/server/twitter-avatar.ts +++ b/src/lib/server/twitter-avatar.ts @@ -10,12 +10,14 @@ // and fail-soft: any error resolves to null and the save proceeds without an // avatar. Registry-linked artists get theirs through the registry instead. +import { errorLabel, timeoutSignal } from './fetch-errors'; + // X web client's public bearer (shipped to every browser) — not a secret. const X_BEARER = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; const X_ACTIVATE = 'https://api.x.com/1.1/guest/activate.json'; const X_USER_BY_SCREEN_NAME = 'https://api.x.com/graphql/IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName'; -const FETCH_TIMEOUT_MS = 5000; +export const FETCH_TIMEOUT_MS = 5000; /** Extract a bare handle from the stored twitter URL formats * ("https://x.com/@ExampleFox/", "twitter.com/examplefox", "@examplefox"). */ @@ -47,12 +49,18 @@ export function to400x400(url: string): string { return url.replace(/_normal(\.[a-z]+)$/i, '_400x400$1'); } -async function activateGuestToken(): Promise { +/** Activate a guest token against the public web bearer. Shared with + * twitter-media.ts; `fetchImpl` is only for tests, `signal` is the caller's + * overall deadline. Never throws. */ +export async function activateGuestToken( + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal +): Promise { try { - const res = await fetch(X_ACTIVATE, { + const res = await fetchImpl(X_ACTIVATE, { method: 'POST', headers: { Authorization: X_BEARER }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + signal: timeoutSignal(FETCH_TIMEOUT_MS, signal) }); if (!res.ok) { // A non-2xx here (esp. 403) is the signal that X is blocking Workers egress — @@ -63,24 +71,31 @@ async function activateGuestToken(): Promise { const body = (await res.json()) as { guest_token?: unknown }; return typeof body.guest_token === 'string' ? body.guest_token : null; } catch (e) { - console.warn(`[avatar] twitter guest-token activation error: ${e instanceof Error ? e.message : String(e)}`); + console.warn(`[avatar] twitter guest-token activation error: ${errorLabel(e)}`); return null; } } -async function userLookup(handle: string, guestToken: string): Promise { +/** The header set every X GraphQL call carries: the bearer, the guest token, + * and a fresh random csrf value mirrored into the cookie the way the web + * client does it. Shared with twitter-media.ts. */ +export function xGraphqlHeaders(guestToken: string): Record { const csrf = [...crypto.getRandomValues(new Uint8Array(16))] .map((b) => b.toString(16).padStart(2, '0')) .join(''); + return { + Authorization: X_BEARER, + 'x-guest-token': guestToken, + 'x-csrf-token': csrf, + 'x-twitter-active-user': 'yes', + Cookie: `guest_id=v1%3A${guestToken}; ct0=${csrf};` + }; +} + +async function userLookup(handle: string, guestToken: string): Promise { const variables = encodeURIComponent(JSON.stringify({ screen_name: handle })); return fetch(`${X_USER_BY_SCREEN_NAME}?variables=${variables}`, { - headers: { - Authorization: X_BEARER, - 'x-guest-token': guestToken, - 'x-csrf-token': csrf, - 'x-twitter-active-user': 'yes', - Cookie: `guest_id=v1%3A${guestToken}; ct0=${csrf};` - }, + headers: xGraphqlHeaders(guestToken), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); } @@ -112,7 +127,7 @@ export async function fetchTwitterAvatar(twitterUrl: string): Promise { + vi.restoreAllMocks(); +}); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** Shaped like the real TweetResultByRestId response, trimmed to what we read. */ +const tweetWith = (media: unknown[]) => ({ + data: { + tweetResult: { + result: { + __typename: 'Tweet', + legacy: { extended_entities: { media } } + } + } + } +}); + +const photo = { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/AbCdEf123.jpg' }; +/** What parseTweetPhotos reports for a tweet that resolved without a photo. */ +const noPhoto = { url: null, photoCount: 0 }; + +describe('parseTweetPhotos', () => { + it('upgrades the first photo to the largest variant', () => { + expect(parseTweetPhotos(tweetWith([photo]))).toEqual({ + url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096', + photoCount: 1 + }); + }); + + it('counts every photo but resolves only the first', () => { + const second = { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/Second.png' }; + const third = { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/Third.png' }; + expect(parseTweetPhotos(tweetWith([photo, second, third]))).toEqual({ + url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096', + photoCount: 3 + }); + }); + + it('clamps the photo count to the four X allows', () => { + // X attaches at most four photos; a body claiming more is not trusted. + const six = Array.from({ length: 6 }, (_, i) => ({ + type: 'photo', + media_url_https: `https://pbs.twimg.com/media/P${i}.jpg` + })); + expect(parseTweetPhotos(tweetWith(six))?.photoCount).toBe(4); + expect(parseTweetPhotos(tweetWith(six.slice(0, 4)))?.photoCount).toBe(4); + }); + + it('passes a media URL with no extension through untouched', () => { + expect( + parseTweetPhotos(tweetWith([{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/NoExt' }])) + ?.url + ).toBe('https://pbs.twimg.com/media/NoExt'); + }); + + it('skips video and animated gif entries', () => { + expect(parseTweetPhotos(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }]))).toEqual( + noPhoto + ); + expect( + parseTweetPhotos(tweetWith([{ type: 'animated_gif', media_url_https: 'https://pbs.twimg.com/y.jpg' }])) + ).toEqual(noPhoto); + // A video alongside a photo is not counted as a photo, and the photo, not + // the video's poster, is what resolves. + expect( + parseTweetPhotos(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }, photo])) + ).toEqual({ url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096', photoCount: 1 }); + }); + + it('reads a tweet nested behind a visibility result', () => { + expect( + parseTweetPhotos({ + data: { + tweetResult: { + result: { + __typename: 'TweetWithVisibilityResults', + tweet: { legacy: { extended_entities: { media: [photo] } } } + } + } + } + })?.url + ).toContain('AbCdEf123'); + }); + + it('ignores entities.media, which cannot tell a video poster from a photo', () => { + // Only extended_entities carries the real media type; entities.media + // types a video's poster frame as a photo, so it is never read, and a + // tweet with nothing else is a tweet with no photo. + expect( + parseTweetPhotos({ + data: { tweetResult: { result: { legacy: { entities: { media: [photo] } } } } } + }) + ).toEqual(noPhoto); + }); + + it('reports no photo on a text-only tweet', () => { + expect(parseTweetPhotos(tweetWith([]))).toEqual(noPhoto); + expect(parseTweetPhotos({ data: { tweetResult: { result: { legacy: {} } } } })).toEqual(noPhoto); + }); + + it('returns null on a tombstone and junk', () => { + expect(parseTweetPhotos({ data: { tweetResult: { result: { __typename: 'TweetTombstone' } } } })).toBeNull(); + expect(parseTweetPhotos({ data: { tweetResult: {} } })).toBeNull(); + expect(parseTweetPhotos(null)).toBeNull(); + }); +}); + +describe('fetchTweetMediaUrl', () => { + const id = '1234567890'; + const unavailable = { ok: false, reason: 'unavailable' }; + + const stub = (lookup: (n: number) => Response) => { + let lookups = 0; + const activations = { count: 0 }; + const fetchImpl = vi.fn(async (target: string | URL | Request, init?: RequestInit) => { + if (String(target).includes('guest/activate')) { + activations.count++; + return json({ guest_token: `gt-${activations.count}` }); + } + tokens.push(String(new Headers(init?.headers).get('x-guest-token'))); + return lookup(++lookups); + }); + const tokens: string[] = []; + return { fetchImpl, activations, tokens }; + }; + + it('activates a guest token and resolves the first photo', async () => { + const { fetchImpl, tokens } = stub(() => json(tweetWith([photo]))); + expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual({ + ok: true, + url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096', + photoCount: 1 + }); + expect(tokens).toEqual(['gt-1']); + const lookup = String(fetchImpl.mock.calls.find(([t]) => !String(t).includes('guest/activate'))?.[0]); + expect(lookup).toContain(encodeURIComponent(`"tweetId":"${id}"`)); + }); + + it('retries once with a fresh token on 401', async () => { + const { fetchImpl, activations, tokens } = stub((n) => + n === 1 ? new Response('nope', { status: 401 }) : json(tweetWith([photo])) + ); + const outcome = await fetchTweetMediaUrl(id, fetchImpl); + expect(outcome.ok && outcome.url).toContain('AbCdEf123'); + expect(activations.count).toBe(2); + expect(tokens).toEqual(['gt-1', 'gt-2']); + }); + + it('retries once with a fresh token on 429', async () => { + const { fetchImpl, activations, tokens } = stub((n) => + n === 1 ? new Response('slow down', { status: 429 }) : json(tweetWith([photo])) + ); + const outcome = await fetchTweetMediaUrl(id, fetchImpl); + expect(outcome.ok && outcome.url).toContain('AbCdEf123'); + expect(activations.count).toBe(2); + expect(tokens).toEqual(['gt-1', 'gt-2']); + }); + + it('returns unavailable without fetching when the deadline has already passed', async () => { + const { fetchImpl } = stub(() => json(tweetWith([photo]))); + expect(await fetchTweetMediaUrl(id, fetchImpl, AbortSignal.abort())).toEqual(unavailable); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('reports a rate limit that survives the retry', async () => { + const { fetchImpl, activations } = stub(() => new Response('slow down', { status: 429 })); + expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual({ ok: false, reason: 'rate_limited' }); + expect(activations.count).toBe(2); + }); + + it('stays rate_limited when the retry cannot even get a fresh token after a 429', async () => { + let activations = 0; + const fetchImpl = vi.fn(async (target: string | URL | Request) => { + if (String(target).includes('guest/activate')) { + activations++; + return activations === 1 ? json({ guest_token: 'gt-1' }) : new Response('slow down', { status: 429 }); + } + return new Response('slow down', { status: 429 }); + }); + expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual({ ok: false, reason: 'rate_limited' }); + expect(activations).toBe(2); + }); + + it('is unavailable after a 401 that survives the retry', async () => { + const { fetchImpl } = stub(() => new Response('nope', { status: 401 })); + expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual(unavailable); + }); + + it('resolves a photoless tweet as ok with no URL, not as an outage', async () => { + // A text, video or GIF tweet is a real answer; the endpoint turns it into + // "no tags" rather than an unavailable classifier. + expect(await fetchTweetMediaUrl(id, stub(() => json(tweetWith([]))).fetchImpl)).toEqual({ + ok: true, + url: null, + photoCount: 0 + }); + }); + + it('reports a tweet the guest token cannot read as not_found, not an outage', async () => { + // Deleted, protected, or a tombstone: X answers 200 with no tweet in it. + // That is the operator's input, so the endpoint answers 404, not 502. + const notFound = { ok: false, reason: 'not_found' }; + expect( + await fetchTweetMediaUrl( + id, + stub(() => json({ data: { tweetResult: { result: { __typename: 'TweetTombstone' } } } })).fetchImpl + ) + ).toEqual(notFound); + expect(await fetchTweetMediaUrl(id, stub(() => json({ data: { tweetResult: {} } })).fetchImpl)).toEqual( + notFound + ); + }); + + it('reports a 200 that carries an errors array as unavailable, not not_found', async () => { + // A rotated query id or features map: X answers 200 with `errors` and no + // tweet. That is the integration, not the operator's post, so 502 not 404. + const errors = [{ message: 'Query not found', code: 42 }]; + expect(await fetchTweetMediaUrl(id, stub(() => json({ errors })).fetchImpl)).toEqual(unavailable); + expect( + await fetchTweetMediaUrl(id, stub(() => json({ errors, data: { tweetResult: {} } })).fetchImpl) + ).toEqual(unavailable); + // An empty errors array carries no error: still the not_found path. + expect( + await fetchTweetMediaUrl(id, stub(() => json({ errors: [], data: { tweetResult: {} } })).fetchImpl) + ).toEqual({ ok: false, reason: 'not_found' }); + }); + + it('fails soft on refusal, a server error, malformed JSON, and network errors', async () => { + expect(await fetchTweetMediaUrl(id, stub(() => new Response('no', { status: 403 })).fetchImpl)).toEqual( + unavailable + ); + expect(await fetchTweetMediaUrl(id, stub(() => new Response('boom', { status: 500 })).fetchImpl)).toEqual( + unavailable + ); + expect(await fetchTweetMediaUrl(id, stub(() => new Response('')).fetchImpl)).toEqual(unavailable); + expect( + await fetchTweetMediaUrl( + id, + vi.fn(async () => { + throw new Error('TimeoutError'); + }) + ) + ).toEqual(unavailable); + }); + + it('is unavailable when the guest token cannot be activated', async () => { + const fetchImpl = vi.fn(async () => new Response('blocked', { status: 403 })); + expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual(unavailable); + }); +}); diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts new file mode 100644 index 00000000..02096114 --- /dev/null +++ b/src/lib/server/twitter-media.ts @@ -0,0 +1,212 @@ +// Resolve the first photo attached to a public tweet, using the same guest-token +// flow as twitter-avatar.ts. The query id and the `features` map below are +// UNDOCUMENTED and rotate — both were lifted from FxEmbed's source +// (packages/atmosphere/src/providers/twitter/graphql/{queries,features}.ts, +// `TweetResultByRestIdQuery` plus its `rwebTweetFeatureKeys`) and verified +// against a real public tweet on 2026-09-07 (activate 200, GraphQL 200, photo +// present). If resolution goes uniformly null, refresh them from FxEmbed. +// +// Fail-soft throughout: any error resolves to a failed outcome and the caller +// proceeds without a media URL. Videos and GIFs are skipped — only photos +// resolve, and only from `extended_entities.media`: the `entities.media` +// fallback is not read, because it cannot tell a video's poster frame from a +// photo. A tweet that resolves but carries no photo is a success with no +// URL, not a failure. + +import { errorLabel, timeoutSignal } from './fetch-errors'; +import { activateGuestToken, xGraphqlHeaders } from './twitter-avatar'; + +const X_TWEET_BY_REST_ID = 'https://api.x.com/graphql/f2sagi1jweVHFkTUIHzmMQ/TweetResultByRestId'; +export const FETCH_TIMEOUT_MS = 5000; +/** X attaches at most four photos to a post; a count past that is a response + * we do not trust, so it is clamped rather than reported. */ +const MAX_TWEET_PHOTOS = 4; + +const QUERY_FEATURES = { + rweb_video_screen_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + responsive_web_profile_redirect_enabled: false, + rweb_tipjar_consumption_enabled: false, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + responsive_web_grok_annotations_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + content_disclosure_indicator_enabled: true, + content_disclosure_ai_generated_indicator_enabled: true, + responsive_web_grok_show_grok_translated_post: true, + responsive_web_grok_analysis_button_from_backend: true, + post_ctas_fetch_enabled: true, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_imagine_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: true, + responsive_web_enhance_cards_enabled: false, + tweet_awards_web_tipping_enabled: false +} as const; + +const QUERY_FIELD_TOGGLES = { + withArticleRichContentState: true, + withArticlePlainText: false, + withGrokAnalyze: false, + withDisallowedReplyControls: false +} as const; + +/** `rate_limited` is X refusing the guest token twice over with a 429; + * `not_found` is a 200 with no tweet in it (deleted, protected, or a + * tombstone: nothing a guest token can read); a tweet that resolved with no + * photo is ok with a null URL; everything else (a non-2xx, a body that does + * not parse, a timeout) is `unavailable`. */ +export type TweetMediaOutcome = + | { ok: true; url: string; photoCount: number } + | { ok: true; url: null; photoCount: 0 } + | { ok: false; reason: TweetMediaFailure }; + +export type TweetMediaFailure = 'not_found' | 'rate_limited' | 'unavailable'; + +const fail = (reason: TweetMediaFailure): TweetMediaOutcome => ({ ok: false, reason }); + +type TweetMedia = { type?: unknown; media_url_https?: unknown }; + +/** The first photo on a tweet, upgraded to its largest variant, and how many + * photos the tweet carried in all; or no URL and a count of zero for a tweet + * that resolved without a photo. */ +export type TweetPhotos = { url: string; photoCount: number } | { url: null; photoCount: 0 }; + +/** + * Extract the photos from a TweetResultByRestId response: the first one's + * URL, asking pbs.twimg.com for its largest variant, plus the photo count. + * A tweet with no photo (text-, video- and GIF-only tweets included) has a + * null URL. Returns null when there is no tweet to read: a tombstone, junk, + * or a rotated GraphQL shape. Pure, so it's testable. + */ +export function parseTweetPhotos(body: unknown): TweetPhotos | null { + const result = (body as { data?: { tweetResult?: { result?: Record } } })?.data + ?.tweetResult?.result; + if (!result) return null; + // A tweet behind a visibility interstitial nests the real tweet one level down. + const tweet = (result.tweet as Record | undefined) ?? result; + const legacy = tweet.legacy as { extended_entities?: { media?: unknown } } | undefined; + // `legacy` is what marks a resolved tweet; a tombstone has none. + if (!legacy || typeof legacy !== 'object') return null; + // Only `extended_entities` is read. `entities.media` is truncated to one + // item and types a video's poster frame as a photo, so it can neither + // count nor tell a photo from a video thumbnail. + const media = legacy.extended_entities?.media; + if (!Array.isArray(media)) return { url: null, photoCount: 0 }; + + let first: string | null = null; + let photoCount = 0; + for (const entry of media as TweetMedia[]) { + if (entry?.type !== 'photo') continue; + const url = entry.media_url_https; + if (typeof url !== 'string' || !url) continue; + photoCount++; + if (first) continue; + const match = url.match(/^(.*)\.([a-z]+)$/i); + first = match ? `${match[1]}?format=${match[2].toLowerCase()}&name=4096x4096` : url; + } + return first ? { url: first, photoCount: Math.min(photoCount, MAX_TWEET_PHOTOS) } : { url: null, photoCount: 0 }; +} + +function tweetLookup( + tweetId: string, + guestToken: string, + fetchImpl: typeof fetch, + signal?: AbortSignal +): Promise { + const variables = encodeURIComponent( + JSON.stringify({ + tweetId, + withCommunity: false, + includePromotedContent: false, + withVoice: false + }) + ); + const features = encodeURIComponent(JSON.stringify(QUERY_FEATURES)); + const fieldToggles = encodeURIComponent(JSON.stringify(QUERY_FIELD_TOGGLES)); + return fetchImpl( + `${X_TWEET_BY_REST_ID}?variables=${variables}&features=${features}&fieldToggles=${fieldToggles}`, + { + headers: xGraphqlHeaders(guestToken), + signal: timeoutSignal(FETCH_TIMEOUT_MS, signal) + } + ); +} + +/** Resolve the first photo on a public tweet to a pbs.twimg.com URL, and + * count the tweet's photos, given the numeric status id classifySourceUrl + * already validated. One guest token, one + * fresh-token retry if X refuses it (401/429), then a failed outcome. Never + * throws. */ +export async function fetchTweetMediaUrl( + tweetId: string, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal +): Promise { + try { + signal?.throwIfAborted(); + let token = await activateGuestToken(fetchImpl, signal); + if (!token) return fail('unavailable'); + let res = await tweetLookup(tweetId, token, fetchImpl, signal); + if (res.status === 401 || res.status === 429) { + // If X was already rate limiting us and now refuses a fresh token + // too, that is still a rate limit, not an outage. + const limited = res.status === 429; + token = await activateGuestToken(fetchImpl, signal); + if (!token) return fail(limited ? 'rate_limited' : 'unavailable'); + res = await tweetLookup(tweetId, token, fetchImpl, signal); + } + if (res.status === 429) { + console.warn('[tweet-media] tweet media lookup rate limited: status=429'); + return fail('rate_limited'); + } + if (!res.ok) { + console.warn(`[tweet-media] tweet media lookup failed: status=${res.status}`); + return fail('unavailable'); + } + const body: unknown = await res.json(); + const errors = (body as { errors?: unknown } | null)?.errors; + if (Array.isArray(errors) && errors.length > 0) { + // X answers 200 with an `errors` array when the undocumented query id + // or features map rotates. That is our integration, not the tweet, so + // it must not read as "post not found". Count only: the messages are + // X's, and can carry the request back at us. + console.warn(`[tweet-media] tweet lookup returned errors: count=${errors.length}`); + return fail('unavailable'); + } + const photos = parseTweetPhotos(body); + if (!photos) { + // 200 but no tweet: a protected or deleted one, which is the operator's + // input and not an outage. If the undocumented GraphQL shape rotated + // (see the file header) this fires for every tweet, so the log stays. + console.warn('[tweet-media] tweet media lookup had no tweet'); + return fail('not_found'); + } + if (photos.url === null) { + // A resolved text, video or GIF tweet: nothing to classify, not an outage. + return { ok: true, url: null, photoCount: 0 }; + } + return { ok: true, url: photos.url, photoCount: photos.photoCount }; + } catch (e) { + console.warn(`[tweet-media] tweet media lookup error: ${errorLabel(e)}`); + return fail('unavailable'); + } +} diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts new file mode 100644 index 00000000..f62feff2 --- /dev/null +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -0,0 +1,195 @@ +import { json } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; +import { getDb } from '$lib/server/db'; +import { images } from '$lib/server/db/schema'; +import { + classifySourceUrl, + classifyMediaUrl, + lookupBlueskySource, + type LookupFailure, + type LookupOutcome +} from '$lib/server/entail'; +import { fetchTweetMediaUrl, type TweetMediaFailure } from '$lib/server/twitter-media'; +import type { RequestHandler } from './$types'; + +// POST /api/admin/tag-suggestions (admin-only via hooks — everything under +// /api except /api/cron/ requires the admin session). +// +// Suggests tags for an image from its source post, by asking entail.dev's +// public classifier what is in the picture (SONA-220). Two request shapes: +// +// Caller contract: a 202 is `not_ready` and carries `{ error }` with no tags, +// even though `res.ok` is true. Branch on `res.status === 202` (retry later) +// before treating an ok response as a suggestion payload. +// +// { imageId } — the edit page, where the source URL is already stored. +// { sourcePostUrl } — the upload page, where there is no image row yet and +// the URL is whatever the operator has typed so far. +// +// Either shape goes through classifySourceUrl first, so the only URLs that +// ever leave this app are ones we built: the canonical bsky.app post URL, or +// the pbs.twimg.com media URL that X's own API handed back. A caller-supplied +// host is never fetched, which is what keeps the sourcePostUrl shape from +// being an SSRF hole. +// +// Nothing here runs on its own — no cron, no render path. entail.dev's +// response body is never logged or stored; only the normalized fields below +// are returned, and the tags have been through the same sanitizer the tag +// inputs use. + +/** What the UI gets back for a failed lookup, and the status carrying it. + * hooks.server.ts counts every 5xx into the site's error metric, so only a + * real upstream failure gets a 5xx; the rest are outcomes, not errors. + * 202 not_ready: queued, or still classifying past our poll cap. Retry. + * 404 not_found: an unknown imageId, a source post the guest token cannot + * read (deleted, protected), or the classifier declined the input. + * 429 rate_limited: entail.dev's or X's per-IP limit. + * 502 unavailable: an upstream failure only (a 5xx, a timeout, a body we + * cannot read). Not 401 or 503: the admin gate answers an expired + * session with its own 401 and a plain-text body, so a 401 here would + * read as a logged-out operator. + * The body's `error` field is what tells the cases apart. */ +const FAILURE_STATUS: Record = { + not_ready: 202, + not_found: 404, + rate_limited: 429, + unavailable: 502 +}; + +const MAX_URL_LENGTH = 2048; +/** Ceiling on the whole lookup chain. The X path is up to four fetches (the + * activate and the tweet lookup can each run twice) plus an enqueue and two + * polls, each with its own timeout, so without this the worst case ran well + * past a minute. The invariant: the deadline never cuts a first attempt short. + * That attempt is the activate (5 s) + the tweet lookup (5 s) + the classify + * enqueue (3 s) + both polls with the pause between them (8 + 0.25 + 8), which + * is 29.25 s — the second poll is the normal path for a job still running, so + * a deadline below this turns the 202 `not_ready` the poll cap exists to + * produce into a 502 the site counts as an error. The ceiling sits just above + * that: it cuts the retry paths, never a chain that is merely slow. The UI's + * own client-side timeout has to stay above this. */ +const LOOKUP_DEADLINE_MS = 32_000; +// SvelteKit rejects any other named export from a +server file unless it +// starts with an underscore; the tests read it under this name. +export { LOOKUP_DEADLINE_MS as _LOOKUP_DEADLINE_MS }; +/** Read before parsing: a valid body is a short object with one field, so + * anything past this is refused without handing it to JSON.parse. */ +const MAX_BODY_BYTES = 4096; + +const failure = (reason: LookupFailure | TweetMediaFailure) => + json({ error: reason }, { status: FAILURE_STATUS[reason] }); + +const invalid = () => json({ error: 'invalid_request' }, { status: 400 }); + +/** Reads the body chunk by chunk and stops at the first byte past the cap, so + * a chunked request with no Content-Length is refused without being buffered + * whole first. Null for no body, a read error, or too many bytes. */ +async function readBody(stream: ReadableStream | null): Promise { + if (!stream) return null; + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > MAX_BODY_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + } catch { + return null; + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +} + +type Body = { imageId?: unknown; sourcePostUrl?: unknown }; + +export const POST: RequestHandler = async ({ request, platform }) => { + const declared = Number(request.headers.get('content-length')); + if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return invalid(); + const bytes = await readBody(request.body); + if (bytes === null) return invalid(); + let body: Body | null; + try { + body = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + body = null; + } + if (!body || typeof body !== 'object' || Array.isArray(body)) return invalid(); + + const hasImageId = body.imageId !== undefined && body.imageId !== null; + const hasUrl = body.sourcePostUrl !== undefined && body.sourcePostUrl !== null; + // Exactly one: two fields would leave it ambiguous which one the operator + // meant, and the answers can differ (the form's unsaved value against the + // stored one). + if (hasImageId === hasUrl) return invalid(); + + let sourcePostUrl: string; + if (hasImageId) { + const imageId = body.imageId; + if (typeof imageId !== 'number' || !Number.isInteger(imageId) || imageId <= 0) return invalid(); + + const row = await getDb(platform!.env.DB) + .select({ sourcePostUrl: images.sourcePostUrl }) + .from(images) + .where(eq(images.id, imageId)) + .get(); + if (!row) return failure('not_found'); + sourcePostUrl = row.sourcePostUrl ?? ''; + } else { + if (typeof body.sourcePostUrl !== 'string') return invalid(); + if (body.sourcePostUrl.length > MAX_URL_LENGTH) return invalid(); + sourcePostUrl = body.sourcePostUrl; + } + + // An image with no source post, or one pointing somewhere we have no + // classifier for, is not a broken request — there is just nothing to ask. + const source = classifySourceUrl(sourcePostUrl); + if (!source) return json({ error: 'unsupported_source' }, { status: 422 }); + + let outcome: LookupOutcome; + // How many images the post carried. Only the Bluesky lookup and the tweet + // lookup see the post; classifyMediaUrl sees one image. + let imageCount: number; + // One deadline for every outbound call below; each lookup returns + // `unavailable` when it fires. + const signal = AbortSignal.timeout(LOOKUP_DEADLINE_MS); + if (source.kind === 'bluesky') { + // The validated source goes straight in, so the canonical URL is not + // percent-decoded a second time by a re-run of classifySourceUrl. + outcome = await lookupBlueskySource(source, fetch, signal); + if (!outcome.ok) return failure(outcome.reason); + imageCount = outcome.imageCount; + } else { + // entail.dev indexes Bluesky, not X, so an X post has to be classified + // from its image. X's API is the only thing that knows which image that + // is, and it hands back a pbs.twimg.com URL — one of the two hosts + // classifyMediaUrl will send on. Only the validated status id goes out. + const media = await fetchTweetMediaUrl(source.id, fetch, signal); + if (!media.ok) return failure(media.reason); + // A tweet with no photo (text, video, GIF) has nothing to classify. That + // is a success with no tags, the same answer a Bluesky post with no + // classified image gets, not an outage. + if (media.url === null) return json({ source: source.kind, tags: [], rating: null, imageCount: 0 }); + outcome = await classifyMediaUrl(media.url, fetch, signal); + if (!outcome.ok) return failure(outcome.reason); + imageCount = media.photoCount; + } + + return json({ + source: source.kind, + tags: outcome.suggestions.tags, + rating: outcome.suggestions.rating, + imageCount + }); +}; diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts new file mode 100644 index 00000000..0c5e977c --- /dev/null +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -0,0 +1,436 @@ +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 { + CLASSIFY_TIMEOUT_MS, + POLL_ATTEMPTS, + POLL_PAUSE_MS, + POLL_TIMEOUT_MS, + type LookupOutcome, + type SourceKind +} from '$lib/server/entail'; +import { FETCH_TIMEOUT_MS as ACTIVATE_TIMEOUT_MS } from '$lib/server/twitter-avatar'; +import { FETCH_TIMEOUT_MS as TWEET_TIMEOUT_MS, type TweetMediaOutcome } from '$lib/server/twitter-media'; +import { makeD1 } from '$lib/server/test/d1'; +import { POST, _LOOKUP_DEADLINE_MS } from './+server'; + +// Only the outbound calls are stubbed. classifySourceUrl stays real, so the +// URL recognition the endpoint depends on is exercised rather than mocked. +const lookupBlueskySource = vi.hoisted(() => + vi.fn( + async (_source: SourceKind, _fetch?: typeof fetch, _signal?: AbortSignal): Promise => ({ + ok: false, + reason: 'unavailable' + }) + ) +); +const classifyMediaUrl = vi.hoisted(() => + vi.fn( + async (_url: string, _fetch?: typeof fetch, _signal?: AbortSignal): Promise => ({ + ok: false, + reason: 'unavailable' + }) + ) +); +const fetchTweetMediaUrl = vi.hoisted(() => + vi.fn( + async (_id: string, _fetch?: typeof fetch, _signal?: AbortSignal): Promise => ({ + ok: false, + reason: 'unavailable' + }) + ) +); + +vi.mock('$lib/server/entail', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, lookupBlueskySource, classifyMediaUrl }; +}); +// Spread the original so the real timeout constants the deadline pin below +// sums stay readable through the mock. +vi.mock('$lib/server/twitter-media', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, fetchTweetMediaUrl }; +}); + +const DDL = `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);`; + +const BSKY_POST = 'https://bsky.app/profile/example.bsky.social/post/3abc'; +const X_POST = 'https://x.com/examplefox/status/1234567890'; +const X_ID = '1234567890'; +const MEDIA_URL = 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096'; + +const suggestions: LookupOutcome = { + ok: true, + suggestions: { tags: ['mammal', 'pink-hair'], rating: 'safe' }, + imageCount: 3 +}; + +function makeEnv() { + const sqlite = new Database(':memory:'); + sqlite.exec(DDL); + const d1 = makeD1(sqlite); + return { sqlite, platform: { env: { DB: d1 } } as unknown as App.Platform }; +} + +function insertImage(sqlite: { prepare: (sql: string) => { run: (...args: unknown[]) => void } }, sourcePostUrl: string | null) { + sqlite + .prepare('INSERT INTO images (id, title, image_url, source_post_url) VALUES (?, ?, ?, ?)') + .run(7, 'A picture', 'https://example.com/a.png', sourcePostUrl); +} + +function event(platform: App.Platform, body: unknown, raw?: string) { + const request = new Request('http://localhost/api/admin/tag-suggestions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: raw ?? JSON.stringify(body) + }); + return { request, platform } as never; +} + +beforeEach(() => { + lookupBlueskySource.mockReset(); + classifyMediaUrl.mockReset(); + fetchTweetMediaUrl.mockReset(); + lookupBlueskySource.mockResolvedValue(suggestions); + classifyMediaUrl.mockResolvedValue(suggestions); + fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL, photoCount: 1 }); +}); + +describe('POST /api/admin/tag-suggestions', () => { + it('suggests tags for a bluesky source URL', async () => { + const { platform } = makeEnv(); + const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + source: 'bluesky', + tags: ['mammal', 'pink-hair'], + rating: 'safe', + imageCount: 3 + }); + // The validated source, not the caller's string. + expect(lookupBlueskySource).toHaveBeenCalledWith( + { kind: 'bluesky', url: BSKY_POST }, + fetch, + expect.any(AbortSignal) + ); + expect(fetchTweetMediaUrl).not.toHaveBeenCalled(); + }); + + it('resolves an X post to its media URL, then classifies that', async () => { + const { platform } = makeEnv(); + const res = await POST(event(platform, { sourcePostUrl: `${X_POST}/photo/1` })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + source: 'x', + tags: ['mammal', 'pink-hair'], + rating: 'safe', + // The tweet's photo count, not whatever classifyMediaUrl reports. + imageCount: 1 + }); + // Only the validated status id goes to X, never the caller's string, and + // both calls share the endpoint's one deadline. + expect(fetchTweetMediaUrl).toHaveBeenCalledWith(X_ID, fetch, expect.any(AbortSignal)); + // The media URL is what reaches entail.dev — never the tweet URL. + expect(classifyMediaUrl).toHaveBeenCalledWith(MEDIA_URL, fetch, expect.any(AbortSignal)); + expect(classifyMediaUrl.mock.calls[0]?.[2]).toBe(fetchTweetMediaUrl.mock.calls[0]?.[2]); + expect(lookupBlueskySource).not.toHaveBeenCalled(); + }); + + it('reports how many photos a multi-photo tweet carried', async () => { + const { platform } = makeEnv(); + fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL, photoCount: 3 }); + classifyMediaUrl.mockResolvedValue({ ...suggestions, imageCount: 1 }); + const res = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(res.status).toBe(200); + expect((await res.json()).imageCount).toBe(3); + }); + + it('answers a photoless tweet with no tags rather than an outage', async () => { + const { platform } = makeEnv(); + // A text, video or GIF tweet has nothing to classify: the same 200 with + // no tags a Bluesky post with no classified image gets, and no + // classifier call. + fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: null, photoCount: 0 }); + const res = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ source: 'x', tags: [], rating: null, imageCount: 0 }); + expect(classifyMediaUrl).not.toHaveBeenCalled(); + }); + + it('gives the lookup chain a deadline that clears one full X round', async () => { + // One activate + one tweet lookup + one enqueue + every poll the cap + // allows, with the pause between them, each at its own timeout. Both + // polls count: a job still running answers the first one 202, so cutting + // the second turns a 202 not_ready into a 502 the site counts as an + // error. Summed from the real constants so the pin tracks them. + const firstAttempt = + ACTIVATE_TIMEOUT_MS + + TWEET_TIMEOUT_MS + + CLASSIFY_TIMEOUT_MS + + POLL_ATTEMPTS * POLL_TIMEOUT_MS + + (POLL_ATTEMPTS - 1) * POLL_PAUSE_MS; + expect(_LOOKUP_DEADLINE_MS).toBeGreaterThan(firstAttempt); + }); + + it('502s unavailable when the deadline fires mid-lookup', async () => { + const { platform } = makeEnv(); + // Own the deadline's signal so the test can fire it, and make the lookup + // answer only once it has: that is how a real lookup reports an abort. + const controller = new AbortController(); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal); + try { + lookupBlueskySource.mockImplementation( + (_source, _fetch, signal) => + new Promise((resolve) => { + signal!.addEventListener('abort', () => resolve({ ok: false, reason: 'unavailable' })); + }) + ); + const pending = POST(event(platform, { sourcePostUrl: BSKY_POST })); + // The body read takes a few ticks; wait until the lookup is in flight. + await vi.waitFor(() => expect(lookupBlueskySource).toHaveBeenCalledTimes(1)); + expect(lookupBlueskySource.mock.calls[0]?.[2]).toBe(controller.signal); + controller.abort(); + const res = await pending; + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: 'unavailable' }); + } finally { + timeout.mockRestore(); + } + }); + + it('refuses a declared Content-Length over the cap before reading the body', async () => { + const { platform } = makeEnv(); + const request = new Request('http://localhost/api/admin/tag-suggestions', { + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': '5000' }, + body: JSON.stringify({ sourcePostUrl: BSKY_POST }) + }); + const res = await POST({ request, platform } as never); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_request' }); + expect(lookupBlueskySource).not.toHaveBeenCalled(); + }); + + it('reads the stored source URL for an imageId', async () => { + const { sqlite, platform } = makeEnv(); + insertImage(sqlite, BSKY_POST); + const res = await POST(event(platform, { imageId: 7 })); + expect(res.status).toBe(200); + expect((await res.json()).source).toBe('bluesky'); + expect(lookupBlueskySource).toHaveBeenCalledWith( + { kind: 'bluesky', url: BSKY_POST }, + fetch, + expect.any(AbortSignal) + ); + }); + + it('404s an unknown imageId', async () => { + const { platform } = makeEnv(); + const res = await POST(event(platform, { imageId: 99 })); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'not_found' }); + expect(lookupBlueskySource).not.toHaveBeenCalled(); + }); + + it('422s a source we have no classifier for, including a stored empty one', async () => { + const { sqlite, platform } = makeEnv(); + insertImage(sqlite, null); + for (const body of [ + { sourcePostUrl: 'https://furaffinity.net/view/12345/' }, + // A malformed percent sequence in the actor is unsupported, not a 500. + { sourcePostUrl: 'https://bsky.app/profile/100%/post/3abc' }, + // A double-encoded actor is refused up front rather than decoded a + // second time on its way to entail.dev. + { sourcePostUrl: 'https://bsky.app/profile/a%252Fb/post/3abc' }, + { sourcePostUrl: '' }, + { imageId: 7 } + ]) { + const res = await POST(event(platform, body)); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ error: 'unsupported_source' }); + } + expect(lookupBlueskySource).not.toHaveBeenCalled(); + }); + + it('400s malformed and ambiguous request bodies', async () => { + const { platform } = makeEnv(); + const bad: Array<[unknown, string | undefined]> = [ + [undefined, 'not json'], + [undefined, '[1,2]'], + [{}, undefined], + [{ imageId: 7, sourcePostUrl: BSKY_POST }, undefined], + [{ imageId: '7' }, undefined], + [{ imageId: 1.5 }, undefined], + [{ imageId: 0 }, undefined], + [{ sourcePostUrl: 42 }, undefined], + [{ sourcePostUrl: `https://bsky.app/profile/a/post/${'x'.repeat(2100)}` }, undefined], + // Over the body cap: refused before JSON.parse ever sees it, even though + // the URL inside is fine and the padding would otherwise be ignored. + [undefined, JSON.stringify({ sourcePostUrl: BSKY_POST, pad: 'x'.repeat(4100) })] + ]; + for (const [body, raw] of bad) { + const res = await POST(event(platform, body, raw)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_request' }); + } + }); + + it('accepts a body of exactly the cap and refuses one byte more', async () => { + const { platform } = makeEnv(); + // ASCII throughout, so characters are bytes. Pad to the cap exactly. + const shell = JSON.stringify({ sourcePostUrl: BSKY_POST, pad: '' }); + const atCap = JSON.stringify({ sourcePostUrl: BSKY_POST, pad: 'x'.repeat(4096 - shell.length) }); + expect(new TextEncoder().encode(atCap).length).toBe(4096); + expect((await POST(event(platform, undefined, atCap))).status).toBe(200); + + const overCap = JSON.stringify({ sourcePostUrl: BSKY_POST, pad: 'x'.repeat(4097 - shell.length) }); + expect(new TextEncoder().encode(overCap).length).toBe(4097); + const res = await POST(event(platform, undefined, overCap)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_request' }); + // Multi-byte characters count as bytes, not characters: 2048 two-byte + // characters fit in the pad's character budget but not its byte budget. + const wide = JSON.stringify({ sourcePostUrl: BSKY_POST, pad: 'é'.repeat(2048) }); + expect((await POST(event(platform, undefined, wide))).status).toBe(400); + }); + + it('stops reading a chunked body at the cap instead of buffering it whole', async () => { + const { platform } = makeEnv(); + // No Content-Length, so the precheck cannot refuse it. Ten 1 KiB chunks: + // the read should give up once it passes 4096 bytes and cancel the + // stream, never pulling the rest. + const chunk = new TextEncoder().encode('x'.repeat(1024)); + let pulled = 0; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + if (pulled === 10) { + controller.close(); + return; + } + pulled += 1; + controller.enqueue(chunk); + }, + cancel() { + cancelled = true; + } + }); + const request = new Request('http://localhost/api/admin/tag-suggestions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + // Node requires this for a streaming request body. + duplex: 'half' + } as RequestInit); + const res = await POST({ request, platform } as never); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_request' }); + expect(cancelled).toBe(true); + expect(pulled).toBeLessThan(10); + expect(lookupBlueskySource).not.toHaveBeenCalled(); + }); + + it('200s with no tags when the classifier found nothing to suggest', async () => { + const { platform } = makeEnv(); + // The classifier read the post and rated it; nothing cleared the + // confidence floor. That is an answer, not a failure. + lookupBlueskySource.mockResolvedValue({ + ok: true, + suggestions: { tags: [], rating: 'safe' }, + imageCount: 3 + }); + const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ source: 'bluesky', tags: [], rating: 'safe', imageCount: 3 }); + }); + + it('passes an imageCount of 0 through as a success', async () => { + const { platform } = makeEnv(); + // A post with no classified images: still 200, still zero, not a failure. + lookupBlueskySource.mockResolvedValue({ + ok: true, + suggestions: { tags: [], rating: null }, + imageCount: 0 + }); + const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ source: 'bluesky', tags: [], rating: null, imageCount: 0 }); + }); + + it('202s not_ready when the post is queued but unclassified', async () => { + const { platform } = makeEnv(); + // Not a 5xx: hooks.server.ts books every 5xx into the site's error + // metric, and a queued post is not a server error. + lookupBlueskySource.mockResolvedValue({ ok: false, reason: 'not_ready' }); + const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(res.status).toBe(202); + expect(await res.json()).toEqual({ error: 'not_ready' }); + }); + + it('502s unavailable for a failed lookup and for an unresolvable tweet', async () => { + const { platform } = makeEnv(); + lookupBlueskySource.mockResolvedValue({ ok: false, reason: 'unavailable' }); + const bsky = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(bsky.status).toBe(502); + expect(await bsky.json()).toEqual({ error: 'unavailable' }); + + fetchTweetMediaUrl.mockResolvedValue({ ok: false, reason: 'unavailable' }); + const x = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(x.status).toBe(502); + expect(await x.json()).toEqual({ error: 'unavailable' }); + expect(classifyMediaUrl).not.toHaveBeenCalled(); + }); + + it('404s not_found when the post cannot be read or the classifier declined it', async () => { + const { platform } = makeEnv(); + // The operator's input, not an outage: the same 404 an unknown imageId + // gets, and not a 5xx the site's error metric would count. + lookupBlueskySource.mockResolvedValue({ ok: false, reason: 'not_found' }); + const bsky = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(bsky.status).toBe(404); + expect(await bsky.json()).toEqual({ error: 'not_found' }); + + fetchTweetMediaUrl.mockResolvedValue({ ok: false, reason: 'not_found' }); + const x = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(x.status).toBe(404); + expect(await x.json()).toEqual({ error: 'not_found' }); + expect(classifyMediaUrl).not.toHaveBeenCalled(); + + fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL, photoCount: 1 }); + classifyMediaUrl.mockResolvedValue({ ok: false, reason: 'not_found' }); + const declined = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(declined.status).toBe(404); + expect(await declined.json()).toEqual({ error: 'not_found' }); + }); + + it('202s not_ready when a classify job is still running at the poll cap', async () => { + const { platform } = makeEnv(); + classifyMediaUrl.mockResolvedValue({ ok: false, reason: 'not_ready' }); + const res = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(res.status).toBe(202); + expect(await res.json()).toEqual({ error: 'not_ready' }); + }); + + it('429s when entail.dev rate limited us', async () => { + const { platform } = makeEnv(); + lookupBlueskySource.mockResolvedValue({ ok: false, reason: 'rate_limited' }); + const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ error: 'rate_limited' }); + }); + + it('429s when X rate limited the tweet lookup', async () => { + const { platform } = makeEnv(); + fetchTweetMediaUrl.mockResolvedValue({ ok: false, reason: 'rate_limited' }); + const res = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ error: 'rate_limited' }); + expect(classifyMediaUrl).not.toHaveBeenCalled(); + }); +});