From 8e69342a0c10aabc477f17b6eb9228a00e10b815 Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 3 Mar 2023 19:01:30 +0000 Subject: [PATCH] feat: parse magnet links --- packages/app/package.json | 1 + packages/app/src/Const.ts | 6 ++ packages/app/src/Element/Magnet.tsx | 18 +++++ packages/app/src/Element/Text.tsx | 31 ++++++-- packages/app/src/Util.test.ts | 20 +++++- packages/app/src/Util.ts | 108 ++++++++++++++++++++++++++++ yarn.lock | 12 ++++ 7 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/Element/Magnet.tsx diff --git a/packages/app/package.json b/packages/app/package.json index 35deb4c6..39fe2445 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -21,6 +21,7 @@ "@types/uuid": "^9.0.0", "@types/webscopeio__react-textarea-autocomplete": "^4.7.2", "@webscopeio/react-textarea-autocomplete": "^4.9.2", + "base32-decode": "^1.0.0", "bech32": "^2.0.0", "dexie": "^3.2.2", "dexie-react-hooks": "^1.1.1", diff --git a/packages/app/src/Const.ts b/packages/app/src/Const.ts index 7adbdf98..c9ea1e52 100644 --- a/packages/app/src/Const.ts +++ b/packages/app/src/Const.ts @@ -44,6 +44,7 @@ export const DefaultRelays = new Map([ ["wss://eden.nostr.land", { read: true, write: false }], ["wss://atlas.nostr.land", { read: true, write: false }], ["wss://relay.orangepill.dev", { read: true, write: false }], + ["wss://nos.lol", { read: true, write: false }], ]); /** @@ -152,3 +153,8 @@ export const AppleMusicRegex = * Nostr Nests embed regex */ export const NostrNestsRegex = /nostrnests\.com\/[a-zA-Z0-9]+/i; + +/* + * Magnet link parser + */ +export const MagnetRegex = /(magnet:[\S]+)/i; diff --git a/packages/app/src/Element/Magnet.tsx b/packages/app/src/Element/Magnet.tsx new file mode 100644 index 00000000..685dfe47 --- /dev/null +++ b/packages/app/src/Element/Magnet.tsx @@ -0,0 +1,18 @@ +import { Magnet } from "Util"; + +interface MagnetLinkProps { + magnet: Magnet; +} + +const MagnetLink = ({ magnet }: MagnetLinkProps) => { + return ( +
+

Magnet Link

+ + {magnet.dn ?? magnet.infoHash} + +
+ ); +}; + +export default MagnetLink; diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx index cd6aacc6..ca0e4d0b 100644 --- a/packages/app/src/Element/Text.tsx +++ b/packages/app/src/Element/Text.tsx @@ -3,17 +3,16 @@ import { useMemo, useCallback } from "react"; import { Link } from "react-router-dom"; import ReactMarkdown from "react-markdown"; import { visit, SKIP } from "unist-util-visit"; +import * as unist from "unist"; +import { HexKey, Tag } from "@snort/nostr"; -import { MentionRegex, InvoiceRegex, HashtagRegex } from "Const"; -import { eventLink, hexToBech32, splitByUrl, unwrap } from "Util"; +import { MentionRegex, InvoiceRegex, HashtagRegex, MagnetRegex } from "Const"; +import { eventLink, hexToBech32, magnetURIDecode, splitByUrl, unwrap } from "Util"; import Invoice from "Element/Invoice"; import Hashtag from "Element/Hashtag"; - -import { Tag } from "@snort/nostr"; import Mention from "Element/Mention"; import HyperText from "Element/HyperText"; -import { HexKey } from "@snort/nostr"; -import * as unist from "unist"; +import MagnetLink from "Element/Magnet"; export type Fragment = string | React.ReactNode; @@ -45,6 +44,25 @@ export default function Text({ content, tags, creator }: TextProps) { .flat(); } + function extractMagnetLinks(fragments: Fragment[]) { + return fragments + .map(f => { + if (typeof f === "string") { + return f.split(MagnetRegex).map(a => { + if (a.startsWith("magnet:")) { + const parsed = magnetURIDecode(a); + if (parsed) { + return ; + } + } + return a; + }); + } + return f; + }) + .flat(); + } + function extractMentions(frag: TextFragment) { return frag.body .map(f => { @@ -135,6 +153,7 @@ export default function Text({ content, tags, creator }: TextProps) { fragments = extractLinks(fragments); fragments = extractInvoices(fragments); fragments = extractHashtags(fragments); + fragments = extractMagnetLinks(fragments); return fragments; } diff --git a/packages/app/src/Util.test.ts b/packages/app/src/Util.test.ts index 878f76ed..558253df 100644 --- a/packages/app/src/Util.test.ts +++ b/packages/app/src/Util.test.ts @@ -1,4 +1,4 @@ -import { splitByUrl } from "./Util"; +import { splitByUrl, magnetURIDecode } from "./Util"; describe("splitByUrl", () => { it("should split a string by URLs", () => { @@ -38,3 +38,21 @@ describe("splitByUrl", () => { expect(splitByUrl(inputStr)).toEqual(expectedOutput); }); }); + +describe("magnet", () => { + it("should parse magnet link", () => { + const book = + "magnet:?xt=urn:btih:d2474e86c95b19b8bcfdb92bc12c9d44667cfa36&xt=urn:btmh:1220d2474e86c95b19b8bcfdb92bc12c9d44667cfa36d2474e86c95b19b8bcfdb92b&dn=Leaves+of+Grass+by+Walt+Whitman.epub&tr=udp%3A%2F%2Ftracker.example4.com%3A80&tr=udp%3A%2F%2Ftracker.example5.com%3A80&tr=udp%3A%2F%2Ftracker.example3.com%3A6969&tr=udp%3A%2F%2Ftracker.example2.com%3A80&tr=udp%3A%2F%2Ftracker.example1.com%3A1337"; + const output = magnetURIDecode(book); + expect(output).not.toBeUndefined(); + expect(output!.dn).toEqual("Leaves of Grass by Walt Whitman.epub"); + expect(output!.infoHash).toEqual("d2474e86c95b19b8bcfdb92bc12c9d44667cfa36"); + expect(output!.tr).toEqual([ + "udp://tracker.example4.com:80", + "udp://tracker.example5.com:80", + "udp://tracker.example3.com:6969", + "udp://tracker.example2.com:80", + "udp://tracker.example1.com:1337", + ]); + }); +}); diff --git a/packages/app/src/Util.ts b/packages/app/src/Util.ts index cca139c8..2be25eb8 100644 --- a/packages/app/src/Util.ts +++ b/packages/app/src/Util.ts @@ -3,6 +3,7 @@ import { sha256 as hash } from "@noble/hashes/sha256"; import { bytesToHex } from "@noble/hashes/utils"; import { decode as invoiceDecode } from "light-bolt11-decoder"; import { bech32 } from "bech32"; +import base32Decode from "base32-decode"; import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix } from "@snort/nostr"; import { MetadataCache } from "State/Users"; @@ -271,3 +272,110 @@ export function decodeInvoice(pr: string) { console.error(e); } } + +export interface Magnet { + dn?: string | string[]; + tr?: string | string[]; + xs?: string | string[]; + as?: string | string[]; + ws?: string | string[]; + kt?: string[]; + ix?: number | number[]; + xt?: string | string[]; + infoHash?: string; + raw?: string; +} + +/** + * Parse a magnet URI and return an object of keys/values + */ +export function magnetURIDecode(uri: string): Magnet | undefined { + try { + const result: Record = { + raw: uri, + }; + + // Support 'magnet:' and 'stream-magnet:' uris + const data = uri.trim().split("magnet:?")[1]; + + const params = data && data.length > 0 ? data.split("&") : []; + + params.forEach(param => { + const split = param.split("="); + const key = split[0]; + const val = decodeURIComponent(split[1]); + + if (!result[key]) { + result[key] = []; + } + + switch (key) { + case "dn": { + (result[key] as string[]).push(val.replace(/\+/g, " ")); + break; + } + case "kt": { + val.split("+").forEach(e => { + (result[key] as string[]).push(e); + }); + break; + } + case "ix": { + (result[key] as number[]).push(Number(val)); + break; + } + case "so": { + // todo: not implemented yet + break; + } + default: { + (result[key] as string[]).push(val); + break; + } + } + }); + + // Convenience properties for parity with `parse-torrent-file` module + let m; + if (result.xt) { + const xts = Array.isArray(result.xt) ? result.xt : [result.xt]; + xts.forEach(xt => { + if (typeof xt === "string") { + if ((m = xt.match(/^urn:btih:(.{40})/))) { + result.infoHash = [m[1].toLowerCase()]; + } else if ((m = xt.match(/^urn:btih:(.{32})/))) { + const decodedStr = base32Decode(m[1], "RFC4648-HEX"); + result.infoHash = [bytesToHex(new Uint8Array(decodedStr))]; + } else if ((m = xt.match(/^urn:btmh:1220(.{64})/))) { + result.infoHashV2 = [m[1].toLowerCase()]; + } + } + }); + } + + if (result.xs) { + const xss = Array.isArray(result.xs) ? result.xs : [result.xs]; + xss.forEach(xs => { + if (typeof xs === "string" && (m = xs.match(/^urn:btpk:(.{64})/))) { + if (!result.publicKey) { + result.publicKey = []; + } + (result.publicKey as string[]).push(m[1].toLowerCase()); + } + }); + } + + for (const [k, v] of Object.entries(result)) { + if (Array.isArray(v)) { + if (v.length === 1) { + result[k] = v[0]; + } else if (v.length === 0) { + result[k] = undefined; + } + } + } + return result; + } catch (e) { + console.warn("Failed to parse magnet link", e); + } +} diff --git a/yarn.lock b/yarn.lock index 3ff2f76e..48a3684d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3168,6 +3168,18 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base32-decode@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/base32-decode/-/base32-decode-1.0.0.tgz#2a821d6a664890c872f20aa9aca95a4b4b80e2a7" + integrity sha512-KNWUX/R7wKenwE/G/qFMzGScOgVntOmbE27vvc6GrniDGYb6a5+qWcuoXl8WIOQL7q0TpK7nZDm1Y04Yi3Yn5g== + +base32@^0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/base32/-/base32-0.0.7.tgz#9d3c21de7efde73086fa0164d57ffcb1120c68ea" + integrity sha512-ire9Jmh+BsUk4Idu0wu6aKeJJr/2j28Mlu0qqJBd1SyOGxW/VRotkfwAv3/KE/entVlNwCefs9bxi7kBeCIxTQ== + dependencies: + minimist "^1.2.6" + base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"