/** * Gives links to the site rich previews in chat apps and social sites, which * don't run the app. It answers a link to a dump, a profile or a public * playlist with `index.html` carrying that page's Open Graph and Twitter card * tags. main.ts installs it before the routers. * * @module */ import { Context, Next } from "@oak/oak"; import { getDump } from "../services/dump-service.ts"; import { getUserByUsername } from "../services/user-service.ts"; import { getPlaylistById } from "../services/playlist-service.ts"; import { FRONTEND_URL, OG_SITE_NAME, PUBLIC_URL } from "../config.ts"; import { loadIndexHtml } from "../lib/index-html.ts"; interface OGMeta { title: string; description?: string; imageUrl?: string; /** * Set for a dump's artwork only. It asks for the large card, which would * stretch a square icon or avatar. */ imageIsWide?: boolean; url: string; } function escapeAttr(s: string): string { return s .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } function buildTags(meta: OGMeta): string { const card = meta.imageUrl && meta.imageIsWide ? "summary_large_image" : "summary"; const tags = [ `${escapeAttr(meta.title)}`, ``, ``, ``, ``, ``, ]; if (meta.description) { tags.push( ``, ``, ); } if (meta.imageUrl) { tags.push( ``, ); } return tags.join("\n "); } function inject(html: string, meta: OGMeta): string { return html .replace(/[^<]*<\/title>/, "") .replace("</head>", ` ${buildTags(meta)}\n </head>`); } /** * Returns the filled `index.html` of dist/, or else the source one, which is * all there is in development. Returns null when neither can be read. */ async function findIndexHtml(): Promise<string | null> { for (const root of [`${Deno.cwd()}/dist`, Deno.cwd()]) { try { return await loadIndexHtml(root); } catch { continue; } } return null; } const DUMP_RE = /^\/dumps\/([^/]+)$/; /** Matches a profile, and its tabs under `/~/<tab>`. */ const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/; const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; /** * Serves `index.html` with the tags of the dump, user or public playlist a * path names, and passes the request on when it names none that exists or * the visitor may see. The page's URL is on `FRONTEND_URL` and the images' on * `PUBLIC_URL`, not on the request's origin, which a reverse proxy may have * rewritten to an internal host or plain http. */ export async function ogMiddleware( ctx: Context, next: Next, ): Promise<unknown> { const { pathname } = ctx.request.url; if (pathname.startsWith("/api/") || pathname.includes(".")) { return await next(); } const origin = PUBLIC_URL; const pageUrl = `${FRONTEND_URL}${pathname}`; let meta: OGMeta | null = null; const dumpMatch = pathname.match(DUMP_RE); const userMatch = pathname.match(USER_RE); const playlistMatch = pathname.match(PLAYLIST_RE); if (dumpMatch) { try { const dump = getDump(dumpMatch[1]); let imageUrl: string | undefined; if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) { imageUrl = `${origin}/api/files/${dump.id}`; } else if (dump.richContent?.thumbnailUrl) { imageUrl = dump.richContent.thumbnailUrl; } // Without artwork, the site's icon: the linked site's favicon is too // small for a card, and may refuse hotlinking. meta = { title: dump.title, description: dump.comment, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, imageIsWide: !!imageUrl, url: pageUrl, }; } catch { /* Unknown, or private. */ } } else if (userMatch) { try { const user = getUserByUsername(userMatch[1]); const imageUrl = user.avatarMime ? `${origin}/api/avatars/${user.id}` : undefined; meta = { title: user.username, description: user.description, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, url: pageUrl, }; } catch { /* Unknown. */ } } else if (playlistMatch) { try { const playlist = getPlaylistById(playlistMatch[1]); // getPlaylistById doesn't check visibility, unlike getDump. if (playlist.isPublic) { const imageUrl = playlist.imageMime ? `${origin}/api/playlists/${playlist.id}/image` : undefined; meta = { title: playlist.title, description: playlist.description, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, url: pageUrl, }; } } catch { /* Unknown. */ } } if (!meta) return await next(); const html = await findIndexHtml(); if (!html) return await next(); ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); ctx.response.body = inject(html, meta); }