fix: harden audited download and release paths

This commit is contained in:
NimBold
2026-07-21 08:39:54 +03:30
parent 69ce2b15ba
commit 886388d5f2
14 changed files with 219 additions and 51 deletions
+4 -3
View File
@@ -230,11 +230,11 @@ export const AddDownloadsModal = () => {
const closeModalFromDismissAction = useCallback(() => {
if (isSubmitting || isSubmittingRef.current || showKeychainModal) return;
const hasPendingInput = Boolean(
urls.trim() || pendingAddUrls.trim() || parsedItems.length || headers.trim() || cookies.trim()
urls.trim() || pendingAddUrls.trim() || parsedItems.some(item => item.selected !== false) || headers.trim() || cookies.trim()
);
if (hasPendingInput && !window.confirm(t($ => $.addDownloads.discardSetup))) return;
toggleAddModal(false);
}, [cookies, headers, isSubmitting, parsedItems.length, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
}, [cookies, headers, isSubmitting, parsedItems, pendingAddUrls, showKeychainModal, toggleAddModal, urls]);
useEffect(() => {
if (!isAddModalOpen) {
@@ -1802,13 +1802,14 @@ export const AddDownloadsModal = () => {
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.cookies)}</label>
<input
type="text"
type="password"
value={cookies}
onChange={e => {
cookiesManuallyEditedRef.current = true;
setCookies(e.target.value);
}}
placeholder={t($ => $.addDownloads.cookiePlaceholder)}
autoComplete="off"
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
aria-label={t($ => $.addDownloads.cookies)}
/>
+3 -1
View File
@@ -16,6 +16,7 @@ interface DownloadItemProps {
queueIndex: number;
queueLength: number;
tableGridTemplate: string;
tableMinWidth: number;
setContextMenu: (menu: { x: number; y: number; id: string }) => void;
handlePause: (id: string, skipConfirm?: boolean) => void;
handleResume: (item: DownloadItemType) => void;
@@ -31,6 +32,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
queueIndex,
queueLength,
tableGridTemplate,
tableMinWidth,
setContextMenu,
handlePause,
handleResume,
@@ -84,7 +86,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
return (
<div
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
style={{ gridTemplateColumns: tableGridTemplate }}
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
onClick={(e) => onClick(e, download)}
onContextMenu={(e) => {
e.preventDefault();
+54 -14
View File
@@ -30,8 +30,28 @@ interface DownloadTableProps {
}
const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170];
const COLUMN_MINIMUMS = [0, 58, 92, 58, 48, 112];
const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
const normalizeColumnWidths = (value: unknown): number[] => {
if (!Array.isArray(value) || value.length !== DEFAULT_COLUMN_WIDTHS.length) {
return DEFAULT_COLUMN_WIDTHS;
}
return value.map((width, index) =>
typeof width === 'number' && Number.isFinite(width)
? Math.max(COLUMN_MINIMUMS[index], width)
: DEFAULT_COLUMN_WIDTHS[index]
);
};
const persistColumnWidths = (widths: number[]): void => {
try {
window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(widths));
} catch {
// Local storage can be unavailable in restricted WebView contexts.
}
};
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { t } = useTranslation();
const { downloads, queues, assignToQueue, openDeleteModal, redownload, moveInQueue } = useDownloadStore();
@@ -54,6 +74,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
const [sortConfig, setSortConfig] = useState<DownloadSortConfig>({ column: 'Date Added', direction: 'desc' });
const [queueSortConfig, setQueueSortConfig] = useState<DownloadSortConfig | null>(null);
const resizeCleanupRef = useRef<(() => void) | null>(null);
const selectedIdsRef = useRef(selectedIds);
const lastSelectedIdRef = useRef(lastSelectedId);
const sortedDownloadsRef = useRef<DownloadItem[]>([]);
@@ -62,38 +83,56 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const [columnWidths, setColumnWidths] = useState(() => {
try {
const stored = JSON.parse(window.localStorage.getItem(COLUMN_WIDTHS_STORAGE_KEY) || 'null');
return Array.isArray(stored) &&
stored.length === DEFAULT_COLUMN_WIDTHS.length &&
stored.every(value => typeof value === 'number' && Number.isFinite(value))
? stored
: DEFAULT_COLUMN_WIDTHS;
return normalizeColumnWidths(stored);
} catch {
return DEFAULT_COLUMN_WIDTHS;
}
});
const columnMinimums = [0, 58, 92, 58, 48, 112];
const tableGridTemplate = columnWidths.map((width, index) => `minmax(${columnMinimums[index]}px, ${width}fr)`).join(' ');
const columnWidthsRef = useRef(columnWidths);
columnWidthsRef.current = columnWidths;
const normalizedColumnWidths = columnWidths.map((width, index) =>
Math.max(COLUMN_MINIMUMS[index], width)
);
const tableGridTemplate = [
...normalizedColumnWidths.slice(0, -1).map(width => `${width}px`),
'minmax(0, 1fr)',
`${normalizedColumnWidths[normalizedColumnWidths.length - 1]}px`
].join(' ');
const tableMinWidth = normalizedColumnWidths.reduce(
(total, width) => total + width,
0
);
const startColumnResize = (index: number, event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
resizeCleanupRef.current?.();
const startX = event.clientX;
const startWidth = columnWidths[index];
const handlePointerMove = (moveEvent: PointerEvent) => {
const nextWidth = Math.max(columnMinimums[index], startWidth + moveEvent.clientX - startX);
setColumnWidths(widths => widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width));
const nextWidth = Math.max(COLUMN_MINIMUMS[index], startWidth + moveEvent.clientX - startX);
setColumnWidths(widths => {
const nextWidths = widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width);
columnWidthsRef.current = nextWidths;
return nextWidths;
});
};
const handlePointerUp = () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
persistColumnWidths(columnWidthsRef.current);
document.body.classList.remove('is-resizing');
resizeCleanupRef.current = null;
};
resizeCleanupRef.current = handlePointerUp;
document.body.classList.add('is-resizing');
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
};
useEffect(() => {
@@ -109,9 +148,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
};
}, []);
useEffect(() => {
window.localStorage.setItem(COLUMN_WIDTHS_STORAGE_KEY, JSON.stringify(columnWidths));
}, [columnWidths]);
useEffect(() => () => {
resizeCleanupRef.current?.();
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -539,7 +578,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
<div className="downloads-table flex-1 flex flex-col">
<div className="download-table-scroll">
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate }}>
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}>
{[
{ key: 'File Name' as const, label: t($ => $.downloadTable.headers.fileName) },
{ key: 'Size' as const, label: t($ => $.downloadTable.headers.size) },
@@ -569,7 +608,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
))}
</div>
<div className="download-table-body">
<div className="download-table-body" style={{ minWidth: tableMinWidth }}>
<div className="download-table-list" ref={animationParent}>
{sortedDownloads.length === 0 ? (
<div className="downloads-empty-state">
@@ -609,6 +648,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
queueIndex={queuePositionsByDownloadId.get(d.id)?.index ?? -1}
queueLength={queuePositionsByDownloadId.get(d.id)?.length ?? 0}
tableGridTemplate={tableGridTemplate}
tableMinWidth={tableMinWidth}
setContextMenu={handleContextMenu}
handlePause={handlePause}
handleResume={handleResume}
+1 -1
View File
@@ -453,7 +453,7 @@ export const PropertiesModal = () => {
)}
<label className="text-xs text-text-muted text-right">{t($ => $.properties.cookies)}</label>
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} placeholder={t($ => $.properties.cookies)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<input type="password" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={transferLocked} autoComplete="off" placeholder={t($ => $.properties.cookies)} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<div className="col-span-2 mt-2">
<label className="block text-xs text-text-muted mb-1.5">{t($ => $.properties.headers)}</label>
+1 -1
View File
@@ -130,7 +130,7 @@ export default function SchedulerView() {
addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true });
return;
}
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) === minuteOfDay(draft.startTime)) {
addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true });
return;
}
+1 -1
View File
@@ -562,7 +562,6 @@ runEngineChecks(false);
});
if (base && typeof base === 'string') {
const approvedBase = await settings.approveDownloadRoot(base);
settings.setBaseDownloadFolder(approvedBase);
try {
if (settings.categorySubfoldersEnabled) {
const safeSubfolders = Object.fromEntries(
@@ -586,6 +585,7 @@ runEngineChecks(false);
showToast(t($ => $.settings.locations.baseFolderCreateFailed, { detail: String(e) }), 'warning');
return;
}
settings.setBaseDownloadFolder(approvedBase);
showToast(t($ => $.settings.locations.baseFolderUpdated), 'success');
}
} catch (e) {
+8 -2
View File
@@ -1902,7 +1902,7 @@ html[data-list-density="relaxed"] {
flex-direction: column;
min-height: 0;
min-width: 0;
overflow-x: hidden;
overflow-x: auto;
overflow-y: hidden;
}
@@ -1935,6 +1935,11 @@ html[data-list-density="relaxed"] {
padding-inline-end: 0;
}
.download-table-header > div:last-child,
.download-row > div:last-child {
grid-column: 7;
}
.download-table-header > div > span {
min-width: 0;
overflow: hidden;
@@ -1991,7 +1996,8 @@ body.is-resizing .column-resize-handle:hover::after {
.download-table-list {
height: 100%;
min-height: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
}
+1 -1
View File
@@ -1879,7 +1879,7 @@ describe('useDownloadStore', () => {
referer: 'https://adult.example/watch/123',
silent: false,
filename: null,
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nUser-Agent: Firefox Test`,
headers: `Cookie: stale=${'x'.repeat(64 * 1024)}\nCookie2: stale=1\nAuthorization: Bearer stale\nProxy-Authorization: Basic stale\nSet-Cookie: stale=1\nSet-Cookie2: stale=1\nUser-Agent: Firefox Test`,
cookies: `oversized=${'x'.repeat(64 * 1024)}`,
cookie_scopes: null,
media: true,
+12 -3
View File
@@ -209,12 +209,21 @@ export class SystemProxyResolutionError extends Error {
const isSystemProxyConfigurationError = (error: unknown): boolean =>
error instanceof SystemProxyResolutionError;
const stripCookieHeaders = (value: string | null | undefined): string =>
const stripSensitiveMediaHeaders = (value: string | null | undefined): string =>
(value || '')
.split(/\r?\n/)
.filter(line => {
const separator = line.indexOf(':');
return separator < 0 || line.slice(0, separator).trim().toLowerCase() !== 'cookie';
if (separator < 0) return true;
const name = line.slice(0, separator).trim().toLowerCase();
return ![
'authorization',
'cookie',
'cookie2',
'proxy-authorization',
'set-cookie',
'set-cookie2'
].includes(name);
})
.join('\n')
.trim();
@@ -1035,7 +1044,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
// extension builds; ordinary captured downloads retain their cookies.
const cookies = request.media === true ? null : request.cookies;
const headers = request.media === true
? stripCookieHeaders(request.headers) || null
? stripSensitiveMediaHeaders(request.headers) || null
: request.headers;
get().openAddModalWithUrls(