598 lines
17 KiB
JavaScript
598 lines
17 KiB
JavaScript
/**
|
|
* 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<object>} - 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<object>} - 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<object>} - 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<object>} - 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<object>} - 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<object>} - 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<object>} - Success status
|
|
*/
|
|
async function saveConfig(config) {
|
|
await browser.storage.local.set(config);
|
|
await api.init();
|
|
return { success: true };
|
|
}
|