Files
gerbeur/api/middleware/og.ts

188 lines
6.2 KiB
TypeScript

/**
* 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, "&lt;")
.replace(/>/g, "&gt;");
}
function buildTags(meta: OGMeta): string {
const card = meta.imageUrl && meta.imageIsWide
? "summary_large_image"
: "summary";
const tags = [
`<title>${escapeAttr(meta.title)}</title>`,
`<meta property="og:site_name" content="${escapeAttr(OG_SITE_NAME)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${escapeAttr(meta.url)}" />`,
`<meta property="og:title" content="${escapeAttr(meta.title)}" />`,
`<meta name="twitter:card" content="${card}" />`,
];
if (meta.description) {
tags.push(
`<meta name="description" content="${escapeAttr(meta.description)}" />`,
`<meta property="og:description" content="${
escapeAttr(meta.description)
}" />`,
);
}
if (meta.imageUrl) {
tags.push(
`<meta property="og:image" content="${escapeAttr(meta.imageUrl)}" />`,
);
}
return tags.join("\n ");
}
function inject(html: string, meta: OGMeta): string {
return html
.replace(/<title>[^<]*<\/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 !== "user" && 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);
}