snort/packages/app/src/Util.ts

604 lines
16 KiB
TypeScript
Raw Normal View History

2023-01-06 14:36:13 +00:00
import * as secp from "@noble/secp256k1";
import { sha256 as hash } from "@noble/hashes/sha256";
2023-03-15 11:09:20 +00:00
import { hmac } from "@noble/hashes/hmac";
2023-03-02 15:23:53 +00:00
import { bytesToHex } from "@noble/hashes/utils";
import { decode as invoiceDecode } from "light-bolt11-decoder";
2023-01-06 14:36:13 +00:00
import { bech32 } from "bech32";
2023-03-03 19:01:30 +00:00
import base32Decode from "base32-decode";
import {
HexKey,
TaggedRawEvent,
u256,
EventKind,
encodeTLV,
NostrPrefix,
decodeTLV,
TLVEntryType,
RawEvent,
} from "@snort/nostr";
2023-03-29 12:10:22 +00:00
import { MetadataCache } from "Cache";
2023-01-03 16:55:51 +00:00
2023-03-28 14:34:01 +00:00
export const sha256 = (str: string | Uint8Array): u256 => {
return secp.utils.bytesToHex(hash(str));
};
2023-02-03 21:38:14 +00:00
2023-05-08 14:36:43 +00:00
export function getPublicKey(privKey: HexKey) {
return secp.utils.bytesToHex(secp.schnorr.getPublicKey(privKey));
}
2023-01-16 17:48:25 +00:00
export async function openFile(): Promise<File | undefined> {
2023-02-09 12:26:54 +00:00
return new Promise(resolve => {
2023-02-07 19:47:57 +00:00
const elm = document.createElement("input");
elm.type = "file";
elm.onchange = (e: Event) => {
2023-02-07 19:47:57 +00:00
const elm = e.target as HTMLInputElement;
if (elm.files) {
resolve(elm.files[0]);
} else {
resolve(undefined);
}
};
elm.click();
});
2023-01-03 16:55:51 +00:00
}
2023-01-06 14:36:13 +00:00
/**
* Parse bech32 ids
2023-01-06 14:45:30 +00:00
* https://github.com/nostr-protocol/nips/blob/master/19.md
2023-01-15 19:40:47 +00:00
* @param id bech32 id
2023-01-06 14:36:13 +00:00
*/
2023-01-15 19:40:47 +00:00
export function parseId(id: string) {
const hrp = ["note", "npub", "nsec"];
try {
2023-02-09 12:26:54 +00:00
if (hrp.some(a => id.startsWith(a))) {
return bech32ToHex(id);
}
2023-02-07 19:47:57 +00:00
} catch (e) {
// Ignore the error.
}
return id;
2023-01-06 14:36:13 +00:00
}
2023-01-15 19:40:47 +00:00
export function bech32ToHex(str: string) {
2023-03-08 15:17:51 +00:00
try {
const nKey = bech32.decode(str, 1_000);
const buff = bech32.fromWords(nKey.words);
return secp.utils.bytesToHex(Uint8Array.from(buff));
} catch {
2023-04-25 10:04:20 +00:00
return str;
2023-03-08 15:17:51 +00:00
}
2023-01-06 14:36:13 +00:00
}
2023-01-07 20:54:12 +00:00
/**
* Decode bech32 to string UTF-8
2023-01-15 19:40:47 +00:00
* @param str bech32 encoded string
* @returns
2023-01-07 20:54:12 +00:00
*/
2023-01-15 19:40:47 +00:00
export function bech32ToText(str: string) {
2023-03-08 15:17:51 +00:00
try {
const decoded = bech32.decode(str, 1000);
const buf = bech32.fromWords(decoded.words);
return new TextDecoder().decode(Uint8Array.from(buf));
} catch {
return "";
}
2023-01-07 20:54:12 +00:00
}
2023-01-06 14:36:13 +00:00
/**
* Convert hex note id to bech32 link url
* @param hex
* @returns
2023-01-06 14:36:13 +00:00
*/
2023-03-25 22:55:34 +00:00
export function eventLink(hex: u256, relays?: Array<string> | string) {
const encoded = relays
? encodeTLV(hex, NostrPrefix.Event, Array.isArray(relays) ? relays : [relays])
: hexToBech32(NostrPrefix.Note, hex);
return `/e/${encoded}`;
}
/**
* Convert hex pubkey to bech32 link url
*/
export function profileLink(hex: HexKey, relays?: Array<string> | string) {
const encoded = relays
? encodeTLV(hex, NostrPrefix.Profile, Array.isArray(relays) ? relays : [relays])
: hexToBech32(NostrPrefix.PublicKey, hex);
return `/p/${encoded}`;
2023-01-09 16:18:34 +00:00
}
/**
* Convert hex to bech32
*/
2023-02-09 12:26:54 +00:00
export function hexToBech32(hrp: string, hex?: string) {
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) {
return "";
}
2023-01-15 19:40:47 +00:00
try {
2023-01-29 19:44:53 +00:00
if (hrp === NostrPrefix.Note || hrp === NostrPrefix.PrivateKey || hrp === NostrPrefix.PublicKey) {
2023-02-14 10:34:43 +00:00
const buf = secp.utils.hexToBytes(hex);
2023-01-29 19:44:53 +00:00
return bech32.encode(hrp, bech32.toWords(buf));
} else {
return encodeTLV(hex, hrp as NostrPrefix);
}
} catch (e) {
console.warn("Invalid hex", hex, e);
return "";
}
2023-01-06 14:36:13 +00:00
}
2023-01-08 00:29:59 +00:00
/**
* Reaction types
*/
export const Reaction = {
Positive: "+",
Negative: "-",
2023-01-08 00:29:59 +00:00
};
/**
* Return normalized reaction content
*/
2023-01-15 19:40:47 +00:00
export function normalizeReaction(content: string) {
switch (content) {
case "-":
return Reaction.Negative;
case "👎":
return Reaction.Negative;
2023-02-09 11:32:35 +00:00
default:
return Reaction.Positive;
}
2023-01-09 11:00:23 +00:00
}
2023-01-17 13:03:15 +00:00
/**
* Get reactions to a specific event (#e + kind filter)
*/
2023-03-28 14:34:01 +00:00
export function getReactions(notes: readonly TaggedRawEvent[] | undefined, id: u256, kind?: EventKind) {
return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && a[1] === id)) || [];
}
export function getAllReactions(notes: readonly TaggedRawEvent[] | undefined, ids: Array<u256>, kind?: EventKind) {
return notes?.filter(a => a.kind === (kind ?? a.kind) && a.tags.some(a => a[0] === "e" && ids.includes(a[1]))) || [];
2023-01-17 13:03:15 +00:00
}
2023-01-17 21:55:53 +00:00
export function unixNow() {
2023-02-20 23:14:15 +00:00
return Math.floor(unixNowMs() / 1000);
}
export function unixNowMs() {
return new Date().getTime();
}
2023-01-24 14:09:56 +00:00
2023-04-14 11:33:19 +00:00
export function deepClone<T>(obj: T) {
if ("structuredClone" in window) {
return structuredClone(obj);
} else {
return JSON.parse(JSON.stringify(obj));
}
}
2023-01-24 14:09:56 +00:00
/**
* Simple debounce
*/
export function debounce(timeout: number, fn: () => void) {
2023-02-07 19:47:57 +00:00
const t = setTimeout(fn, timeout);
return () => clearTimeout(t);
2023-02-03 21:38:14 +00:00
}
2023-02-08 05:56:00 +00:00
2023-02-08 21:10:26 +00:00
export function dedupeByPubkey(events: TaggedRawEvent[]) {
const deduped = events.reduce(
({ list, seen }: { list: TaggedRawEvent[]; seen: Set<HexKey> }, ev) => {
if (seen.has(ev.pubkey)) {
return { list, seen };
}
seen.add(ev.pubkey);
return {
seen,
2023-02-21 14:35:53 +00:00
list: [...list, ev],
};
},
{ list: [], seen: new Set([]) }
);
return deduped.list as TaggedRawEvent[];
}
2023-04-13 18:43:43 +00:00
export function dedupeById<T extends { id: string }>(events: Array<T>) {
2023-02-21 14:35:53 +00:00
const deduped = events.reduce(
2023-04-13 18:43:43 +00:00
({ list, seen }: { list: Array<T>; seen: Set<string> }, ev) => {
2023-02-21 14:35:53 +00:00
if (seen.has(ev.id)) {
return { list, seen };
}
seen.add(ev.id);
return {
seen,
2023-02-08 21:10:26 +00:00
list: [...list, ev],
};
},
{ list: [], seen: new Set([]) }
);
2023-04-13 18:43:43 +00:00
return deduped.list as Array<T>;
2023-02-08 21:10:26 +00:00
}
2023-02-07 19:47:57 +00:00
2023-03-28 14:34:01 +00:00
/**
* Return newest event by pubkey
* @param events List of all notes to filter from
* @returns
*/
export function getLatestByPubkey(events: TaggedRawEvent[]): Map<HexKey, TaggedRawEvent> {
const deduped = events.reduce((results: Map<HexKey, TaggedRawEvent>, ev) => {
if (!results.has(ev.pubkey)) {
const latest = getNewest(events.filter(a => a.pubkey === ev.pubkey));
if (latest) {
results.set(ev.pubkey, latest);
}
}
return results;
}, new Map<HexKey, TaggedRawEvent>());
return deduped;
}
export function getLatestProfileByPubkey(profiles: MetadataCache[]): Map<HexKey, MetadataCache> {
const deduped = profiles.reduce((results: Map<HexKey, MetadataCache>, ev) => {
if (!results.has(ev.pubkey)) {
const latest = getNewestProfile(profiles.filter(a => a.pubkey === ev.pubkey));
if (latest) {
results.set(ev.pubkey, latest);
}
}
return results;
}, new Map<HexKey, MetadataCache>());
return deduped;
}
export function dedupe<T>(v: Array<T>) {
return [...new Set(v)];
}
export function appendDedupe<T>(a?: Array<T>, b?: Array<T>) {
return dedupe([...(a ?? []), ...(b ?? [])]);
}
2023-02-07 19:47:57 +00:00
export function unwrap<T>(v: T | undefined | null): T {
if (v === undefined || v === null) {
throw new Error("missing value");
}
return v;
}
2023-02-10 19:23:52 +00:00
export function randomSample<T>(coll: T[], size: number) {
const random = [...coll];
return random.sort(() => (Math.random() >= 0.5 ? 1 : -1)).slice(0, size);
}
2023-03-28 14:34:01 +00:00
export function getNewest(rawNotes: readonly TaggedRawEvent[]) {
const notes = [...rawNotes];
2023-03-28 14:34:01 +00:00
notes.sort((a, b) => b.created_at - a.created_at);
if (notes.length > 0) {
return notes[0];
}
}
2023-03-28 14:34:01 +00:00
export function getNewestProfile(rawNotes: MetadataCache[]) {
const notes = [...rawNotes];
notes.sort((a, b) => b.created - a.created);
if (notes.length > 0) {
return notes[0];
}
}
export function getNewestEventTagsByKey(evs: TaggedRawEvent[], tag: string) {
const newest = getNewest(evs);
if (newest) {
const keys = newest.tags.filter(p => p && p.length === 2 && p[0] === tag).map(p => p[1]);
return {
keys,
createdAt: newest.created_at,
};
}
}
export function tagFilterOfTextRepost(note: TaggedRawEvent, id?: u256): (tag: string[], i: number) => boolean {
return (tag, i) =>
tag[0] === "e" && tag[3] === "mention" && note.content === `#[${i}]` && (id ? tag[1] === id : true);
}
2023-02-20 23:14:15 +00:00
export function groupByPubkey(acc: Record<HexKey, MetadataCache>, user: MetadataCache) {
return { ...acc, [user.pubkey]: user };
}
export function splitByUrl(str: string) {
const urlRegex =
2023-04-29 08:53:04 +00:00
/((?:http|ftp|https|nostr|web\+nostr|magnet):\/?\/?(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~()_|]))/i;
return str.split(urlRegex);
}
export const delay = (t: number) => {
return new Promise(resolve => {
setTimeout(resolve, t);
});
};
2023-03-02 15:23:53 +00:00
2023-03-06 10:18:33 +00:00
export interface InvoiceDetails {
amount?: number;
expire?: number;
timestamp?: number;
description?: string;
descriptionHash?: string;
paymentHash?: string;
expired: boolean;
}
export function decodeInvoice(pr: string): InvoiceDetails | undefined {
2023-03-02 15:23:53 +00:00
try {
const parsed = invoiceDecode(pr);
const amountSection = parsed.sections.find(a => a.name === "amount");
2023-03-05 16:58:34 +00:00
const amount = amountSection ? Number(amountSection.value as number | string) : undefined;
2023-03-02 15:23:53 +00:00
const timestampSection = parsed.sections.find(a => a.name === "timestamp");
2023-03-06 10:18:33 +00:00
const timestamp = timestampSection ? Number(timestampSection.value as number | string) : undefined;
2023-03-02 15:23:53 +00:00
const expirySection = parsed.sections.find(a => a.name === "expiry");
2023-03-06 10:18:33 +00:00
const expire = expirySection ? Number(expirySection.value as number | string) : undefined;
2023-03-02 15:23:53 +00:00
const descriptionSection = parsed.sections.find(a => a.name === "description")?.value;
const descriptionHashSection = parsed.sections.find(a => a.name === "description_hash")?.value;
const paymentHashSection = parsed.sections.find(a => a.name === "payment_hash")?.value;
const ret = {
2023-03-05 16:58:34 +00:00
amount: amount,
2023-03-06 10:18:33 +00:00
expire: timestamp && expire ? timestamp + expire : undefined,
timestamp: timestamp,
2023-03-02 15:23:53 +00:00
description: descriptionSection as string | undefined,
descriptionHash: descriptionHashSection ? bytesToHex(descriptionHashSection as Uint8Array) : undefined,
paymentHash: paymentHashSection ? bytesToHex(paymentHashSection as Uint8Array) : undefined,
expired: false,
};
if (ret.expire) {
ret.expired = ret.expire < new Date().getTime() / 1000;
}
return ret;
} catch (e) {
console.error(e);
}
}
2023-03-03 19:01:30 +00:00
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);
}
}
2023-03-09 10:13:10 +00:00
export function chunks<T>(arr: T[], length: number) {
const result = [];
let idx = 0;
let n = arr.length / length;
while (n > 0) {
result.push(arr.slice(idx, idx + length));
idx += length;
n -= 1;
}
return result;
}
export function findTag(e: RawEvent, tag: string) {
2023-03-09 10:13:10 +00:00
const maybeTag = e.tags.find(evTag => {
return evTag[0] === tag;
});
return maybeTag && maybeTag[1];
}
2023-03-15 11:09:20 +00:00
2023-04-18 11:47:01 +00:00
export function hmacSha256(key: Uint8Array, ...messages: Uint8Array[]) {
return hmac(hash, key, secp.utils.concatBytes(...messages));
2023-03-15 11:09:20 +00:00
}
export function getRelayName(url: string) {
const parsedUrl = new URL(url);
return parsedUrl.host + parsedUrl.search;
}
2023-03-25 22:55:34 +00:00
export interface NostrLink {
type: NostrPrefix;
id: string;
kind?: number;
author?: string;
relays?: Array<string>;
encode(): string;
}
2023-04-20 00:29:37 +00:00
export function validateNostrLink(link: string): boolean {
try {
const parsedLink = parseNostrLink(link);
if (!parsedLink) {
return false;
}
2023-04-20 20:01:29 +00:00
if (parsedLink.type === NostrPrefix.PublicKey || parsedLink.type === NostrPrefix.Note) {
return parsedLink.id.length === 64;
}
return true;
2023-04-20 00:29:37 +00:00
} catch {
return false;
}
}
2023-04-25 10:04:20 +00:00
export function parseNostrLink(link: string, prefixHint?: NostrPrefix): NostrLink | undefined {
2023-03-25 22:55:34 +00:00
const entity = link.startsWith("web+nostr:") || link.startsWith("nostr:") ? link.split(":")[1] : link;
2023-04-25 10:04:20 +00:00
const isPrefix = (prefix: NostrPrefix) => {
2023-04-28 18:26:46 +00:00
return entity.startsWith(prefix);
2023-04-25 10:04:20 +00:00
};
if (isPrefix(NostrPrefix.PublicKey)) {
2023-03-25 22:55:34 +00:00
const id = bech32ToHex(entity);
return {
type: NostrPrefix.PublicKey,
id: id,
encode: () => hexToBech32(NostrPrefix.PublicKey, id),
};
2023-04-25 10:04:20 +00:00
} else if (isPrefix(NostrPrefix.Note)) {
2023-03-25 22:55:34 +00:00
const id = bech32ToHex(entity);
return {
type: NostrPrefix.Note,
id: id,
encode: () => hexToBech32(NostrPrefix.Note, id),
};
2023-04-25 10:04:20 +00:00
} else if (isPrefix(NostrPrefix.Profile) || isPrefix(NostrPrefix.Event) || isPrefix(NostrPrefix.Address)) {
2023-03-25 22:55:34 +00:00
const decoded = decodeTLV(entity);
const id = decoded.find(a => a.type === TLVEntryType.Special)?.value as string;
const relays = decoded.filter(a => a.type === TLVEntryType.Relay).map(a => a.value as string);
const author = decoded.find(a => a.type === TLVEntryType.Author)?.value as string;
const kind = decoded.find(a => a.type === TLVEntryType.Kind)?.value as number;
const encode = () => {
return entity; // return original
};
2023-04-25 10:04:20 +00:00
if (isPrefix(NostrPrefix.Profile)) {
2023-03-25 22:55:34 +00:00
return {
type: NostrPrefix.Profile,
id,
relays,
kind,
author,
encode,
};
2023-04-25 10:04:20 +00:00
} else if (isPrefix(NostrPrefix.Event)) {
2023-03-25 22:55:34 +00:00
return {
type: NostrPrefix.Event,
id,
relays,
kind,
author,
encode,
};
2023-04-25 10:04:20 +00:00
} else if (isPrefix(NostrPrefix.Address)) {
2023-03-25 22:55:34 +00:00
return {
type: NostrPrefix.Address,
id,
relays,
kind,
author,
encode,
};
}
2023-04-28 18:26:46 +00:00
} else if (prefixHint) {
return {
type: prefixHint,
id: link,
encode: () => hexToBech32(prefixHint, link),
};
} else {
throw new Error("Invalid nostr link");
2023-03-25 22:55:34 +00:00
}
}
2023-03-28 14:34:01 +00:00
export function sanitizeRelayUrl(url: string) {
try {
return new URL(url).toString();
} catch {
// ignore
}
}