mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-11 03:59:09 +00:00
fix(aria2): harden protocol and torrent transfers
This commit is contained in:
@@ -4,4 +4,4 @@ import type { DownloadErrorKind } from "./DownloadErrorKind";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, };
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, sftpHostKeyMd?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, credentialsRequired?: boolean, lastErrorKind?: DownloadErrorKind, lastResolverFallback?: boolean, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
||||
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, };
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, sftp_host_key_md?: string, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, };
|
||||
|
||||
@@ -284,6 +284,7 @@ export const AddDownloadsModal = () => {
|
||||
const [useAuth, setUseAuth] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [sftpHostKeyMd, setSftpHostKeyMd] = useState('');
|
||||
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(false);
|
||||
const [playlistQualityExpanded, setPlaylistQualityExpanded] = useState(true);
|
||||
@@ -411,6 +412,7 @@ export const AddDownloadsModal = () => {
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setSftpHostKeyMd('');
|
||||
setAdvancedExpanded(false);
|
||||
setChecksumEnabled(false);
|
||||
setChecksumAlgo('SHA-256');
|
||||
@@ -1515,6 +1517,9 @@ export const AddDownloadsModal = () => {
|
||||
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
|
||||
username: useAuth ? username.trim() : undefined,
|
||||
password: useAuth ? password.trim() : undefined,
|
||||
sftpHostKeyMd: !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:')
|
||||
? sftpHostKeyMd.trim() || undefined
|
||||
: undefined,
|
||||
headers: headersForRow(contextUrl) || undefined,
|
||||
checksum: checksumEnabled && checksumValue.trim()
|
||||
? `${checksumAlgo}=${checksumValue.trim()}`
|
||||
@@ -1706,6 +1711,9 @@ export const AddDownloadsModal = () => {
|
||||
return Boolean(selected && selected.length > 0 && selected.length < item.torrentFiles.length);
|
||||
};
|
||||
const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
||||
const hasSftpRows = parsedItems.some(item => item.selected !== false
|
||||
&& !item.isTorrent
|
||||
&& item.sourceUrl.trim().toLowerCase().startsWith('sftp:'));
|
||||
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
|
||||
const selectedPlaylistRows = selectedPlaylistSourceUrl
|
||||
? parsedItems.filter(item => item.playlistSourceUrl === selectedPlaylistSourceUrl && item.selected !== false)
|
||||
@@ -2854,6 +2862,23 @@ export const AddDownloadsModal = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSftpRows && (
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">
|
||||
{t($ => $.addDownloads.sftpHostKeyMd)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sftpHostKeyMd}
|
||||
onChange={event => setSftpHostKeyMd(event.target.value)}
|
||||
placeholder={t($ => $.addDownloads.sftpHostKeyMdHint)}
|
||||
className="add-download-control w-full px-3 py-1.5 text-xs font-mono"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-text-muted">{t($ => $.addDownloads.sftpHostKeyMdDescription)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">{t($ => $.addDownloads.headers)}</label>
|
||||
<textarea
|
||||
|
||||
@@ -214,6 +214,7 @@ export const PropertiesWindowApp = () => {
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [destination, setDestination] = useState('');
|
||||
const [connections, setConnections] = useState('');
|
||||
const [sftpHostKeyMd, setSftpHostKeyMd] = useState('');
|
||||
const [trackers, setTrackers] = useState('');
|
||||
const [excludedTrackers, setExcludedTrackers] = useState('');
|
||||
const [downloadLimit, setDownloadLimit] = useState('');
|
||||
@@ -278,6 +279,7 @@ export const PropertiesWindowApp = () => {
|
||||
detailsRef.current = details;
|
||||
|
||||
const isTorrent = snapshot?.isTorrent === true;
|
||||
const isSftp = Boolean(snapshot?.url.trim().toLowerCase().startsWith('sftp:'));
|
||||
const tabs = useMemo(() => getPropertiesTabs(isTorrent), [isTorrent]);
|
||||
const peerDiagnosticState = getPropertiesPeerDiagnosticState(peers, diagnosticsLoading, peerDiagnosticPhase);
|
||||
const availabilityDiagnosticState = getPropertiesAvailabilityDiagnosticState(availability, diagnosticsLoading, availabilityDiagnosticPhase);
|
||||
@@ -394,6 +396,7 @@ export const PropertiesWindowApp = () => {
|
||||
setFileName(next.fileName);
|
||||
setDestination(next.destination ?? '');
|
||||
setConnections(next.connections === undefined ? '' : String(next.connections));
|
||||
setSftpHostKeyMd(next.sftpHostKeyMd ?? '');
|
||||
setTrackers(next.torrentTrackers ?? '');
|
||||
setExcludedTrackers(next.torrentExcludeTrackers ?? '');
|
||||
setSelectedFiles(next.torrentFileIndices ? [...next.torrentFileIndices] : null);
|
||||
@@ -973,6 +976,9 @@ export const PropertiesWindowApp = () => {
|
||||
if (nextFileAllocation !== snapshot.torrentFileAllocation) patch.torrentFileAllocation = encodePropertiesPatchValue(nextFileAllocation);
|
||||
}
|
||||
} else if (activeTab === 'advanced') {
|
||||
if (isSftp && sftpHostKeyMd !== (snapshot.sftpHostKeyMd ?? '')) {
|
||||
patch.sftpHostKeyMd = encodePropertiesPatchValue(sftpHostKeyMd.trim() || undefined);
|
||||
}
|
||||
for (const name of SECRET_NAMES) {
|
||||
const draft = secretDrafts[name];
|
||||
if (!draft.touched) continue;
|
||||
@@ -980,7 +986,7 @@ export const PropertiesWindowApp = () => {
|
||||
}
|
||||
}
|
||||
await requestAction('apply-properties', patch);
|
||||
}, [activeTab, checkIntegrity, connections, destination, downloadLimit, encryptionPolicy, excludedTrackers, fileAllocation, fileName, fileProgress, isTorrent, maxPeers, peerSpeedLimit, prioritizePiece, removeUnselectedFile, requestAction, secretDrafts, seedRatio, seedTime, selectedFiles, snapshot, stopTimeout, trackerConnectTimeout, trackerInterval, trackerTimeout, trackers, t, uploadLimit]);
|
||||
}, [activeTab, checkIntegrity, connections, destination, downloadLimit, encryptionPolicy, excludedTrackers, fileAllocation, fileName, fileProgress, isSftp, isTorrent, maxPeers, peerSpeedLimit, prioritizePiece, removeUnselectedFile, requestAction, sftpHostKeyMd, secretDrafts, seedRatio, seedTime, selectedFiles, snapshot, stopTimeout, trackerConnectTimeout, trackerInterval, trackerTimeout, trackers, t, uploadLimit]);
|
||||
|
||||
const chooseTab = (tab: PropertiesTab) => {
|
||||
if (tab === activeTab) {
|
||||
@@ -1538,6 +1544,8 @@ export const PropertiesWindowApp = () => {
|
||||
|
||||
{activeTab === 'advanced' && <div className="space-y-4">
|
||||
<p className="text-xs text-text-muted">{t($ => $.properties.advancedTransfer)}</p>
|
||||
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
||||
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
||||
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
<div><span className="text-text-muted">{t($ => $.properties.connections)}</span><p className="mt-1">{snapshot.isMedia === true ? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}` : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'}`}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.speedCap)}</span><p className="mt-1">{snapshot.speedLimit || '—'}</p></div>
|
||||
|
||||
@@ -101,6 +101,15 @@ const copyEditablePatch = (rawPatch: PropertiesPatch): Partial<DownloadItem> =>
|
||||
if (safePatch.destination !== undefined && typeof safePatch.destination !== 'string') {
|
||||
throw new Error('Invalid destination');
|
||||
}
|
||||
if (safePatch.sftpHostKeyMd !== undefined) {
|
||||
if (typeof safePatch.sftpHostKeyMd !== 'string') throw new Error('Invalid SFTP host-key fingerprint');
|
||||
const fingerprint = safePatch.sftpHostKeyMd.trim().toLowerCase();
|
||||
const valid = /^(md5|sha-1)=[0-9a-f]+$/.test(fingerprint)
|
||||
&& ((fingerprint.startsWith('md5=') && fingerprint.length === 36)
|
||||
|| (fingerprint.startsWith('sha-1=') && fingerprint.length === 45));
|
||||
if (!valid) throw new Error('Invalid SFTP host-key fingerprint');
|
||||
safePatch.sftpHostKeyMd = fingerprint;
|
||||
}
|
||||
|
||||
if (safePatch.connections !== undefined
|
||||
&& (!Number.isInteger(safePatch.connections) || safePatch.connections < 1 || safePatch.connections > 16)) {
|
||||
|
||||
@@ -269,6 +269,7 @@ const common = {
|
||||
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
||||
editingUnavailable: 'These properties cannot be edited while the download is active.',
|
||||
credentialsRequired: 'Credentials are required again after restart. Add them in Advanced and resume this download.',
|
||||
liveTorrentUploadLimit: 'Live Torrent upload limit',
|
||||
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
||||
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
||||
@@ -459,6 +460,9 @@ const common = {
|
||||
algorithm: 'Algorithm',
|
||||
digest: 'Digest',
|
||||
expectedDigest: 'Expected digest',
|
||||
sftpHostKeyMd: 'SFTP host-key fingerprint',
|
||||
sftpHostKeyMdHint: 'sha-1=40 hex characters or md5=32 hex characters',
|
||||
sftpHostKeyMdDescription: 'Optional Aria2 host-key verification. Leave blank only if you accept Aria2’s unverified SFTP host key.',
|
||||
cookies: 'Cookies',
|
||||
headers: 'Headers',
|
||||
mirrors: 'Mirrors',
|
||||
@@ -763,6 +767,9 @@ const common = {
|
||||
verifyChecksum: 'Verify Checksum',
|
||||
checksumAlgorithm: 'Checksum algorithm',
|
||||
expectedDigest: 'Expected digest',
|
||||
sftpHostKeyMd: 'SFTP host-key fingerprint',
|
||||
sftpHostKeyMdHint: 'sha-1=40 hex characters or md5=32 hex characters',
|
||||
sftpHostKeyMdDescription: 'Optional Aria2 host-key verification. Leave blank only if you accept Aria2’s unverified SFTP host key.',
|
||||
headers: 'Headers',
|
||||
requestHeaders: 'Request headers',
|
||||
cookies: 'Cookies',
|
||||
|
||||
@@ -269,6 +269,7 @@ const fa = {
|
||||
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
||||
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگیها ممکن نیست.',
|
||||
credentialsRequired: 'پس از راهاندازی مجدد، دوباره اطلاعات ورود لازم است. آنها را در بخش پیشرفته وارد و دانلود را ادامه دهید.',
|
||||
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
||||
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
||||
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
||||
@@ -459,6 +460,9 @@ const fa = {
|
||||
algorithm: 'الگوریتم',
|
||||
digest: 'هش',
|
||||
expectedDigest: 'هش مورد انتظار',
|
||||
sftpHostKeyMd: 'اثر انگشت کلید میزبان SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=۴۰ نویسهٔ هگز یا md5=۳۲ نویسهٔ هگز',
|
||||
sftpHostKeyMdDescription: 'اعتبارسنجی اختیاری کلید میزبان در Aria2. اگر خالی بگذارید، کلید SFTP بدون اعتبارسنجی پذیرفته میشود.',
|
||||
cookies: 'کوکیها',
|
||||
headers: 'هدرها',
|
||||
mirrors: 'آینهها',
|
||||
@@ -763,6 +767,9 @@ const fa = {
|
||||
verifyChecksum: 'تأیید چکسام',
|
||||
checksumAlgorithm: 'الگوریتم چکسام',
|
||||
expectedDigest: 'هش مورد انتظار',
|
||||
sftpHostKeyMd: 'اثر انگشت کلید میزبان SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=۴۰ نویسهٔ هگز یا md5=۳۲ نویسهٔ هگز',
|
||||
sftpHostKeyMdDescription: 'اعتبارسنجی اختیاری کلید میزبان در Aria2. اگر خالی بگذارید، کلید SFTP بدون اعتبارسنجی پذیرفته میشود.',
|
||||
headers: 'هدرها',
|
||||
requestHeaders: 'هدرهای درخواست',
|
||||
cookies: 'کوکیها',
|
||||
|
||||
@@ -269,6 +269,7 @@ const he = {
|
||||
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
||||
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
|
||||
credentialsRequired: 'לאחר הפעלה מחדש נדרשים שוב פרטי התחברות. הוסף אותם במתקדם והמשך את ההורדה.',
|
||||
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
||||
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
||||
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
||||
@@ -459,6 +460,9 @@ const he = {
|
||||
algorithm: 'אלגוריתם',
|
||||
digest: 'ערך גיבוב',
|
||||
expectedDigest: 'ערך גיבוב צפוי',
|
||||
sftpHostKeyMd: 'טביעת אצבע של מפתח מארח SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 תווי hex או md5=32 תווי hex',
|
||||
sftpHostKeyMdDescription: 'אימות אופציונלי של מפתח המארח ב-Aria2. השאר ריק רק אם מקובל עליך מפתח SFTP ללא אימות.',
|
||||
cookies: 'עוגיות',
|
||||
headers: 'כותרות (Headers)',
|
||||
mirrors: 'מראות',
|
||||
@@ -763,6 +767,9 @@ const he = {
|
||||
verifyChecksum: 'אימות סכום ביקורת',
|
||||
checksumAlgorithm: 'אלגוריתם סכום ביקורת',
|
||||
expectedDigest: 'ערך גיבוב צפוי',
|
||||
sftpHostKeyMd: 'טביעת אצבע של מפתח מארח SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 תווי hex או md5=32 תווי hex',
|
||||
sftpHostKeyMdDescription: 'אימות אופציונלי של מפתח המארח ב-Aria2. השאר ריק רק אם מקובל עליך מפתח SFTP ללא אימות.',
|
||||
headers: 'כותרות (Headers)',
|
||||
requestHeaders: 'כותרות בקשה',
|
||||
cookies: 'עוגיות',
|
||||
|
||||
@@ -269,6 +269,7 @@ const ru = {
|
||||
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
||||
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
|
||||
credentialsRequired: 'После перезапуска снова нужны учётные данные. Добавьте их в разделе «Дополнительно» и возобновите загрузку.',
|
||||
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
||||
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
||||
@@ -459,6 +460,9 @@ const ru = {
|
||||
algorithm: 'Алгоритм',
|
||||
digest: 'Хеш',
|
||||
expectedDigest: 'Ожидаемый хеш',
|
||||
sftpHostKeyMd: 'Отпечаток ключа хоста SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 шестнадцатеричных символов или md5=32',
|
||||
sftpHostKeyMdDescription: 'Необязательная проверка ключа хоста Aria2. Оставляйте поле пустым только если принимаете непроверенный ключ SFTP.',
|
||||
cookies: 'Файлы cookie',
|
||||
headers: 'Заголовки',
|
||||
mirrors: 'Зеркала',
|
||||
@@ -763,6 +767,9 @@ const ru = {
|
||||
verifyChecksum: 'Проверять контрольную сумму',
|
||||
checksumAlgorithm: 'Алгоритм контрольной суммы',
|
||||
expectedDigest: 'Ожидаемый хеш',
|
||||
sftpHostKeyMd: 'Отпечаток ключа хоста SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 шестнадцатеричных символов или md5=32',
|
||||
sftpHostKeyMdDescription: 'Необязательная проверка ключа хоста Aria2. Оставляйте поле пустым только если принимаете непроверенный ключ SFTP.',
|
||||
headers: 'Заголовки',
|
||||
requestHeaders: 'Заголовки запроса',
|
||||
cookies: 'Файлы cookie',
|
||||
|
||||
@@ -269,6 +269,7 @@ const uk = {
|
||||
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
||||
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
|
||||
credentialsRequired: 'Після перезапуску облікові дані потрібні знову. Додайте їх у розділі «Додатково» та відновіть завантаження.',
|
||||
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
||||
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
||||
@@ -459,6 +460,9 @@ const uk = {
|
||||
algorithm: 'Алгоритм',
|
||||
digest: 'Хеш',
|
||||
expectedDigest: 'Очікуваний хеш',
|
||||
sftpHostKeyMd: 'Відбиток ключа вузла SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 шістнадцяткових символів або md5=32',
|
||||
sftpHostKeyMdDescription: 'Необов’язкова перевірка ключа вузла Aria2. Залишайте поле порожнім лише якщо приймаєте неперевірений ключ SFTP.',
|
||||
cookies: 'Файли cookie',
|
||||
headers: 'Заголовки',
|
||||
mirrors: 'Дзеркала',
|
||||
@@ -763,6 +767,9 @@ const uk = {
|
||||
verifyChecksum: 'Перевірити контрольну суму',
|
||||
checksumAlgorithm: 'Алгоритм контрольної суми',
|
||||
expectedDigest: 'Очікуваний хеш',
|
||||
sftpHostKeyMd: 'Відбиток ключа вузла SFTP',
|
||||
sftpHostKeyMdHint: 'sha-1=40 шістнадцяткових символів або md5=32',
|
||||
sftpHostKeyMdDescription: 'Необов’язкова перевірка ключа вузла Aria2. Залишайте поле порожнім лише якщо приймаєте неперевірений ключ SFTP.',
|
||||
headers: 'Заголовки',
|
||||
requestHeaders: 'Заголовки запиту',
|
||||
cookies: 'Файли cookie',
|
||||
|
||||
@@ -269,6 +269,7 @@ const zhCN = {
|
||||
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
||||
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
||||
editingUnavailable: '下载进行时无法编辑这些属性。',
|
||||
credentialsRequired: '重启后需要再次提供凭据。请在“高级”中添加凭据,然后恢复下载。',
|
||||
liveTorrentUploadLimit: '实时种子上传限速',
|
||||
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
||||
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
||||
@@ -459,6 +460,9 @@ const zhCN = {
|
||||
algorithm: '算法',
|
||||
digest: '哈希值',
|
||||
expectedDigest: '预期哈希值',
|
||||
sftpHostKeyMd: 'SFTP 主机密钥指纹',
|
||||
sftpHostKeyMdHint: 'sha-1=40 个十六进制字符或 md5=32 个',
|
||||
sftpHostKeyMdDescription: '可选的 Aria2 主机密钥验证。仅在接受未验证的 SFTP 主机密钥时留空。',
|
||||
cookies: 'Cookie',
|
||||
headers: '请求头',
|
||||
mirrors: '镜像源',
|
||||
@@ -763,6 +767,9 @@ const zhCN = {
|
||||
verifyChecksum: '验证校验和',
|
||||
checksumAlgorithm: '校验和算法',
|
||||
expectedDigest: '预期摘要',
|
||||
sftpHostKeyMd: 'SFTP 主机密钥指纹',
|
||||
sftpHostKeyMdHint: 'sha-1=40 个十六进制字符或 md5=32 个',
|
||||
sftpHostKeyMdDescription: '可选的 Aria2 主机密钥验证。仅在接受未验证的 SFTP 主机密钥时留空。',
|
||||
headers: '请求头',
|
||||
requestHeaders: '请求头',
|
||||
cookies: 'Cookie',
|
||||
|
||||
@@ -64,6 +64,7 @@ const PROPERTIES_SNAPSHOT_KEYS = [
|
||||
'resumable',
|
||||
'connections',
|
||||
'speedLimit',
|
||||
'sftpHostKeyMd',
|
||||
'checksum',
|
||||
'destination',
|
||||
'isMedia',
|
||||
@@ -73,6 +74,7 @@ const PROPERTIES_SNAPSHOT_KEYS = [
|
||||
'queuePosition',
|
||||
'hasBeenDispatched',
|
||||
'lastError',
|
||||
'credentialsRequired',
|
||||
'lastErrorKind',
|
||||
'lastResolverFallback',
|
||||
'lastTry',
|
||||
@@ -195,6 +197,7 @@ export type SecretPatch =
|
||||
|
||||
export const PROPERTIES_PATCH_CLEARABLE_KEYS = [
|
||||
'destination',
|
||||
'sftpHostKeyMd',
|
||||
'speedLimit',
|
||||
'torrentTrackers',
|
||||
'torrentExcludeTrackers',
|
||||
|
||||
@@ -135,6 +135,30 @@ describe('useDownloadStore', () => {
|
||||
expect(fileName.endsWith('.mp4')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the credential-required marker when the last secret is cleared', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'credential-marker',
|
||||
url: 'https://secure.example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'failed',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
credentialsRequired: true
|
||||
}] as any[]
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().applyProperties('credential-marker', {
|
||||
password: ''
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBe(true);
|
||||
|
||||
await useDownloadStore.getState().applyProperties('credential-marker', {
|
||||
password: 'secret'
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads[0].credentialsRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('clears a persisted Torrent removal reservation when a paused item disables cleanup', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
@@ -1817,6 +1841,30 @@ describe('useDownloadStore', () => {
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
});
|
||||
|
||||
it('does not resume a paused backend lifecycle without restored credentials', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'credential-resume-gated',
|
||||
url: 'https://secure.example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
credentialsRequired: true
|
||||
}] as any[],
|
||||
backendRegisteredIds: new Set(['credential-resume-gated'])
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload('credential-resume-gated'))
|
||||
.resolves.toBe(false);
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
||||
'resume_download',
|
||||
expect.anything()
|
||||
);
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
|
||||
});
|
||||
|
||||
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
|
||||
@@ -114,6 +114,26 @@ const waitForPendingStartupResume = async (): Promise<void> => {
|
||||
if (pending) await pending.catch(() => undefined);
|
||||
};
|
||||
|
||||
const credentialsRequiredMessage = (): string =>
|
||||
i18n.t($ => $.properties.credentialsRequired);
|
||||
|
||||
const hasCredentialMaterial = (value: string | null | undefined): boolean =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
const markCredentialsRequired = (id: string): void => {
|
||||
useDownloadStore.getState().updateDownload(id, {
|
||||
status: 'paused',
|
||||
lastError: credentialsRequiredMessage(),
|
||||
});
|
||||
useDownloadStore.setState(state => ({
|
||||
pendingOrder: state.pendingOrder.filter(value => value !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
const clearCredentialsRequired = (id: string): void => {
|
||||
useDownloadStore.getState().updateDownload(id, { credentialsRequired: false });
|
||||
};
|
||||
|
||||
const currentQueueControlGeneration = (queueId: string): number =>
|
||||
queueControlGenerations.get(queueId) ?? 0;
|
||||
|
||||
@@ -316,6 +336,16 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
}
|
||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
||||
|
||||
if (item.credentialsRequired === true
|
||||
&& !hasCredentialMaterial(item.password)
|
||||
&& !hasCredentialMaterial(item.cookies)
|
||||
&& !hasCredentialMaterial(item.headers)
|
||||
&& !hasCredentialMaterial(keychainPassword)) {
|
||||
markCredentialsRequired(id);
|
||||
return false;
|
||||
}
|
||||
if (item.credentialsRequired === true) clearCredentialsRequired(id);
|
||||
|
||||
const proxy = proxyOverride === undefined
|
||||
? await getProxyArgs(settings)
|
||||
: proxyOverride;
|
||||
@@ -333,6 +363,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword,
|
||||
sftp_host_key_md: item.sftpHostKeyMd || undefined,
|
||||
headers: item.headers || null,
|
||||
checksum: item.checksum || null,
|
||||
cookies: item.cookies || null,
|
||||
@@ -1017,9 +1048,22 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
const state = get();
|
||||
const item = state.downloads.find(d => d.id === id);
|
||||
if (!item) return;
|
||||
const normalizedUpdates = updates.fileName === undefined
|
||||
? updates
|
||||
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) };
|
||||
const credentialsUpdated = (['password', 'cookies', 'headers'] as const)
|
||||
.some(field => Object.prototype.hasOwnProperty.call(updates, field));
|
||||
const nextCredentialMaterial = (['password', 'cookies', 'headers'] as const)
|
||||
.some(field => hasCredentialMaterial(
|
||||
Object.prototype.hasOwnProperty.call(updates, field) ? updates[field] : item[field]
|
||||
));
|
||||
const normalizedUpdates = {
|
||||
...(updates.fileName === undefined
|
||||
? updates
|
||||
: { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) }),
|
||||
...(credentialsUpdated && nextCredentialMaterial
|
||||
? { credentialsRequired: false }
|
||||
: credentialsUpdated && item.credentialsRequired === true
|
||||
? { credentialsRequired: true }
|
||||
: {}),
|
||||
};
|
||||
const disablingTorrentRemoval = item.isTorrent === true
|
||||
&& normalizedUpdates.torrentRemoveUnselectedFile === false
|
||||
&& item.torrentRemoveUnselectedFile !== false;
|
||||
@@ -1085,6 +1129,30 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
let targetItem = get().downloads.find(d => d.id === id);
|
||||
if (!targetItem) return false;
|
||||
|
||||
if (targetItem.credentialsRequired === true
|
||||
&& !hasCredentialMaterial(targetItem.password)
|
||||
&& !hasCredentialMaterial(targetItem.cookies)
|
||||
&& !hasCredentialMaterial(targetItem.headers)) {
|
||||
const settings = useSettingsStore.getState();
|
||||
const login = getSiteLogin(targetItem.url, settings);
|
||||
let keychainPassword: string | null = null;
|
||||
if (login && settings.keychainAccessReady) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch keychain password for resume:', error);
|
||||
}
|
||||
}
|
||||
if (!hasCredentialMaterial(keychainPassword)) {
|
||||
if (login && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
|
||||
settings.setShowKeychainModal(true);
|
||||
}
|
||||
markCredentialsRequired(id);
|
||||
return false;
|
||||
}
|
||||
clearCredentialsRequired(id);
|
||||
}
|
||||
|
||||
setDownloadControlIntent(id, 'resume');
|
||||
let previousStatus = targetItem.status;
|
||||
try {
|
||||
@@ -2343,6 +2411,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
console.warn("Could not fetch keychain password for login:", e);
|
||||
}
|
||||
}
|
||||
if (item.credentialsRequired === true
|
||||
&& !hasCredentialMaterial(item.password)
|
||||
&& !hasCredentialMaterial(item.cookies)
|
||||
&& !hasCredentialMaterial(item.headers)
|
||||
&& !hasCredentialMaterial(keychainPassword)) {
|
||||
markCredentialsRequired(item.id);
|
||||
continue;
|
||||
}
|
||||
if (item.credentialsRequired === true) clearCredentialsRequired(item.id);
|
||||
const destPath = item.destination ||
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
itemsToEnqueue.push({
|
||||
@@ -2357,6 +2434,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword,
|
||||
sftp_host_key_md: item.sftpHostKeyMd || undefined,
|
||||
headers: item.headers || null,
|
||||
checksum: item.checksum || null,
|
||||
cookies: item.cookies || null,
|
||||
|
||||
@@ -60,6 +60,20 @@ describe('download persistence progress snapshots', () => {
|
||||
expect(sanitized.lastResolverFallback).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks redacted downloads that need credentials after restart', () => {
|
||||
const persisted = redactDownloadForPersistence({
|
||||
...item('paused'),
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
cookies: 'session=redacted',
|
||||
headers: 'Authorization: redacted',
|
||||
});
|
||||
expect(persisted.credentialsRequired).toBe(true);
|
||||
expect(persisted.password).toBeUndefined();
|
||||
expect(persisted.cookies).toBeUndefined();
|
||||
expect(persisted.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
|
||||
'keeps byte counters for %s snapshots',
|
||||
(status) => {
|
||||
|
||||
@@ -566,6 +566,10 @@ const VOLATILE_PROGRESS_STATUSES = new Set([
|
||||
*/
|
||||
export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem => {
|
||||
const copy: DownloadItem = { ...item };
|
||||
if (item.credentialsRequired === true
|
||||
|| DOWNLOAD_SECRET_FIELDS.some(field => Boolean(item[field]))) {
|
||||
copy.credentialsRequired = true;
|
||||
}
|
||||
delete copy.fraction;
|
||||
delete copy.speed;
|
||||
delete copy.eta;
|
||||
|
||||
Reference in New Issue
Block a user