diff --git a/js/app.js b/js/app.js index 43c0eeee..9196a325 100644 --- a/js/app.js +++ b/js/app.js @@ -5,7 +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 { normalizeAndAugmentMagnet, safeDecodeMagnet } from "./magnetUtils.js"; import { deriveTorrentPlaybackConfig } from "./playbackUtils.js"; import { initialWhitelist, @@ -38,12 +38,15 @@ function isValidMagnetUri(magnet) { return false; } - if (/^[0-9a-f]{40}$/i.test(trimmed)) { + const decoded = safeDecodeMagnet(trimmed); + const candidate = decoded || trimmed; + + if (/^[0-9a-f]{40}$/i.test(candidate)) { return true; } try { - const parsed = new URL(trimmed); + const parsed = new URL(candidate); if (parsed.protocol.toLowerCase() !== "magnet:") { return false; } @@ -2227,8 +2230,10 @@ class bitvidApp { const legacyInfoHash = typeof video.infoHash === "string" ? video.infoHash.trim().toLowerCase() : ""; const magnetCandidate = rawMagnet || legacyInfoHash; - const magnetSupported = isValidMagnetUri(magnetCandidate); - const sanitizedMagnet = magnetSupported ? magnetCandidate : ""; + const decodedMagnetCandidate = safeDecodeMagnet(magnetCandidate); + const usableMagnetCandidate = decodedMagnetCandidate || magnetCandidate; + const magnetSupported = isValidMagnetUri(usableMagnetCandidate); + const sanitizedMagnet = magnetSupported ? usableMagnetCandidate : ""; this.currentVideo = { ...video, @@ -2297,7 +2302,7 @@ class bitvidApp { await this.playVideoWithFallback({ url: trimmedUrl, - magnet: magnetCandidate, + magnet: usableMagnetCandidate, infoHash: legacyInfoHash, title: video.title, description: video.description, @@ -2312,8 +2317,10 @@ class bitvidApp { } = {}) { const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; - const magnetSupported = isValidMagnetUri(trimmedMagnet); - const sanitizedMagnet = magnetSupported ? trimmedMagnet : ""; + const decodedMagnet = safeDecodeMagnet(trimmedMagnet); + const usableMagnet = decodedMagnet || trimmedMagnet; + const magnetSupported = isValidMagnetUri(usableMagnet); + const sanitizedMagnet = magnetSupported ? usableMagnet : ""; if (!sanitizedUrl && !sanitizedMagnet) { const message = trimmedMagnet && !magnetSupported @@ -2368,7 +2375,7 @@ class bitvidApp { await this.playVideoWithFallback({ url: sanitizedUrl, - magnet: trimmedMagnet, + magnet: usableMagnet, title, description, }); diff --git a/js/magnetUtils.js b/js/magnetUtils.js index 4cf206a3..7de9ab87 100644 --- a/js/magnetUtils.js +++ b/js/magnetUtils.js @@ -51,6 +51,38 @@ function sanitizeHttpUrl(candidate) { return ""; } +export function safeDecodeMagnet(value) { + if (typeof value !== "string") { + return ""; + } + + let decoded = value.trim(); + if (!decoded) { + return ""; + } + + for (let i = 0; i < 2; i += 1) { + if (!decoded.includes("%")) { + break; + } + + try { + const candidate = decodeURIComponent(decoded); + if (!candidate) { + break; + } + if (candidate === decoded) { + break; + } + decoded = candidate.trim(); + } catch (err) { + break; + } + } + + return decoded; +} + export function normalizeAndAugmentMagnet( rawValue, { diff --git a/js/playbackUtils.js b/js/playbackUtils.js index 9311022b..a670effe 100644 --- a/js/playbackUtils.js +++ b/js/playbackUtils.js @@ -1,6 +1,9 @@ // js/playbackUtils.js -import { normalizeAndAugmentMagnet } from "./magnetUtils.js"; +import { + normalizeAndAugmentMagnet, + safeDecodeMagnet, +} from "./magnetUtils.js"; const HEX_INFO_HASH = /^[0-9a-f]{40}$/i; const MAGNET_URI = /^magnet:\?/i; @@ -13,18 +16,21 @@ export function deriveTorrentPlaybackConfig({ appProtocol, } = {}) { const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; + const decodedMagnet = safeDecodeMagnet(trimmedMagnet); + const magnetCandidate = decodedMagnet || trimmedMagnet; const trimmedInfoHash = typeof infoHash === "string" ? infoHash.trim().toLowerCase() : ""; const sanitizedUrl = typeof url === "string" ? url.trim() : ""; - const magnetIsUri = MAGNET_URI.test(trimmedMagnet); - const magnetLooksLikeInfoHash = HEX_INFO_HASH.test(trimmedMagnet); + const magnetIsUri = MAGNET_URI.test(magnetCandidate); + const magnetLooksLikeInfoHash = HEX_INFO_HASH.test(magnetCandidate); const resolvedInfoHash = trimmedInfoHash || (magnetLooksLikeInfoHash - ? trimmedMagnet.toLowerCase() + ? magnetCandidate.toLowerCase() : ""); - const normalizationInput = magnetIsUri ? trimmedMagnet : resolvedInfoHash; + const normalizationInput = magnetIsUri ? magnetCandidate : resolvedInfoHash; const provided = Boolean(trimmedMagnet || trimmedInfoHash); + const decodeChanged = magnetCandidate !== trimmedMagnet; if (!normalizationInput) { return { @@ -66,7 +72,7 @@ export function deriveTorrentPlaybackConfig({ provided, usedInfoHash, originalInput: normalizationInput, - didMutate: normalization.didChange || usedInfoHash, + didMutate: normalization.didChange || usedInfoHash || decodeChanged, infoHash: resolvedInfoHash, }; } diff --git a/tests/legacy-infohash.test.mjs b/tests/legacy-infohash.test.mjs index add5a474..d2ac0ff8 100644 --- a/tests/legacy-infohash.test.mjs +++ b/tests/legacy-infohash.test.mjs @@ -96,4 +96,25 @@ const LEGACY_INFO_HASH = "0123456789abcdef0123456789abcdef01234567"; assert.equal(result.provided, true); })(); +(function testPlaybackConfigDecodesEncodedMagnet() { + const rawMagnet = + `magnet:?xt=urn:btih:${LEGACY_INFO_HASH}&dn=Legacy+Example`; + const encodedMagnet = encodeURIComponent(rawMagnet); + const result = deriveTorrentPlaybackConfig({ + magnet: encodedMagnet, + infoHash: "", + url: "", + }); + + assert.ok(result.magnet.startsWith("magnet:?")); + const parsed = new URL(result.magnet); + assert.equal( + parsed.searchParams.get("xt"), + `urn:btih:${LEGACY_INFO_HASH}`, + "Expected encoded magnet to be normalized" + ); + assert.equal(result.provided, true); + assert.equal(result.usedInfoHash, false); +})(); + console.log("legacy infohash tests passed"); diff --git a/tests/magnet-utils.test.mjs b/tests/magnet-utils.test.mjs index 209d596f..601ac14e 100644 --- a/tests/magnet-utils.test.mjs +++ b/tests/magnet-utils.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { DEFAULT_WSS_TRACKERS, normalizeAndAugmentMagnet, + safeDecodeMagnet, } from "../js/magnetUtils.js"; function getParamValues(magnet, key) { @@ -92,4 +93,25 @@ function getParamValues(magnet, key) { assert.deepEqual(seeds, ["http://cdn.example.com/video.mp4"]); })(); +(function testSafeDecodeMagnetHandlesEncodedValues() { + const rawMagnet = + "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example"; + const encodedMagnet = encodeURIComponent(rawMagnet); + assert.equal( + safeDecodeMagnet(encodedMagnet), + rawMagnet, + "Expected percent-encoded magnet to be decoded" + ); +})(); + +(function testSafeDecodeMagnetLeavesPlainStringsUntouched() { + const rawMagnet = + "magnet:?xt=urn:btih:abcdefabcdefabcdefabcdefabcdefabcdefabcd"; + assert.equal( + safeDecodeMagnet(rawMagnet), + rawMagnet, + "Plain magnet strings should pass through unchanged" + ); +})(); + console.log("magnet-utils tests passed");