/** * Open Graph and Twitter card tags for shared links: a dump, user or public * playlist page is answered with `index.html` carrying that page's title, * description and image, so link unfurlers that don't run the app see them. * * @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 { OG_SITE_NAME } from "../config.ts"; import { loadIndexHtml } from "../lib/index-html.ts"; interface OGMeta { title: string; description?: string; imageUrl?: string; /** * True only for real artwork — a square site icon must not claim a wide * card. */ 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 the build, or of the working directory * when there is no build; null when there is neither. */ 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\/([^/]+)$/; /** A profile, with an optional `/~/<tab>` suffix so its tabs keep OG meta. */ const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/; const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; /** * Serves `index.html` with OG tags for a dump, user or public playlist page * that exists, and passes every other request on. */ 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 = ctx.request.url.origin; const pageUrl = `${origin}${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; } // No artwork: fall back to our own site icon rather than the target's // favicon, which is typically too small to survive a social card (and may // be hotlink-protected). meta = { title: dump.title, description: dump.comment, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, imageIsWide: !!imageUrl, url: pageUrl, }; } catch { /* not found or private — serve default */ } } 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 { /* not found */ } } else if (playlistMatch) { try { const playlist = getPlaylistById(playlistMatch[1]); 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 { /* not found or private */ } } 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); }