diff --git a/js/app.js b/js/app.js index 236a3ba5..c62eb13b 100644 --- a/js/app.js +++ b/js/app.js @@ -5,6 +5,7 @@ import { nostrClient } from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; import { isDevMode } from "./config.js"; import { isWhitelistEnabled } from "./config.js"; +import { normalizeAndAugmentMagnet } from "./magnetUtils.js"; import { initialWhitelist, initialBlacklist, @@ -21,103 +22,6 @@ function fakeDecrypt(str) { const UNSUPPORTED_BTITH_MESSAGE = "This magnet link is missing a compatible BitTorrent v1 info hash."; -const DEFAULT_WSS_TRACKERS = [ - "wss://tracker.openwebtorrent.com", - "wss://tracker.btorrent.xyz", - "wss://tracker.fastcast.nz", -]; - -/** - * Append optional ws/xs parameters to a magnet URI when valid. - * - * Always appends the default WSS tracker list, injects any provided hosted - * video URL as an `xs=` hint, and de-duplicates repeated query parameters. - */ -function augmentMagnet(raw, ws, xs, hostedUrl) { - const magnet = (raw || "").trim(); - if (!magnet) { - return ""; - } - - let parsed; - try { - parsed = new URL(magnet); - } catch (err) { - return magnet; - } - - if (parsed.protocol !== "magnet:") { - return magnet; - } - - const appendIfMissing = (key, value, { normalize } = {}) => { - if (!value) return; - const trimmed = typeof value === "string" ? value.trim() : ""; - if (!trimmed) return; - const normalizer = - typeof normalize === "function" - ? normalize - : (val) => (typeof val === "string" ? val.trim() : ""); - const candidate = normalizer(trimmed); - if (!candidate) return; - const existing = parsed.searchParams - .getAll(key) - .map((existingValue) => normalizer(existingValue)); - if (!existing.includes(candidate)) { - parsed.searchParams.append(key, trimmed); - } - }; - - const sanitizeHostedUrl = (value) => { - const trimmed = typeof value === "string" ? value.trim() : ""; - if (!trimmed) { - return ""; - } - try { - const parsedUrl = new URL(trimmed); - if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") { - return parsedUrl.toString(); - } - } catch (err) { - return ""; - } - return ""; - }; - - const normalizedHostedSeed = sanitizeHostedUrl(hostedUrl); - - appendIfMissing("ws", ws); - appendIfMissing("xs", xs); - appendIfMissing("xs", normalizedHostedSeed); - - DEFAULT_WSS_TRACKERS.forEach((tracker) => { - appendIfMissing("tr", tracker, { - normalize: (value) => (typeof value === "string" ? value.trim().toLowerCase() : ""), - }); - }); - - const dedupeParam = (key, normalizer = (value) => value.trim()) => { - const seen = new Set(); - const values = parsed.searchParams.getAll(key); - parsed.searchParams.delete(key); - for (const value of values) { - const trimmed = typeof value === "string" ? value.trim() : ""; - if (!trimmed) continue; - const normalized = normalizer(trimmed); - if (!normalized) continue; - if (seen.has(normalized)) continue; - seen.add(normalized); - parsed.searchParams.append(key, trimmed); - } - }; - - dedupeParam("tr", (value) => value.trim().toLowerCase()); - dedupeParam("ws"); - dedupeParam("xs"); - - return parsed.toString(); -} - /** * Basic validation for BitTorrent magnet URIs. * @@ -1003,12 +907,21 @@ class bitvidApp { return; } - formData.magnet = augmentMagnet( - formData.magnet, - ws, - xs, - sanitizedHostedUrl - ); + const webSeedCandidates = []; + if (ws) { + webSeedCandidates.push(ws); + } + if (sanitizedHostedUrl) { + webSeedCandidates.push(sanitizedHostedUrl); + } + + const magnetAugmentation = normalizeAndAugmentMagnet(formData.magnet, { + webSeed: webSeedCandidates, + torrentUrl: xs, + logger: (message) => this.log(message), + }); + + formData.magnet = magnetAugmentation.magnet; try { await nostrClient.publishVideo(formData, this.pubkey); @@ -2033,14 +1946,17 @@ class bitvidApp { } } - async playHttp(url, { fallbackMagnet = "" } = {}) { + async playHttp(url, { fallbackMagnet = "", fallbackRawMagnet = "" } = {}) { if (!this.modalVideo) { throw new Error("No modal video element available for playback."); } - const trimmedMagnet = isValidMagnetUri(fallbackMagnet) + const normalizedFallback = isValidMagnetUri(fallbackMagnet) ? fallbackMagnet.trim() : ""; + const rawFallback = isValidMagnetUri(fallbackRawMagnet) + ? fallbackRawMagnet.trim() + : ""; this.resetTorrentStats(); @@ -2058,7 +1974,7 @@ class bitvidApp { once: true, }); - if (trimmedMagnet) { + if (normalizedFallback) { this.modalVideo.addEventListener( "error", async () => { @@ -2067,7 +1983,9 @@ class bitvidApp { this.modalStatus.textContent = "Switching to WebTorrent..."; } try { - await this.playViaWebTorrent(trimmedMagnet); + await this.playViaWebTorrent(normalizedFallback, { + fallbackMagnet: rawFallback, + }); this.autoplayModalVideo(); } catch (torrentErr) { console.error("WebTorrent fallback failed:", torrentErr); @@ -2082,45 +2000,75 @@ class bitvidApp { this.modalVideo.load(); } - async playViaWebTorrent(magnet) { - const trimmedMagnet = - typeof magnet === "string" ? magnet.trim() : ""; - if (!trimmedMagnet) { - throw new Error("No magnet URI provided for torrent playback."); - } - if (!isValidMagnetUri(trimmedMagnet)) { - if (this.modalStatus) { - this.modalStatus.textContent = UNSUPPORTED_BTITH_MESSAGE; + async playViaWebTorrent(magnet, { fallbackMagnet = "" } = {}) { + const attemptStream = async (candidate) => { + const trimmedCandidate = + typeof candidate === "string" ? candidate.trim() : ""; + if (!trimmedCandidate) { + throw new Error("No magnet URI provided for torrent playback."); + } + if (!isValidMagnetUri(trimmedCandidate)) { + if (this.modalStatus) { + this.modalStatus.textContent = UNSUPPORTED_BTITH_MESSAGE; + } + throw new Error(UNSUPPORTED_BTITH_MESSAGE); + } + if (!this.modalVideo) { + throw new Error( + "No modal video element available for torrent playback." + ); } - throw new Error(UNSUPPORTED_BTITH_MESSAGE); - } - if (!this.modalVideo) { - throw new Error("No modal video element available for torrent playback."); - } - let cacheBustedMagnet = trimmedMagnet; + let cacheBustedMagnet = trimmedCandidate; + try { + const parsed = new URL(trimmedCandidate); + parsed.searchParams.set("ts", Date.now().toString()); + cacheBustedMagnet = parsed.toString(); + } catch (err) { + const separator = trimmedCandidate.includes("?") ? "&" : "?"; + cacheBustedMagnet = `${trimmedCandidate}${separator}ts=${Date.now()}`; + } + + await torrentClient.cleanup(); + this.resetTorrentStats(); + + if (this.modalStatus) { + this.modalStatus.textContent = "Streaming via WebTorrent"; + } + + const torrentInstance = await torrentClient.streamVideo( + cacheBustedMagnet, + this.modalVideo + ); + this.startTorrentStatusMirrors(torrentInstance); + return torrentInstance; + }; + + const primaryTrimmed = + typeof magnet === "string" ? magnet.trim() : ""; + const fallbackTrimmed = + typeof fallbackMagnet === "string" ? fallbackMagnet.trim() : ""; + const hasFallback = + !!fallbackTrimmed && fallbackTrimmed !== primaryTrimmed; + try { - const parsed = new URL(trimmedMagnet); - parsed.searchParams.set("ts", Date.now().toString()); - cacheBustedMagnet = parsed.toString(); - } catch (err) { - const separator = trimmedMagnet.includes("?") ? "&" : "?"; - cacheBustedMagnet = `${trimmedMagnet}${separator}ts=${Date.now()}`; + return await attemptStream(primaryTrimmed); + } catch (primaryError) { + if (!hasFallback) { + throw primaryError; + } + this.log( + `[playViaWebTorrent] Normalized magnet failed: ${primaryError.message}` + ); + this.log( + "[playViaWebTorrent] Primary magnet failed, retrying original string." + ); + try { + return await attemptStream(fallbackTrimmed); + } catch (fallbackError) { + throw fallbackError; + } } - - await torrentClient.cleanup(); - this.resetTorrentStats(); - - if (this.modalStatus) { - this.modalStatus.textContent = "Streaming via WebTorrent"; - } - - const torrentInstance = await torrentClient.streamVideo( - cacheBustedMagnet, - this.modalVideo - ); - this.startTorrentStatusMirrors(torrentInstance); - return torrentInstance; } /** @@ -2137,14 +2085,24 @@ class bitvidApp { const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; const magnetSupported = isValidMagnetUri(trimmedMagnet); const sanitizedMagnet = magnetSupported ? trimmedMagnet : ""; - const augmentedMagnet = sanitizedMagnet - ? augmentMagnet(sanitizedMagnet, undefined, undefined, sanitizedUrl) + const magnetNormalization = sanitizedMagnet + ? normalizeAndAugmentMagnet(sanitizedMagnet, { + webSeed: sanitizedUrl ? [sanitizedUrl] : [], + logger: (message) => this.log(message), + }) + : { magnet: "", didChange: false }; + const magnetForPlayback = sanitizedMagnet + ? magnetNormalization.magnet || sanitizedMagnet + : ""; + const fallbackMagnet = magnetNormalization.didChange + ? sanitizedMagnet : ""; - const magnetForPlayback = augmentedMagnet || sanitizedMagnet; const magnetProvided = trimmedMagnet.length > 0; if (this.currentVideo) { this.currentVideo.magnet = magnetForPlayback; + this.currentVideo.normalizedMagnet = magnetForPlayback; + this.currentVideo.normalizedMagnetFallback = fallbackMagnet; if (!magnetForPlayback) { this.currentVideo.torrentSupported = false; } @@ -2174,9 +2132,8 @@ class bitvidApp { this.resetTorrentStats(); if (magnetForPlayback) { - const magnetChanged = magnetForPlayback !== sanitizedMagnet; - const label = magnetChanged - ? "[playVideoWithFallback] Using augmented magnet URI:" + const label = magnetNormalization.didChange + ? "[playVideoWithFallback] Using normalized magnet URI:" : "[playVideoWithFallback] Using magnet URI:"; this.log(`${label} ${magnetForPlayback}`); } @@ -2189,9 +2146,12 @@ class bitvidApp { if (urlPlayable) { await this.playHttp(sanitizedUrl, { fallbackMagnet: magnetForPlayback, + fallbackRawMagnet: fallbackMagnet, }); } else if (magnetForPlayback) { - await this.playViaWebTorrent(magnetForPlayback); + await this.playViaWebTorrent(magnetForPlayback, { + fallbackMagnet, + }); } else { const message = magnetProvided && !magnetSupported ? UNSUPPORTED_BTITH_MESSAGE diff --git a/js/magnetUtils.js b/js/magnetUtils.js new file mode 100644 index 00000000..25908f49 --- /dev/null +++ b/js/magnetUtils.js @@ -0,0 +1,213 @@ +// js/magnetUtils.js + +export const DEFAULT_WSS_TRACKERS = [ + "wss://tracker.openwebtorrent.com", + "wss://tracker.btorrent.xyz", + "wss://tracker.fastcast.nz", +]; + +const HEX_INFO_HASH = /^[0-9a-f]{40}$/i; +const BTIH_PREFIX = "urn:btih:"; +const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi; + +function normalizeForComparison(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + try { + const parsed = new URL(trimmed); + const pathname = parsed.pathname.replace(/\/?$/, ""); + const normalizedPath = pathname ? pathname : ""; + return ( + `${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}` + .trim() + .toLowerCase() + ); + } catch (err) { + return trimmed.replace(/\/?$/, "").toLowerCase(); + } +} + +function sanitizeHttpUrl(candidate) { + if (typeof candidate !== "string") { + return ""; + } + const trimmed = candidate.trim(); + if (!trimmed) { + return ""; + } + try { + const parsed = new URL(trimmed); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return parsed.toString(); + } + } catch (err) { + return ""; + } + return ""; +} + +export function normalizeAndAugmentMagnet( + rawValue, + { + webSeed, + torrentUrl, + xs, + extraTrackers = [], + logger, + appProtocol, + } = {} +) { + const log = typeof logger === "function" ? logger : () => {}; + const initial = typeof rawValue === "string" ? rawValue.trim() : ""; + if (!initial) { + return { magnet: "", didChange: false }; + } + + let working = initial; + let didMutate = false; + const bareHashMatch = HEX_INFO_HASH.test(working); + if (bareHashMatch) { + working = `magnet:?xt=${BTIH_PREFIX}${working.toLowerCase()}`; + didMutate = true; + } + + const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => { + didMutate = true; + return `xt=${BTIH_PREFIX}${hash}`; + }); + working = decodedXt; + + let parsed; + try { + parsed = new URL(working); + } catch (err) { + return { + magnet: working, + didChange: didMutate || working !== initial, + }; + } + + if (parsed.protocol !== "magnet:") { + return { + magnet: working, + didChange: didMutate || working !== initial, + }; + } + + const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim() + ? torrentUrl + : typeof xs === "string" + ? xs.trim() + : ""; + + const safeTorrentHint = sanitizeHttpUrl(torrentHint); + const seedInputs = Array.isArray(webSeed) + ? webSeed + : typeof webSeed === "string" + ? [webSeed] + : []; + + const resolvedProtocol = typeof appProtocol === "string" && appProtocol + ? appProtocol.toLowerCase() + : typeof window !== "undefined" && window.location && window.location.protocol + ? window.location.protocol.toLowerCase() + : "https:"; + + const existingTrackers = new Set( + parsed.searchParams + .getAll("tr") + .map((value) => normalizeForComparison(value)) + .filter(Boolean) + ); + + const trackerCandidates = [ + ...DEFAULT_WSS_TRACKERS, + ...extraTrackers, + ]; + + for (const tracker of trackerCandidates) { + if (typeof tracker !== "string") { + continue; + } + const trimmedTracker = tracker.trim(); + if (!trimmedTracker) { + continue; + } + if (!/^wss:\/\//i.test(trimmedTracker)) { + continue; + } + const normalizedTracker = normalizeForComparison(trimmedTracker); + if (!normalizedTracker || existingTrackers.has(normalizedTracker)) { + continue; + } + parsed.searchParams.append("tr", trimmedTracker); + existingTrackers.add(normalizedTracker); + didMutate = true; + } + + if (safeTorrentHint) { + const existingXs = new Set( + parsed.searchParams + .getAll("xs") + .map((value) => normalizeForComparison(value)) + .filter(Boolean) + ); + const normalizedXs = normalizeForComparison(safeTorrentHint); + if (normalizedXs && !existingXs.has(normalizedXs)) { + parsed.searchParams.append("xs", safeTorrentHint); + didMutate = true; + } + } + + if (seedInputs.length) { + const existingWs = new Set( + parsed.searchParams + .getAll("ws") + .map((value) => normalizeForComparison(value)) + .filter(Boolean) + ); + for (const seedInput of seedInputs) { + if (typeof seedInput !== "string") { + continue; + } + const trimmedSeed = seedInput.trim(); + if (!trimmedSeed) { + continue; + } + try { + const parsedSeed = new URL(trimmedSeed); + const seedProtocol = parsedSeed.protocol; + const allowHttpSeed = resolvedProtocol === "http:"; + if ( + seedProtocol === "https:" || + (seedProtocol === "http:" && allowHttpSeed) + ) { + const seedValue = parsedSeed.toString(); + const normalizedSeed = normalizeForComparison(seedValue); + if (normalizedSeed && !existingWs.has(normalizedSeed)) { + parsed.searchParams.append("ws", seedValue); + existingWs.add(normalizedSeed); + didMutate = true; + } + } else if (seedProtocol === "http:") { + log( + `[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}` + ); + } + } catch (err) { + // Ignore invalid web seed values silently. + } + } + } + + const finalMagnet = parsed.toString(); + return { + magnet: finalMagnet, + didChange: didMutate || finalMagnet !== initial, + }; +} diff --git a/tests/magnet-utils.test.mjs b/tests/magnet-utils.test.mjs new file mode 100644 index 00000000..209d596f --- /dev/null +++ b/tests/magnet-utils.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { + DEFAULT_WSS_TRACKERS, + normalizeAndAugmentMagnet, +} from "../js/magnetUtils.js"; + +function getParamValues(magnet, key) { + const parsed = new URL(magnet); + return parsed.searchParams.getAll(key); +} + +(function testBareInfoHashNormalization() { + const infoHash = "0123456789abcdef0123456789abcdef01234567"; + const result = normalizeAndAugmentMagnet(infoHash); + assert.ok(result.didChange, "Bare info hash should mark didChange true"); + const xtValues = getParamValues(result.magnet, "xt"); + assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]); + const trackerValues = getParamValues(result.magnet, "tr"); + for (const tracker of DEFAULT_WSS_TRACKERS) { + assert.ok( + trackerValues.includes(tracker), + `Expected tracker ${tracker} to be appended` + ); + } +})(); + +(function testEncodedXtDecoding() { + const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd"; + const encodedMagnet = `magnet:?xt=urn%3Abtih%3A${infoHash}&dn=Example`; + const result = normalizeAndAugmentMagnet(encodedMagnet); + const xtValues = getParamValues(result.magnet, "xt"); + assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]); +})(); + +(function testDuplicateTrackerFiltering() { + const infoHash = "fedcba9876543210fedcba9876543210fedcba98"; + const baseMagnet = + `magnet:?xt=urn:btih:${infoHash}` + + "&tr=wss://tracker.openwebtorrent.com" + + "&tr=WsS://tracker.openwebtorrent.com/" + + "&tr=https://not-allowed.example"; + const result = normalizeAndAugmentMagnet(baseMagnet, { + extraTrackers: [ + "wss://tracker.fastcast.nz", + "http://tracker.invalid", + "wss://tracker.openwebtorrent.com", + ], + }); + const trackers = getParamValues(result.magnet, "tr"); + const openwebCount = trackers.filter( + (value) => value === "wss://tracker.openwebtorrent.com" + ).length; + assert.equal(openwebCount, 1, "Duplicate WSS tracker should be collapsed"); + assert.ok( + trackers.includes("wss://tracker.fastcast.nz"), + "Expected extra WSS tracker to be preserved" + ); + assert.ok( + !trackers.includes("http://tracker.invalid"), + "Non-WSS tracker candidates should be ignored" + ); +})(); + +(function testWebSeedHandlingWithLogging() { + const infoHash = "0123456789abcdef0123456789abcdef01234567"; + const messages = []; + const result = normalizeAndAugmentMagnet(`magnet:?xt=urn:btih:${infoHash}`, { + webSeed: [ + "https://cdn.example.com/video.mp4", + "http://cdn.example.com/legacy.mp4", + "https://cdn.example.com/video.mp4", + ], + logger: (msg) => messages.push(msg), + appProtocol: "https:", + }); + const seeds = getParamValues(result.magnet, "ws"); + assert.deepEqual(seeds, ["https://cdn.example.com/video.mp4"]); + assert.equal(messages.length, 1, "HTTP seed should have been skipped with a log"); + assert.ok( + messages[0].includes("Skipping insecure web seed"), + "Expected skip message for HTTP seed" + ); +})(); + +(function testHttpWebSeedAllowedOnHttpOrigin() { + const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd"; + const result = normalizeAndAugmentMagnet(`magnet:?xt=urn:btih:${infoHash}`, { + webSeed: "http://cdn.example.com/video.mp4", + appProtocol: "http:", + }); + const seeds = getParamValues(result.magnet, "ws"); + assert.deepEqual(seeds, ["http://cdn.example.com/video.mp4"]); +})(); + +console.log("magnet-utils tests passed");