@ctrl/magnet-link
Version:
Parse a magnet URI into an object
281 lines (280 loc) • 9.27 kB
JavaScript
import { base32 } from 'rfc4648';
import { hexToUint8Array, uint8ArrayToHex } from 'uint8array-extras';
import * as bep53Range from './bep53.js';
const start = 'magnet:?';
const btihHexPattern = /^urn:btih:([a-fA-F0-9]{40})$/;
const btihBase32Pattern = /^urn:btih:([a-z2-7]{32})$/i;
const btmhSha256Pattern = /^urn:btmh:1220([a-fA-F0-9]{64})$/;
const btpkPattern = /^urn:btpk:([a-fA-F0-9]{64})$/;
export function magnetDecode(uri) {
// Support 'stream-magnet:' as well
const startIdx = uri.indexOf(start);
const queryStart = startIdx === -1 ? uri.length : startIdx + start.length;
// Query keys are user-controlled. Keep this as a null-prototype object so
// keys like `__proto__` and `toString` are parsed as data, not prototype
// accessors/inherited properties. Use own-key checks if this changes.
const result = Object.create(null);
for (let paramStart = queryStart; paramStart < uri.length;) {
const ampIdx = uri.indexOf('&', paramStart);
const paramEnd = ampIdx === -1 ? uri.length : ampIdx;
const eqIdx = uri.indexOf('=', paramStart);
// No '=' found, or empty key — skip
if (eqIdx > paramStart && eqIdx < paramEnd) {
// Reject params with multiple '=' (preserves original split('=').length !== 2 check)
const secondEqIdx = uri.indexOf('=', eqIdx + 1);
if (secondEqIdx === -1 || secondEqIdx >= paramEnd) {
const key = uri.slice(paramStart, eqIdx);
let val;
try {
val = parseQueryParamValue(key, uri.slice(eqIdx + 1, paramEnd));
}
catch {
val = undefined;
}
if (val !== undefined) {
const r = result[key];
if (r === undefined) {
result[key] = val;
}
else if (Array.isArray(r)) {
// If there are repeated parameters, return an array of values
if (Array.isArray(val)) {
r.push(...val);
}
else {
r.push(val);
}
}
else {
result[key] = Array.isArray(val) ? [r, ...val] : [r, val];
}
}
}
}
if (ampIdx === -1) {
break;
}
paramStart = ampIdx + 1;
}
if (result.xt) {
const xts = Array.isArray(result.xt) ? result.xt : undefined;
const xtCount = xts ? xts.length : 1;
for (let i = 0; i < xtCount; i++) {
const xt = xts ? xts[i] : result.xt;
const btihHex = btihHexPattern.exec(xt);
if (btihHex) {
result.infoHash = btihHex[1].toLowerCase();
continue;
}
const btihBase32 = btihBase32Pattern.exec(xt);
if (btihBase32) {
result.infoHash = uint8ArrayToHex(base32.parse(btihBase32[1].toUpperCase()));
continue;
}
const btmhSha256 = btmhSha256Pattern.exec(xt);
if (btmhSha256) {
result.infoHashV2 = btmhSha256[1].toLowerCase();
}
}
}
if (result.xs) {
const xss = Array.isArray(result.xs) ? result.xs : undefined;
const xsCount = xss ? xss.length : 1;
for (let i = 0; i < xsCount; i++) {
const xs = xss ? xss[i] : result.xs;
const btpk = btpkPattern.exec(xs);
if (btpk) {
result.publicKey = btpk[1].toLowerCase();
}
}
}
if (result.infoHash) {
result.infoHashIntArray = hexToUint8Array(result.infoHash);
}
if (result.infoHashV2) {
result.infoHashV2IntArray = hexToUint8Array(result.infoHashV2);
}
if (result.publicKey) {
result.publicKeyIntArray = hexToUint8Array(result.publicKey);
}
if (result.dn) {
result.name = result.dn;
}
if (result.kt) {
result.keywords = result.kt;
}
if (typeof result.tr === 'string') {
result.announce = [result.tr];
}
else if (Array.isArray(result.tr)) {
result.announce = [...result.tr];
}
else {
result.announce = [];
}
const urlList = [];
if (typeof result.as === 'string') {
urlList.push(result.as);
}
else if (Array.isArray(result.as)) {
for (const item of result.as) {
urlList.push(item);
}
}
if (typeof result.ws === 'string') {
urlList.push(result.ws);
}
else if (Array.isArray(result.ws)) {
for (const item of result.ws) {
urlList.push(item);
}
}
const peerAddresses = [];
if (typeof result['x.pe'] === 'string') {
peerAddresses.push(result['x.pe']);
}
else if (Array.isArray(result['x.pe'])) {
for (const item of result['x.pe']) {
peerAddresses.push(item);
}
}
if (result.s) {
result.salt = result.s;
}
result.announce = [...new Set(result.announce)];
result.urlList = [...new Set(urlList)];
result.peerAddresses = [...new Set(peerAddresses)];
return result;
}
/**
* Specific query parameters have expected formats, this attempts to parse them in the correct way
*/
function parseQueryParamValue(key, val) {
// Clean up torrent name
if (key === 'dn') {
return decodeURIComponent(val.replaceAll('+', ' '));
}
// Address tracker (tr), exact source (xs), and acceptable source (as) are encoded
// URIs, so decode them
if (key === 'tr' || key === 'xs' || key === 'as' || key === 'ws') {
return decodeURIComponent(val);
}
// Return keywords as an array
if (key === 'kt') {
const keywords = val.split('+');
for (let i = 0; i < keywords.length; i++) {
keywords[i] = decodeURIComponent(keywords[i]);
}
return keywords;
}
// bep53
if (key === 'so') {
return bep53Range.parseRange(decodeURIComponent(val).split(','));
}
// Cast file index (ix) to a number
if (key === 'ix') {
return Number(val);
}
return val;
}
export function magnetEncode(data) {
const obj = { ...data }; // Shallow clone object
const xts = new Set(obj.xt ? (Array.isArray(obj.xt) ? obj.xt : [obj.xt]) : []);
if (obj.infoHashIntArray) {
xts.add(`urn:btih:${uint8ArrayToHex(obj.infoHashIntArray)}`);
}
if (obj.infoHash) {
xts.add(`urn:btih:${obj.infoHash}`);
}
if (obj.infoHashV2IntArray) {
xts.add(`urn:btmh:1220${uint8ArrayToHex(obj.infoHashV2IntArray)}`);
}
if (obj.infoHashV2) {
xts.add(`urn:btmh:1220${obj.infoHashV2}`);
}
if (xts.size === 1) {
obj.xt = xts.values().next().value;
}
if (xts.size > 1) {
obj.xt = [...xts];
}
// Support using convenience names, in addition to spec names
// (example: `infoHash` for `xt`, `name` for `dn`)
if (obj.publicKeyIntArray) {
obj.xs = `urn:btpk:${uint8ArrayToHex(obj.publicKeyIntArray)}`;
}
if (obj.publicKey) {
obj.xs = `urn:btpk:${obj.publicKey}`;
}
if (obj.name) {
obj.dn = obj.name;
}
if (obj.keywords) {
obj.kt = obj.keywords;
}
if (obj.announce) {
obj.tr = obj.announce;
}
if (obj.urlList) {
obj.ws = obj.urlList;
delete obj.as;
}
if (obj.peerAddresses) {
obj['x.pe'] = obj.peerAddresses;
}
if (obj.salt) {
obj.s = obj.salt;
}
let acc = start;
let paramIdx = 0;
const keys = Object.keys(obj);
for (const key of keys) {
if (key.length !== 2 && key !== 's' && key !== 'x.pe') {
continue;
}
const raw = obj[key];
if (key === 'so') {
if (paramIdx > 0) {
acc += '&';
}
acc += `${key}=${bep53Range.composeRange(Array.isArray(raw) ? raw : [raw])}`;
paramIdx++;
continue;
}
if (Array.isArray(raw)) {
for (let j = 0; j < raw.length; j++) {
const val = encodeParamValue(key, raw[j]);
if (key === 'kt' && j > 0) {
acc += `+${val}`;
}
else {
if (paramIdx > 0 || j > 0) {
acc += '&';
}
acc += `${key}=${val}`;
}
}
}
else {
const val = encodeParamValue(key, raw);
if (paramIdx > 0) {
acc += '&';
}
acc += `${key}=${val}`;
}
paramIdx++;
}
return acc;
}
function encodeParamValue(key, val) {
if (key === 'dn') {
return encodeURIComponent(val).replaceAll('%20', '+');
}
if (key === 'tr' || key === 'as' || key === 'ws' || key === 'kt') {
return encodeURIComponent(val);
}
if (key === 'xs' && !val.startsWith('urn:btpk:')) {
return encodeURIComponent(val);
}
return val;
}