diff --git a/deno.json b/deno.json index 964490c6..d7eff020 100644 --- a/deno.json +++ b/deno.json @@ -44,6 +44,7 @@ "@std/json": "jsr:@std/json@^0.223.0", "@std/media-types": "jsr:@std/media-types@^0.224.1", "@std/streams": "jsr:@std/streams@^0.223.0", + "campfire.js": "https://esm.sh/campfire.js@4.0.0-alpha3", "comlink": "npm:comlink@^4.4.1", "comlink-async-generator": "npm:comlink-async-generator@^0.0.1", "deno-safe-fetch/load": "https://gitlab.com/soapbox-pub/deno-safe-fetch/-/raw/v1.0.0/load.ts", @@ -64,6 +65,7 @@ "nostr-relaypool": "npm:nostr-relaypool2@0.6.34", "nostr-tools": "npm:nostr-tools@2.5.1", "nostr-wasm": "npm:nostr-wasm@^0.1.0", + "path-to-regexp": "npm:path-to-regexp@6.2.1", "postgres": "https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/mod.js", "prom-client": "npm:prom-client@^15.1.2", "question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts", diff --git a/deno.lock b/deno.lock index 5b403d44..17ac0e5e 100644 --- a/deno.lock +++ b/deno.lock @@ -71,6 +71,8 @@ "npm:nostr-tools@^2.5.0": "npm:nostr-tools@2.5.1", "npm:nostr-tools@^2.7.0": "npm:nostr-tools@2.7.0", "npm:nostr-wasm@^0.1.0": "npm:nostr-wasm@0.1.0", + "npm:path-to-regexp@6.2.1": "npm:path-to-regexp@6.2.1", + "npm:path-to-regexp@7.1.0": "npm:path-to-regexp@7.1.0", "npm:postgres@3.4.4": "npm:postgres@3.4.4", "npm:prom-client@^15.1.2": "npm:prom-client@15.1.2", "npm:tldts@^6.0.14": "npm:tldts@6.1.18", @@ -933,6 +935,14 @@ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dependencies": {} }, + "path-to-regexp@6.2.1": { + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "dependencies": {} + }, + "path-to-regexp@7.1.0": { + "integrity": "sha512-ZToe+MbUF4lBqk6dV8GKot4DKfzrxXsplOddH8zN3YK+qw9/McvP7+4ICjZvOne0jQhN4eJwHsX6tT0Ns19fvw==", + "dependencies": {} + }, "picomatch@2.3.1": { "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dependencies": {} @@ -1734,7 +1744,9 @@ "https://deno.land/x/postgres@v0.19.0/utils/deferred.ts": "5420531adb6c3ea29ca8aac57b9b59bd3e4b9a938a4996bbd0947a858f611080", "https://deno.land/x/postgres@v0.19.0/utils/utils.ts": "ca47193ea03ff5b585e487a06f106d367e509263a960b787197ce0c03113a738", "https://deno.land/x/sentry@7.112.2/index.mjs": "04382d5c2f4e233ba389611db46f77943b2a7f6efbeaaf31193f6e586f4366ef", + "https://esm.sh/campfire.js@4.0.0-alpha3": "81a8acce35c2b5dad01f8699d14671e0bef233908d86e2b3f12ebde3a3ac3436", "https://esm.sh/kysely@0.17.1/dist/esm/index-nodeless.js": "9c23bfd307118e3ccd3a9f0ec1261fc3451fb5301aa34aa6f28e05156818755a", + "https://esm.sh/v135/campfire.js@4.0.0-alpha3/denonext/campfire.mjs": "192fa94e07ff4356fe8afa75b59063a217eec1f0d68c26fe4b4547730e6fc3f2", "https://esm.sh/v135/kysely@0.17.1/denonext/dist/esm/index-nodeless.js": "6f73bbf2d73bc7e96cdabf941c4ae8c12f58fd7b441031edec44c029aed9532b", "https://gitlab.com/soapbox-pub/deno-safe-fetch/-/raw/v1.0.0/load.ts": "3f74ab08cf97d4a3e6994cb79422e9b0069495e017416858121d5ff8ae04ac2a", "https://gitlab.com/soapbox-pub/deno-safe-fetch/-/raw/v1.0.0/mod.ts": "5f505cd265aefbcb687cde6f98c79344d3292ee1dd978e85e5ffa84a617c6682", @@ -1841,6 +1853,7 @@ "npm:nostr-relaypool2@0.6.34", "npm:nostr-tools@2.5.1", "npm:nostr-wasm@^0.1.0", + "npm:path-to-regexp@6.2.1", "npm:prom-client@^15.1.2", "npm:tldts@^6.0.14", "npm:tseep@^1.2.1", diff --git a/src/app.ts b/src/app.ts index 66c33424..0b21bf15 100644 --- a/src/app.ts +++ b/src/app.ts @@ -121,6 +121,7 @@ import { requireSigner } from '@/middleware/requireSigner.ts'; import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; +import { openGraphFrontendController } from '@/middleware/opengraphMiddleware.ts'; interface AppEnv extends HonoEnv { Variables: { @@ -314,7 +315,7 @@ app.use('/oauth/*', notImplementedController); const publicFiles = serveStatic({ root: './public/' }); const staticFiles = serveStatic({ root: './static/' }); -const frontendController = serveStatic({ path: './public/index.html' }); +const frontendController = openGraphFrontendController({ path: './public/index.html' }); // Known frontend routes app.get('/@:acct', frontendController); diff --git a/src/middleware/opengraphMiddleware.ts b/src/middleware/opengraphMiddleware.ts new file mode 100644 index 00000000..9efdd50a --- /dev/null +++ b/src/middleware/opengraphMiddleware.ts @@ -0,0 +1,250 @@ +import { Context, Env, MiddlewareHandler, Next } from '@hono/hono'; +import { serveStatic as baseServeStatic, ServeStaticOptions } from '@hono/hono/serve-static'; +import { match } from 'path-to-regexp'; +import { html, r } from 'campfire.js'; +import { nip05Cache } from '@/utils/nip05.ts'; +import { getAuthor, getEvent } from '@/queries.ts'; +import { nip19 } from 'nostr-tools'; +import { NostrMetadata, NSchema as n } from '@nostrify/nostrify'; +import { getInstanceMetadata } from '@/utils/instance.ts'; +import { Storages } from '@/storages.ts'; +import { Conf } from '@/config.ts'; + +/* + * TODO: implement caching for posts (LRUCache) + */ + +interface OpenGraphTemplateOpts { + title: string; + type: 'article' | 'profile' | 'website'; + url: string; + image?: StatusInfo['image']; + description: string; +} + +interface PathParams { + statusId?: string; + acct?: string; +} + +interface StatusInfo { + description: string; + image?: { + url: string; + w: number; + h: number; + alt?: string; + }; +} + +const store = await Storages.db(); + +const instanceName = async () => { + const meta = await getInstanceMetadata(store, AbortSignal.timeout(1000)); + return meta?.name || 'Ditto'; +}; + +const tpl = async ({ title, type, url, image, description }: OpenGraphTemplateOpts): Promise => + html`\ + + + + + + +${ + image + ? r(html` + + + +${image.alt ? r(html``) : ''} +`) + : '' + } + + + + +${ + image + ? r(html` + +${image.alt ? r(html``) : ''} +`) + : '' + } +`.replace(/\n+/g, '\n'); + +const BLANK_META = (url: string) => + tpl({ + title: 'Ditto', + type: 'website', + url, + description: 'Ditto, a decentralized, self-hosted social media server', + }); + +/** Placeholder to find & replace with metadata. */ +const OG_META_PLACEHOLDER = '' as const; + +/** URL routes to serve metadata on. */ +const SSR_ROUTES = [ + '/@:acct/posts/:statusId', + '/@:acct/:statusId', + '/@:acct', + '/users/:acct/statuses/:statusId', + '/users/:acct', + '/statuses/:statusId', + '/notice/:statusId', +] as const; + +const SSR_ROUTE_MATCHERS = SSR_ROUTES.map((route) => match(route, { decode: decodeURIComponent })); + +const getPathParams = (path: string) => { + for (const matcher of SSR_ROUTE_MATCHERS) { + const result = matcher(path); + if (result) return result.params as PathParams; + } +}; + +const normalizeHandle = async (handle: string) => { + const id = `${handle}`; + const parts = id.match(/(?:(.+))?@(.+)/); + + if (parts) { + const key = `${parts[1] || ''}@${parts[2]}`; + return await nip05Cache.fetch(key, { signal: AbortSignal.timeout(1000) }).then((res) => res.pubkey); + } else if (id.startsWith('npub1')) { + return nip19.decode(id as `npub1${string}`).data; + } + + // shouldn't ever happen for a well-formed link + return ''; +}; + +const getKind0 = async (handle: string | undefined): Promise>> => { + const id = await normalizeHandle(handle || ''); + const kind0 = await getAuthor(id); + + const short = nip19.npubEncode(id).substring(0, 8); + const blank = { name: short, about: `@${short}'s ditto profile` }; + if (!kind0) return blank; + + return Object.assign( + blank, + n.json().pipe(n.metadata()).parse(kind0.content), + ); +}; + +const truncate = (s: string, len: number, ellipsis = '...') => { + if (s.length <= len) return s; + return s.slice(0, len) + ellipsis; +}; + +const getStatus = async (id: string | undefined, handle?: string): Promise => { + const event = await getEvent(id || ''); + if (!event || !id) { + return { description: `A post on Ditto by @${handle}` }; + } + + const res: StatusInfo = { + description: event.content + .replace(/nostr:(npub1(?:[0-9]|[a-z]){58})/g, (_, key: string) => `@${key.slice(0, 8)}`), + }; + + let url: string; + let w: number; + let h: number; + + for (const [tag, ...values] of event.tags) { + if (tag !== 'imeta') continue; + for (const value of values) { + const [item, datum] = value.split(' '); + if (!['dim', 'url'].includes(item)) continue; + if (item === 'dim') { + [w, h] = datum.split('x').map(Number); + } else if (item === 'url') { + url = datum; + } + } + } + + // @ts-ignore conditional assign + if (url && w && h) { + res.image = { url, w, h }; + res.description = res.description.replace(url.trim(), ''); + } + + // needs to be done last incase the image url was surrounded by newlines + res.description = truncate(res.description.trim(), 140); + return res; +}; + +const buildMetaTags = async (params: PathParams, url: string): Promise => { + if (!params.acct && !params.statusId) return await BLANK_META(url); + + const kind0 = await getKind0(params.acct); + const { description, image } = await getStatus(params.statusId || ''); + + if (params.acct && params.statusId) { + return tpl({ + title: `View @${kind0.name}'s post on Ditto`, + type: 'article', + image, + description, + url, + }); + } else if (params.acct) { + return tpl({ + title: `View @${kind0.name}'s profile on Ditto`, + type: 'profile', + description: kind0.about, + url, + }); + } else if (params.statusId) { + return tpl({ + title: `View post on Ditto`, + type: 'profile', + description, + image, + url, + }); + } + + return await BLANK_META(url); +}; + +export const openGraphFrontendController = ( + options: ServeStaticOptions, +): MiddlewareHandler => { + // deno-lint-ignore require-await + return async function serveStatic(c: Context, next: Next) { + let file = ''; + const getContent = async (path: string) => { + try { + if (!file) file = await Deno.readTextFile(path); + if (!file) throw new Error(`File at ${path} was empty!`); + if (c.req.header('accept')?.includes('html') && file.includes(OG_META_PLACEHOLDER)) { + const params = getPathParams(c.req.path); + if (params) { + const meta = await buildMetaTags(params, Conf.local(c.req.path)); + return file.replace(OG_META_PLACEHOLDER, meta); + } + } + return file; + } catch (e) { + console.warn(`${e}`); + } + + return ''; + }; + const pathResolve = (path: string) => { + return `./${path}`; + }; + return baseServeStatic({ + ...options, + getContent, + pathResolve, + })(c, next); + }; +};