/** * Background script - handles cookie operations and API calls */ // Store for captured Discord token let discordToken = null; let discordTokenCapturedAt = null; // Store for Pixiv OAuth let pixivRefreshToken = null; let pixivTokenCapturedAt = null; let pixivOAuthPending = null; // { codeVerifier, tabId, resolve, reject } // Pixiv OAuth constants (these are public - used by all Pixiv mobile apps) 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'; // Track initialization state let initialized = false; /** * Ensure extension is initialized */ async function ensureInitialized() { if (!initialized) { console.log('Initializing extension...'); try { await api.init(); await loadDiscordToken(); await loadPixivToken(); initialized = true; console.log('Extension initialized successfully'); } catch (error) { console.error('Failed to initialize extension:', error); throw error; } } } // Initialize API on startup browser.runtime.onInstalled.addListener(async () => { console.log('GallerySubscriber extension installed'); await ensureInitialized(); }); browser.runtime.onStartup.addListener(async () => { console.log('GallerySubscriber extension startup'); await ensureInitialized(); }); // Also initialize immediately in case background wakes up from idle ensureInitialized().catch(err => console.error('Initial init failed:', err)); /** * Load Discord token from storage */ async function loadDiscordToken() { const stored = await browser.storage.local.get(['discordToken', 'discordTokenCapturedAt']); if (stored.discordToken) { discordToken = stored.discordToken; discordTokenCapturedAt = stored.discordTokenCapturedAt; console.log('Loaded Discord token from storage (captured:', discordTokenCapturedAt, ')'); } } /** * Save Discord token to storage */ async function saveDiscordToken(token) { discordToken = token; discordTokenCapturedAt = new Date().toISOString(); await browser.storage.local.set({ discordToken: token, discordTokenCapturedAt: discordTokenCapturedAt }); console.log('Discord token captured and saved'); } /** * Load Pixiv token from storage */ async function loadPixivToken() { const stored = await browser.storage.local.get(['pixivRefreshToken', 'pixivTokenCapturedAt']); if (stored.pixivRefreshToken) { pixivRefreshToken = stored.pixivRefreshToken; pixivTokenCapturedAt = stored.pixivTokenCapturedAt; console.log('Loaded Pixiv refresh token from storage (captured:', pixivTokenCapturedAt, ')'); } } /** * Save Pixiv refresh token to storage */ async function savePixivToken(token) { pixivRefreshToken = token; pixivTokenCapturedAt = new Date().toISOString(); await browser.storage.local.set({ pixivRefreshToken: token, pixivTokenCapturedAt: pixivTokenCapturedAt }); console.log('Pixiv refresh token captured and saved'); } /** * Generate a random code verifier for PKCE (43-128 chars, URL-safe) */ function generateCodeVerifier() { const array = new Uint8Array(32); crypto.getRandomValues(array); return base64UrlEncode(array); } /** * Generate code challenge from verifier (SHA-256 hash, base64url encoded) */ async function generateCodeChallenge(verifier) { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hash = await crypto.subtle.digest('SHA-256', data); return base64UrlEncode(new Uint8Array(hash)); } /** * Base64 URL encode (no padding, URL-safe characters) */ function base64UrlEncode(buffer) { const base64 = btoa(String.fromCharCode(...buffer)); return base64 .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } /** * Initiate Pixiv OAuth flow * Opens a new tab for the user to log in, then captures the authorization code */ async function initiatePixivOAuth() { // Generate PKCE values const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); // Build OAuth URL const params = new URLSearchParams({ code_challenge: codeChallenge, code_challenge_method: 'S256', client: 'pixiv-android' }); const authUrl = `${PIXIV_OAUTH_URL}?${params.toString()}`; console.log('Initiating Pixiv OAuth flow...'); // Create a promise that will be resolved when we get the callback return new Promise((resolve, reject) => { // Store the pending OAuth state pixivOAuthPending = { codeVerifier, resolve, reject, timeoutId: setTimeout(() => { pixivOAuthPending = null; reject(new Error('Pixiv OAuth timed out. Please try again.')); }, 5 * 60 * 1000) // 5 minute timeout }; // Open the auth URL in a new tab browser.tabs.create({ url: authUrl }).then(tab => { pixivOAuthPending.tabId = tab.id; console.log('Opened Pixiv OAuth tab:', tab.id); }).catch(err => { clearTimeout(pixivOAuthPending.timeoutId); pixivOAuthPending = null; reject(err); }); }); } /** * Exchange authorization code for tokens */ async function exchangePixivCode(code, codeVerifier) { console.log('Exchanging Pixiv authorization code for tokens...'); const params = new URLSearchParams({ client_id: PIXIV_CLIENT_ID, client_secret: PIXIV_CLIENT_SECRET, code: code, code_verifier: codeVerifier, grant_type: 'authorization_code', include_policy: 'true', redirect_uri: PIXIV_REDIRECT_URI }); const response = await fetch(PIXIV_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'PixivAndroidApp/5.0.234 (Android 11; Pixel 5)' }, body: params.toString() }); if (!response.ok) { const errorText = await response.text(); console.error('Pixiv token exchange failed:', response.status, errorText); throw new Error(`Token exchange failed: ${response.status}`); } const data = await response.json(); console.log('Pixiv token exchange successful'); if (!data.refresh_token) { throw new Error('No refresh token in response'); } return data.refresh_token; } /** * Listen for Pixiv OAuth callback * Intercepts the redirect to pixiv:// scheme and extracts the authorization code */ browser.webRequest.onBeforeRequest.addListener( (details) => { // Check if this is the OAuth callback if (!pixivOAuthPending) return; const url = new URL(details.url); // Check for the callback URL pattern if (url.pathname.includes('/callback') && url.searchParams.has('code')) { const code = url.searchParams.get('code'); console.log('Captured Pixiv OAuth callback with code'); // Get the pending OAuth state const { codeVerifier, resolve, reject, timeoutId, tabId } = pixivOAuthPending; pixivOAuthPending = null; clearTimeout(timeoutId); // Close the OAuth tab if (tabId) { browser.tabs.remove(tabId).catch(() => {}); } // Exchange the code for tokens exchangePixivCode(code, codeVerifier) .then(refreshToken => { savePixivToken(refreshToken); resolve(refreshToken); }) .catch(err => { reject(err); }); // Cancel the request (don't actually navigate to the callback URL) return { cancel: true }; } }, { urls: ['*://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] }, ['blocking'] ); /** * Listen for Discord API requests to capture Authorization header */ browser.webRequest.onSendHeaders.addListener( (details) => { // Look for Authorization header const authHeader = details.requestHeaders.find( h => h.name.toLowerCase() === 'authorization' ); if (authHeader && authHeader.value) { // Discord user tokens don't have "Bearer " prefix // Bot tokens have "Bot " prefix - we want user tokens const token = authHeader.value; // Only capture if it looks like a user token (not a bot token) if (!token.startsWith('Bot ') && token.length > 50) { // Only save if it's different from what we have if (token !== discordToken) { saveDiscordToken(token); } } } }, { urls: ['*://discord.com/api/*', '*://*.discord.com/api/*'] }, ['requestHeaders'] ); // Handle messages from popup and options pages browser.runtime.onMessage.addListener((message, sender, sendResponse) => { handleMessage(message) .then(sendResponse) .catch(error => { console.error('Message handler error:', error); sendResponse({ error: error.message }); }); return true; // Keep channel open for async response }); /** * Route messages to appropriate handlers * @param {object} message - Message object with type property * @returns {Promise} - Response data */ async function handleMessage(message) { console.log('Handling message:', message.type); // Ensure initialized for operations that need it if (['EXPORT_COOKIES', 'EXPORT_ALL_COOKIES', 'GET_PLATFORM_STATUS', 'TEST_CONNECTION'].includes(message.type)) { await ensureInitialized(); } switch (message.type) { case 'EXPORT_COOKIES': return await exportCookies(message.platform); case 'EXPORT_ALL_COOKIES': return await exportAllCookies(); case 'GET_PLATFORM_STATUS': return await getPlatformStatus(); case 'TEST_CONNECTION': return await testConnection(); case 'GET_CONFIG': return await getConfig(); case 'SAVE_CONFIG': return await saveConfig(message.config); default: throw new Error(`Unknown message type: ${message.type}`); } } /** * Export cookies for a single platform to the backend * @param {string} platformKey - Platform identifier * @returns {Promise} - Result object */ async function exportCookies(platformKey) { await api.init(); const platform = PLATFORMS[platformKey]; if (!platform) { throw new Error(`Unknown platform: ${platformKey}`); } let credentialData; let credentialType; if (platform.authType === 'token') { // Discord - use captured token from webRequest listener if (platformKey === 'discord') { if (!discordToken) { throw new Error(`No Discord token captured. Please open Discord in this browser and interact with it (e.g., switch channels) to capture the token.`); } credentialData = discordToken; credentialType = 'token'; // Send to backend const result = await api.uploadCredentials(platformKey, credentialType, credentialData); return { success: true, platform: platformKey, tokenCapturedAt: discordTokenCapturedAt, message: result.message || 'Token exported successfully' }; } // Pixiv - use OAuth flow to get refresh token if (platformKey === 'pixiv') { // If we already have a token, use it; otherwise initiate OAuth if (!pixivRefreshToken) { try { await initiatePixivOAuth(); } catch (err) { throw new Error(`Pixiv OAuth failed: ${err.message}`); } } if (!pixivRefreshToken) { throw new Error('Failed to obtain Pixiv refresh token'); } credentialData = pixivRefreshToken; credentialType = 'token'; // Send to backend const result = await api.uploadCredentials(platformKey, credentialType, credentialData); return { success: true, platform: platformKey, tokenCapturedAt: pixivTokenCapturedAt, message: result.message || 'Refresh token exported successfully' }; } // Other token-based platforms require manual entry throw new Error(`${platform.name} requires manual token entry in the web UI.`); } // Cookie-based auth const cookies = await extractCookiesForPlatform(platformKey); if (cookies.length === 0) { throw new Error(`No cookies found for ${platform.name}. Please ensure you are logged in.`); } credentialData = toNetscapeFormat(cookies); credentialType = 'cookies'; // Send to backend const result = await api.uploadCredentials(platformKey, credentialType, credentialData); return { success: true, platform: platformKey, cookieCount: cookies.length, message: result.message || 'Credentials exported successfully' }; } /** * Export cookies for all cookie-based platforms * @returns {Promise} - Results for each platform */ async function exportAllCookies() { await api.init(); const results = {}; const platforms = Object.keys(PLATFORMS); for (const platformKey of platforms) { const platform = PLATFORMS[platformKey]; // Skip token-based platforms (except Discord and Pixiv if we have tokens) if (platform.authType === 'token' && !['discord', 'pixiv'].includes(platformKey)) { results[platformKey] = { success: false, platform: platformKey, skipped: true, message: 'Token-based auth requires manual entry' }; continue; } // Skip Discord if no token captured if (platformKey === 'discord' && !discordToken) { results[platformKey] = { success: false, platform: platformKey, skipped: true, message: 'No token captured - open Discord in browser first' }; continue; } // Skip Pixiv if no token captured (don't auto-trigger OAuth in bulk export) if (platformKey === 'pixiv' && !pixivRefreshToken) { results[platformKey] = { success: false, platform: platformKey, skipped: true, message: 'No token - click Pixiv card to authenticate' }; continue; } try { results[platformKey] = await exportCookies(platformKey); } catch (error) { results[platformKey] = { success: false, platform: platformKey, error: error.message }; } } return results; } /** * Get status information for all platforms * @returns {Promise} - Status for each platform */ async function getPlatformStatus() { const status = {}; for (const [key, platform] of Object.entries(PLATFORMS)) { try { if (platform.authType === 'token') { // Token-based platforms if (key === 'discord') { status[key] = { name: platform.name, authType: 'token', hasToken: !!discordToken, tokenCapturedAt: discordTokenCapturedAt, hasCookies: false, cookieCount: 0, note: discordToken ? `Token captured ${new Date(discordTokenCapturedAt).toLocaleString()}` : 'Open Discord in browser to capture token' }; } else if (key === 'pixiv') { status[key] = { name: platform.name, authType: 'token', hasToken: !!pixivRefreshToken, tokenCapturedAt: pixivTokenCapturedAt, hasCookies: false, cookieCount: 0, note: pixivRefreshToken ? `Token captured ${new Date(pixivTokenCapturedAt).toLocaleString()}` : 'Click to authenticate with Pixiv' }; } else { status[key] = { name: platform.name, authType: 'token', hasCookies: false, cookieCount: 0, note: platform.note || 'Requires manual token entry' }; } } else { // Cookie-based platforms const cookies = await extractCookiesForPlatform(key); status[key] = { name: platform.name, authType: 'cookies', hasCookies: cookies.length > 0, cookieCount: cookies.length }; } } catch (error) { status[key] = { name: platform.name, authType: platform.authType, hasCookies: false, cookieCount: 0, error: error.message }; } } return status; } /** * Test connection to the backend * @returns {Promise} - Connection status */ async function testConnection() { await api.init(); if (!api.isConfigured()) { return { connected: false, error: 'Not configured' }; } try { await api.testConnection(); return { connected: true }; } catch (error) { return { connected: false, error: error.message }; } } /** * Get current configuration * @returns {Promise} - Config object */ async function getConfig() { const config = await browser.storage.local.get(['apiUrl', 'apiKey']); return config; } /** * Save configuration * @param {object} config - Config object with apiUrl and apiKey * @returns {Promise} - Success status */ async function saveConfig(config) { await browser.storage.local.set(config); await api.init(); return { success: true }; }