/** * 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.' }; // Verify the captured cookies are actually live BEFORE // uploading. Skips upload on confirmed-stale sessions so we // don't overwrite FC-side credentials with garbage. Platforms // without a verify config (verify.ok === null) fall through // to upload as before. const v = await verifyCookiesForPlatform(key); if (v.ok === false) { return { error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.reason}). Log in again in this browser, then retry.`, }; } const data = toNetscapeFormat(cookies); await api.uploadCredentials(key, 'cookies', data); return { success: true, cookieCount: cookies.length, verified: v.ok === true }; } 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; } const v = await verifyCookiesForPlatform(key); if (v.ok === false) { results[key] = { error: `verify failed: ${v.reason}` }; continue; } await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies)); results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true }; } 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': { // apiUrl is configured with the /api suffix (see // options/options.html placeholder); the SPA artist route is // /artist/:slug, served from the same origin. Strip /api so the // browser-level URL hits the Vue router, not the JSON API. const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, ''); 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 }; } } default: return { error: `Unknown message type: ${msg.type}` }; } });