CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
extension / lint (push) Successful in 28s
CI / integration (push) Successful in 3m52s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Failing after 5s
Build images / build-agent (push) Successful in 13s
Build images / build-web (push) Successful in 2m4s
Closes the half of the ask the signing work didn't: a way to tell a dev
build from a main one. FC_CHANNEL is baked into the web image at build
time and /api/extension/manifest reports it as its own key, next to
version — the popup banner, the toolbar tooltip and the Settings card all
name it.
Beside the version, never inside it. A `1.0.3499884-dev` suffix is the
obvious shortcut and it is the exact failure this design comes from:
versionIsNewer parses each dotted segment with parseInt, so a suffixed
segment reads as 0, every dev build compares equal to every other, and
"no update available" stops being distinguishable from "I cannot read this
version". The comparator already degrades rather than discarding (rule
150), which is a reason not to NEED the suffix, not a licence to add one.
Two tests hold the line — one backend, asserting version and channel are
separate keys; one frontend, asserting the rendered version text stays the
bare derived number.
Optional on the read side, and absent rather than defaulted. An image
built before this field says nothing by not having the key; an image built
without a channel now says nothing the same way, so there is one absence
to handle instead of a second spelling of "unknown". Every reader drops
the label entirely when it is missing and reads exactly as it did before.
Reported verbatim rather than validated against {dev, main}: if an image
declares something else, showing what it claims helps whoever is debugging
more than dropping it would.
FC_CHANNEL is declared LAST in the Dockerfile. An ARG invalidates every
layer below it, and this is the one value that differs between the dev and
main builds of identical source — earlier, and the two channels could
never share a cached pip install. A tag push counts as main: a vYY.MM.DD
tag is cut from main, so that image is a main-channel artifact wearing an
immutable name.
No channel switcher, deliberately. background.js:34 already records that
Firefox's static update_url cannot apply, because every FC instance is a
different host — so the extension asks its configured backend, and the
channel IS the instance it points at. Switching is repointing apiUrl and
reinstalling from that host. A separate setting would contradict each
server build shipping its own extension.
This commit touches packaged extension files, so it moves the derived
version and will sign a new one via AMO — the first push to exercise the
extension-changed path from dev end to end.
390 lines
14 KiB
JavaScript
390 lines
14 KiB
JavaScript
/**
|
|
* 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));
|
|
|
|
// ---- 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);
|
|
}
|
|
},
|
|
{ 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*'] },
|
|
);
|
|
|
|
// 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 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 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);
|
|
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 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);
|
|
} catch (e) {
|
|
return { error: e.message };
|
|
}
|
|
|
|
case 'PROBE_SOURCE':
|
|
try {
|
|
return await api.probeSource(msg.url);
|
|
} 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}` };
|
|
}
|
|
});
|