From 3b4bb7f0df1ab873f08c1618545fbe395eb4fcd4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 24 Sep 2025 20:53:54 -0400 Subject: [PATCH] Add form magnet normalizer and tests --- js/magnet.js | 177 +++++++++++++++++++++++++++++++++--- tests/magnet-utils.test.mjs | 7 ++ 2 files changed, 171 insertions(+), 13 deletions(-) diff --git a/js/magnet.js b/js/magnet.js index 2f5934e9..5fe5d80c 100644 --- a/js/magnet.js +++ b/js/magnet.js @@ -1,19 +1,170 @@ import { WSS_TRACKERS } from "./constants.js"; -import { normalizeAndAugmentMagnet as normalizeLegacy } from "./magnetUtils.js"; -export function normalizeAndAugmentMagnet(rawValue, { ws = "", xs = "" } = {}) { - const trimmedInput = typeof rawValue === "string" ? rawValue.trim() : ""; - const trimmedWs = typeof ws === "string" ? ws.trim() : ""; - const trimmedXs = typeof xs === "string" ? xs.trim() : ""; +const HEX_INFO_HASH = /^[0-9a-f]{40}$/i; +const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi; - const webSeeds = trimmedWs ? [trimmedWs] : []; +function getOriginProtocol() { + if (typeof window !== "undefined" && window.location?.protocol) { + return window.location.protocol.toLowerCase(); + } + return "https:"; +} - const result = normalizeLegacy(trimmedInput, { - webSeed: webSeeds, - torrentUrl: trimmedXs, - xs: trimmedXs, - extraTrackers: WSS_TRACKERS, - }); +function decodeLoose(value) { + if (typeof value !== "string") { + return ""; + } + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + try { + return decodeURIComponent(trimmed); + } catch (err) { + return trimmed; + } +} - return result.magnet; +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() : ""; + if (!initial) { + return ""; + } + + let working = initial; + + if (HEX_INFO_HASH.test(working)) { + working = `magnet:?xt=urn:btih:${working.toLowerCase()}`; + } + + 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); + } + + const normalizedXs = typeof xs === "string" ? xs.trim() : ""; + if (normalizedXs) { + appendUniqueParam("xs", normalizedXs); + } + + 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 queryString = params + .map(({ key, value }) => (value ? `${key}=${value}` : key)) + .join("&"); + + return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`; } diff --git a/tests/magnet-utils.test.mjs b/tests/magnet-utils.test.mjs index 6d0aad55..1297664c 100644 --- a/tests/magnet-utils.test.mjs +++ b/tests/magnet-utils.test.mjs @@ -135,6 +135,13 @@ function getParamValues(magnet, key) { ["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() {