diff --git a/backend/app/api/platforms.py b/backend/app/api/platforms.py index 9c6d384..89650ff 100644 --- a/backend/app/api/platforms.py +++ b/backend/app/api/platforms.py @@ -89,7 +89,7 @@ async def list_platforms(): "name": "Pixiv", "description": "Download artwork from Pixiv artists", "requires_auth": True, - "auth_type": "cookies", + "auth_type": "token", "url_pattern": "https://www.pixiv.net/users/{user_id}", "url_examples": [ "https://www.pixiv.net/users/12345678", @@ -103,6 +103,7 @@ async def list_platforms(): "bookmarks": "Bookmarked works", }, "default_config": gdl_service.get_default_config_for_platform("pixiv"), + "notes": "Requires OAuth refresh token. Run 'gallery-dl oauth:pixiv' to obtain one.", }, "deviantart": { "name": "DeviantArt", diff --git a/backend/app/services/gallery_dl.py b/backend/app/services/gallery_dl.py index 5c1ef66..b852658 100644 --- a/backend/app/services/gallery_dl.py +++ b/backend/app/services/gallery_dl.py @@ -387,6 +387,8 @@ class GalleryDLService: "please login", "must be logged in", "authentication required", + "authenticationerror", + "refresh-token", # Pixiv OAuth "session expired", "invalid cookie", "cookies are expired", @@ -560,6 +562,12 @@ class GalleryDLService: config["extractor"]["discord"] = {} config["extractor"]["discord"]["token"] = auth_token + # Add refresh token for Pixiv + if auth_token and platform == "pixiv": + if "pixiv" not in config["extractor"]: + config["extractor"]["pixiv"] = {} + config["extractor"]["pixiv"]["refresh-token"] = auth_token + # Write temporary config file with tempfile.NamedTemporaryFile( mode="w", diff --git a/backend/app/tasks/downloads.py b/backend/app/tasks/downloads.py index 43a121d..ea4e023 100644 --- a/backend/app/tasks/downloads.py +++ b/backend/app/tasks/downloads.py @@ -207,7 +207,7 @@ async def _download_source_async(source_id: int, download_id: int = None) -> dic cookies_path = None auth_token = None - if source_platform == "discord": + if source_platform in ("discord", "pixiv"): auth_token = await cred_manager.get_token(source_platform) if not auth_token: logger.warning(f"No token for {source_platform}, download may fail") diff --git a/extension/background/background.js b/extension/background/background.js index 1759af7..1946cd3 100644 --- a/extension/background/background.js +++ b/extension/background/background.js @@ -6,6 +6,18 @@ 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; @@ -18,6 +30,7 @@ async function ensureInitialized() { try { await api.init(); await loadDiscordToken(); + await loadPixivToken(); initialized = true; console.log('Extension initialized successfully'); } catch (error) { @@ -66,6 +79,190 @@ async function saveDiscordToken(token) { 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 */ @@ -178,6 +375,35 @@ async function exportCookies(platformKey) { }; } + // 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.`); } @@ -216,8 +442,8 @@ async function exportAllCookies() { for (const platformKey of platforms) { const platform = PLATFORMS[platformKey]; - // Skip token-based platforms (except Discord if we have a token) - if (platform.authType === 'token' && platformKey !== 'discord') { + // 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, @@ -238,6 +464,17 @@ async function exportAllCookies() { 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) { @@ -275,6 +512,18 @@ async function getPlatformStatus() { ? `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, diff --git a/extension/manifest.json b/extension/manifest.json index 80e9af4..72b27f4 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -19,13 +19,16 @@ "tabs", "activeTab", "webRequest", + "webRequestBlocking", "*://*.patreon.com/*", "*://*.subscribestar.com/*", "*://*.subscribestar.adult/*", "*://*.hentai-foundry.com/*", "*://*.discord.com/*", "*://*.pixiv.net/*", - "*://*.deviantart.com/*" + "*://*.deviantart.com/*", + "*://app-api.pixiv.net/*", + "*://oauth.secure.pixiv.net/*" ], "browser_action": { diff --git a/extension/popup/popup.js b/extension/popup/popup.js index c44d300..59c3f4c 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -165,9 +165,9 @@ function createPlatformCard(key, platform, status) { card.className = 'platform-card'; card.dataset.platform = key; - // Discord is clickable if we have a token captured + // Discord and Pixiv are clickable (Pixiv initiates OAuth if no token) // Other token-based platforms are disabled - const isDisabled = platform.authType === 'token' && key !== 'discord'; + const isDisabled = platform.authType === 'token' && !['discord', 'pixiv'].includes(key); const isDiscordWithoutToken = key === 'discord' && !status.hasToken; if (isDisabled || isDiscordWithoutToken) { @@ -236,6 +236,14 @@ function getStatusText(status, platform, key) { return 'Open Discord in browser to capture token'; } + // Special handling for Pixiv OAuth + if (key === 'pixiv') { + if (status.hasToken) { + return 'Token captured - ready to export'; + } + return 'Click to authenticate with Pixiv'; + } + if (platform.authType === 'token') { return 'Manual token entry required'; } @@ -261,6 +269,11 @@ function getStatusClass(status, platform, key) { return status.hasToken ? 'ready' : 'no-cookies'; } + // Special handling for Pixiv + if (key === 'pixiv') { + return status.hasToken ? 'ready' : 'no-cookies'; + } + if (platform.authType === 'token') { return 'no-cookies'; } diff --git a/frontend/src/views/Credentials.vue b/frontend/src/views/Credentials.vue index 3b58ae5..4af710b 100644 --- a/frontend/src/views/Credentials.vue +++ b/frontend/src/views/Credentials.vue @@ -104,7 +104,11 @@ -