188 lines
6.1 KiB
TypeScript
188 lines
6.1 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, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
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;
|
|
|
|
if (pathname.startsWith("/api/") || pathname.includes(".")) {
|
|
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);
|
|
}
|