All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
5.0 KiB
TypeScript
173 lines
5.0 KiB
TypeScript
import { Router } from "@oak/oak";
|
|
import {
|
|
fetchWithTimeout,
|
|
isValidHttpUrl,
|
|
tryFetchRichContent,
|
|
} from "../services/rich-content-service.ts";
|
|
import {
|
|
APIErrorCode,
|
|
type APIFailure,
|
|
type APIResponse,
|
|
type LinkPreview,
|
|
} from "../model/interfaces.ts";
|
|
import { authMiddleware } from "../middleware/auth.ts";
|
|
|
|
const previewRouter = new Router();
|
|
|
|
const PROXYABLE_IMAGE_TYPES = new Set([
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
"image/avif",
|
|
"image/bmp",
|
|
"image/x-icon",
|
|
"image/vnd.microsoft.icon",
|
|
]);
|
|
|
|
// Signed-in only: it's the create form's preview, and an open one lets anyone
|
|
// use the server to fetch pages for them.
|
|
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
|
const url = ctx.request.url.searchParams.get("url") ?? "";
|
|
if (!isValidHttpUrl(url)) {
|
|
const responseBody: APIFailure = {
|
|
success: false,
|
|
error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" },
|
|
};
|
|
ctx.response.status = 400;
|
|
ctx.response.body = responseBody;
|
|
return;
|
|
}
|
|
// `reached` is reported separately because a failed fetch still yields a
|
|
// usable stub (hostname only). Without the flag the create form cannot tell
|
|
// "this page has no preview" from "this link is dead", and shows the same
|
|
// bare card for both.
|
|
const { ok, content } = await tryFetchRichContent(url);
|
|
const responseBody: APIResponse<LinkPreview> = {
|
|
success: true,
|
|
data: { reached: ok, richContent: content ?? null },
|
|
};
|
|
ctx.response.body = responseBody;
|
|
});
|
|
|
|
// Proxied images, by URL: every viewer of a feed asks for the same
|
|
// thumbnails, which were fetched from their hosts again for each one. Bounded
|
|
// by total size; the least recently used go first.
|
|
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
const imageCache = new Map<
|
|
string,
|
|
{ bytes: Uint8Array; contentType: string; at: number }
|
|
>();
|
|
let imageCacheBytes = 0;
|
|
|
|
function readImageCache(
|
|
url: string,
|
|
): { bytes: Uint8Array; contentType: string } | undefined {
|
|
const hit = imageCache.get(url);
|
|
if (!hit) return undefined;
|
|
imageCache.delete(url);
|
|
if (Date.now() - hit.at > IMAGE_CACHE_TTL_MS) {
|
|
imageCacheBytes -= hit.bytes.byteLength;
|
|
return undefined;
|
|
}
|
|
imageCache.set(url, hit); // most recently used
|
|
return hit;
|
|
}
|
|
|
|
function writeImageCache(
|
|
url: string,
|
|
bytes: Uint8Array,
|
|
contentType: string,
|
|
): void {
|
|
const old = imageCache.get(url);
|
|
if (old) {
|
|
imageCache.delete(url);
|
|
imageCacheBytes -= old.bytes.byteLength;
|
|
}
|
|
while (
|
|
imageCacheBytes + bytes.byteLength > IMAGE_CACHE_MAX_BYTES &&
|
|
imageCache.size > 0
|
|
) {
|
|
const [oldestUrl, oldest] = imageCache.entries().next().value!;
|
|
imageCache.delete(oldestUrl);
|
|
imageCacheBytes -= oldest.bytes.byteLength;
|
|
}
|
|
imageCache.set(url, { bytes, contentType, at: Date.now() });
|
|
imageCacheBytes += bytes.byteLength;
|
|
}
|
|
|
|
/** Forget every proxied image (tests). */
|
|
export function clearImageCache(): void {
|
|
imageCache.clear();
|
|
imageCacheBytes = 0;
|
|
}
|
|
|
|
function sendImage(
|
|
ctx: { response: { headers: Headers; body?: unknown } },
|
|
bytes: Uint8Array,
|
|
contentType: string,
|
|
): void {
|
|
ctx.response.headers.set("Content-Type", contentType);
|
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
|
ctx.response.headers.set(
|
|
"Content-Security-Policy",
|
|
"sandbox; default-src 'none'",
|
|
);
|
|
ctx.response.headers.set("Cache-Control", "public, max-age=86400");
|
|
ctx.response.body = bytes;
|
|
}
|
|
|
|
/**
|
|
* Proxy an external image through the server so HTTP thumbnail URLs don't
|
|
* trigger mixed-content blocks when the frontend is served over HTTPS.
|
|
*/
|
|
previewRouter.get("/api/proxy-image", async (ctx) => {
|
|
const url = ctx.request.url.searchParams.get("url") ?? "";
|
|
if (!isValidHttpUrl(url)) {
|
|
ctx.response.status = 400;
|
|
return;
|
|
}
|
|
|
|
const cached = readImageCache(url);
|
|
if (cached) {
|
|
sendImage(ctx, cached.bytes, cached.contentType);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetchWithTimeout(url, 8000);
|
|
// Raster formats only. The bytes go out under this site's origin, so an
|
|
// SVG (which can carry script) or anything else a browser might execute
|
|
// would run with the app's origin if opened directly.
|
|
const contentType = (res.headers.get("content-type") ?? "")
|
|
.split(";")[0].trim().toLowerCase();
|
|
if (!PROXYABLE_IMAGE_TYPES.has(contentType)) {
|
|
await res.body?.cancel();
|
|
ctx.response.status = 400;
|
|
return;
|
|
}
|
|
|
|
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
const contentLength = Number(res.headers.get("content-length") ?? "0");
|
|
if (contentLength > MAX_SIZE) {
|
|
await res.body?.cancel();
|
|
ctx.response.status = 400;
|
|
return;
|
|
}
|
|
|
|
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
if (bytes.length > MAX_SIZE) {
|
|
ctx.response.status = 400;
|
|
return;
|
|
}
|
|
|
|
writeImageCache(url, bytes, contentType);
|
|
sendImage(ctx, bytes, contentType);
|
|
} catch {
|
|
ctx.response.status = 502;
|
|
}
|
|
});
|
|
|
|
export default previewRouter;
|