From 87386c9950e1eed3bc7857c189e040eb93b0e8e4 Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 10 Jan 2024 16:15:26 +0000 Subject: [PATCH] feat: use new opengraph functions --- functions/_middleware.ts | 78 +++++++++++++++ functions/bech32.ts | 208 +++++++++++++++++++++++++++++++++++++++ functions/e/[id].ts | 30 ------ functions/hex.ts | 30 ++++++ functions/p/[id].ts | 30 ------ 5 files changed, 316 insertions(+), 60 deletions(-) create mode 100644 functions/_middleware.ts create mode 100644 functions/bech32.ts delete mode 100644 functions/e/[id].ts create mode 100644 functions/hex.ts delete mode 100644 functions/p/[id].ts diff --git a/functions/_middleware.ts b/functions/_middleware.ts new file mode 100644 index 00000000..c2653b71 --- /dev/null +++ b/functions/_middleware.ts @@ -0,0 +1,78 @@ +interface Env {} + +import { bech32 } from "./bech32"; +import { fromHex } from "./hex"; + +interface NostrJson { + names: Record; +} + +const HOST = "snort.social"; + +async function fetchNostrAddress(name: string, domain: string) { + try { + const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`, { + signal: AbortSignal.timeout(1000), + }); + const data: NostrJson = await res.json(); + const match = Object.keys(data.names).find(n => { + return n.toLowerCase() === name.toLowerCase(); + }); + return match ? data.names[match] : undefined; + } catch { + // ignored + } +} + +export const onRequest: PagesFunction = async context => { + const u = new URL(context.request.url); + + const prefixes = ["npub1", "nprofile1", "naddr1", "nevent1", "note1"]; + const isEntityPath = () => { + return prefixes.some( + a => u.pathname.startsWith(`/${a}`) || u.pathname.startsWith(`/e/${a}`) || u.pathname.startsWith(`/p/${a}`), + ); + }; + + const nostrAddress = u.pathname.match(/^\/([a-zA-Z0-9_]+)$/i); + + const next = await context.next(); + if (u.pathname != "/" && (isEntityPath() || nostrAddress)) { + try { + let id = nostrAddress ? nostrAddress[1] : u.pathname.split("/").at(-1); + if (nostrAddress) { + const pubkey = await fetchNostrAddress(id, HOST); + if (pubkey) { + id = bech32.encode("npub", bech32.toWords(fromHex(pubkey))); + } else { + return next; + } + } + const rsp = await fetch( + `http://nostr.api.v0l.io/api/v1/opengraph/${id}?canonical=${encodeURIComponent(`https://${HOST}/%s`)}`, + { + method: "POST", + body: await next.arrayBuffer(), + headers: { + "user-agent": `SnortFunctions/1.0 (https://${HOST})`, + "content-type": "text/html", + accept: "text/html", + }, + }, + ); + if (rsp.ok) { + const body = await rsp.text(); + if (body.length > 0) { + return new Response(body, { + headers: { + "content-type": "text/html", + }, + }); + } + } + } catch { + // ignore + } + } + return next; +}; diff --git a/functions/bech32.ts b/functions/bech32.ts new file mode 100644 index 00000000..857b833d --- /dev/null +++ b/functions/bech32.ts @@ -0,0 +1,208 @@ +'use strict'; +const ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + +const ALPHABET_MAP: { [key: string]: number } = {}; +for (let z = 0; z < ALPHABET.length; z++) { + const x = ALPHABET.charAt(z); + ALPHABET_MAP[x] = z; +} + +function polymodStep(pre: number): number { + const b = pre >> 25; + return ( + ((pre & 0x1ffffff) << 5) ^ + (-((b >> 0) & 1) & 0x3b6a57b2) ^ + (-((b >> 1) & 1) & 0x26508e6d) ^ + (-((b >> 2) & 1) & 0x1ea119fa) ^ + (-((b >> 3) & 1) & 0x3d4233dd) ^ + (-((b >> 4) & 1) & 0x2a1462b3) + ); +} + +function prefixChk(prefix: string): number | string { + let chk = 1; + for (let i = 0; i < prefix.length; ++i) { + const c = prefix.charCodeAt(i); + if (c < 33 || c > 126) return 'Invalid prefix (' + prefix + ')'; + + chk = polymodStep(chk) ^ (c >> 5); + } + chk = polymodStep(chk); + + for (let i = 0; i < prefix.length; ++i) { + const v = prefix.charCodeAt(i); + chk = polymodStep(chk) ^ (v & 0x1f); + } + return chk; +} + +function convert(data: ArrayLike, inBits: number, outBits: number, pad: true): number[]; +function convert( + data: ArrayLike, + inBits: number, + outBits: number, + pad: false, +): number[] | string; +function convert( + data: ArrayLike, + inBits: number, + outBits: number, + pad: boolean, +): number[] | string { + let value = 0; + let bits = 0; + const maxV = (1 << outBits) - 1; + + const result: number[] = []; + for (let i = 0; i < data.length; ++i) { + value = (value << inBits) | data[i]; + bits += inBits; + + while (bits >= outBits) { + bits -= outBits; + result.push((value >> bits) & maxV); + } + } + + if (pad) { + if (bits > 0) { + result.push((value << (outBits - bits)) & maxV); + } + } else { + if (bits >= inBits) return 'Excess padding'; + if ((value << (outBits - bits)) & maxV) return 'Non-zero padding'; + } + + return result; +} + +function toWords(bytes: ArrayLike): number[] { + return convert(bytes, 8, 5, true); +} + +function fromWordsUnsafe(words: ArrayLike): number[] | undefined { + const res = convert(words, 5, 8, false); + if (Array.isArray(res)) return res; +} + +function fromWords(words: ArrayLike): number[] { + const res = convert(words, 5, 8, false); + if (Array.isArray(res)) return res; + + throw new Error(res); +} + +function getLibraryFromEncoding(encoding: 'bech32' | 'bech32m'): BechLib { + let ENCODING_CONST: number; + if (encoding === 'bech32') { + ENCODING_CONST = 1; + } else { + ENCODING_CONST = 0x2bc830a3; + } + + function encode(prefix: string, words: ArrayLike, LIMIT?: number): string { + LIMIT = LIMIT || 90; + if (prefix.length + 7 + words.length > LIMIT) throw new TypeError('Exceeds length limit'); + + prefix = prefix.toLowerCase(); + + // determine chk mod + let chk = prefixChk(prefix); + if (typeof chk === 'string') throw new Error(chk); + + let result = prefix + '1'; + for (let i = 0; i < words.length; ++i) { + const x = words[i]; + if (x >> 5 !== 0) throw new Error('Non 5-bit word'); + + chk = polymodStep(chk) ^ x; + result += ALPHABET.charAt(x); + } + + for (let i = 0; i < 6; ++i) { + chk = polymodStep(chk); + } + chk ^= ENCODING_CONST; + + for (let i = 0; i < 6; ++i) { + const v = (chk >> ((5 - i) * 5)) & 0x1f; + result += ALPHABET.charAt(v); + } + + return result; + } + + function __decode(str: string, LIMIT?: number): Decoded | string { + LIMIT = LIMIT || 90; + if (str.length < 8) return str + ' too short'; + if (str.length > LIMIT) return 'Exceeds length limit'; + + // don't allow mixed case + const lowered = str.toLowerCase(); + const uppered = str.toUpperCase(); + if (str !== lowered && str !== uppered) return 'Mixed-case string ' + str; + str = lowered; + + const split = str.lastIndexOf('1'); + if (split === -1) return 'No separator character for ' + str; + if (split === 0) return 'Missing prefix for ' + str; + + const prefix = str.slice(0, split); + const wordChars = str.slice(split + 1); + if (wordChars.length < 6) return 'Data too short'; + + let chk = prefixChk(prefix); + if (typeof chk === 'string') return chk; + + const words = []; + for (let i = 0; i < wordChars.length; ++i) { + const c = wordChars.charAt(i); + const v = ALPHABET_MAP[c]; + if (v === undefined) return 'Unknown character ' + c; + chk = polymodStep(chk) ^ v; + + // not in the checksum? + if (i + 6 >= wordChars.length) continue; + words.push(v); + } + + if (chk !== ENCODING_CONST) return 'Invalid checksum for ' + str; + return { prefix, words }; + } + + function decodeUnsafe(str: string, LIMIT?: number): Decoded | undefined { + const res = __decode(str, LIMIT); + if (typeof res === 'object') return res; + } + + function decode(str: string, LIMIT?: number): Decoded { + const res = __decode(str, LIMIT); + if (typeof res === 'object') return res; + + throw new Error(res); + } + + return { + decodeUnsafe, + decode, + encode, + toWords, + fromWordsUnsafe, + fromWords, + }; +} + +export const bech32 = getLibraryFromEncoding('bech32'); +export const bech32m = getLibraryFromEncoding('bech32m'); +export interface Decoded { + prefix: string; + words: number[]; +} +export interface BechLib { + decodeUnsafe: (str: string, LIMIT?: number | undefined) => Decoded | undefined; + decode: (str: string, LIMIT?: number | undefined) => Decoded; + encode: (prefix: string, words: ArrayLike, LIMIT?: number | undefined) => string; + toWords: typeof toWords; + fromWordsUnsafe: typeof fromWordsUnsafe; + fromWords: typeof fromWords; +} \ No newline at end of file diff --git a/functions/e/[id].ts b/functions/e/[id].ts deleted file mode 100644 index 519a0dc9..00000000 --- a/functions/e/[id].ts +++ /dev/null @@ -1,30 +0,0 @@ -interface Env {} - -export const onRequest: PagesFunction = async context => { - const id = context.params.id as string; - - const next = await context.next(); - try { - const rsp = await fetch(`https://api.snort.social/api/v1/og/tag/e/${id}`, { - method: "POST", - body: await next.arrayBuffer(), - headers: { - "user-agent": "Snort-Functions/1.0 (https://snort.social)", - "content-type": "text/plain", - }, - }); - if (rsp.ok) { - const body = await rsp.text(); - if (body.length > 0) { - return new Response(body, { - headers: { - "content-type": "text/html", - }, - }); - } - } - } catch { - // ignore - } - return next; -}; diff --git a/functions/hex.ts b/functions/hex.ts new file mode 100644 index 00000000..4b19bab1 --- /dev/null +++ b/functions/hex.ts @@ -0,0 +1,30 @@ +const SHORT_TO_HEX: { [key: number]: string } = {}; +const HEX_TO_SHORT: Record = {}; + +for (let i = 0; i < 256; i++) { + let encodedByte = i.toString(16).toLowerCase(); + if (encodedByte.length === 1) { + encodedByte = `0${encodedByte}`; + } + + SHORT_TO_HEX[i] = encodedByte; + HEX_TO_SHORT[encodedByte] = i; +} + +export function fromHex(encoded: string): Uint8Array { + if (encoded.length % 2 !== 0) { + throw new Error("Hex encoded strings must have an even number length"); + } + + const out = new Uint8Array(encoded.length / 2); + for (let i = 0; i < encoded.length; i += 2) { + const encodedByte = encoded.slice(i, i + 2).toLowerCase(); + if (encodedByte in HEX_TO_SHORT) { + out[i / 2] = HEX_TO_SHORT[encodedByte]; + } else { + throw new Error(`Cannot decode unrecognized sequence ${encodedByte} as hexadecimal`); + } + } + + return out; +} diff --git a/functions/p/[id].ts b/functions/p/[id].ts deleted file mode 100644 index dfe50105..00000000 --- a/functions/p/[id].ts +++ /dev/null @@ -1,30 +0,0 @@ -interface Env {} - -export const onRequest: PagesFunction = async context => { - const id = context.params.id as string; - - const next = await context.next(); - try { - const rsp = await fetch(`https://api.snort.social/api/v1/og/tag/p/${id}`, { - method: "POST", - body: await next.arrayBuffer(), - headers: { - "user-agent": "Snort-Functions/1.0 (https://snort.social)", - "content-type": "text/plain", - }, - }); - if (rsp.ok) { - const body = await rsp.text(); - if (body.length > 0) { - return new Response(body, { - headers: { - "content-type": "text/html", - }, - }); - } - } - } catch { - // ignore - } - return next; -};