From a06c4f009f0a2f1f1b62a00b75591a0aa64fdc52 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:32:23 -0400 Subject: [PATCH 01/19] fc3g(ext): bootstrap extension/ skeleton (manifest, package, icon, README) Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/.gitignore | 4 ++ extension/README.md | 45 +++++++++++++++++++++++ extension/icons/icon.svg | 5 +++ extension/manifest.json | 71 ++++++++++++++++++++++++++++++++++++ extension/package.json | 15 ++++++++ extension/web-ext-config.cjs | 13 +++++++ 6 files changed, 153 insertions(+) create mode 100644 extension/.gitignore create mode 100644 extension/README.md create mode 100644 extension/icons/icon.svg create mode 100644 extension/manifest.json create mode 100644 extension/package.json create mode 100644 extension/web-ext-config.cjs diff --git a/extension/.gitignore b/extension/.gitignore new file mode 100644 index 0000000..eaea30b --- /dev/null +++ b/extension/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +web-ext-artifacts/ +.web-ext-id +.amo-upload-uuid diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..5220623 --- /dev/null +++ b/extension/README.md @@ -0,0 +1,45 @@ +# FabledCurator Firefox Extension + +Self-hosted Firefox extension that pushes session cookies from supported +platforms (Patreon, SubscribeStar, Hentai-Foundry, Discord, Pixiv, +DeviantArt) into FabledCurator, and lets you add a creator as a Source +from their page in one click. + +## Install (operator) + +The signed XPI is bundled into the FC Docker image. Open FC → +Settings → Maintenance → Browser extension → click "Install Firefox +extension". Firefox shows its native install prompt. After installing, +open the extension's options page (about:addons → FabledCurator → +Preferences) and paste in the FC URL + extension API key shown on the +same card. + +## Develop + +```sh +cd extension/ +npm install --no-save # web-ext only +npm run lint # web-ext lint +npm run start # launches Firefox with extension loaded +npm run build # unsigned XPI in web-ext-artifacts/ +``` + +## Smoke checklist (after every release that touches `extension/**`) + +- [ ] `npm run lint` passes +- [ ] `npm run start` loads the extension in a clean Firefox profile +- [ ] Options page accepts FC URL + key, indicator turns green +- [ ] Cookie export: log into patreon.com, click Patreon card → "X cookies exported" +- [ ] Discord token: open discord.com, click Discord card → "Token captured" +- [ ] Pixiv OAuth: click Pixiv card → login redirects, token stored +- [ ] Add as source: visit patreon.com/, click floating button → toast +- [ ] Subscriptions list: popup → "Sources" tab → list renders +- [ ] Check now: click play icon on source row → no error toast + +## Release + +Bump `manifest.json` + `package.json` SemVer (both files) and commit +under `extension/**`. The `.forgejo/workflows/extension.yml` workflow +runs `web-ext sign` on main, commits the signed XPI to +`frontend/public/extension/`, and the next FC server build bundles it +into the Docker image. diff --git a/extension/icons/icon.svg b/extension/icons/icon.svg new file mode 100644 index 0000000..95f1e2d --- /dev/null +++ b/extension/icons/icon.svg @@ -0,0 +1,5 @@ + + + F + diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..92b18d9 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,71 @@ +{ + "manifest_version": 3, + "name": "FabledCurator", + "version": "1.0.0", + "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", + + "browser_specific_settings": { + "gecko": { + "id": "fabledcurator@fabledsword.com", + "strict_min_version": "115.0" + } + }, + + "permissions": [ + "cookies", + "storage", + "tabs", + "activeTab", + "webRequest", + "webRequestBlocking" + ], + + "host_permissions": [ + "*://*.patreon.com/*", + "*://*.subscribestar.com/*", + "*://*.subscribestar.adult/*", + "*://*.hentai-foundry.com/*", + "*://*.discord.com/*", + "*://*.pixiv.net/*", + "*://*.deviantart.com/*", + "*://app-api.pixiv.net/*", + "*://oauth.secure.pixiv.net/*", + "*://*/*" + ], + + "action": { + "default_popup": "popup/popup.html", + "default_icon": "icons/icon.svg", + "default_title": "FabledCurator" + }, + + "background": { + "scripts": ["lib/platforms.js", "lib/cookies.js", "lib/api.js", "background/background.js"] + }, + + "options_ui": { + "page": "options/options.html", + "browser_style": true + }, + + "content_scripts": [ + { + "matches": [ + "*://*.patreon.com/*", + "*://*.subscribestar.com/*", + "*://*.subscribestar.adult/*", + "*://*.hentai-foundry.com/*", + "*://*.deviantart.com/*", + "*://*.pixiv.net/*" + ], + "js": ["lib/platforms.js", "content/content-script.js"], + "css": ["content/content-script.css"], + "run_at": "document_idle" + } + ], + + "icons": { + "48": "icons/icon.svg", + "96": "icons/icon.svg" + } +} diff --git a/extension/package.json b/extension/package.json new file mode 100644 index 0000000..1a0bb06 --- /dev/null +++ b/extension/package.json @@ -0,0 +1,15 @@ +{ + "name": "fabledcurator-extension", + "version": "1.0.0", + "private": true, + "description": "Firefox extension for FabledCurator", + "scripts": { + "lint": "web-ext lint --source-dir=.", + "start": "web-ext run --source-dir=. --firefox=firefox", + "build": "web-ext build --source-dir=. --overwrite-dest", + "sign": "web-ext sign --source-dir=. --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET" + }, + "devDependencies": { + "web-ext": "^8.0.0" + } +} diff --git a/extension/web-ext-config.cjs b/extension/web-ext-config.cjs new file mode 100644 index 0000000..019579a --- /dev/null +++ b/extension/web-ext-config.cjs @@ -0,0 +1,13 @@ +module.exports = { + sourceDir: '.', + artifactsDir: './web-ext-artifacts', + ignoreFiles: [ + 'package.json', + 'package-lock.json', + 'web-ext-config.cjs', + 'web-ext-artifacts', + 'node_modules', + 'README.md', + '.gitignore', + ], +}; From 9b01b196660c52594e4377701917ce103f1591ea Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:32:47 -0400 Subject: [PATCH 02/19] fc3g(ext): platform registry + artist-page URL patterns Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/lib/platforms.js | 85 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 extension/lib/platforms.js diff --git a/extension/lib/platforms.js b/extension/lib/platforms.js new file mode 100644 index 0000000..9092c80 --- /dev/null +++ b/extension/lib/platforms.js @@ -0,0 +1,85 @@ +/** + * Platform definitions — mirrored from backend/app/services/platforms.py. + * Two copies of these regexes (here and in backend extension_service.py) + * is cheaper than building a shared definition file for two short + * tables in different runtimes. Keep in sync by hand; reviewers catch + * drift. + */ + +const PLATFORMS = { + patreon: { + name: 'Patreon', + domains: ['.patreon.com', 'www.patreon.com', 'patreon.com'], + authType: 'cookies', + color: '#FF424D', + urlPattern: /^https?:\/\/(www\.)?patreon\.com/, + }, + subscribestar: { + name: 'SubscribeStar', + domains: [ + '.subscribestar.com', + '.subscribestar.adult', + 'www.subscribestar.com', + 'subscribestar.com', + 'subscribestar.adult', + ], + authType: 'cookies', + color: '#FFD700', + urlPattern: /^https?:\/\/(www\.)?subscribestar\.(com|adult)/, + }, + hentaifoundry: { + name: 'Hentai Foundry', + domains: ['.hentai-foundry.com', 'www.hentai-foundry.com', 'hentai-foundry.com'], + authType: 'cookies', + color: '#9C27B0', + urlPattern: /^https?:\/\/(www\.)?hentai-foundry\.com/, + }, + discord: { + name: 'Discord', + domains: ['.discord.com', 'discord.com'], + authType: 'token', + color: '#5865F2', + urlPattern: /^https?:\/\/(www\.)?discord\.com/, + note: 'Open Discord in browser to capture token', + }, + pixiv: { + name: 'Pixiv', + domains: ['.pixiv.net', 'www.pixiv.net', 'pixiv.net'], + authType: 'token', + color: '#0096FA', + urlPattern: /^https?:\/\/(www\.)?pixiv\.net/, + note: 'Click to authenticate via OAuth', + }, + deviantart: { + name: 'DeviantArt', + domains: ['.deviantart.com', 'www.deviantart.com', 'deviantart.com'], + authType: 'cookies', + color: '#05CC47', + urlPattern: /^https?:\/\/(www\.)?deviantart\.com/, + }, +}; + +/** + * Per-platform artist-page URL patterns — these are STRICTER than + * urlPattern (which matches any URL on the domain). Used by the content + * script to decide whether to show the floating "Add as source" button. + */ +const PLATFORM_ARTIST_PATTERNS = { + patreon: /^https?:\/\/(www\.)?patreon\.com\/(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c\/)[^/?#]+\/?$/i, + subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i, + hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i, + deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i, + pixiv: /^https?:\/\/(www\.)?pixiv\.net\/(en\/)?users\/\d+/i, +}; + +function getPlatformFromUrl(url) { + for (const [key, platform] of Object.entries(PLATFORMS)) { + if (platform.urlPattern.test(url)) return key; + } + return null; +} + +function isArtistPage(url, platformKey) { + const pattern = PLATFORM_ARTIST_PATTERNS[platformKey]; + return pattern ? pattern.test(url) : false; +} From c1d304677849af15fdf1406eba552a8cdb3cf26f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:33:26 -0400 Subject: [PATCH 03/19] fc3g(ext): cookies extraction + FC backend API client Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/lib/api.js | 93 ++++++++++++++++++++++++++++++++++++++++ extension/lib/cookies.js | 56 ++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 extension/lib/api.js create mode 100644 extension/lib/cookies.js diff --git a/extension/lib/api.js b/extension/lib/api.js new file mode 100644 index 0000000..60fc95d --- /dev/null +++ b/extension/lib/api.js @@ -0,0 +1,93 @@ +/** + * FC backend client. Talks to /api/credentials (FC-3b), /api/sources + * (FC-3a), and the new /api/extension/* endpoints (FC-3g). + */ + +class FabledCuratorAPI { + constructor() { + this.baseUrl = null; + this.apiKey = null; + } + + async init() { + const cfg = await browser.storage.local.get(['apiUrl', 'apiKey']); + this.baseUrl = cfg.apiUrl || null; + this.apiKey = cfg.apiKey || null; + return this.isConfigured(); + } + + isConfigured() { + return !!(this.baseUrl && this.apiKey); + } + + async request(method, endpoint, data = null) { + if (!this.isConfigured()) { + throw new Error('Not configured. Set FC URL + API key in settings.'); + } + const url = `${this.baseUrl.replace(/\/+$/, '')}${endpoint}`; + const options = { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Extension-Key': this.apiKey, + }, + }; + if (data) options.body = JSON.stringify(data); + + let response; + try { + response = await fetch(url, options); + } catch (e) { + throw new Error('Cannot connect to FabledCurator. Check URL.'); + } + + if (!response.ok) { + let message; + try { + const body = await response.json(); + message = body.error || `HTTP ${response.status}`; + if (body.detail) message += `: ${body.detail}`; + } catch { + message = `HTTP ${response.status}: ${response.statusText}`; + } + const err = new Error(message); + err.status = response.status; + throw err; + } + + if (response.status === 204) return { success: true }; + return response.json(); + } + + // FC-3b — credentials. + uploadCredentials(platform, credentialType, data) { + return this.request('POST', '/credentials', { + platform, + credential_type: credentialType, + data, + }); + } + getCredentials() { + return this.request('GET', '/credentials'); + } + + // FC-3a — sources. + listSources() { + return this.request('GET', '/sources'); + } + triggerSourceCheck(sourceId) { + return this.request('POST', `/sources/${sourceId}/check`); + } + + // FC-3g — extension-specific. + quickAddSource(url) { + return this.request('POST', '/extension/quick-add-source', { url }); + } + + // Connection test = the cheapest read with auth. + testConnection() { + return this.request('GET', '/credentials'); + } +} + +const api = new FabledCuratorAPI(); diff --git a/extension/lib/cookies.js b/extension/lib/cookies.js new file mode 100644 index 0000000..b13ca4d --- /dev/null +++ b/extension/lib/cookies.js @@ -0,0 +1,56 @@ +/** + * Cookie extraction + Netscape format conversion. + * Direct port of GS extension/lib/cookies.js. + */ + +async function extractCookiesForPlatform(platformKey) { + const platform = PLATFORMS[platformKey]; + if (!platform) throw new Error(`Unknown platform: ${platformKey}`); + + const all = []; + for (const domain of platform.domains) { + try { + const cookies = await browser.cookies.getAll({ domain }); + all.push(...cookies); + } catch (e) { + console.warn(`cookies.getAll failed for ${domain}:`, e); + } + } + return deduplicateCookies(all); +} + +function deduplicateCookies(cookies) { + const seen = new Map(); + for (const c of cookies) { + const key = `${c.name}|${c.domain}|${c.path}`; + if (!seen.has(key)) { + seen.set(key, c); + } else { + const existing = seen.get(key); + if (c.expirationDate && (!existing.expirationDate || c.expirationDate > existing.expirationDate)) { + seen.set(key, c); + } + } + } + return Array.from(seen.values()); +} + +function toNetscapeFormat(cookies) { + const lines = ['# Netscape HTTP Cookie File']; + for (const c of cookies) { + let domain = c.domain.replace(/^\.?www\./, '.'); + if (!domain.startsWith('.')) domain = '.' + domain; + const secure = c.secure ? 'TRUE' : 'FALSE'; + const expiration = c.expirationDate ? Math.floor(c.expirationDate) : 0; + lines.push([domain, 'TRUE', c.path || '/', secure, String(expiration), c.name, c.value].join('\t')); + } + return lines.join('\n'); +} + +async function getCookieCount(platformKey) { + try { + return (await extractCookiesForPlatform(platformKey)).length; + } catch { + return 0; + } +} From df82abe75e0cd8b25e6315faa352026fa2e5b9b4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:34:11 -0400 Subject: [PATCH 04/19] =?UTF-8?q?fc3g(ext):=20background=20script=20?= =?UTF-8?q?=E2=80=94=20message=20router,=20Discord=20token=20capture,=20Pi?= =?UTF-8?q?xiv=20OAuth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/background/background.js | 265 +++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 extension/background/background.js diff --git a/extension/background/background.js b/extension/background/background.js new file mode 100644 index 0000000..988647a --- /dev/null +++ b/extension/background/background.js @@ -0,0 +1,265 @@ +/** + * Background script — message router + Discord token capture + * (webRequest) + Pixiv PKCE OAuth. Direct port of GS background.js; + * api.js client points at FC instead of GS. + */ + +let discordToken = null; +let discordTokenCapturedAt = null; + +let pixivRefreshToken = null; +let pixivTokenCapturedAt = null; +let pixivOAuthPending = null; + +const PIXIV_CLIENT_ID = 'MOBrBDS8blbauoSck0ZfDbtuzpyT'; +const PIXIV_CLIENT_SECRET = 'lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj'; +const PIXIV_OAUTH_URL = 'https://app-api.pixiv.net/web/v1/login'; +const PIXIV_TOKEN_URL = 'https://oauth.secure.pixiv.net/auth/token'; +const PIXIV_REDIRECT_URI = 'https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback'; + +let initialized = false; + +async function ensureInitialized() { + if (initialized) return; + await api.init(); + await loadDiscordToken(); + await loadPixivToken(); + initialized = true; +} + +browser.runtime.onInstalled.addListener(() => ensureInitialized()); +browser.runtime.onStartup.addListener(() => ensureInitialized()); +ensureInitialized().catch(e => console.error('init failed:', e)); + +// ---- Discord token capture via webRequest ---- + +browser.webRequest.onBeforeSendHeaders.addListener( + (details) => { + const auth = details.requestHeaders?.find(h => h.name.toLowerCase() === 'authorization'); + if (auth?.value && auth.value !== discordToken) { + saveDiscordToken(auth.value); + } + }, + { urls: ['https://discord.com/api/*'] }, + ['requestHeaders'], +); + +async function loadDiscordToken() { + const s = await browser.storage.local.get(['discordToken', 'discordTokenCapturedAt']); + discordToken = s.discordToken || null; + discordTokenCapturedAt = s.discordTokenCapturedAt || null; +} + +async function saveDiscordToken(token) { + discordToken = token; + discordTokenCapturedAt = new Date().toISOString(); + await browser.storage.local.set({ discordToken: token, discordTokenCapturedAt }); +} + +// ---- Pixiv PKCE OAuth ---- + +async function loadPixivToken() { + const s = await browser.storage.local.get(['pixivRefreshToken', 'pixivTokenCapturedAt']); + pixivRefreshToken = s.pixivRefreshToken || null; + pixivTokenCapturedAt = s.pixivTokenCapturedAt || null; +} + +async function savePixivToken(token) { + pixivRefreshToken = token; + pixivTokenCapturedAt = new Date().toISOString(); + await browser.storage.local.set({ pixivRefreshToken: token, pixivTokenCapturedAt }); +} + +function generateCodeVerifier() { + const a = new Uint8Array(32); + crypto.getRandomValues(a); + return base64UrlEncode(a); +} + +async function generateCodeChallenge(verifier) { + const data = new TextEncoder().encode(verifier); + const hash = await crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(new Uint8Array(hash)); +} + +function base64UrlEncode(buf) { + return btoa(String.fromCharCode(...buf)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +async function initiatePixivOAuth() { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + + const params = new URLSearchParams({ + code_challenge: codeChallenge, + code_challenge_method: 'S256', + client: 'pixiv-android', + }); + const tab = await browser.tabs.create({ url: `${PIXIV_OAUTH_URL}?${params}` }); + + return new Promise((resolve, reject) => { + pixivOAuthPending = { codeVerifier, tabId: tab.id, resolve, reject }; + setTimeout(() => { + if (pixivOAuthPending && pixivOAuthPending.tabId === tab.id) { + pixivOAuthPending = null; + reject(new Error('Pixiv OAuth timed out (5 min)')); + } + }, 5 * 60 * 1000); + }); +} + +browser.webRequest.onBeforeRedirect.addListener( + async (details) => { + if (!pixivOAuthPending) return; + if (details.tabId !== pixivOAuthPending.tabId) return; + const url = new URL(details.redirectUrl); + const code = url.searchParams.get('code'); + if (!code) return; + const verifier = pixivOAuthPending.codeVerifier; + const resolve = pixivOAuthPending.resolve; + const reject = pixivOAuthPending.reject; + pixivOAuthPending = null; + try { + const tokenResp = await fetch(PIXIV_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: PIXIV_CLIENT_ID, + client_secret: PIXIV_CLIENT_SECRET, + code, + code_verifier: verifier, + grant_type: 'authorization_code', + include_policy: 'true', + redirect_uri: PIXIV_REDIRECT_URI, + }), + }); + const body = await tokenResp.json(); + if (!body.refresh_token) { + reject(new Error(`Pixiv token exchange failed: ${JSON.stringify(body)}`)); + return; + } + await savePixivToken(body.refresh_token); + try { await browser.tabs.remove(details.tabId); } catch {} + resolve(body.refresh_token); + } catch (e) { + reject(e); + } + }, + { urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] }, +); + +// ---- Message router ---- + +browser.runtime.onMessage.addListener(async (msg) => { + await ensureInitialized(); + switch (msg.type) { + case 'GET_CONFIG': + return { apiUrl: api.baseUrl, apiKey: api.apiKey }; + + case 'TEST_CONNECTION': + try { + await api.testConnection(); + return { connected: true }; + } catch (e) { + return { connected: false, error: e.message }; + } + + case 'GET_PLATFORM_STATUS': { + const status = {}; + for (const key of Object.keys(PLATFORMS)) { + if (PLATFORMS[key].authType === 'cookies') { + status[key] = { hasCookies: false, cookieCount: 0 }; + try { + const n = await getCookieCount(key); + status[key] = { hasCookies: n > 0, cookieCount: n }; + } catch (e) { + status[key] = { error: e.message }; + } + } else if (key === 'discord') { + status[key] = { hasToken: !!discordToken, capturedAt: discordTokenCapturedAt }; + } else if (key === 'pixiv') { + status[key] = { hasToken: !!pixivRefreshToken, capturedAt: pixivTokenCapturedAt }; + } else { + status[key] = {}; + } + } + return status; + } + + case 'EXPORT_COOKIES': { + const key = msg.platform; + const platform = PLATFORMS[key]; + if (!platform) return { error: `Unknown platform: ${key}` }; + try { + if (platform.authType === 'cookies') { + const cookies = await extractCookiesForPlatform(key); + if (cookies.length === 0) return { error: 'No cookies found — log in first.' }; + const data = toNetscapeFormat(cookies); + await api.uploadCredentials(key, 'cookies', data); + return { success: true, cookieCount: cookies.length }; + } + if (key === 'discord') { + if (!discordToken) return { error: 'Open discord.com to capture a token first.' }; + await api.uploadCredentials('discord', 'token', discordToken); + return { success: true }; + } + if (key === 'pixiv') { + if (!pixivRefreshToken) { + await initiatePixivOAuth(); + } + await api.uploadCredentials('pixiv', 'token', pixivRefreshToken); + return { success: true }; + } + return { error: 'Unsupported platform.' }; + } catch (e) { + return { error: e.message }; + } + } + + case 'EXPORT_ALL_COOKIES': { + const results = {}; + for (const key of Object.keys(PLATFORMS)) { + if (PLATFORMS[key].authType !== 'cookies') { + results[key] = { skipped: true }; + continue; + } + try { + const cookies = await extractCookiesForPlatform(key); + if (cookies.length === 0) { + results[key] = { skipped: true, reason: 'no cookies' }; + continue; + } + await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies)); + results[key] = { success: true, cookieCount: cookies.length }; + } catch (e) { + results[key] = { error: e.message }; + } + } + return results; + } + + case 'LIST_SOURCES': + try { + return { sources: await api.listSources() }; + } catch (e) { + return { error: e.message }; + } + + case 'CHECK_SOURCE': + try { + return await api.triggerSourceCheck(msg.sourceId); + } catch (e) { + return { error: e.message }; + } + + case 'ADD_AS_SOURCE': + try { + return await api.quickAddSource(msg.url); + } catch (e) { + return { error: e.message }; + } + + default: + return { error: `Unknown message type: ${msg.type}` }; + } +}); From b7832c941d94009ccfa4e269751fc1c92b15346c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:35:55 -0400 Subject: [PATCH 05/19] =?UTF-8?q?fc3g(ext):=20popup=20UI=20=E2=80=94=20pla?= =?UTF-8?q?tforms=20+=20sources=20tabs,=20export-all=20CTA,=20status=20ind?= =?UTF-8?q?icator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/popup/popup.css | 94 +++++++++++++++ extension/popup/popup.html | 47 ++++++++ extension/popup/popup.js | 239 +++++++++++++++++++++++++++++++++++++ 3 files changed, 380 insertions(+) create mode 100644 extension/popup/popup.css create mode 100644 extension/popup/popup.html create mode 100644 extension/popup/popup.js diff --git a/extension/popup/popup.css b/extension/popup/popup.css new file mode 100644 index 0000000..7f796f8 --- /dev/null +++ b/extension/popup/popup.css @@ -0,0 +1,94 @@ +:root { + --bg: #14171A; + --surface: #1F2428; + --on-surface: #E8E4D8; + --on-surface-variant: rgba(232, 228, 216, 0.65); + --accent: #F4BA7A; + --error: #DC5050; + --success: #4CAF50; + --warning: #FFB300; +} +* { box-sizing: border-box; } +body { + margin: 0; + min-width: 340px; + font: 14px/1.4 system-ui, sans-serif; + background: var(--bg); + color: var(--on-surface); +} +.hidden { display: none !important; } +.topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; background: var(--surface); +} +.brand { font-family: Georgia, serif; font-size: 16px; font-weight: 500; color: var(--accent); } +.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--on-surface-variant); } +.dot.connected { background: var(--success); } + +.setup { padding: 20px; text-align: center; } +.alert { + background: var(--surface); padding: 12px; border-radius: 6px; + margin-bottom: 12px; border-left: 3px solid var(--accent); +} +.alert.alert-error { border-left-color: var(--error); } +.alert strong { display: block; margin-bottom: 4px; } +.alert p { margin: 0; color: var(--on-surface-variant); font-size: 13px; } + +.tabs { display: flex; border-bottom: 1px solid var(--surface); } +.tab { + flex: 1; padding: 10px 0; background: none; border: none; + color: var(--on-surface-variant); cursor: pointer; font: inherit; + border-bottom: 2px solid transparent; +} +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.tab-panel { padding: 10px; } + +.platform-card, .source-row { + display: flex; align-items: center; gap: 10px; + padding: 10px; margin-bottom: 6px; background: var(--surface); + border-radius: 6px; cursor: pointer; +} +.platform-card.disabled { opacity: 0.5; cursor: not-allowed; } +.platform-card.loading { opacity: 0.6; cursor: wait; } +.platform-icon { + width: 28px; height: 28px; border-radius: 50%; + display: grid; place-items: center; font-weight: 600; color: white; +} +.info { flex: 1; min-width: 0; } +.info .name { font-weight: 500; } +.info .status { font-size: 12px; color: var(--on-surface-variant); } +.info .status.ready { color: var(--success); } +.info .status.error { color: var(--error); } +.info .status.no-cookies { color: var(--warning); } +.action-icon { color: var(--on-surface-variant); } + +.btn { + padding: 10px 14px; border: none; border-radius: 6px; cursor: pointer; + font: 500 14px/1 system-ui, sans-serif; +} +.btn.primary { background: var(--accent); color: var(--bg); } +.btn.primary:hover { filter: brightness(1.05); } +.btn.primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn.block { display: block; width: 100%; margin-top: 8px; } +.btn.link { background: none; color: var(--on-surface-variant); padding: 4px; } +.btn.link:hover { color: var(--accent); } + +.source-row .play { + background: none; border: none; color: var(--on-surface-variant); + cursor: pointer; padding: 4px; +} +.source-row .play:hover { color: var(--accent); } +.source-row .url { font-size: 11px; color: var(--on-surface-variant); word-break: break-all; } + +.status-message { + padding: 10px 14px; margin: 8px 10px; border-radius: 6px; + background: var(--surface); border-left: 3px solid; +} +.status-message.success { border-left-color: var(--success); } +.status-message.error { border-left-color: var(--error); } +.status-message.warning { border-left-color: var(--warning); } + +.footer { + border-top: 1px solid var(--surface); + padding: 6px 10px; text-align: right; +} diff --git a/extension/popup/popup.html b/extension/popup/popup.html new file mode 100644 index 0000000..d9d9ddf --- /dev/null +++ b/extension/popup/popup.html @@ -0,0 +1,47 @@ + + + + + + FabledCurator + + +
+ FabledCurator + +
+ + + + + + + + + diff --git a/extension/popup/popup.js b/extension/popup/popup.js new file mode 100644 index 0000000..cc0ca7c --- /dev/null +++ b/extension/popup/popup.js @@ -0,0 +1,239 @@ +document.addEventListener('DOMContentLoaded', init); + +const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000; + +async function init() { + try { + const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' }); + if (!cfg || !cfg.apiUrl || !cfg.apiKey) { + showSetupRequired(); + return; + } + document.getElementById('setup-required').classList.add('hidden'); + document.getElementById('main-content').classList.remove('hidden'); + setupEventListeners(); + showPlatformsLoading(); + testConnectionIfNeeded(); + loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`)); + } catch (e) { + showSetupRequired(); + const alert = document.querySelector('#setup-required .alert'); + alert.textContent = ''; + const s = document.createElement('strong'); s.textContent = 'Error'; + const p = document.createElement('p'); p.textContent = e.message; + alert.appendChild(s); alert.appendChild(p); + alert.classList.add('alert-error'); + } +} + +function showSetupRequired() { + document.getElementById('setup-required').classList.remove('hidden'); + document.getElementById('main-content').classList.add('hidden'); + document.getElementById('open-settings-btn').addEventListener('click', () => { + browser.runtime.openOptionsPage(); + }); +} + +function showPlatformsLoading() { + const c = document.getElementById('platforms-list'); + c.textContent = ''; + const d = document.createElement('div'); + d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);'; + d.textContent = 'Loading platforms…'; + c.appendChild(d); +} + +async function testConnectionIfNeeded() { + const stored = await browser.storage.local.get(['lastConnectionTest', 'lastConnectionStatus']); + const now = Date.now(); + if (now - (stored.lastConnectionTest || 0) < CONNECTION_TEST_INTERVAL && stored.lastConnectionStatus !== undefined) { + updateConnectionDot(stored.lastConnectionStatus); + if (!stored.lastConnectionStatus) showError('Cannot connect to backend (cached).'); + return; + } + const r = await browser.runtime.sendMessage({ type: 'TEST_CONNECTION' }); + await browser.storage.local.set({ lastConnectionTest: now, lastConnectionStatus: r.connected }); + updateConnectionDot(r.connected); + if (!r.connected) showError(`Cannot connect to backend: ${r.error}`); +} + +function updateConnectionDot(connected) { + const d = document.getElementById('connection-status'); + d.classList.toggle('connected', connected); + d.title = connected ? 'Connected to FabledCurator' : 'Disconnected'; +} + +async function loadPlatformStatus() { + const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' }); + const c = document.getElementById('platforms-list'); + c.textContent = ''; + for (const [key, platform] of Object.entries(PLATFORMS)) { + c.appendChild(createPlatformCard(key, platform, status[key] || {})); + } +} + +function createPlatformCard(key, platform, status) { + const card = document.createElement('div'); + card.className = 'platform-card'; + card.dataset.platform = key; + + const isTokenOnly = platform.authType === 'token' && !['discord', 'pixiv'].includes(key); + const discordNeedsToken = key === 'discord' && !status.hasToken; + if (isTokenOnly || discordNeedsToken) card.classList.add('disabled'); + + const icon = document.createElement('div'); + icon.className = 'platform-icon'; + icon.style.background = platform.color; + icon.textContent = platform.name[0]; + + const info = document.createElement('div'); + info.className = 'info'; + const name = document.createElement('div'); + name.className = 'name'; + name.textContent = platform.name; + const st = document.createElement('div'); + st.className = `status ${statusClass(status, platform, key)}`; + st.textContent = statusText(status, platform, key); + info.appendChild(name); info.appendChild(st); + + const act = document.createElement('span'); + act.className = 'action-icon'; + act.textContent = '↥'; + + card.appendChild(icon); card.appendChild(info); card.appendChild(act); + + if (!isTokenOnly && !discordNeedsToken) { + card.addEventListener('click', () => exportPlatformCookies(key, card)); + } + return card; +} + +function statusText(s, platform, key) { + if (key === 'discord') return s.hasToken ? 'Token captured — ready' : 'Open Discord to capture token'; + if (key === 'pixiv') return s.hasToken ? 'Token captured — ready' : 'Click to authenticate via OAuth'; + if (platform.authType === 'token') return 'Manual token entry required'; + if (s.error) return 'Error checking cookies'; + if (!s.hasCookies || !s.cookieCount) return 'No cookies — log in first'; + return `${s.cookieCount} cookies ready`; +} +function statusClass(s, platform, key) { + if (key === 'discord') return s.hasToken ? 'ready' : 'no-cookies'; + if (key === 'pixiv') return s.hasToken ? 'ready' : 'no-cookies'; + if (platform.authType === 'token') return 'no-cookies'; + if (s.error) return 'error'; + if (!s.hasCookies || !s.cookieCount) return 'no-cookies'; + return 'ready'; +} + +async function exportPlatformCookies(key, card) { + card.classList.add('loading'); hideStatusMessage(); + try { + const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key }); + if (r.error) showError(r.error); + else { + const n = r.cookieCount ?? null; + const msg = n !== null + ? `${PLATFORMS[key].name}: ${n} cookies exported` + : `${PLATFORMS[key].name}: token exported`; + showSuccess(msg); + await loadPlatformStatus(); + } + } catch (e) { showError(e.message); } + finally { card.classList.remove('loading'); } +} + +async function exportAllCookies() { + const btn = document.getElementById('export-all-btn'); + btn.disabled = true; btn.textContent = 'Exporting…'; hideStatusMessage(); + try { + const r = await browser.runtime.sendMessage({ type: 'EXPORT_ALL_COOKIES' }); + const wins = Object.values(r).filter(x => x.success).length; + const fails = Object.values(r).filter(x => !x.success && !x.skipped).length; + if (wins && !fails) showSuccess(`Exported ${wins} platforms`); + else if (wins) showWarning(`${wins} succeeded, ${fails} failed`); + else if (fails) showError('All exports failed. Are you logged in?'); + else showWarning('Nothing to export'); + await loadPlatformStatus(); + } catch (e) { showError(e.message); } + finally { btn.disabled = false; btn.textContent = 'Export all platforms'; } +} + +async function loadSources() { + const c = document.getElementById('sources-list'); + c.textContent = ''; + const d = document.createElement('div'); + d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);'; + d.textContent = 'Loading sources…'; + c.appendChild(d); + const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' }); + c.textContent = ''; + if (r.error) { + const e = document.createElement('div'); + e.style.cssText = 'padding:12px;color:var(--error);'; + e.textContent = r.error; + c.appendChild(e); + return; + } + if (!r.sources || r.sources.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);'; + empty.textContent = 'No sources yet.'; + c.appendChild(empty); + return; + } + for (const src of r.sources) c.appendChild(createSourceRow(src)); +} + +function createSourceRow(src) { + const row = document.createElement('div'); + row.className = 'source-row'; + const info = document.createElement('div'); + info.className = 'info'; + const name = document.createElement('div'); + name.className = 'name'; + name.textContent = `${src.platform} · #${src.id}`; + const url = document.createElement('div'); + url.className = 'url'; + url.textContent = src.url; + info.appendChild(name); info.appendChild(url); + const play = document.createElement('button'); + play.className = 'play'; + play.textContent = '▶'; + play.title = 'Check now'; + play.addEventListener('click', async () => { + play.disabled = true; + const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id }); + play.disabled = false; + if (r.error) showError(r.error); + else showSuccess(`Triggered check for source #${src.id}`); + }); + row.appendChild(info); row.appendChild(play); + return row; +} + +function setupEventListeners() { + document.getElementById('export-all-btn').addEventListener('click', exportAllCookies); + document.getElementById('settings-btn').addEventListener('click', () => browser.runtime.openOptionsPage()); + for (const tab of document.querySelectorAll('.tab')) { + tab.addEventListener('click', () => { + for (const t of document.querySelectorAll('.tab')) t.classList.remove('active'); + tab.classList.add('active'); + for (const p of document.querySelectorAll('.tab-panel')) p.classList.add('hidden'); + document.getElementById(`tab-${tab.dataset.tab}`).classList.remove('hidden'); + if (tab.dataset.tab === 'sources') loadSources(); + }); + } +} + +function showSuccess(m) { showStatusMessage(m, 'success'); } +function showError(m) { showStatusMessage(m, 'error'); } +function showWarning(m) { showStatusMessage(m, 'warning'); } +function showStatusMessage(text, kind) { + const el = document.getElementById('status-message'); + el.textContent = text; + el.className = `status-message ${kind}`; + el.classList.remove('hidden'); +} +function hideStatusMessage() { + document.getElementById('status-message').classList.add('hidden'); +} From b067a3eec16330757f0808b9744d3385be7a8326 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:36:31 -0400 Subject: [PATCH 06/19] =?UTF-8?q?fc3g(ext):=20options=20page=20=E2=80=94?= =?UTF-8?q?=20FC=20URL=20+=20API=20key=20form=20with=20test-connection=20b?= =?UTF-8?q?utton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/options/options.html | 41 ++++++++++++++++++++++++++++++ extension/options/options.js | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 extension/options/options.html create mode 100644 extension/options/options.js diff --git a/extension/options/options.html b/extension/options/options.html new file mode 100644 index 0000000..27e5136 --- /dev/null +++ b/extension/options/options.html @@ -0,0 +1,41 @@ + + + + + FabledCurator — Settings + + + +

FabledCurator extension

+ + + +
Find this on FC → Settings → Maintenance → Browser extension.
+ + + +
Generate or rotate on FC → Settings → Maintenance → Browser extension.
+ +
+ + +
+ + + + + + diff --git a/extension/options/options.js b/extension/options/options.js new file mode 100644 index 0000000..4798183 --- /dev/null +++ b/extension/options/options.js @@ -0,0 +1,46 @@ +document.addEventListener('DOMContentLoaded', async () => { + const stored = await browser.storage.local.get(['apiUrl', 'apiKey']); + document.getElementById('api-url').value = stored.apiUrl || ''; + document.getElementById('api-key').value = stored.apiKey || ''; + + document.getElementById('save-btn').addEventListener('click', save); + document.getElementById('test-btn').addEventListener('click', test); +}); + +async function save() { + const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, ''); + const apiKey = document.getElementById('api-key').value.trim(); + if (!apiUrl || !apiKey) { + showStatus('Both fields are required.', 'err'); + return; + } + await browser.storage.local.set({ apiUrl, apiKey }); + await browser.storage.local.remove(['lastConnectionTest', 'lastConnectionStatus']); + showStatus('Saved.', 'ok'); +} + +async function test() { + const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, ''); + const apiKey = document.getElementById('api-key').value.trim(); + if (!apiUrl || !apiKey) { + showStatus('Fill both fields first.', 'err'); + return; + } + try { + const r = await fetch(`${apiUrl}/credentials`, { + method: 'GET', + headers: { 'X-Extension-Key': apiKey }, + }); + if (r.ok) showStatus(`Connected — HTTP ${r.status}.`, 'ok'); + else showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err'); + } catch (e) { + showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err'); + } +} + +function showStatus(text, kind) { + const el = document.getElementById('status'); + el.textContent = text; + el.className = `status ${kind}`; + el.style.display = 'block'; +} From 379445c24457f21fb3247c31154b1c1159f4f7ca Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:37:02 -0400 Subject: [PATCH 07/19] =?UTF-8?q?fc3g(ext):=20content=20script=20=E2=80=94?= =?UTF-8?q?=20floating=20'Add=20to=20FabledCurator'=20button=20on=20artist?= =?UTF-8?q?=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/content/content-script.css | 27 +++++++++++++ extension/content/content-script.js | 60 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 extension/content/content-script.css create mode 100644 extension/content/content-script.js diff --git a/extension/content/content-script.css b/extension/content/content-script.css new file mode 100644 index 0000000..d55243f --- /dev/null +++ b/extension/content/content-script.css @@ -0,0 +1,27 @@ +.fc-add-source-btn { + all: revert; + position: fixed; bottom: 24px; right: 24px; z-index: 2147483647; + padding: 10px 16px; border-radius: 999px; border: none; + background: rgb(20, 23, 26); color: rgb(244, 186, 122); + font: 500 14px/1.2 system-ui, sans-serif; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer; + transition: transform 100ms ease; +} +.fc-add-source-btn:hover { transform: translateY(-1px); } +.fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; } + +.fc-toast { + all: revert; + position: fixed; bottom: 84px; right: 24px; z-index: 2147483647; + max-width: 360px; padding: 12px 16px; border-radius: 8px; + background: rgb(20, 23, 26); color: rgb(232, 228, 216); + font: 14px/1.4 system-ui, sans-serif; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + animation: fc-toast-in 200ms ease; +} +.fc-toast--success { border-left: 3px solid rgb(244, 186, 122); } +.fc-toast--error { border-left: 3px solid rgb(220, 80, 80); } +@keyframes fc-toast-in { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} diff --git a/extension/content/content-script.js b/extension/content/content-script.js new file mode 100644 index 0000000..51154b0 --- /dev/null +++ b/extension/content/content-script.js @@ -0,0 +1,60 @@ +(function () { + if (window.__fc_addsource_injected) return; + window.__fc_addsource_injected = true; + + evaluate(); + + const reEval = () => evaluate(); + window.addEventListener('popstate', reEval); + const origPush = history.pushState; + history.pushState = function () { origPush.apply(this, arguments); reEval(); }; + + function evaluate() { + const platform = getPlatformFromUrl(window.location.href); + const onArtist = platform && isArtistPage(window.location.href, platform); + let btn = document.getElementById('fc-add-source-btn'); + if (onArtist && !btn) injectButton(); + else if (!onArtist && btn) btn.remove(); + } + + function injectButton() { + const btn = document.createElement('button'); + btn.id = 'fc-add-source-btn'; + btn.className = 'fc-add-source-btn'; + btn.textContent = '+ Add to FabledCurator'; + btn.addEventListener('click', onClick); + document.body.appendChild(btn); + } + + async function onClick() { + const btn = document.getElementById('fc-add-source-btn'); + btn.disabled = true; + const original = btn.textContent; + btn.textContent = 'Adding…'; + try { + const r = await browser.runtime.sendMessage({ + type: 'ADD_AS_SOURCE', + url: window.location.href, + }); + if (r.error) { + showToast(`Error: ${r.error}`, 'error'); + } else { + const verb = r.created_source ? 'Added' : 'Already a source for'; + showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success'); + } + } catch (e) { + showToast(`Error: ${e.message}`, 'error'); + } finally { + btn.disabled = false; + btn.textContent = original; + } + } + + function showToast(text, kind) { + const t = document.createElement('div'); + t.className = `fc-toast fc-toast--${kind}`; + t.textContent = text; + document.body.appendChild(t); + setTimeout(() => t.remove(), 4000); + } +})(); From 46d199450dff793d6afc9645fcd80070bd7465be Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:37:39 -0400 Subject: [PATCH 08/19] =?UTF-8?q?fc3g:=20ExtensionService.quick=5Fadd=5Fso?= =?UTF-8?q?urce=20=E2=80=94=20derive=20platform+slug,=20find-or-create=20A?= =?UTF-8?q?rtist+Source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/extension_service.py | 127 ++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 backend/app/services/extension_service.py diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py new file mode 100644 index 0000000..ff88c9c --- /dev/null +++ b/backend/app/services/extension_service.py @@ -0,0 +1,127 @@ +"""FC-3g: backend support for the Firefox extension. + +`ExtensionService.quick_add_source(url)` derives platform + artist +slug from a URL using regex patterns mirrored from +extension/lib/platforms.js, then find-or-creates Artist + Source rows +and returns a JSON-shaped dict for the API layer. +""" + +from __future__ import annotations + +import re + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Artist, Source +from ..utils.slug import slugify + + +class UnknownPlatformError(Exception): + """URL didn't match any platform pattern.""" + + +class InvalidUrlError(Exception): + """URL was empty or missing a scheme.""" + + +# Mirrored byte-for-byte from extension/lib/platforms.js +# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand — +# reviewers catch drift. +_PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ + ("patreon", re.compile( + r"^https?://(?:www\.)?patreon\.com/" + r"(?!home$|search\b|messages\b|notifications\b|library\b|settings\b|posts\b|c/)" + r"(?P[^/?#]+)/?$", + re.IGNORECASE, + )), + ("subscribestar", re.compile( + r"^https?://(?:www\.)?subscribestar\.(?:com|adult)/" + r"(?!feed$|messages$|library$)" + r"(?P[^/?#]+)/?$", + re.IGNORECASE, + )), + ("hentaifoundry", re.compile( + r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P[^/?#]+)", + re.IGNORECASE, + )), + ("deviantart", re.compile( + r"^https?://(?:www\.)?deviantart\.com/" + r"(?!home$|watch\b|tag\b|browse\b)" + r"(?P[^/?#]+)/?$", + re.IGNORECASE, + )), + ("pixiv", re.compile( + r"^https?://(?:www\.)?pixiv\.net/(?:en/)?users/(?P\d+)", + re.IGNORECASE, + )), +] + + +class ExtensionService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def quick_add_source(self, url: str) -> dict: + platform, raw_slug = self._derive(url) + artist, created_artist = await self._find_or_create_artist(raw_slug) + source, created_source = await self._find_or_create_source( + artist_id=artist.id, platform=platform, url=url, + ) + return { + "source": { + "id": source.id, + "artist_id": source.artist_id, + "platform": source.platform, + "url": source.url, + "enabled": source.enabled, + }, + "artist": { + "id": artist.id, + "name": artist.name, + "slug": artist.slug, + }, + "created_source": created_source, + "created_artist": created_artist, + } + + def _derive(self, url: str) -> tuple[str, str]: + if not isinstance(url, str) or not url.strip(): + raise InvalidUrlError("url is empty") + if not url.startswith(("http://", "https://")): + raise InvalidUrlError(f"url must include http:// or https:// scheme: {url!r}") + for platform, pattern in _PLATFORM_PATTERNS: + m = pattern.match(url) + if m: + return platform, m.group("slug") + raise UnknownPlatformError(f"no platform pattern matched {url!r}") + + async def _find_or_create_artist(self, raw_name: str) -> tuple[Artist, bool]: + slug = slugify(raw_name) + existing = (await self.session.execute( + select(Artist).where(Artist.slug == slug) + )).scalar_one_or_none() + if existing is not None: + return existing, False + artist = Artist(name=raw_name, slug=slug, is_subscription=True) + self.session.add(artist) + await self.session.flush() + return artist, True + + async def _find_or_create_source( + self, *, artist_id: int, platform: str, url: str, + ) -> tuple[Source, bool]: + existing = (await self.session.execute( + select(Source).where( + Source.artist_id == artist_id, + Source.platform == platform, + Source.url == url, + ) + )).scalar_one_or_none() + if existing is not None: + return existing, False + src = Source(artist_id=artist_id, platform=platform, url=url, enabled=True) + self.session.add(src) + await self.session.flush() + await self.session.commit() + return src, True From 2065672a313106a1a4e59923c92a573f8bb5b34a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:38:35 -0400 Subject: [PATCH 09/19] =?UTF-8?q?fc3g:=20/api/extension=20blueprint=20?= =?UTF-8?q?=E2=80=94=20quick-add-source=20+=20manifest=20endpoints,=20regi?= =?UTF-8?q?stered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/__init__.py | 2 + backend/app/api/extension.py | 103 +++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 backend/app/api/extension.py diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 2de6a32..62ad522 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -21,6 +21,7 @@ def all_blueprints() -> list[Blueprint]: from .attachments import attachments_bp from .credentials import credentials_bp from .downloads import downloads_bp + from .extension import extension_bp from .gallery import gallery_bp from .import_admin import import_admin_bp from .migrate import migrate_bp @@ -53,5 +54,6 @@ def all_blueprints() -> list[Blueprint]: platforms_bp, posts_bp, credentials_bp, + extension_bp, downloads_bp, ] diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py new file mode 100644 index 0000000..99bf5e2 --- /dev/null +++ b/backend/app/api/extension.py @@ -0,0 +1,103 @@ +"""FC-3g: /api/extension — quick-add-source for the Firefox extension ++ install-time manifest for the Settings card. +""" + +from __future__ import annotations + +import hashlib +import re +from pathlib import Path + +from quart import Blueprint, jsonify, request +from sqlalchemy import select + +from ..extensions import get_session +from ..models import AppSetting +from ..services.extension_service import ( + ExtensionService, + InvalidUrlError, + UnknownPlatformError, +) +from ..services.source_service import KNOWN_PLATFORMS + +extension_bp = Blueprint("extension", __name__, url_prefix="/api/extension") + +# Default XPI directory; tests override via monkeypatching this module- +# level constant. +XPI_DIR = Path("/app/frontend/dist/extension") + +_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P[\w.-]+)\.xpi$") + + +def _bad(error: str, *, status: int = 400, **extra): + body = {"error": error} + body.update(extra) + return jsonify(body), status + + +async def _ext_key_required(session) -> bool: + """Unlike /api/credentials (which accepts the browser path with no + header), quick-add-source writes server state and must be explicitly + authenticated.""" + supplied = request.headers.get("X-Extension-Key") + if supplied is None: + return False + stored = (await session.execute( + select(AppSetting.value).where(AppSetting.key == "extension_api_key") + )).scalar_one_or_none() + return stored is not None and supplied == stored + + +def _extract_version(xpi_name: str) -> str: + m = _XPI_VERSION_RE.search(xpi_name) + return m.group("version") if m else "unknown" + + +def _sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fp: + for chunk in iter(lambda: fp.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +@extension_bp.route("/quick-add-source", methods=["POST"]) +async def quick_add_source(): + body = await request.get_json(silent=True) + if not isinstance(body, dict): + return _bad("invalid_body", detail="body must be a JSON object") + url = body.get("url") + if not isinstance(url, str) or not url.strip(): + return _bad("invalid_body", detail="url is required") + + async with get_session() as session: + if not await _ext_key_required(session): + return _bad("unauthorized", status=401) + try: + result = await ExtensionService(session).quick_add_source(url) + except UnknownPlatformError as exc: + return _bad( + "unknown_platform", + detail=str(exc), + known=sorted(KNOWN_PLATFORMS), + ) + except InvalidUrlError as exc: + return _bad("invalid_url", detail=str(exc)) + return jsonify(result), (201 if result["created_source"] else 200) + + +@extension_bp.route("/manifest", methods=["GET"]) +async def extension_manifest(): + if not XPI_DIR.is_dir(): + return jsonify({"installed": False}), 404 + xpis = sorted(XPI_DIR.glob("fabledcurator-*.xpi"), key=lambda p: p.stat().st_mtime) + if not xpis: + return jsonify({"installed": False}), 404 + latest = xpis[-1] + return jsonify({ + "installed": True, + "version": _extract_version(latest.name), + "xpi_url": f"/extension/{latest.name}", + "latest_url": "/extension/fabledcurator-latest.xpi", + "sha256": _sha256(latest), + }) From 2e1aaffd93e9e89da9f3504d7772c430587f255b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:39:10 -0400 Subject: [PATCH 10/19] fc3g: serve /extension/ with x-xpinstall MIME so Firefox installs in one click Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/frontend.py | 46 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/backend/app/frontend.py b/backend/app/frontend.py index 7855148..8bf97ee 100644 --- a/backend/app/frontend.py +++ b/backend/app/frontend.py @@ -1,13 +1,18 @@ """Serves the built Vue SPA from frontend/dist/ with history-mode fallback, -and the on-disk image library + thumbnails from /images. +the on-disk image library + thumbnails from /images, and the signed +Firefox extension XPI from frontend/dist/extension/. """ +import re from pathlib import Path -from quart import Blueprint, abort, send_from_directory +from quart import Blueprint, abort, send_file, send_from_directory FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" IMAGES_ROOT = Path("/images") +XPI_DIR = FRONTEND_DIST / "extension" + +_XPI_NAME_RE = re.compile(r"^fabledcurator-[\w.-]+\.xpi$") frontend_bp = Blueprint("frontend", __name__) @@ -31,6 +36,43 @@ async def serve_image(subpath: str): return await send_from_directory(IMAGES_ROOT, subpath) +@frontend_bp.route("/extension/") +async def serve_extension(filename: str): + """Serve the signed FC Firefox extension XPI. + + Path whitelist: filename must match fabledcurator-*.xpi. The special + name fabledcurator-latest.xpi serves the most-recently-modified XPI + in the directory. + + The application/x-xpinstall MIME tells Firefox to show its native + install prompt instead of downloading the file as a blob. + """ + if not _XPI_NAME_RE.fullmatch(filename): + abort(404) + if not XPI_DIR.is_dir(): + abort(404) + if filename == "fabledcurator-latest.xpi": + xpis = sorted(XPI_DIR.glob("fabledcurator-*.xpi"), key=lambda p: p.stat().st_mtime) + if not xpis: + abort(404) + latest = xpis[-1] + return await send_file( + latest, mimetype="application/x-xpinstall", + attachment_filename=latest.name, + ) + target = (XPI_DIR / filename).resolve() + try: + target.relative_to(XPI_DIR) + except ValueError: + abort(404) + if not target.is_file(): + abort(404) + return await send_file( + target, mimetype="application/x-xpinstall", + attachment_filename=filename, + ) + + @frontend_bp.route("/") @frontend_bp.route("/") async def serve_spa(subpath: str = ""): From cd838ec904009a3fa8ad4d718e8404453487d49e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:40:00 -0400 Subject: [PATCH 11/19] fc3g: integration tests for /api/extension/* and /extension/ Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_api_extension.py | 277 ++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 tests/test_api_extension.py diff --git a/tests/test_api_extension.py b/tests/test_api_extension.py new file mode 100644 index 0000000..ca3671f --- /dev/null +++ b/tests/test_api_extension.py @@ -0,0 +1,277 @@ +"""FC-3g: /api/extension and /extension/ integration tests.""" + +import hashlib + +import pytest +import pytest_asyncio +from sqlalchemy import func, select + +from backend.app import create_app +from backend.app import frontend as frontend_module +from backend.app.api import extension as extension_module +from backend.app.models import AppSetting, Artist, Source + +pytestmark = pytest.mark.integration + + +@pytest.fixture +async def app(): + return create_app() + + +@pytest.fixture +async def client(app): + async with app.test_client() as c: + yield c + + +@pytest_asyncio.fixture +async def ext_key(db): + db.add(AppSetting(key="extension_api_key", value="test-ext-key")) + await db.commit() + return "test-ext-key" + + +# --- /api/extension/quick-add-source --------------------------------- + + +@pytest.mark.asyncio +async def test_quick_add_source_creates_artist_and_source(client, ext_key, db_sync): + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": "https://www.patreon.com/maewix"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 201 + body = await resp.get_json() + assert body["created_artist"] is True + assert body["created_source"] is True + assert body["artist"]["slug"] == "maewix" + assert body["source"]["platform"] == "patreon" + assert body["source"]["enabled"] is True + + # Async Core-DML assertion: column select, not ORM attribute access. + artist_count = db_sync.execute( + select(func.count(Artist.id)).where(Artist.slug == "maewix") + ).scalar_one() + assert artist_count == 1 + source_count = db_sync.execute( + select(func.count(Source.id)).where( + Source.platform == "patreon", + Source.url == "https://www.patreon.com/maewix", + ) + ).scalar_one() + assert source_count == 1 + + +@pytest.mark.asyncio +async def test_quick_add_source_idempotent(client, ext_key): + body = {"url": "https://www.subscribestar.com/some-creator"} + headers = {"X-Extension-Key": ext_key} + r1 = await client.post("/api/extension/quick-add-source", json=body, headers=headers) + r2 = await client.post("/api/extension/quick-add-source", json=body, headers=headers) + assert r1.status_code == 201 + assert r2.status_code == 200 + body2 = await r2.get_json() + assert body2["created_source"] is False + assert body2["created_artist"] is False + + +@pytest.mark.parametrize("url,platform,slug", [ + ("https://www.patreon.com/maewix", "patreon", "maewix"), + ("https://patreon.com/maewix", "patreon", "maewix"), + ("https://www.subscribestar.com/foobar", "subscribestar", "foobar"), + ("https://subscribestar.adult/foobar", "subscribestar", "foobar"), + ("https://www.hentai-foundry.com/user/Foo/profile", "hentaifoundry", "Foo"), + ("https://www.deviantart.com/baz", "deviantart", "baz"), + ("https://www.pixiv.net/users/12345", "pixiv", "12345"), + ("https://www.pixiv.net/en/users/12345", "pixiv", "12345"), +]) +@pytest.mark.asyncio +async def test_quick_add_source_url_patterns(client, ext_key, url, platform, slug): + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": url}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 201, await resp.get_json() + body = await resp.get_json() + assert body["source"]["platform"] == platform + # slugify lowercases — the Artist slug should reflect that. + assert body["artist"]["slug"] == slug.lower() + + +@pytest.mark.asyncio +async def test_quick_add_source_unknown_url_400(client, ext_key): + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": "https://example.com/foo"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "unknown_platform" + assert "known" in body + + +@pytest.mark.asyncio +async def test_quick_add_source_invalid_url_400(client, ext_key): + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": "not-a-url"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "invalid_url" + + +@pytest.mark.asyncio +async def test_quick_add_source_missing_key_401(client): + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": "https://www.patreon.com/maewix"}, + ) + assert resp.status_code == 401 + body = await resp.get_json() + assert body["error"] == "unauthorized" + + +@pytest.mark.asyncio +async def test_quick_add_source_wrong_key_401(client, ext_key): + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": "https://www.patreon.com/maewix"}, + headers={"X-Extension-Key": "wrong-key"}, + ) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_quick_add_source_missing_body_400(client, ext_key): + resp = await client.post( + "/api/extension/quick-add-source", + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "invalid_body" + + +@pytest.mark.asyncio +async def test_quick_add_source_attaches_to_existing_artist(client, ext_key, db, db_sync): + db.add(Artist(name="Maewix Original", slug="maewix", is_subscription=False)) + await db.commit() + + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": "https://www.patreon.com/maewix"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 201 + body = await resp.get_json() + assert body["created_artist"] is False + assert body["created_source"] is True + + artist_count = db_sync.execute( + select(func.count(Artist.id)).where(Artist.slug == "maewix") + ).scalar_one() + assert artist_count == 1 # no duplicate created + + +# --- /api/extension/manifest --------------------------------------- + + +@pytest.mark.asyncio +async def test_extension_manifest_returns_404_when_dir_missing(client, monkeypatch, tmp_path): + monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path / "does-not-exist") + resp = await client.get("/api/extension/manifest") + assert resp.status_code == 404 + body = await resp.get_json() + assert body == {"installed": False} + + +@pytest.mark.asyncio +async def test_extension_manifest_returns_404_when_no_xpi_files(client, monkeypatch, tmp_path): + monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path) + resp = await client.get("/api/extension/manifest") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_extension_manifest_returns_metadata_when_xpi_present(client, monkeypatch, tmp_path): + xpi = tmp_path / "fabledcurator-1.2.3.xpi" + xpi.write_bytes(b"fake-xpi-content") + monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path) + resp = await client.get("/api/extension/manifest") + assert resp.status_code == 200 + body = await resp.get_json() + assert body["installed"] is True + assert body["version"] == "1.2.3" + assert body["xpi_url"] == "/extension/fabledcurator-1.2.3.xpi" + assert body["latest_url"] == "/extension/fabledcurator-latest.xpi" + assert body["sha256"] == hashlib.sha256(b"fake-xpi-content").hexdigest() + + +# --- /extension/ ----------------------------------------- + + +@pytest.mark.asyncio +async def test_serve_extension_rejects_non_xpi_filename(client, monkeypatch, tmp_path): + monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) + resp = await client.get("/extension/passwd") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_serve_extension_rejects_path_traversal(client, monkeypatch, tmp_path): + monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) + # Path-traversal attempt — the route regex alone catches this since + # `..` characters aren't in [\w.-]+, but cover the case anyway. + resp = await client.get("/extension/fabledcurator-..%2Fetc%2Fpasswd.xpi") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_serve_extension_404_when_xpi_missing(client, monkeypatch, tmp_path): + monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) + resp = await client.get("/extension/fabledcurator-1.0.0.xpi") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_serve_extension_serves_specific_xpi_with_correct_mime( + client, monkeypatch, tmp_path, +): + xpi = tmp_path / "fabledcurator-1.0.0.xpi" + xpi.write_bytes(b"xpi-bytes") + monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) + resp = await client.get("/extension/fabledcurator-1.0.0.xpi") + assert resp.status_code == 200 + assert resp.headers["Content-Type"].startswith("application/x-xpinstall") + + +@pytest.mark.asyncio +async def test_serve_extension_latest_returns_most_recent_xpi( + client, monkeypatch, tmp_path, +): + import os + import time + + older = tmp_path / "fabledcurator-1.0.0.xpi" + newer = tmp_path / "fabledcurator-1.0.1.xpi" + older.write_bytes(b"old") + newer.write_bytes(b"new") + os.utime(older, (time.time() - 10, time.time() - 10)) + monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) + resp = await client.get("/extension/fabledcurator-latest.xpi") + assert resp.status_code == 200 + data = await resp.get_data() + assert data == b"new" + + +@pytest.mark.asyncio +async def test_serve_extension_latest_404_when_dir_empty(client, monkeypatch, tmp_path): + monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path) + resp = await client.get("/extension/fabledcurator-latest.xpi") + assert resp.status_code == 404 From d85c108cec3d6a42f4178b3cbba0b79e5a1287e5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:40:56 -0400 Subject: [PATCH 12/19] =?UTF-8?q?fc3g(ui):=20BrowserExtensionCard=20on=20S?= =?UTF-8?q?ettings=20=E2=86=92=20Maintenance=20=E2=80=94=20one-click=20Fir?= =?UTF-8?q?efox=20install=20+=20key=20copy/rotate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/BrowserExtensionCard.vue | 189 ++++++++++++++++++ .../components/settings/MaintenancePanel.vue | 2 + 2 files changed, 191 insertions(+) create mode 100644 frontend/src/components/settings/BrowserExtensionCard.vue diff --git a/frontend/src/components/settings/BrowserExtensionCard.vue b/frontend/src/components/settings/BrowserExtensionCard.vue new file mode 100644 index 0000000..2dbe591 --- /dev/null +++ b/frontend/src/components/settings/BrowserExtensionCard.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index 9639edf..1ebdd08 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -12,6 +12,7 @@ + @@ -22,6 +23,7 @@ import CentroidRecomputeCard from './CentroidRecomputeCard.vue' import MLThresholdSliders from './MLThresholdSliders.vue' import AllowlistTable from './AllowlistTable.vue' import AliasTable from './AliasTable.vue' +import BrowserExtensionCard from './BrowserExtensionCard.vue' import LegacyMigrationCard from './LegacyMigrationCard.vue' From 4da853805463ace7ddad631e4cd3665b81005730 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:41:15 -0400 Subject: [PATCH 13/19] =?UTF-8?q?fc3g(ci):=20extension=20workflow=20?= =?UTF-8?q?=E2=80=94=20lint=20on=20dev/main,=20sign+commit=20XPI=20on=20ma?= =?UTF-8?q?in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .forgejo/workflows/extension.yml | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .forgejo/workflows/extension.yml diff --git a/.forgejo/workflows/extension.yml b/.forgejo/workflows/extension.yml new file mode 100644 index 0000000..c992410 --- /dev/null +++ b/.forgejo/workflows/extension.yml @@ -0,0 +1,61 @@ +name: extension +on: + push: + branches: [dev, main] + paths: ['extension/**'] + workflow_dispatch: + +jobs: + lint: + runs-on: python-ci + container: + image: node:22-bookworm-slim + steps: + - uses: actions/checkout@v4 + - name: Install web-ext + run: cd extension && npm install --no-save --no-audit --no-fund + - name: Lint + run: cd extension && npm run lint + + sign-and-publish: + needs: lint + if: github.ref == 'refs/heads/main' + runs-on: python-ci + container: + image: node:22-bookworm-slim + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.RELEASE_TOKEN }} + - name: Install web-ext + git + run: | + apt-get update && apt-get install -y --no-install-recommends git ca-certificates + cd extension && npm install --no-save --no-audit --no-fund + - name: Sign XPI + run: cd extension && npm run sign + env: + WEB_EXT_API_KEY: ${{ secrets.MOZILLA_AMO_JWT_KEY }} + WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }} + - name: Commit signed XPI to frontend/public/extension/ + run: | + set -e + XPI=$(ls extension/web-ext-artifacts/fabledcurator-*.xpi | head -1) + if [ -z "$XPI" ]; then + echo "No XPI produced by web-ext sign — exiting" + exit 1 + fi + mkdir -p frontend/public/extension + cp "$XPI" frontend/public/extension/ + # Also copy as -latest.xpi so the FC server can serve a stable URL. + cp "$XPI" "frontend/public/extension/fabledcurator-latest.xpi" + git config user.name "FC extension CI" + git config user.email "noreply@fabledsword.com" + git add frontend/public/extension/ + if git diff --cached --quiet; then + echo "No changes to commit" + else + git commit -m "ext: publish signed XPI $(basename $XPI) + +Co-Authored-By: FC extension CI " + git push origin HEAD:main + fi From 4ae9815d61b8e588974ecd25a9c7468f801a20fa Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 23 May 2026 23:43:44 -0400 Subject: [PATCH 14/19] fix(ui): useApi passes FormData through unaltered so multipart uploads work (IR/GS ingest from UI was JSON-stringifying FormData into '{}') Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/composables/useApi.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/composables/useApi.js b/frontend/src/composables/useApi.js index cc88c0d..1be0c31 100644 --- a/frontend/src/composables/useApi.js +++ b/frontend/src/composables/useApi.js @@ -24,8 +24,16 @@ async function request(method, url, { body, params, signal } = {}) { const headers = {} const init = { method, headers, signal } if (body !== undefined) { - headers['Content-Type'] = 'application/json' - init.body = JSON.stringify(body) + if (body instanceof FormData) { + // Let the browser set Content-Type with the multipart boundary. + // JSON-stringifying FormData produces "{}" and the backend sees + // an empty multipart payload (this broke FC-5 IR/GS ingest from + // the UI for several releases). + init.body = body + } else { + headers['Content-Type'] = 'application/json' + init.body = JSON.stringify(body) + } } const r = await fetch(fullUrl, init) From dd4874ae8dda36a2246edcd7527ab5ac96b93646 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 11:10:04 -0400 Subject: [PATCH 15/19] fix: 30min wall-clock timeout on backup subprocess + global padding-top on .fc-content so views don't start under the sticky TopNav Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/migrators/backup.py | 11 ++++++++++- frontend/src/components/AppShell.vue | 6 ++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/backend/app/services/migrators/backup.py b/backend/app/services/migrators/backup.py index 4d3cb96..d0bbf72 100644 --- a/backend/app/services/migrators/backup.py +++ b/backend/app/services/migrators/backup.py @@ -37,12 +37,21 @@ def _backups_dir(images_root: Path | None = None) -> Path: return p +_DEFAULT_SUBPROCESS_TIMEOUT_S = 30 * 60 # 30 minutes + + def _run_subprocess(cmd: list[str], **kwargs: Any): - # Overridable for tests via monkeypatch. + # Overridable for tests via monkeypatch. Hard wall-clock timeout + # guards against pg_dump / tar / zstd hangs on NFS — without it the + # task pretends to be 'running' forever (operator hit this 2026-05- + # 23 with two backups stuck in MigrationRun). On timeout + # subprocess.run raises TimeoutExpired which the caller surfaces as + # a task error. return subprocess.run( cmd, capture_output=True, check=True, + timeout=_DEFAULT_SUBPROCESS_TIMEOUT_S, **{k: v for k, v in kwargs.items() if not k.startswith("_")}, ) diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 3546e40..a583526 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -18,5 +18,11 @@ const route = useRoute() From be0f472894a300e063aaa16e2c246aa11a13ed4b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 11:46:36 -0400 Subject: [PATCH 16/19] =?UTF-8?q?fix(workers):=20worker-level=20recovery?= =?UTF-8?q?=20=E2=80=94=20autoretry=20on=20transient=20errors=20+=20tighte?= =?UTF-8?q?ned=20sweep=20(5min)=20+=20import=5Fmedia=5Ffile=20body-wrap=20?= =?UTF-8?q?so=20no=20path=20leaves=20rows=20stuck=20in=20'processing'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/tasks/download.py | 14 +- backend/app/tasks/import_file.py | 236 +++++++++++++++++++------------ backend/app/tasks/maintenance.py | 14 +- backend/app/tasks/ml.py | 13 +- backend/app/tasks/thumbnail.py | 14 +- 5 files changed, 195 insertions(+), 96 deletions(-) diff --git a/backend/app/tasks/download.py b/backend/app/tasks/download.py index a6416ab..8801a56 100644 --- a/backend/app/tasks/download.py +++ b/backend/app/tasks/download.py @@ -4,6 +4,7 @@ import asyncio from pathlib import Path from sqlalchemy import select +from sqlalchemy.exc import DBAPIError, OperationalError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from ..celery_app import celery @@ -27,7 +28,18 @@ def _async_session_factory(): return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False), engine -@celery.task(name="backend.app.tasks.download.download_source", bind=True, acks_late=True) +@celery.task( + name="backend.app.tasks.download.download_source", + bind=True, + acks_late=True, + autoretry_for=(OperationalError, DBAPIError, OSError), + retry_backoff=10, + retry_backoff_max=120, + retry_jitter=True, + max_retries=3, + soft_time_limit=900, + time_limit=1200, +) def download_source(self, source_id: int) -> int: """Returns the DownloadEvent.id.""" diff --git a/backend/app/tasks/import_file.py b/backend/app/tasks/import_file.py index 562e7e7..7fd1066 100644 --- a/backend/app/tasks/import_file.py +++ b/backend/app/tasks/import_file.py @@ -1,11 +1,21 @@ """import_media_file task: imports one file via the Importer service and updates the ImportTask state machine + ImportBatch counters atomically. + +Resilience contract (2026-05-24, operator-mandated): once a row has been +flipped to 'processing' inside this task, EVERY exit path MUST flip it +to a terminal state (complete / skipped / failed) or rely on Celery's +autoretry to attempt the work again. No exit path is allowed to leave +the row stuck in 'processing'. The `recover_interrupted_tasks` +maintenance sweep is the safety net (5 min threshold) for the case +where even the failure-marking commit can't be written. """ from datetime import UTC, datetime from pathlib import Path +from celery.exceptions import SoftTimeLimitExceeded from sqlalchemy import select, update +from sqlalchemy.exc import DBAPIError, OperationalError from ..celery_app import celery from ..models import ImportBatch, ImportSettings, ImportTask @@ -29,9 +39,54 @@ def _map_result_to_status(result): return ("failed", False) -@celery.task(name="backend.app.tasks.import_file.import_media_file", bind=True) +def _mark_failed(session, task, error_msg: str) -> None: + """Best-effort flip of a 'processing' row to 'failed' + batch counter + increment. Wrapped in its own try because if the DB is what just + broke, this commit will also fail — that's why the maintenance sweep + exists as a backstop.""" + try: + task.status = "failed" + task.error = error_msg + task.finished_at = datetime.now(UTC) + session.add(task) + session.execute( + update(ImportBatch) + .where(ImportBatch.id == task.batch_id) + .values(failed=ImportBatch.failed + 1) + ) + session.commit() + except Exception: # noqa: BLE001 — best-effort, sweep catches the rest + try: + session.rollback() + except Exception: # noqa: BLE001 + pass + + +@celery.task( + name="backend.app.tasks.import_file.import_media_file", + bind=True, + autoretry_for=(OperationalError, DBAPIError, OSError), + retry_backoff=5, + retry_backoff_max=60, + retry_jitter=True, + max_retries=3, + soft_time_limit=300, + time_limit=360, +) def import_media_file(self, import_task_id: int) -> dict: - """Returns a dict so the eager-mode tests can assert without DB.""" + """Returns a dict so the eager-mode tests can assert without DB. + + Decorator notes: + - autoretry_for: transient DB / filesystem errors retry with + exponential backoff (5s base, jitter, max 3 attempts). On final + give-up the task raises and acks_late=True (set globally on the + Celery app) does NOT redeliver — the recovery sweep catches the + row instead. + - soft_time_limit (300s) raises SoftTimeLimitExceeded in this + process so the task can mark its row failed before being killed. + - time_limit (360s) is the hard cap; SIGKILL if the soft signal + was swallowed. + """ SessionLocal = _sync_session_factory() with SessionLocal() as session: task = session.get(ImportTask, import_task_id) @@ -43,99 +98,104 @@ def import_media_file(self, import_task_id: int) -> dict: session.add(task) session.commit() - settings = session.execute( - select(ImportSettings).where(ImportSettings.id == 1) - ).scalar_one() - import_root = Path(settings.import_scan_path) - batch = session.get(ImportBatch, task.batch_id) - deep = bool(batch and batch.scan_mode == "deep") - importer = Importer( - session=session, - images_root=IMAGES_ROOT, - import_root=import_root, - thumbnailer=Thumbnailer(images_root=IMAGES_ROOT), - settings=settings, - deep=deep, - ) - try: - result = importer.import_one(Path(task.source_path)) - except Exception as exc: # pragma: no cover — pipeline crash - task.status = "failed" - task.error = f"{type(exc).__name__}: {exc}" - task.finished_at = datetime.now(UTC) - session.execute( - update(ImportBatch) - .where(ImportBatch.id == task.batch_id) - .values(failed=ImportBatch.failed + 1) - ) - session.add(task) - session.commit() + return _do_import(session, task, import_task_id) + except SoftTimeLimitExceeded: + _mark_failed(session, task, "soft_time_limit exceeded (>300s)") + raise + except (OperationalError, DBAPIError, OSError): + # Retryable per the decorator; do NOT mark failed (let + # autoretry have a clean go at it). If autoretry exhausts, + # the row stays 'processing' and the maintenance sweep + # flips it within 5 min. + raise + except Exception as exc: # noqa: BLE001 — pipeline crash, mark + re-raise + _mark_failed(session, task, f"{type(exc).__name__}: {exc}") raise - if result.status in ("imported", "superseded"): - task.status = "complete" - task.result_image_id = result.image_id - counter_col_name = "imported" - counter_col = ImportBatch.imported - elif result.status == "attached": - task.status = "complete" - counter_col_name = "attachments" - counter_col = ImportBatch.attachments - elif result.status == "skipped": - task.status = "skipped" - task.error = ( - f"{result.skip_reason.value}: {result.error}" - if result.skip_reason - else result.error - ) - task.result_image_id = result.image_id - counter_col_name = "skipped" - counter_col = ImportBatch.skipped - else: - task.status = "failed" - task.error = result.error - counter_col_name = "failed" - counter_col = ImportBatch.failed - task.finished_at = datetime.now(UTC) +def _do_import(session, task, import_task_id: int) -> dict: + """Actual work, called from inside the resilience wrapper.""" + settings = session.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + import_root = Path(settings.import_scan_path) + batch = session.get(ImportBatch, task.batch_id) + deep = bool(batch and batch.scan_mode == "deep") + importer = Importer( + session=session, + images_root=IMAGES_ROOT, + import_root=import_root, + thumbnailer=Thumbnailer(images_root=IMAGES_ROOT), + settings=settings, + deep=deep, + ) + + result = importer.import_one(Path(task.source_path)) + + if result.status in ("imported", "superseded"): + task.status = "complete" + task.result_image_id = result.image_id + counter_col_name = "imported" + counter_col = ImportBatch.imported + elif result.status == "attached": + task.status = "complete" + counter_col_name = "attachments" + counter_col = ImportBatch.attachments + elif result.status == "skipped": + task.status = "skipped" + task.error = ( + f"{result.skip_reason.value}: {result.error}" + if result.skip_reason + else result.error + ) + task.result_image_id = result.image_id + counter_col_name = "skipped" + counter_col = ImportBatch.skipped + else: + task.status = "failed" + task.error = result.error + counter_col_name = "failed" + counter_col = ImportBatch.failed + + task.finished_at = datetime.now(UTC) + session.execute( + update(ImportBatch) + .where(ImportBatch.id == task.batch_id) + .values({counter_col_name: counter_col + 1}) + ) + session.add(task) + session.commit() + + # Enqueue thumbnail + ML for newly imported AND superseded images + # (a superseded row has cleared ML + no thumbnail). + if result.status in ("imported", "superseded"): + from .ml import tag_and_embed + from .thumbnail import generate_thumbnail + + ids = list(result.member_image_ids) + if result.image_id is not None and result.image_id not in ids: + ids.append(result.image_id) + for img_id in ids: + generate_thumbnail.delay(img_id) + tag_and_embed.delay(img_id) + + # If this was the last task in the batch, mark the batch complete. + remaining = session.execute( + select(ImportTask.id) + .where(ImportTask.batch_id == task.batch_id) + .where(ImportTask.status.in_(["pending", "queued", "processing"])) + .limit(1) + ).scalar_one_or_none() + if remaining is None: session.execute( update(ImportBatch) .where(ImportBatch.id == task.batch_id) - .values({counter_col_name: counter_col + 1}) + .values( + status="complete", + finished_at=datetime.now(UTC), + ) ) - session.add(task) session.commit() - # Enqueue thumbnail + ML for newly imported AND superseded images - # (a superseded row has cleared ML + no thumbnail). - if result.status in ("imported", "superseded"): - from .ml import tag_and_embed - from .thumbnail import generate_thumbnail - - ids = list(result.member_image_ids) - if result.image_id is not None and result.image_id not in ids: - ids.append(result.image_id) - for img_id in ids: - generate_thumbnail.delay(img_id) - tag_and_embed.delay(img_id) - - # If this was the last task in the batch, mark the batch complete. - remaining = session.execute( - select(ImportTask.id) - .where(ImportTask.batch_id == task.batch_id) - .where(ImportTask.status.in_(["pending", "queued", "processing"])) - .limit(1) - ).scalar_one_or_none() - if remaining is None: - session.execute( - update(ImportBatch) - .where(ImportBatch.id == task.batch_id) - .values( - status="complete", - finished_at=datetime.now(UTC), - ) - ) - session.commit() - - return {"task_id": import_task_id, "status": task.status} + return {"task_id": import_task_id, "status": task.status} diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 4b23ac0..2a50e86 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -15,7 +15,7 @@ from ._sync_engine import sync_session_factory as _sync_session_factory log = logging.getLogger(__name__) -STUCK_THRESHOLD_MINUTES = 30 +STUCK_THRESHOLD_MINUTES = 5 OLD_TASK_DAYS = 7 PHASH_PAGE = 500 VERIFY_PAGE = 200 @@ -24,11 +24,15 @@ FFPROBE_TIMEOUT_SECONDS = 10 @celery.task(name="backend.app.tasks.maintenance.recover_interrupted_tasks") def recover_interrupted_tasks() -> int: - """Find ImportTask rows stuck in 'processing' for >30 min and re-queue them. + """Find ImportTask rows stuck in 'processing' for >5 min and re-queue them. - Why 30 min: large videos can legitimately take many minutes to import; - 30 is a safe gate that catches actual crashes (which leave the row stuck - forever) without resetting slow-but-still-running jobs. + Why 5 min: import_media_file is sub-second for the vast majority of + files; even a large-video transcode caps at the per-task soft_time_limit + (5 min) defined on the task itself. Anything still 'processing' after + that window is a confirmed crash (worker died, DB disconnect mid-flush, + OOM) and must be recycled. Was 30 min historically; tightened + 2026-05-24 after operator hit a 2224-row zombie pile during the IR + migration scan. """ SessionLocal = _sync_session_factory() cutoff = datetime.now(UTC) - timedelta(minutes=STUCK_THRESHOLD_MINUTES) diff --git a/backend/app/tasks/ml.py b/backend/app/tasks/ml.py index a208376..75de41d 100644 --- a/backend/app/tasks/ml.py +++ b/backend/app/tasks/ml.py @@ -9,6 +9,7 @@ apply_allowlist_tags sweeps which are 'maintenance' lane. Sync sessions from pathlib import Path from sqlalchemy import select +from sqlalchemy.exc import DBAPIError, OperationalError from ..celery_app import celery from ..models import ImageRecord, MLSettings @@ -22,7 +23,17 @@ def _is_video(path: Path) -> bool: return path.suffix.lower() in VIDEO_EXTS -@celery.task(name="backend.app.tasks.ml.tag_and_embed", bind=True) +@celery.task( + name="backend.app.tasks.ml.tag_and_embed", + bind=True, + autoretry_for=(OperationalError, DBAPIError, OSError), + retry_backoff=5, + retry_backoff_max=60, + retry_jitter=True, + max_retries=3, + soft_time_limit=300, + time_limit=420, +) def tag_and_embed(self, image_id: int) -> dict: """Run Camie + SigLIP on one image; store predictions + embedding; then enqueue per-image allowlist application. diff --git a/backend/app/tasks/thumbnail.py b/backend/app/tasks/thumbnail.py index 61bed80..dec644f 100644 --- a/backend/app/tasks/thumbnail.py +++ b/backend/app/tasks/thumbnail.py @@ -7,6 +7,8 @@ so they deserve their own queue lane. from pathlib import Path +from sqlalchemy.exc import DBAPIError, OperationalError + from ..celery_app import celery from ..models import ImageRecord from ..services.importer import is_video @@ -16,7 +18,17 @@ from ._sync_engine import sync_session_factory as _sync_session_factory IMAGES_ROOT = Path("/images") -@celery.task(name="backend.app.tasks.thumbnail.generate_thumbnail", bind=True) +@celery.task( + name="backend.app.tasks.thumbnail.generate_thumbnail", + bind=True, + autoretry_for=(OperationalError, DBAPIError, OSError), + retry_backoff=5, + retry_backoff_max=60, + retry_jitter=True, + max_retries=3, + soft_time_limit=120, + time_limit=180, +) def generate_thumbnail(self, image_id: int) -> dict: SessionLocal = _sync_session_factory() with SessionLocal() as session: From 9f008af6c8a671f8b8f44ce29af9e762f09c7f6e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 11:58:39 -0400 Subject: [PATCH 17/19] =?UTF-8?q?fix(ci):=20extension.yml=20=E2=80=94=20si?= =?UTF-8?q?ngle-line=20bot=20commit=20message=20so=20YAML=20block=20scalar?= =?UTF-8?q?=20doesn't=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .forgejo/workflows/extension.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.forgejo/workflows/extension.yml b/.forgejo/workflows/extension.yml index c992410..63c23e8 100644 --- a/.forgejo/workflows/extension.yml +++ b/.forgejo/workflows/extension.yml @@ -54,8 +54,6 @@ jobs: if git diff --cached --quiet; then echo "No changes to commit" else - git commit -m "ext: publish signed XPI $(basename $XPI) - -Co-Authored-By: FC extension CI " + git commit -m "ext: publish signed XPI $(basename $XPI)" git push origin HEAD:main fi From 96039fc9839947d1fa2a3a27968631e7615e85c0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 12:20:36 -0400 Subject: [PATCH 18/19] fix(ext): wrap /api/extension/manifest filesystem work in asyncio.to_thread (ruff ASYNC240) Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/extension.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py index 99bf5e2..54d569f 100644 --- a/backend/app/api/extension.py +++ b/backend/app/api/extension.py @@ -4,6 +4,7 @@ from __future__ import annotations +import asyncio import hashlib import re from pathlib import Path @@ -86,18 +87,28 @@ async def quick_add_source(): return jsonify(result), (201 if result["created_source"] else 200) -@extension_bp.route("/manifest", methods=["GET"]) -async def extension_manifest(): +def _read_manifest_sync() -> dict | None: + """All the filesystem-touching work for /api/extension/manifest, + in a sync helper so the async route can dispatch it via + asyncio.to_thread (ASYNC240: no pathlib I/O in async functions).""" if not XPI_DIR.is_dir(): - return jsonify({"installed": False}), 404 + return None xpis = sorted(XPI_DIR.glob("fabledcurator-*.xpi"), key=lambda p: p.stat().st_mtime) if not xpis: - return jsonify({"installed": False}), 404 + return None latest = xpis[-1] - return jsonify({ + return { "installed": True, "version": _extract_version(latest.name), "xpi_url": f"/extension/{latest.name}", "latest_url": "/extension/fabledcurator-latest.xpi", "sha256": _sha256(latest), - }) + } + + +@extension_bp.route("/manifest", methods=["GET"]) +async def extension_manifest(): + info = await asyncio.to_thread(_read_manifest_sync) + if info is None: + return jsonify({"installed": False}), 404 + return jsonify(info) From 443748889984fa8789b0203a6fe8e1fa0fe08040 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 12:40:27 -0400 Subject: [PATCH 19/19] fix(tests): unpin test_recover_interrupted_only_old from STUCK_THRESHOLD_MINUTES (was -5min fresh / now -30sec so threshold can move freely) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_maintenance.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index f580916..1484b77 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -30,9 +30,14 @@ def test_recover_interrupted_only_old(db_sync, monkeypatch): batch_id = _make_batch(db_sync) now = datetime.now(UTC) + # "Fresh" must sit comfortably under whatever STUCK_THRESHOLD_MINUTES + # currently is (5 min as of 2026-05-24, tightened from 30); 30 + # seconds is well below any reasonable threshold. "Stale" stays at + # 2 hours so the test remains valid if the threshold ever moves + # back up. fresh = ImportTask( batch_id=batch_id, source_path="/import/a.jpg", task_type="media", - status="processing", started_at=now - timedelta(minutes=5), + status="processing", started_at=now - timedelta(seconds=30), ) stale = ImportTask( batch_id=batch_id, source_path="/import/b.jpg", task_type="media",