feat: parse magnet links

This commit is contained in:
Kieran 2023-03-03 19:01:30 +00:00
parent 4325c49435
commit 8e69342a0c
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
7 changed files with 189 additions and 7 deletions

View File

@ -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",

View File

@ -44,6 +44,7 @@ export const DefaultRelays = new Map<string, RelaySettings>([
["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;

View File

@ -0,0 +1,18 @@
import { Magnet } from "Util";
interface MagnetLinkProps {
magnet: Magnet;
}
const MagnetLink = ({ magnet }: MagnetLinkProps) => {
return (
<div className="note-invoice">
<h4>Magnet Link</h4>
<a href={magnet.raw} rel="noreferrer">
{magnet.dn ?? magnet.infoHash}
</a>
</div>
);
};
export default MagnetLink;

View File

@ -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 <MagnetLink magnet={parsed} />;
}
}
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;
}

View File

@ -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",
]);
});
});

View File

@ -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<string, string | number | number[] | string[] | undefined> = {
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);
}
}

View File

@ -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"