@ctrl/magnet-link
Version:
Parse a magnet URI into an object
221 lines (220 loc) • 7.12 kB
JavaScript
import { base32 } from 'rfc4648';
import { hexToUint8Array, uint8ArrayToHex } from 'uint8array-extras';
import * as bep53Range from './bep53.js';
const start = 'magnet:?';
export function magnetDecode(uri) {
// Support 'stream-magnet:' as well
const data = uri.substr(uri.indexOf(start) + start.length);
const params = data && data.length >= 0 ? data.split('&') : [];
const result = {};
params.forEach(param => {
const keyval = param.split('=');
// This keyval is invalid, skip it
if (keyval.length !== 2) {
return;
}
const key = keyval[0];
const val = parseQueryParamValue(key, keyval[1]);
if (val === undefined) {
return;
}
const r = result[key];
if (!r) {
result[key] = val;
return result;
}
// If there are repeated parameters, return an array of values
if (r && Array.isArray(r)) {
r.push(val);
return;
}
result[key] = [r, val];
// eslint-disable-next-line no-useless-return
return;
});
if (result.xt) {
let m;
const xts = Array.isArray(result.xt) ? result.xt : [result.xt];
xts.forEach((xt) => {
if ((m = xt.match(/^urn:btih:(.{40})/))) {
result.infoHash = m[1].toLowerCase();
}
else if ((m = xt.match(/^urn:btih:(.{32})/))) {
const decodedStr = base32.parse(m[1]);
result.infoHash = uint8ArrayToHex(decodedStr);
}
else if ((m = xt.match(/^urn:btmh:1220(.{64})/))) {
result.infoHashV2 = m[1].toLowerCase();
}
});
}
if (result.xs) {
let m;
const xss = Array.isArray(result.xs) ? result.xs : [result.xs];
xss.forEach(xs => {
if ((m = /^urn:btpk:(.{64})/.exec(xs))) {
result.publicKey = m[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 = [];
}
result.urlList = [];
if (typeof result.as === 'string' || Array.isArray(result.as)) {
result.urlList = result.urlList.concat(result.as);
}
if (typeof result.ws === 'string' || Array.isArray(result.ws)) {
result.urlList = result.urlList.concat(result.ws);
}
result.peerAddresses = [];
if (typeof result['x.pe'] === 'string' || Array.isArray(result['x.pe'])) {
result.peerAddresses = result.peerAddresses.concat(result['x.pe']);
}
result.announce = [...new Set(result.announce)].sort((a, b) => a.localeCompare(b));
result.urlList = [...new Set(result.urlList)].sort((a, b) => a.localeCompare(b));
result.peerAddresses = [...new Set(result.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).replace(/\+/g, ' ');
}
// 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') {
return decodeURIComponent(val).split('+');
}
// 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
// Deduplicate xt by using a set
let xts = new Set();
if (obj.xt && typeof obj.xt === 'string') {
xts.add(obj.xt);
}
if (obj.xt && Array.isArray(obj.xt)) {
xts = new Set(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((obj.xt = `urn:btmh:1220${uint8ArrayToHex(obj.infoHashV2IntArray)}`));
}
if (obj.infoHashV2) {
xts.add(`urn:btmh:1220${obj.infoHashV2}`);
}
const xtsDeduped = Array.from(xts);
if (xtsDeduped.length === 1) {
obj.xt = xtsDeduped[0];
}
if (xtsDeduped.length > 1) {
obj.xt = xtsDeduped;
}
// Support using convenience names, in addition to spec names
// (example: `infoHash` for `xt`, `name` for `dn`)
if (obj.infoHash) {
obj.xt = `urn:btih:${obj.infoHash}`;
}
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;
}
return Object.keys(obj)
.filter(key => key.length === 2 || key === 'x.pe')
.reduce((prev, key, i) => {
let acc = prev;
const values = Array.isArray(obj[key]) ? obj[key] : [obj[key]];
values.forEach((val, j) => {
if ((i > 0 || j > 0) && ((key !== 'kt' && key !== 'so') || j === 0)) {
acc += '&';
}
if (key === 'dn') {
val = encodeURIComponent(val).replace(/%20/g, '+');
}
if (key === 'tr' || key === 'as' || key === 'ws') {
val = encodeURIComponent(val);
}
// Don't URI encode BEP46 keys
if (key === 'xs' && !val.startsWith('urn:btpk:')) {
val = encodeURIComponent(val);
}
if (key === 'kt') {
val = encodeURIComponent(val);
}
if (key === 'so') {
return;
}
if (key === 'kt' && j > 0) {
acc += `+${val}`;
}
else {
acc += `${key}=${val}`;
}
});
if (key === 'so') {
acc += `${key}=${bep53Range.composeRange(values)}`;
}
return acc;
}, `${start}`);
}