feat(torrents): add tracker controls

This commit is contained in:
NimBold
2026-08-01 23:53:07 +03:30
parent e31a3fcc90
commit 0f1f4e8003
20 changed files with 434 additions and 15 deletions
+1
View File
@@ -65,6 +65,7 @@ export interface AddDownloadDraftRow {
torrentMaxPeers?: number;
torrentPeerSpeedLimit?: string;
torrentCheckIntegrity?: boolean;
torrentTrackers?: string;
}
/**
+17
View File
@@ -6,6 +6,7 @@ import {
downloadMediaKindsMatch,
MAX_DOWNLOAD_FILENAME_BYTES,
canonicalizeDownloadFileName,
isValidTorrentTrackerList,
redactDownloadForPersistence,
resolveDownloadConnections
} from './downloads';
@@ -51,6 +52,22 @@ describe('download persistence progress snapshots', () => {
);
});
describe('Torrent tracker input validation', () => {
it('accepts supported trackers separated by lines or commas', () => {
expect(isValidTorrentTrackerList(
' https://tracker.example/announce\nudp://tracker.example:6969/announce '
)).toBe(true);
expect(isValidTorrentTrackerList('https://tracker.example/announce,https://tracker.example/announce')).toBe(true);
});
it('rejects unsupported, credential-bearing, empty, and oversized entries', () => {
expect(isValidTorrentTrackerList('ftp://tracker.example/announce')).toBe(false);
expect(isValidTorrentTrackerList('https://user:pass@tracker.example/announce')).toBe(false);
expect(isValidTorrentTrackerList('https://tracker.example/announce,')).toBe(false);
expect(isValidTorrentTrackerList(Array.from({ length: 65 }, (_, index) => `https://tracker${index}.example/announce`).join('\n'))).toBe(false);
});
});
describe('download connection resolution', () => {
it('uses a clamped fallback for legacy rows without a saved value', () => {
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
+46
View File
@@ -118,6 +118,52 @@ export const normalizeSpeedLimitForBackend = (value?: string | null): string | n
return unit ? `${amount}${unit}` : `${amount}K`;
};
const MAX_TORRENT_TRACKERS = 64;
const MAX_TORRENT_TRACKER_BYTES = 16 * 1024;
/**
* Performs the same user-facing safety checks as the native tracker boundary.
* The Rust validator remains authoritative because persisted data can bypass
* this helper and the browser URL parser is not the native URL parser.
*/
export const isValidTorrentTrackerList = (value: string): boolean => {
const raw = value.trim();
if (!raw) return true;
if (utf8ByteLength(raw) > MAX_TORRENT_TRACKER_BYTES) return false;
const normalized = new Set<string>();
let serializedBytes = 0;
for (const line of raw.split(/[\r\n]/)) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
for (const part of trimmedLine.split(',')) {
const token = part.trim();
if (!token || [...token].some(character => character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f)) {
return false;
}
let parsed: URL;
try {
parsed = new URL(token);
} catch {
return false;
}
if (!['http:', 'https:', 'udp:'].includes(parsed.protocol) || !parsed.hostname) {
return false;
}
if (parsed.username || parsed.password || parsed.hash) {
return false;
}
const canonical = parsed.toString();
if (normalized.has(canonical)) continue;
normalized.add(canonical);
if (normalized.size > MAX_TORRENT_TRACKERS) return false;
serializedBytes += utf8ByteLength(canonical) + (normalized.size > 1 ? 1 : 0);
if (serializedBytes > MAX_TORRENT_TRACKER_BYTES) return false;
}
}
return normalized.size > 0;
};
export const initMediaDomains = async () => {
try {
const domains = await invoke('get_supported_media_domains');