feat: parse magnet links
This commit is contained in:
parent
4325c49435
commit
8e69342a0c
@ -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",
|
||||
|
@ -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;
|
||||
|
18
packages/app/src/Element/Magnet.tsx
Normal file
18
packages/app/src/Element/Magnet.tsx
Normal 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;
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
12
yarn.lock
12
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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user