CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 6s
CI and images / sign-extension (push) Successful in 4m48s
CI and images / build-web (push) Failing after 6s
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
Operator: "yes add the artist picker to patreon and subscribestar too. but generally we treat the patreon name as the canon" - Every add now goes through the panel. On Patreon/SubscribeStar it opens on the creator's display name (read only when the panel opens: probe?names=1), searches FC's artists with it, and auto-picks a match. An untouched URL handle sends no name, so the server still resolves it. - Patreon is canon: joining a Patreon source to an artist known by another name offers "Rename “x” to the Patreon name “X”", ticked by default. quick-add's use_platform_name renames server-side from the name it reads itself; name only, the slug never moves (#130); never to a URL handle when the name can't be read; ignored on SubscribeStar and Discord. - _platform_display_name returns None rather than the handle, bounded by the same 6s lookup budget as Discord's names. - panelDefaults / addRequest / renameOffer replace the Discord-only helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
314 lines
12 KiB
JavaScript
314 lines
12 KiB
JavaScript
/**
|
|
* Background script — message router + Discord token capture (webRequest).
|
|
* Direct port of GS background.js; api.js client points at FC instead of GS.
|
|
*
|
|
* pixiv's PKCE OAuth flow lived here until FC retired pixiv (milestone #406).
|
|
* It was removed together with pixiv's host permissions rather than left
|
|
* behind: a webRequest listener on a host the manifest no longer grants is at
|
|
* best dead and at worst a startup failure for the whole background script.
|
|
*/
|
|
|
|
let discordToken = null;
|
|
let discordTokenCapturedAt = null;
|
|
|
|
let initialized = false;
|
|
|
|
async function ensureInitialized() {
|
|
if (initialized) return;
|
|
await api.init();
|
|
await loadDiscordToken();
|
|
await forgetRetiredPixivToken();
|
|
initialized = true;
|
|
}
|
|
|
|
// A browser that authenticated pixiv before the retirement still holds a live
|
|
// OAuth refresh token in extension storage. Nothing reads it any more, and a
|
|
// credential for a service FC no longer uses is a liability with no benefit
|
|
// (the same reasoning as the server-side cleanup, issue #3980). Removing keys
|
|
// that are absent is a no-op, so this is safe on every startup.
|
|
async function forgetRetiredPixivToken() {
|
|
await browser.storage.local.remove(['pixivRefreshToken', 'pixivTokenCapturedAt']);
|
|
}
|
|
|
|
browser.runtime.onInstalled.addListener(() => ensureInitialized());
|
|
browser.runtime.onStartup.addListener(() => ensureInitialized());
|
|
ensureInitialized().catch(e => console.error('init failed:', e));
|
|
|
|
// ---- Extension self-update check (#1489) ----
|
|
// Installed per-instance from the operator's FC host, so Firefox's static
|
|
// update_url can't apply (each instance has a different host). Instead ask the
|
|
// configured backend for the latest published version and nudge the operator to
|
|
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
|
|
// a toolbar badge (daily). /api/extension/manifest is public and returns
|
|
// {version, latest_url, sha256} plus an OPTIONAL {channel} naming which channel
|
|
// that instance serves ("dev"/"main", #3113); the XPI is served from the web
|
|
// root (not /api).
|
|
//
|
|
// The channel IS the instance: Firefox's static update_url cannot apply here
|
|
// because every FC install is a different host, so the extension asks its
|
|
// configured backend — which means switching channel is repointing apiUrl in
|
|
// options and reinstalling from that host. There is no separate channel
|
|
// setting to build, and building one would contradict each server build
|
|
// shipping its own extension.
|
|
|
|
function versionIsNewer(candidate, current) {
|
|
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
|
|
const a = String(candidate).split('.').map(n => parseInt(n, 10) || 0);
|
|
const b = String(current).split('.').map(n => parseInt(n, 10) || 0);
|
|
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function checkForUpdateInfo() {
|
|
await ensureInitialized();
|
|
if (!api.isConfigured()) return { updateAvailable: false, configured: false };
|
|
let info;
|
|
try {
|
|
info = await api.getExtensionManifest();
|
|
} catch (e) {
|
|
return { updateAvailable: false, error: e.message };
|
|
}
|
|
const currentVersion = browser.runtime.getManifest().version;
|
|
const latestVersion = info && info.version ? info.version : null;
|
|
// Which channel the configured instance serves — reported ALONGSIDE the
|
|
// version, never folded into it. A `-dev` suffix would have to survive
|
|
// versionIsNewer's parseInt above, and it wouldn't: the segment would read
|
|
// as 0 and every dev build would compare equal to every other.
|
|
//
|
|
// null is a normal answer, not a failure — an instance built before the
|
|
// field existed, or one built locally with no channel declared. Nothing
|
|
// below branches on it except the label.
|
|
const channel = info && info.channel ? info.channel : null;
|
|
// latest_url is served from the web root, not the JSON API.
|
|
const base = api.webRoot();
|
|
return {
|
|
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
|
|
currentVersion,
|
|
latestVersion,
|
|
channel,
|
|
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
|
|
};
|
|
}
|
|
|
|
async function refreshUpdateBadge() {
|
|
let r;
|
|
try { r = await checkForUpdateInfo(); } catch { return; }
|
|
try {
|
|
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
|
|
if (r.updateAvailable) {
|
|
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
|
|
// Channel first, version second, and the channel dropped entirely when
|
|
// the instance doesn't report one — so the tooltip reads exactly as it
|
|
// did before the field existed rather than saying "(unknown ...)".
|
|
const label = r.channel ? `${r.channel} v${r.latestVersion}` : `v${r.latestVersion}`;
|
|
await browser.action.setTitle({ title: `FabledCurator — update available (${label})` });
|
|
} else {
|
|
await browser.action.setTitle({ title: 'FabledCurator' });
|
|
}
|
|
} catch { /* action API unavailable — non-fatal */ }
|
|
}
|
|
|
|
// Daily proactive check (needs the "alarms" permission). create() is idempotent
|
|
// by name, so re-running it on each event-page load is safe.
|
|
browser.alarms.create('fc-update-check', { periodInMinutes: 24 * 60, delayInMinutes: 1 });
|
|
browser.alarms.onAlarm.addListener((alarm) => {
|
|
if (alarm.name === 'fc-update-check') refreshUpdateBadge();
|
|
});
|
|
browser.runtime.onStartup.addListener(() => refreshUpdateBadge());
|
|
browser.runtime.onInstalled.addListener(() => refreshUpdateBadge());
|
|
|
|
// ---- 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);
|
|
}
|
|
},
|
|
// ptb/canary are Discord's beta clients; their API calls carry the same token.
|
|
{ urls: ['https://discord.com/api/*', '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 });
|
|
}
|
|
|
|
// Extract → verify → upload one cookie-auth platform. Returns a structured
|
|
// outcome so the two callers (EXPORT_COOKIES single, EXPORT_ALL_COOKIES) shape
|
|
// their own response + skip semantics. Verifies the captured cookies are
|
|
// actually live BEFORE uploading, so a confirmed-stale session doesn't overwrite
|
|
// good FC-side credentials; platforms with no verify config (v.ok === null) fall
|
|
// through to upload.
|
|
async function exportPlatformCookies(key) {
|
|
const cookies = await extractCookiesForPlatform(key);
|
|
if (cookies.length === 0) return { status: 'empty' };
|
|
const v = await verifyCookiesForPlatform(key);
|
|
if (v.ok === false) return { status: 'stale', reason: v.reason, cookieCount: cookies.length };
|
|
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
|
|
return { status: 'ok', cookieCount: cookies.length, verified: v.ok === true };
|
|
}
|
|
|
|
// ---- 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 {
|
|
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 r = await exportPlatformCookies(key);
|
|
if (r.status === 'empty') return { error: 'No cookies found — log in first.' };
|
|
if (r.status === 'stale') {
|
|
return {
|
|
error: `Captured ${r.cookieCount} ${platform.name} cookies but they don't appear authenticated (${r.reason}). Log in again in this browser, then retry.`,
|
|
};
|
|
}
|
|
return { success: true, cookieCount: r.cookieCount, verified: r.verified };
|
|
}
|
|
if (key === 'discord') {
|
|
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
|
|
await api.uploadCredentials('discord', 'token', discordToken);
|
|
// Then have FC try it against a Discord source, so a token Discord
|
|
// has already revoked shows up here rather than at the next check.
|
|
// A failed verify never undoes the upload: valid=null means FC could
|
|
// not test (no Discord source yet), not that the token is bad.
|
|
let verify = null;
|
|
try {
|
|
verify = await api.verifyCredential('discord');
|
|
} catch (e) {
|
|
verify = { valid: null, reason: e.message };
|
|
}
|
|
return { success: true, verify };
|
|
}
|
|
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 r = await exportPlatformCookies(key);
|
|
if (r.status === 'empty') results[key] = { skipped: true, reason: 'no cookies' };
|
|
else if (r.status === 'stale') results[key] = { error: `verify failed: ${r.reason}` };
|
|
else results[key] = { success: true, cookieCount: r.cookieCount, verified: r.verified };
|
|
} 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, {
|
|
artistId: msg.artistId ?? null,
|
|
artistName: msg.artistName ?? null,
|
|
usePlatformName: msg.usePlatformName === true,
|
|
});
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
|
|
case 'SEARCH_ARTISTS':
|
|
try {
|
|
return { artists: await api.searchArtists(msg.q || '') };
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
|
|
case 'PROBE_SOURCE':
|
|
try {
|
|
return await api.probeSource(msg.url, { names: msg.names === true });
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
|
|
case 'OPEN_ARTIST_PAGE': {
|
|
// The SPA artist route (/artist/:slug) is served from the web root, not
|
|
// the JSON API — see api.webRoot().
|
|
const base = api.webRoot();
|
|
const slug = encodeURIComponent(msg.slug || '');
|
|
if (!base || !slug) return { error: 'apiUrl or slug missing' };
|
|
try {
|
|
await browser.tabs.create({ url: `${base}/artist/${slug}` });
|
|
return { success: true };
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
}
|
|
|
|
case 'CHECK_UPDATE':
|
|
return await checkForUpdateInfo();
|
|
|
|
default:
|
|
return { error: `Unknown message type: ${msg.type}` };
|
|
}
|
|
});
|