mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-13 13:06:52 +00:00
fix(downloads): preserve authenticated capture metadata
Keep Gmail attachment filenames and origin-scoped browser cookies intact through metadata redirects, while rejecting Google sign-in responses.\n\nFixes #21
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ExtensionCookieScope = { url: string, cookies: string, };
|
||||
@@ -1,3 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
|
||||
|
||||
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, media: boolean, };
|
||||
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, };
|
||||
|
||||
@@ -51,9 +51,48 @@ const normalizeComparableUrl = (rawUrl: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const urlsHaveDifferentHosts = (sourceUrl: string, targetUrl: string) => {
|
||||
const urlsHaveDifferentOrigins = (sourceUrl: string, targetUrl: string) => {
|
||||
try {
|
||||
return new URL(sourceUrl).hostname.toLowerCase() !== new URL(targetUrl).hostname.toLowerCase();
|
||||
const source = new URL(sourceUrl);
|
||||
const target = new URL(targetUrl);
|
||||
return source.protocol !== target.protocol
|
||||
|| source.hostname.toLowerCase() !== target.hostname.toLowerCase()
|
||||
|| source.port !== target.port;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const cookieScopeForUrl = (context: PendingAddRequestContext | undefined, targetUrl: string) => {
|
||||
if (!context?.cookieScopes?.length) return '';
|
||||
try {
|
||||
const target = new URL(targetUrl);
|
||||
return context.cookieScopes.find(scope => {
|
||||
try {
|
||||
const scopeUrl = new URL(scope.url);
|
||||
const sameGoogleusercontentSite = scopeUrl.protocol === target.protocol
|
||||
&& scopeUrl.port === target.port
|
||||
&& scopeUrl.hostname.toLowerCase() === 'googleusercontent.com'
|
||||
&& target.hostname.toLowerCase().endsWith('.googleusercontent.com');
|
||||
return sameGoogleusercontentSite || (scopeUrl.protocol === target.protocol
|
||||
&& scopeUrl.hostname.toLowerCase() === target.hostname.toLowerCase()
|
||||
&& scopeUrl.port === target.port);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})?.cookies.trim() || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isGoogleAuthenticatedCaptureUrl = (rawUrl: string) => {
|
||||
try {
|
||||
const hostname = new URL(rawUrl).hostname.toLowerCase();
|
||||
return hostname === 'mail.google.com'
|
||||
|| hostname === 'accounts.google.com'
|
||||
|| hostname === 'googleusercontent.com'
|
||||
|| hostname.endsWith('.googleusercontent.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -152,14 +191,16 @@ export const AddDownloadsModal = () => {
|
||||
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => {
|
||||
if (cookiesManuallyEditedRef.current) return cookies.trim();
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context && context.cookies && urlsHaveDifferentHosts(sourceUrl, targetUrl)) {
|
||||
return '';
|
||||
}
|
||||
const scopedCookies = cookieScopeForUrl(context, targetUrl);
|
||||
if (scopedCookies) return scopedCookies;
|
||||
if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return '';
|
||||
if (context) return context.cookies.trim();
|
||||
return hasExtensionRequestContext ? '' : cookies.trim();
|
||||
};
|
||||
const shouldDeferCookiesForRow = (sourceUrl: string) =>
|
||||
!cookiesManuallyEditedRef.current && Boolean(requestContextForUrl(sourceUrl));
|
||||
!cookiesManuallyEditedRef.current
|
||||
&& Boolean(requestContextForUrl(sourceUrl))
|
||||
&& !isGoogleAuthenticatedCaptureUrl(sourceUrl);
|
||||
const suggestedFilenameForRow = (sourceUrl: string) => {
|
||||
const context = requestContextForUrl(sourceUrl);
|
||||
if (context?.filename) return context.filename;
|
||||
@@ -389,6 +430,7 @@ export const AddDownloadsModal = () => {
|
||||
const proxy = await getProxyArgs(settingsStore);
|
||||
const login = getSiteLogin(row.sourceUrl, settingsStore);
|
||||
const contextUrl = requestContextUrlForRow(row);
|
||||
const requestContext = requestContextForUrl(contextUrl);
|
||||
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
|
||||
settingsStore.setShowKeychainModal(true);
|
||||
return;
|
||||
@@ -493,6 +535,7 @@ export const AddDownloadsModal = () => {
|
||||
password: useAuth ? password || null : keychainPassword,
|
||||
headers: headersForRow(contextUrl) || null,
|
||||
cookies: cookiesForRow(contextUrl, row.sourceUrl) || null,
|
||||
cookieScopes: requestContext?.cookieScopes || null,
|
||||
proxy,
|
||||
deferCookies: shouldDeferCookiesForRow(row.sourceUrl)
|
||||
});
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import type { DownloadCategory } from './bindings/DownloadCategory';
|
||||
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
|
||||
import type { DownloadStateEvent } from './bindings/DownloadStateEvent';
|
||||
import type { ExtensionDownload } from './bindings/ExtensionDownload';
|
||||
import type { ExtensionCookieScope } from './bindings/ExtensionCookieScope';
|
||||
import type { MediaMetadata } from './bindings/MediaMetadata';
|
||||
import type { MediaPlaylistMetadata } from './bindings/MediaPlaylistMetadata';
|
||||
import type { MetadataResponse } from './bindings/MetadataResponse';
|
||||
@@ -18,7 +19,7 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; proxy: string | null; deferCookies?: boolean };
|
||||
args: { url: string; userAgent: string | null; username: string | null; password: string | null; headers: string | null; cookies: string | null; cookieScopes: Array<ExtensionCookieScope> | null; proxy: string | null; deferCookies?: boolean };
|
||||
result: MetadataResponse;
|
||||
};
|
||||
fetch_media_metadata: {
|
||||
|
||||
@@ -1318,6 +1318,10 @@ describe('useDownloadStore', () => {
|
||||
filename: 'file.bin',
|
||||
headers: 'X-Test: value',
|
||||
cookies: 'session=secret',
|
||||
cookie_scopes: [
|
||||
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
|
||||
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
|
||||
],
|
||||
media: false
|
||||
});
|
||||
|
||||
@@ -1328,6 +1332,10 @@ describe('useDownloadStore', () => {
|
||||
expect(state.pendingAddFilename).toBe('file.bin');
|
||||
expect(state.pendingAddHeaders).toBe('X-Test: value');
|
||||
expect(state.pendingAddCookies).toBe('session=secret');
|
||||
expect(state.pendingAddRequestContexts['https://example.com/file.bin'].cookieScopes).toEqual([
|
||||
{ url: 'https://mail.google.com/', cookies: 'SID=mail-session' },
|
||||
{ url: 'https://accounts.google.com/', cookies: 'SID=account-session' }
|
||||
]);
|
||||
expect(state.pendingAddMediaUrls).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1349,6 +1357,7 @@ describe('useDownloadStore', () => {
|
||||
filename: null,
|
||||
headers: 'User-Agent: Firefox Test',
|
||||
cookies: null,
|
||||
cookie_scopes: null,
|
||||
media: false
|
||||
});
|
||||
|
||||
@@ -1369,6 +1378,7 @@ describe('useDownloadStore', () => {
|
||||
filename: 'report.pdf',
|
||||
headers: 'User-Agent: Test',
|
||||
cookies: 'session=secret',
|
||||
cookie_scopes: null,
|
||||
media: false
|
||||
});
|
||||
|
||||
@@ -1390,6 +1400,7 @@ describe('useDownloadStore', () => {
|
||||
filename: 'first.zip',
|
||||
headers: 'User-Agent: First Browser',
|
||||
cookies: 'first=session',
|
||||
cookie_scopes: null,
|
||||
media: false
|
||||
});
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
@@ -1399,6 +1410,7 @@ describe('useDownloadStore', () => {
|
||||
filename: 'second.zip',
|
||||
headers: 'User-Agent: Second Browser',
|
||||
cookies: 'second=session',
|
||||
cookie_scopes: null,
|
||||
media: false
|
||||
});
|
||||
|
||||
@@ -1435,6 +1447,7 @@ describe('useDownloadStore', () => {
|
||||
filename: null,
|
||||
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`,
|
||||
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
|
||||
cookie_scopes: null,
|
||||
media: true
|
||||
});
|
||||
|
||||
@@ -1454,12 +1467,31 @@ describe('useDownloadStore', () => {
|
||||
filename: 'private.zip',
|
||||
headers: null,
|
||||
cookies: 'session=secret',
|
||||
cookie_scopes: null,
|
||||
media: false
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddCookies).toBe('session=secret');
|
||||
});
|
||||
|
||||
it('drops extension cookie scopes for explicit media captures', async () => {
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
urls: ['https://media.example/watch/123'],
|
||||
referer: 'https://media.example/watch/123',
|
||||
silent: true,
|
||||
filename: null,
|
||||
headers: null,
|
||||
cookies: null,
|
||||
cookie_scopes: [
|
||||
{ url: 'https://media.example/', cookies: 'session=secret' }
|
||||
],
|
||||
media: true
|
||||
});
|
||||
|
||||
expect(useDownloadStore.getState().pendingAddRequestContexts['https://media.example/watch/123']?.cookieScopes)
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears stale request context when the same URL is captured without it later', async () => {
|
||||
const url = 'https://example.com/file.zip';
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
@@ -1469,6 +1501,7 @@ describe('useDownloadStore', () => {
|
||||
filename: 'private.zip',
|
||||
headers: 'Authorization: secret',
|
||||
cookies: 'session=secret',
|
||||
cookie_scopes: null,
|
||||
media: false
|
||||
});
|
||||
await useDownloadStore.getState().handleExtensionDownload({
|
||||
@@ -1478,6 +1511,7 @@ describe('useDownloadStore', () => {
|
||||
filename: null,
|
||||
headers: null,
|
||||
cookies: null,
|
||||
cookie_scopes: null,
|
||||
media: false
|
||||
});
|
||||
|
||||
@@ -1505,6 +1539,7 @@ describe('useDownloadStore', () => {
|
||||
filename: null,
|
||||
headers: 'User-Agent: Firefox Test',
|
||||
cookies: 'session=secret',
|
||||
cookie_scopes: null,
|
||||
media: true
|
||||
});
|
||||
|
||||
@@ -1524,6 +1559,7 @@ describe('useDownloadStore', () => {
|
||||
filename: 'file.bin',
|
||||
headers: 'User-Agent: Firefox Test',
|
||||
cookies: null,
|
||||
cookie_scopes: null,
|
||||
media: false
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { invokeCommand as invoke } from '../ipc';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
@@ -451,6 +452,7 @@ export type PendingAddRequestContext = {
|
||||
filename: string;
|
||||
headers: string;
|
||||
cookies: string;
|
||||
cookieScopes?: ExtensionCookieScope[];
|
||||
media: boolean;
|
||||
};
|
||||
|
||||
@@ -487,7 +489,8 @@ interface DownloadState {
|
||||
filename?: string | null,
|
||||
headers?: string | null,
|
||||
cookies?: string | null,
|
||||
media?: boolean
|
||||
media?: boolean,
|
||||
cookieScopes?: ExtensionCookieScope[] | null
|
||||
) => void;
|
||||
handleExtensionDownload: (request: ExtensionDownloadRequest) => Promise<void>;
|
||||
deleteModalState: DeleteModalState;
|
||||
@@ -652,7 +655,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
// opened or closed without URLs.
|
||||
pendingAddRequestVersion: state.pendingAddRequestVersion + 1
|
||||
})),
|
||||
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false) => set((state) => {
|
||||
openAddModalWithUrls: (urls, referer, filename, headers, cookies, media = false, cookieScopes) => set((state) => {
|
||||
const isAppending = state.isAddModalOpen && Boolean(state.pendingAddUrls);
|
||||
const existingUrls = isAppending ? state.pendingAddUrls : '';
|
||||
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
|
||||
@@ -660,6 +663,12 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
const cleanFilename = filename?.trim() || '';
|
||||
const cleanHeaders = headers?.trim() || '';
|
||||
const cleanCookies = cookies?.trim() || '';
|
||||
const cleanCookieScopes = cookieScopes
|
||||
?.map(scope => ({
|
||||
url: scope.url.trim(),
|
||||
cookies: scope.cookies.trim()
|
||||
}))
|
||||
.filter(scope => scope.url && scope.cookies);
|
||||
const requestVersion = state.pendingAddRequestVersion + 1;
|
||||
const pendingAddRequestContexts = isAppending
|
||||
? { ...state.pendingAddRequestContexts }
|
||||
@@ -683,6 +692,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
filename: cleanFilename,
|
||||
headers: cleanHeaders,
|
||||
cookies: cleanCookies,
|
||||
...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}),
|
||||
media
|
||||
};
|
||||
}
|
||||
@@ -719,7 +729,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
urls.length === 1 ? request.filename : null,
|
||||
headers,
|
||||
cookies,
|
||||
request.media === true
|
||||
request.media === true,
|
||||
request.media === true ? undefined : request.cookie_scopes
|
||||
);
|
||||
},
|
||||
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
|
||||
|
||||
Reference in New Issue
Block a user