diff --git a/src/routes/index.ts b/src/routes/index.ts index cf41c9311d..0bac4e0756 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -4,6 +4,7 @@ import rss from './rss'; import alerts from './alerts'; import redirector from './redirector'; import devcards from './devcards'; +import shareImages from './shareImages'; import privateRoutes from './private'; import whoami from './whoami'; import notifications from './notifications'; @@ -49,6 +50,7 @@ export default async function (fastify: FastifyInstance): Promise { fastify.register(redirector, { prefix: '/r' }); fastify.register(emailTracking, { prefix: '/em/t' }); fastify.register(devcards, { prefix: '/devcards' }); + fastify.register(shareImages, { prefix: '/og' }); if (process.env.ENABLE_PRIVATE_ROUTES === 'true') { fastify.register(privateRoutes, { prefix: '/p' }); } diff --git a/src/routes/shareImages.ts b/src/routes/shareImages.ts new file mode 100644 index 0000000000..50086e0aeb --- /dev/null +++ b/src/routes/shareImages.ts @@ -0,0 +1,54 @@ +import { FastifyInstance, FastifyReply } from 'fastify'; +import { retryFetch } from '../integrations/retry'; +import { WEBAPP_MAGIC_IMAGE_PREFIX } from '../config'; + +// Contextual Open Graph share images. Mirrors the devcard v2 approach: render a +// real webapp page and screenshot it via the scraper — no Satori. Each type +// maps to /image-generator/share// on the webapp, captured at the +// `#screenshot_wrapper` element (sized to 1200×630 by the page). +const ALLOWED_TYPES = new Set([ + 'posts', + 'comments', + 'sources', + 'squads', + 'profile', + 'tags', + 'invite', + 'plus', +]); + +export default async function (fastify: FastifyInstance): Promise { + fastify.get<{ + Params: { type: string; name: string }; + Querystring: { userid?: string }; + }>('/:type/:name', async (req, res): Promise => { + const { type } = req.params; + const [id, format] = req.params.name.split('.'); + + if (!ALLOWED_TYPES.has(type) || format !== 'png' || !id) { + return res.status(404).send(); + } + + const url = new URL( + `${WEBAPP_MAGIC_IMAGE_PREFIX}/share/${type}/${encodeURIComponent(id)}`, + process.env.COMMENTS_PREFIX, + ); + // Forward the sharer for post-share attribution ("{name} shared"). + if (req.query?.userid) { + url.searchParams.set('userid', req.query.userid); + } + + const response = await retryFetch(`${process.env.SCRAPER_URL}/screenshot`, { + method: 'POST', + body: JSON.stringify({ url, selector: '#screenshot_wrapper' }), + headers: { 'content-type': 'application/json' }, + }); + + return res + .type(response.headers.get('content-type')!) + .header('cross-origin-opener-policy', 'cross-origin') + .header('cross-origin-resource-policy', 'cross-origin') + .header('cache-control', 'public, max-age=3600, s-maxage=3600') + .send(await response.buffer()); + }); +}