Add magnet normalization helper and tests

This commit is contained in:
thePR0M3TH3AN committed 2025-09-23 13:01:14 -04:00
1 parent 88eaff3107
commit bfee3b7a2a
3 files changed
+417 -149

No files matched your search

+109 -149
View File
@@ -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
+213
View File
@@ -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,
};
}
+95
View File
@@ -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");