/** * 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, and a link by a former name with a 301 to the current one. main.ts * installs it before the routers. * * @module */ import { Context, Next } from "@oak/oak"; import { getDumpByPath } from "../services/dump-service.ts"; import { resolveUsername } from "../services/user-service.ts"; import { getPlaylistByPath } from "../services/playlist-service.ts"; import { dumpPath, parsePagePath, playlistPath, userPath, } from "../../shared/paths.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; } /** Answers `canonical` with a 301, keeping the request's query string. */ function redirect(ctx: Context, canonical: string): void { ctx.response.status = 301; // Revalidated each time: a slug taken back would otherwise loop through a // redirect the browser kept. ctx.response.headers.set("Cache-Control", "no-cache"); ctx.response.redirect(`${FRONTEND_URL}${canonical}${ctx.request.url.search}`); } /** * Serves `index.html` with the tags of the dump, user or public playlist a * path names (`parsePagePath`), and passes the request on when it names none * that exists or the visitor may see. A path naming one by a former name, a * user's or a slug, or in another case, gets a 301 to its current path. 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; // A file's path never starts with `@`, so parsePagePath passes it on, while // a remote user's has dots in it: `/@bob@music.example`. if (pathname.startsWith("/api/")) return await next(); const page = parsePagePath(pathname); if (!page || (page.kind === "dump" && page.edit)) return await next(); const origin = PUBLIC_URL; let meta: OGMeta | null = null; let canonical: string | null = null; try { const user = resolveUsername(page.username); if (page.kind === "dump") { const dump = getDumpByPath(user.id, page.slug); canonical = dumpPath(dump); let imageUrl: string | undefined; if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) { imageUrl = `${origin}/api/dumps/${dump.id}/file`; } 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: `${FRONTEND_URL}${canonical}`, }; } else if (page.kind === "user") { canonical = [userPath(user.username), ...page.rest].join("/"); const imageUrl = user.avatarMime ? `${origin}/api/users/${user.id}/avatar` : undefined; meta = { title: user.username, description: user.description, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, url: `${FRONTEND_URL}${canonical}`, }; } else { const playlist = getPlaylistByPath(user.id, page.slug); // getPlaylistByPath doesn't check visibility, unlike getDumpByPath. if (playlist.isPublic) { canonical = playlistPath(playlist); const imageUrl = playlist.imageMime ? `${origin}/api/playlists/${playlist.id}/cover` : undefined; meta = { title: playlist.title, description: playlist.description, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, url: `${FRONTEND_URL}${canonical}`, }; } } } catch { /* Unknown, or private. */ } if (!meta || !canonical) return await next(); if (canonical !== pathname.replace(/\/+$/, "")) { return redirect(ctx, canonical); } 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); }