From 7f586a38c400a269e3850a2e7390b255b548df12 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:30:12 -0400 Subject: [PATCH] Refactor magnet helpers into shared module --- js/magnet.js | 236 ++++------------------- js/magnetShared.js | 360 ++++++++++++++++++++++++++++++++++++ js/magnetUtils.js | 305 +++++------------------------- tests/magnet-utils.test.mjs | 293 ++++++++++++++++------------- 4 files changed, 604 insertions(+), 590 deletions(-) create mode 100644 js/magnetShared.js diff --git a/js/magnet.js b/js/magnet.js index f4282ea7..8f8eb005 100644 --- a/js/magnet.js +++ b/js/magnet.js @@ -1,224 +1,50 @@ -import { WSS_TRACKERS } from "./constants.js"; +import { + buildMagnetUri, + ensureTrackers, + ensureTorrentHint, + ensureWebSeeds, + formatAbsoluteUrl, + normalizeMagnetInput, + resolveAppProtocol, + extractMagnetHints as sharedExtractMagnetHints, +} from "./magnetShared.js"; -const HEX_INFO_HASH = /^[0-9a-f]{40}$/i; -const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi; +export const extractMagnetHints = sharedExtractMagnetHints; -function getOriginProtocol() { - if (typeof window !== "undefined" && window.location?.protocol) { - return window.location.protocol.toLowerCase(); - } - return "https:"; -} - -function decodeLoose(value) { - if (typeof value !== "string") { - return ""; - } - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - try { - return decodeURIComponent(trimmed); - } catch (err) { - return trimmed; - } -} - -function normalizeForComparison(value) { - if (typeof value !== "string") { - return ""; - } - return value.trim().replace(/\/+$/, "").toLowerCase(); -} - -function tryFormatAbsoluteUrl(candidate) { - if (typeof candidate !== "string") { - return ""; - } - const trimmed = candidate.trim(); - if (!trimmed) { - return ""; - } - try { - const parsed = new URL(trimmed); - return parsed.toString(); - } catch (err) { - return trimmed; - } -} - -/** - * Normalize and enrich a magnet string entered in the upload form so that it - * always produces a WebTorrent-friendly URI. The logic mirrors the behaviour of - * the legacy magnetUtils helper but returns a plain string for ease of use. - */ export function normalizeAndAugmentMagnet(rawValue, { ws = "", xs = "" } = {}) { - const initial = typeof rawValue === "string" ? rawValue.trim() : ""; + const { + initial, + canonicalValue, + isMagnet, + normalizedScheme, + fragment, + params, + } = normalizeMagnetInput(rawValue); + if (!initial) { return ""; } - let working = initial; - - if (HEX_INFO_HASH.test(working)) { - working = `magnet:?xt=urn:btih:${working.toLowerCase()}`; + if (!isMagnet) { + return canonicalValue; } - working = working.replace(ENCODED_BTih_PATTERN, (_, hash) => `xt=urn:btih:${hash}`); - - if (!/^magnet:/i.test(working)) { - return working; - } - - let fragment = ""; - const hashIndex = working.indexOf("#"); - if (hashIndex !== -1) { - fragment = working.slice(hashIndex); - working = working.slice(0, hashIndex); - } - - const [, queryPart = ""] = working.split("?", 2); - const normalizedScheme = "magnet:"; - const rawParams = queryPart - .split("&") - .map((part) => part.trim()) - .filter(Boolean); - - const params = []; - - for (const rawParam of rawParams) { - const [rawKey, rawVal = ""] = rawParam.split("=", 2); - const key = rawKey.trim(); - if (!key) { - continue; - } - let value = rawVal.trim(); - if (key.toLowerCase() === "xt" && value) { - const decoded = decodeLoose(value); - if (decoded) { - value = decoded; - } - } - params.push({ - key, - value, - lowerKey: key.toLowerCase(), - compareValue: normalizeForComparison(value), - }); - } - - const appendUniqueParam = (key, value) => { - if (!value) { - return; - } - const trimmedValue = value.trim(); - if (!trimmedValue) { - return; - } - const lowerKey = key.toLowerCase(); - const compareValue = normalizeForComparison(trimmedValue); - if (compareValue) { - const exists = params.some( - (param) => param.lowerKey === lowerKey && param.compareValue === compareValue - ); - if (exists) { - return; - } - } - params.push({ - key, - value: trimmedValue, - lowerKey, - compareValue, - }); - }; - - for (const tracker of WSS_TRACKERS) { - if (typeof tracker !== "string") { - continue; - } - const trimmedTracker = tracker.trim(); - if (!/^wss:\/\//i.test(trimmedTracker)) { - continue; - } - appendUniqueParam("tr", trimmedTracker); - } + ensureTrackers(params); const normalizedXs = typeof xs === "string" ? xs.trim() : ""; if (normalizedXs) { - appendUniqueParam("xs", normalizedXs); + ensureTorrentHint(params, normalizedXs, { requireHttp: false }); } - const originProtocol = getOriginProtocol(); const rawWs = typeof ws === "string" ? ws.trim() : ""; if (rawWs) { - const formattedWs = tryFormatAbsoluteUrl(rawWs); - const lowerWs = formattedWs.toLowerCase(); - const isHttpSeed = lowerWs.startsWith("http://"); - const allowHttpSeed = originProtocol === "http:"; - if (!isHttpSeed || allowHttpSeed) { - appendUniqueParam("ws", formattedWs); - } + const formattedWs = formatAbsoluteUrl(rawWs); + const allowHttpSeed = resolveAppProtocol() === "http:"; + ensureWebSeeds(params, formattedWs, { + allowHttp: allowHttpSeed, + allowUnparsed: true, + }); } - const queryString = params - .map(({ key, value }) => (value ? `${key}=${value}` : key)) - .join("&"); - - return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`; -} - -export function extractMagnetHints(rawValue) { - const hints = { ws: "", xs: "" }; - if (typeof rawValue !== "string") { - return hints; - } - - const trimmed = rawValue.trim(); - if (!trimmed || !trimmed.toLowerCase().startsWith("magnet:")) { - return hints; - } - - const queryIndex = trimmed.indexOf("?"); - if (queryIndex === -1 || queryIndex === trimmed.length - 1) { - return hints; - } - - const query = trimmed.slice(queryIndex + 1); - if (!query) { - return hints; - } - - const parts = query.split("&"); - for (const part of parts) { - if (!part) { - continue; - } - const [rawKey, rawValue = ""] = part.split("=", 2); - if (!rawKey) { - continue; - } - const lowerKey = rawKey.trim().toLowerCase(); - if (lowerKey !== "ws" && lowerKey !== "xs") { - continue; - } - if (hints[lowerKey]) { - continue; - } - - let decoded = rawValue; - try { - decoded = decodeURIComponent(rawValue); - } catch (err) { - // Ignore decode errors and use the raw value. - } - const clean = typeof decoded === "string" ? decoded.trim() : ""; - if (!clean) { - continue; - } - hints[lowerKey] = clean; - } - - return hints; + return buildMagnetUri(normalizedScheme, params, fragment); } diff --git a/js/magnetShared.js b/js/magnetShared.js new file mode 100644 index 00000000..a71f18fe --- /dev/null +++ b/js/magnetShared.js @@ -0,0 +1,360 @@ +import { WSS_TRACKERS } from "./constants.js"; + +const HEX_INFO_HASH = /^[0-9a-f]{40}$/i; +const BTIH_PREFIX = "urn:btih:"; +const MAGNET_SCHEME = "magnet:"; +const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi; + +function decodeLoose(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + try { + return decodeURIComponent(trimmed); + } catch (err) { + return trimmed; + } +} + +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 || candidate === decoded) { + break; + } + decoded = candidate.trim(); + } catch (err) { + break; + } + } + + return decoded; +} + +export 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 createParam(key, value) { + const trimmedValue = typeof value === "string" ? value.trim() : ""; + const decoded = decodeLoose(trimmedValue); + const comparisonBasis = decoded || trimmedValue; + return { + key, + value: trimmedValue, + lowerKey: key.toLowerCase(), + decoded, + comparison: normalizeForComparison(comparisonBasis), + }; +} + +export function normalizeMagnetInput(rawValue) { + const initial = typeof rawValue === "string" ? rawValue.trim() : ""; + if (!initial) { + return { + initial, + canonicalValue: "", + didMutate: false, + isMagnet: false, + normalizedScheme: "", + fragment: "", + params: [], + }; + } + + let working = initial; + let didMutate = false; + + if (HEX_INFO_HASH.test(working)) { + working = `${MAGNET_SCHEME}?xt=${BTIH_PREFIX}${working.toLowerCase()}`; + didMutate = true; + } + + ENCODED_BTih_PATTERN.lastIndex = 0; + const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => { + didMutate = true; + return `xt=${BTIH_PREFIX}${hash}`; + }); + working = decodedXt; + + let fragment = ""; + const hashIndex = working.indexOf("#"); + if (hashIndex !== -1) { + fragment = working.slice(hashIndex); + working = working.slice(0, hashIndex); + } + + const canonicalValue = working; + if (!/^magnet:/i.test(working)) { + return { + initial, + canonicalValue, + didMutate: didMutate || canonicalValue !== initial, + isMagnet: false, + normalizedScheme: "", + fragment: "", + params: [], + }; + } + + const [schemePart, queryPart = ""] = working.split("?", 2); + const normalizedScheme = MAGNET_SCHEME; + if (schemePart !== normalizedScheme) { + didMutate = true; + } + + const rawParams = queryPart + .split("&") + .map((part) => part.trim()) + .filter(Boolean); + + const params = []; + for (const rawParam of rawParams) { + const [rawKey, rawVal = ""] = rawParam.split("=", 2); + const key = rawKey.trim(); + if (!key) { + continue; + } + const lowerKey = key.toLowerCase(); + let value = rawVal.trim(); + if (lowerKey === "xt" && value) { + const decoded = decodeLoose(value); + if (decoded && decoded !== value) { + value = decoded; + didMutate = true; + } + } + params.push(createParam(key, value)); + } + + return { + initial, + canonicalValue, + didMutate, + isMagnet: true, + normalizedScheme, + fragment, + params, + }; +} + +export function buildMagnetUri(normalizedScheme, params, fragment) { + const queryString = params + .map(({ key, value }) => (value ? `${key}=${value}` : key)) + .join("&"); + return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment || ""}`; +} + +export function appendUniqueParam(params, key, value) { + if (!Array.isArray(params)) { + return false; + } + const candidate = createParam(key, value); + if (!candidate.value && candidate.value !== "") { + return false; + } + if (candidate.comparison) { + const exists = params.some( + (param) => param.lowerKey === candidate.lowerKey && param.comparison === candidate.comparison + ); + if (exists) { + return false; + } + } else { + const exists = params.some( + (param) => param.lowerKey === candidate.lowerKey && param.value === candidate.value + ); + if (exists) { + return false; + } + } + params.push(candidate); + return true; +} + +export function ensureTrackers(params, trackers = WSS_TRACKERS) { + let didMutate = false; + for (const tracker of trackers) { + if (typeof tracker !== "string") { + continue; + } + const trimmedTracker = tracker.trim(); + if (!trimmedTracker) { + continue; + } + if (!/^wss:\/\//i.test(trimmedTracker)) { + continue; + } + if (appendUniqueParam(params, "tr", trimmedTracker)) { + didMutate = true; + } + } + return didMutate; +} + +export 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 ensureTorrentHint(params, candidate, { requireHttp = true } = {}) { + if (requireHttp) { + const sanitized = sanitizeHttpUrl(candidate); + if (!sanitized) { + return false; + } + return appendUniqueParam(params, "xs", sanitized); + } + + if (typeof candidate !== "string") { + return false; + } + const trimmed = candidate.trim(); + if (!trimmed) { + return false; + } + return appendUniqueParam(params, "xs", trimmed); +} + +export function resolveAppProtocol(explicitProtocol) { + if (typeof explicitProtocol === "string" && explicitProtocol.trim()) { + return explicitProtocol.trim().toLowerCase(); + } + if (typeof window !== "undefined" && window.location?.protocol) { + return window.location.protocol.toLowerCase(); + } + return "https:"; +} + +export function ensureWebSeeds( + params, + seeds, + { allowHttp = false, allowUnparsed = false, logger } = {} +) { + const list = Array.isArray(seeds) + ? seeds + : typeof seeds === "string" + ? [seeds] + : []; + let didMutate = false; + for (const seed of list) { + if (typeof seed !== "string") { + continue; + } + const trimmedSeed = seed.trim(); + if (!trimmedSeed) { + continue; + } + let finalValue = trimmedSeed; + let parsed; + try { + parsed = new URL(trimmedSeed); + const protocol = parsed.protocol; + if (protocol === "https:") { + finalValue = parsed.toString(); + } else if (protocol === "http:") { + if (!allowHttp) { + if (typeof logger === "function") { + logger(`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`); + } + continue; + } + finalValue = parsed.toString(); + } else if (!allowUnparsed) { + continue; + } + } catch (err) { + if (!allowUnparsed) { + continue; + } + } + + if (appendUniqueParam(params, "ws", finalValue)) { + didMutate = true; + } + } + return didMutate; +} + +export function formatAbsoluteUrl(candidate) { + if (typeof candidate !== "string") { + return ""; + } + const trimmed = candidate.trim(); + if (!trimmed) { + return ""; + } + try { + const parsed = new URL(trimmed); + return parsed.toString(); + } catch (err) { + return trimmed; + } +} + +export function extractMagnetHints(rawValue) { + const { params } = normalizeMagnetInput(rawValue); + const hints = { ws: "", xs: "" }; + for (const param of params) { + if (param.lowerKey === "ws" && !hints.ws) { + hints.ws = param.decoded || param.value; + } else if (param.lowerKey === "xs" && !hints.xs) { + hints.xs = param.decoded || param.value; + } + if (hints.ws && hints.xs) { + break; + } + } + return hints; +} + diff --git a/js/magnetUtils.js b/js/magnetUtils.js index 29057dc3..7861767b 100644 --- a/js/magnetUtils.js +++ b/js/magnetUtils.js @@ -1,101 +1,19 @@ // js/magnetUtils.js import { WSS_TRACKERS } from "./constants.js"; +import { + buildMagnetUri, + ensureTrackers, + ensureTorrentHint, + ensureWebSeeds, + normalizeMagnetInput, + resolveAppProtocol, + safeDecodeMagnet as sharedSafeDecodeMagnet, +} from "./magnetShared.js"; export { WSS_TRACKERS }; +export const safeDecodeMagnet = sharedSafeDecodeMagnet; -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 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; -} - -/** - * Normalize a magnet URI while preserving legacy payload quirks and augmenting it - * for browser playback. - * - * Key behaviors: - * - Accepts bare info-hash strings and promotes them to full `magnet:?xt=urn:btih:` links. - * - Leaves the existing `xt` payload untouched—no percent re-encoding or normalization - * beyond decoding legacy `%3A` segments. - * - Only appends browser-safe WSS trackers in addition to whatever the caller provides. - * - Skips insecure `http:` web seeds unless the application itself is running over HTTP. - * - * Note: `didChange` may be reported even if the resulting magnet string is textually - * identical to the input. Callers should not rely on `didChange` to detect a modified - * URI. - */ export function normalizeAndAugmentMagnet( rawValue, { @@ -107,83 +25,32 @@ export function normalizeAndAugmentMagnet( appProtocol, } = {} ) { - const log = typeof logger === "function" ? logger : () => {}; - const initial = typeof rawValue === "string" ? rawValue.trim() : ""; + const { + initial, + canonicalValue, + didMutate, + isMagnet, + normalizedScheme, + fragment, + params, + } = normalizeMagnetInput(rawValue); + 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 hashIndex = working.indexOf("#"); - let fragment = ""; - if (hashIndex !== -1) { - fragment = working.slice(hashIndex); - working = working.slice(0, hashIndex); - } - - const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => { - didMutate = true; - return `xt=${BTIH_PREFIX}${hash}`; - }); - working = decodedXt; - - if (!/^magnet:/i.test(working)) { + if (!isMagnet) { + const magnet = canonicalValue; return { - magnet: working, - didChange: didMutate || working !== initial, + magnet, + didChange: didMutate || magnet !== initial, }; } - const [schemePart, queryPart = ""] = working.split("?", 2); - const normalizedScheme = "magnet:"; - if (schemePart !== normalizedScheme) { - didMutate = true; - } + let didChange = didMutate; - const rawParams = queryPart - .split("&") - .map((part) => part.trim()) - .filter(Boolean); - - const params = []; - - const decodeLoose = (value) => { - if (typeof value !== "string" || !value) { - return ""; - } - try { - return decodeURIComponent(value); - } catch (err) { - return value; - } - }; - - for (const rawParam of rawParams) { - const [rawKey, rawValue = ""] = rawParam.split("=", 2); - const key = rawKey.trim(); - if (!key) { - continue; - } - let value = rawValue.trim(); - if (key.toLowerCase() === "xt" && value) { - const decoded = decodeLoose(value); - if (decoded !== value) { - value = decoded; - didMutate = true; - } - } - params.push({ - key, - value, - decoded: decodeLoose(value), - }); + if (ensureTrackers(params, [...WSS_TRACKERS, ...extraTrackers])) { + didChange = true; } const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim() @@ -192,118 +59,34 @@ export function normalizeAndAugmentMagnet( ? xs.trim() : ""; - const safeTorrentHint = sanitizeHttpUrl(torrentHint); + if (torrentHint) { + if (ensureTorrentHint(params, torrentHint, { requireHttp: true })) { + didChange = true; + } + } + 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 resolvedProtocol = resolveAppProtocol(appProtocol); - const existingTrackers = new Set( - params - .filter((param) => param.key.toLowerCase() === "tr") - .map((param) => normalizeForComparison(param.decoded)) - .filter(Boolean) - ); - - const trackerCandidates = [ - ...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; - } - params.push({ key: "tr", value: trimmedTracker, decoded: trimmedTracker }); - existingTrackers.add(normalizedTracker); - didMutate = true; + if ( + ensureWebSeeds(params, seedInputs, { + allowHttp: resolvedProtocol === "http:", + allowUnparsed: false, + logger, + }) + ) { + didChange = true; } - if (safeTorrentHint) { - const existingXs = new Set( - params - .filter((param) => param.key.toLowerCase() === "xs") - .map((param) => normalizeForComparison(param.decoded)) - .filter(Boolean) - ); - const normalizedXs = normalizeForComparison(safeTorrentHint); - if (normalizedXs && !existingXs.has(normalizedXs)) { - params.push({ - key: "xs", - value: safeTorrentHint, - decoded: safeTorrentHint, - }); - didMutate = true; - } - } - - if (seedInputs.length) { - const existingWs = new Set( - params - .filter((param) => param.key.toLowerCase() === "ws") - .map((param) => normalizeForComparison(param.decoded)) - .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)) { - params.push({ key: "ws", value: seedValue, decoded: 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 queryString = params - .map(({ key, value }) => (value ? `${key}=${value}` : key)) - .join("&"); - - const finalMagnet = `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`; + const finalMagnet = buildMagnetUri(normalizedScheme, params, fragment); return { magnet: finalMagnet, - didChange: didMutate || finalMagnet !== initial, + didChange: didChange || finalMagnet !== initial, }; } diff --git a/tests/magnet-utils.test.mjs b/tests/magnet-utils.test.mjs index 1297664c..bfadfc76 100644 --- a/tests/magnet-utils.test.mjs +++ b/tests/magnet-utils.test.mjs @@ -1,160 +1,205 @@ import assert from "node:assert/strict"; +import test from "node:test"; + import { WSS_TRACKERS } from "../js/constants.js"; -import { normalizeAndAugmentMagnet, safeDecodeMagnet } from "../js/magnetUtils.js"; -import { normalizeAndAugmentMagnet as formNormalize } from "../js/magnet.js"; +import { + normalizeAndAugmentMagnet as normalizeMagnetObject, + safeDecodeMagnet, +} from "../js/magnetUtils.js"; +import { + extractMagnetHints, + normalizeAndAugmentMagnet as normalizeMagnetString, +} from "../js/magnet.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"); +function assertDefaultTrackersPresent(magnet) { + const trackers = getParamValues(magnet, "tr"); for (const tracker of WSS_TRACKERS) { assert.ok( - trackerValues.includes(tracker), - `Expected tracker ${tracker} to be appended` + trackers.includes(tracker), + `Expected default tracker ${tracker} to be present` ); } -})(); +} -(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"]); -})(); - -(function testSafeDecodeMagnetHandlesEncodedValues() { +test("safeDecodeMagnet handles encoded values", () => { 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" + "Percent-encoded magnets should be decoded" ); -})(); +}); -(function testSafeDecodeMagnetLeavesPlainStringsUntouched() { +test("safeDecodeMagnet leaves plain strings untouched", () => { const rawMagnet = "magnet:?xt=urn:btih:abcdefabcdefabcdefabcdefabcdefabcdefabcd"; assert.equal( safeDecodeMagnet(rawMagnet), rawMagnet, - "Plain magnet strings should pass through unchanged" + "Plain magnet strings should pass through" ); -})(); +}); -(function testFormHelperAugmentsWsXsAndDecodesXt() { - const infoHash = "abcdef0123456789abcdef0123456789abcdef01"; - const encodedMagnet = `magnet:?xt=urn%3Abtih%3A${infoHash}`; - const normalized = formNormalize(encodedMagnet, { - ws: "https://cdn.example.com/video-base/", +test("bare hashes normalize consistently across helpers", () => { + const infoHash = "0123456789abcdef0123456789abcdef01234567"; + const objectResult = normalizeMagnetObject(infoHash); + const stringResult = normalizeMagnetString(infoHash); + + assert.ok(objectResult.didChange, "Bare hashes should mark didChange true"); + assert.equal( + stringResult, + objectResult.magnet, + "String helper should match object helper output" + ); + + const xtValues = getParamValues(stringResult, "xt"); + assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]); + assertDefaultTrackersPresent(stringResult); +}); + +test("legacy %3A payloads decode across helpers", () => { + const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd"; + const encoded = `magnet:?xt=urn%3Abtih%3A${infoHash}&dn=Example`; + const objectResult = normalizeMagnetObject(encoded); + const stringResult = normalizeMagnetString(encoded); + + assert.equal( + stringResult, + objectResult.magnet, + "Helpers should agree on encoded xt payload normalization" + ); + + const xtValues = getParamValues(stringResult, "xt"); + assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]); +}); + +test("duplicate trackers and hints stay in sync", () => { + const infoHash = "fedcba9876543210fedcba9876543210fedcba98"; + const baseMagnet = + `magnet:?xt=urn:btih:${infoHash}` + + "&tr=wss://tracker.openwebtorrent.com" + + "&tr=WsS://tracker.openwebtorrent.com/"; + + const objectResult = normalizeMagnetObject(baseMagnet, { + extraTrackers: [ + "wss://tracker.fastcast.nz", + "http://tracker.invalid", + "wss://tracker.openwebtorrent.com", + ], + xs: "https://cdn.example.com/video.torrent", + webSeed: "https://cdn.example.com/files/", + }); + + const stringResult = normalizeMagnetString(baseMagnet, { + ws: "https://cdn.example.com/files/", // ensure string helper adds hint xs: "https://cdn.example.com/video.torrent", }); - const parsed = new URL(normalized); - assert.equal( - parsed.searchParams.get("xt"), - `urn:btih:${infoHash}`, - "Form helper should decode encoded xt payloads" - ); - assert.deepEqual( - parsed.searchParams.getAll("ws"), - ["https://cdn.example.com/video-base/"], - "Expected ws hint to be appended" - ); - assert.deepEqual( - parsed.searchParams.getAll("xs"), - ["https://cdn.example.com/video.torrent"], - "Expected xs hint to be appended" - ); - const trackers = parsed.searchParams.getAll("tr"); - for (const tracker of WSS_TRACKERS) { - assert.ok( - trackers.includes(tracker), - `Expected helper to append tracker ${tracker}` - ); - } -})(); -(function testFormHelperSkipsInsecureWsOnHttpsOrigin() { + assert.equal( + stringResult, + objectResult.magnet, + "Helpers should emit identical magnets after augmentation" + ); + + const trackers = getParamValues(stringResult, "tr"); + const openwebCount = trackers.filter( + (value) => value === "wss://tracker.openwebtorrent.com" + ).length; + assert.equal(openwebCount, 1, "Duplicate trackers should be deduped when appended"); + assert.ok( + trackers.includes("wss://tracker.fastcast.nz"), + "Extra WSS tracker should be retained" + ); + assert.ok( + !trackers.includes("http://tracker.invalid"), + "HTTP tracker candidates should be ignored" + ); + + const webSeeds = getParamValues(stringResult, "ws"); + assert.deepEqual(webSeeds, ["https://cdn.example.com/files/"]); + + const xsValues = getParamValues(stringResult, "xs"); + assert.deepEqual(xsValues, ["https://cdn.example.com/video.torrent"]); +}); + +test("object helper enforces HTTPS web seeds when on HTTPS", () => { const infoHash = "0123456789abcdef0123456789abcdef01234567"; - const normalized = formNormalize(`magnet:?xt=urn:btih:${infoHash}`, { + const messages = []; + const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, { + webSeed: [ + "https://cdn.example.com/video.mp4", + "http://cdn.example.com/legacy.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 web seeds should trigger a log message"); + assert.ok( + messages[0].includes("Skipping insecure web seed"), + "Expected skip log for HTTP seed" + ); +}); + +test("object helper allows HTTP seeds on HTTP origins", () => { + const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd"; + const result = normalizeMagnetObject(`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"]); +}); + +test("extractMagnetHints returns first ws/xs pair", () => { + const magnet = + "magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01" + + "&ws=https://cdn.example.com/base/" + + "&ws=https://cdn.example.com/duplicate/" + + "&xs=https://cdn.example.com/video.torrent"; + + const hints = extractMagnetHints(magnet); + assert.deepEqual(hints, { + ws: "https://cdn.example.com/base/", + xs: "https://cdn.example.com/video.torrent", + }); +}); + +test("string helper skips insecure ws hints on HTTPS origins", () => { + const infoHash = "0123456789abcdef0123456789abcdef01234567"; + const normalized = normalizeMagnetString(`magnet:?xt=urn:btih:${infoHash}`, { ws: "http://cdn.example.com/video-base/", }); const parsed = new URL(normalized); - assert.equal( - parsed.searchParams.getAll("ws").length, - 0, - "HTTP ws value should be skipped on HTTPS origins" - ); -})(); + assert.equal(parsed.searchParams.getAll("ws").length, 0); +}); -console.log("magnet-utils tests passed"); +test("object helper reports didChange when output differs", () => { + const infoHash = "abcdef0123456789abcdef0123456789abcdef01"; + const result = normalizeMagnetObject(`magnet:?xt=urn:btih:${infoHash}`, { + webSeed: "https://cdn.example.com/video.mp4", + }); + assert.ok(result.didChange, "Augmenting with web seeds should mark didChange"); +}); + +test("helpers trim fragments from non-magnet values", () => { + const url = "https://example.com/video.mp4#fragment"; + const objectResult = normalizeMagnetObject(url); + const stringResult = normalizeMagnetString(url); + assert.equal(objectResult.magnet, "https://example.com/video.mp4"); + assert.equal(stringResult, "https://example.com/video.mp4"); + assert.ok( + objectResult.didChange, + "Dropping the fragment should be reflected in didChange" + ); +});