mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
first version of opengraph functionality
This commit is contained in:
parent
950adb25c6
commit
7889ee5db4
4 changed files with 267 additions and 1 deletions
|
|
@ -44,6 +44,7 @@
|
||||||
"@std/json": "jsr:@std/json@^0.223.0",
|
"@std/json": "jsr:@std/json@^0.223.0",
|
||||||
"@std/media-types": "jsr:@std/media-types@^0.224.1",
|
"@std/media-types": "jsr:@std/media-types@^0.224.1",
|
||||||
"@std/streams": "jsr:@std/streams@^0.223.0",
|
"@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": "npm:comlink@^4.4.1",
|
||||||
"comlink-async-generator": "npm:comlink-async-generator@^0.0.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",
|
"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-relaypool": "npm:nostr-relaypool2@0.6.34",
|
||||||
"nostr-tools": "npm:nostr-tools@2.5.1",
|
"nostr-tools": "npm:nostr-tools@2.5.1",
|
||||||
"nostr-wasm": "npm:nostr-wasm@^0.1.0",
|
"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",
|
"postgres": "https://raw.githubusercontent.com/xyzshantaram/postgres.js/8a9bbce88b3f6425ecaacd99a80372338b157a53/deno/mod.js",
|
||||||
"prom-client": "npm:prom-client@^15.1.2",
|
"prom-client": "npm:prom-client@^15.1.2",
|
||||||
"question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts",
|
"question-deno": "https://raw.githubusercontent.com/ocpu/question-deno/10022b8e52555335aa510adb08b0a300df3cf904/mod.ts",
|
||||||
|
|
|
||||||
13
deno.lock
generated
13
deno.lock
generated
|
|
@ -71,6 +71,8 @@
|
||||||
"npm:nostr-tools@^2.5.0": "npm:nostr-tools@2.5.1",
|
"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-tools@^2.7.0": "npm:nostr-tools@2.7.0",
|
||||||
"npm:nostr-wasm@^0.1.0": "npm:nostr-wasm@0.1.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:postgres@3.4.4": "npm:postgres@3.4.4",
|
||||||
"npm:prom-client@^15.1.2": "npm:prom-client@15.1.2",
|
"npm:prom-client@^15.1.2": "npm:prom-client@15.1.2",
|
||||||
"npm:tldts@^6.0.14": "npm:tldts@6.1.18",
|
"npm:tldts@^6.0.14": "npm:tldts@6.1.18",
|
||||||
|
|
@ -933,6 +935,14 @@
|
||||||
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
|
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
|
||||||
"dependencies": {}
|
"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": {
|
"picomatch@2.3.1": {
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dependencies": {}
|
"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/deferred.ts": "5420531adb6c3ea29ca8aac57b9b59bd3e4b9a938a4996bbd0947a858f611080",
|
||||||
"https://deno.land/x/postgres@v0.19.0/utils/utils.ts": "ca47193ea03ff5b585e487a06f106d367e509263a960b787197ce0c03113a738",
|
"https://deno.land/x/postgres@v0.19.0/utils/utils.ts": "ca47193ea03ff5b585e487a06f106d367e509263a960b787197ce0c03113a738",
|
||||||
"https://deno.land/x/sentry@7.112.2/index.mjs": "04382d5c2f4e233ba389611db46f77943b2a7f6efbeaaf31193f6e586f4366ef",
|
"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/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://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/load.ts": "3f74ab08cf97d4a3e6994cb79422e9b0069495e017416858121d5ff8ae04ac2a",
|
||||||
"https://gitlab.com/soapbox-pub/deno-safe-fetch/-/raw/v1.0.0/mod.ts": "5f505cd265aefbcb687cde6f98c79344d3292ee1dd978e85e5ffa84a617c6682",
|
"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-relaypool2@0.6.34",
|
||||||
"npm:nostr-tools@2.5.1",
|
"npm:nostr-tools@2.5.1",
|
||||||
"npm:nostr-wasm@^0.1.0",
|
"npm:nostr-wasm@^0.1.0",
|
||||||
|
"npm:path-to-regexp@6.2.1",
|
||||||
"npm:prom-client@^15.1.2",
|
"npm:prom-client@^15.1.2",
|
||||||
"npm:tldts@^6.0.14",
|
"npm:tldts@^6.0.14",
|
||||||
"npm:tseep@^1.2.1",
|
"npm:tseep@^1.2.1",
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ import { requireSigner } from '@/middleware/requireSigner.ts';
|
||||||
import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
|
import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
|
||||||
import { storeMiddleware } from '@/middleware/storeMiddleware.ts';
|
import { storeMiddleware } from '@/middleware/storeMiddleware.ts';
|
||||||
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
|
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
|
||||||
|
import { openGraphFrontendController } from '@/middleware/opengraphMiddleware.ts';
|
||||||
|
|
||||||
interface AppEnv extends HonoEnv {
|
interface AppEnv extends HonoEnv {
|
||||||
Variables: {
|
Variables: {
|
||||||
|
|
@ -314,7 +315,7 @@ app.use('/oauth/*', notImplementedController);
|
||||||
|
|
||||||
const publicFiles = serveStatic({ root: './public/' });
|
const publicFiles = serveStatic({ root: './public/' });
|
||||||
const staticFiles = serveStatic({ root: './static/' });
|
const staticFiles = serveStatic({ root: './static/' });
|
||||||
const frontendController = serveStatic({ path: './public/index.html' });
|
const frontendController = openGraphFrontendController({ path: './public/index.html' });
|
||||||
|
|
||||||
// Known frontend routes
|
// Known frontend routes
|
||||||
app.get('/@:acct', frontendController);
|
app.get('/@:acct', frontendController);
|
||||||
|
|
|
||||||
250
src/middleware/opengraphMiddleware.ts
Normal file
250
src/middleware/opengraphMiddleware.ts
Normal file
|
|
@ -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<string> =>
|
||||||
|
html`\
|
||||||
|
<meta property="og:title" content="${title}">
|
||||||
|
<meta property="og:type" content="${type}">
|
||||||
|
<meta property="og:url" content="${url}">
|
||||||
|
<meta property="og:description" content="${description}">
|
||||||
|
<meta property="og:site_name" content="${await instanceName()}">
|
||||||
|
|
||||||
|
${
|
||||||
|
image
|
||||||
|
? r(html`
|
||||||
|
<meta property="og:image" content="${image.url}">
|
||||||
|
<meta property="og:image:width" content="${image.w}">
|
||||||
|
<meta property="og:image:height" content="${image.h}">
|
||||||
|
${image.alt ? r(html`<meta property="og:image:alt" content="${image.alt}">`) : ''}
|
||||||
|
`)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="${image ? 'summary' : 'summary_large_image'}">
|
||||||
|
<meta name="twitter:title" content="${title}">
|
||||||
|
<meta name="twitter:description" content="${description}">
|
||||||
|
${
|
||||||
|
image
|
||||||
|
? r(html`
|
||||||
|
<meta name="twitter:image" content="${image.url}">
|
||||||
|
${image.alt ? r(html`<meta property="twitter:image:alt" content="${image.alt}">`) : ''}
|
||||||
|
`)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`.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 = '<!--server-generated-meta-->' 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<Required<Pick<NostrMetadata, 'name' | 'about'>>> => {
|
||||||
|
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<StatusInfo> => {
|
||||||
|
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<string> => {
|
||||||
|
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 = <E extends Env>(
|
||||||
|
options: ServeStaticOptions<E>,
|
||||||
|
): 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);
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Add table
Reference in a new issue