UNPKG

@ctrl/magnet-link

Version:

Parse a magnet URI into an object

221 lines (220 loc) 7.12 kB
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}`); }