Files
runners-calendar/backend/src/raceCoverImage.ts
Vakanaut a317c9b760
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
feat: complete p0 hardening sprint
2026-07-12 15:08:00 +03:00

191 lines
5.2 KiB
TypeScript

import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const IMAGE_META_KEYS = new Set([
"og:image",
"og:image:url",
"twitter:image",
"twitter:image:src",
]);
const FETCH_TIMEOUT_MS = 5_000;
const MAX_REDIRECTS = 3;
const MAX_HTML_BYTES = 1_000_000;
function getAttribute(tag: string, name: string): string | null {
const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, "i");
return tag.match(pattern)?.[1] ?? null;
}
function toHttpUrl(value: string, baseUrl: string): string | null {
try {
const url = new URL(value, baseUrl);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
function isPrivateIp(address: string): boolean {
if (isIP(address) === 4) {
const [first, second] = address.split(".").map(Number);
return first === 0 || first === 10 || first === 127 || first >= 224 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && (second === 0 || second === 168)) ||
(first === 198 && (second === 18 || second === 19));
}
const normalized = address.toLowerCase();
return normalized === "::" || normalized === "::1" || normalized.startsWith("::ffff:") ||
/^f[cd]/.test(normalized) || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff");
}
async function isSafePublicHttpUrl(value: string): Promise<boolean> {
const normalized = toHttpUrl(value, value);
if (!normalized) {
return false;
}
const hostname = new URL(normalized).hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
return false;
}
if (isIP(hostname)) {
return !isPrivateIp(hostname);
}
try {
const addresses = await lookup(hostname, { all: true, verbatim: true });
return addresses.length > 0 && addresses.every(({ address }) => !isPrivateIp(address));
} catch {
return false;
}
}
async function readHtml(response: Response): Promise<string | null> {
const contentLength = response.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_HTML_BYTES) {
return null;
}
const reader = response.body?.getReader();
if (!reader) {
return "";
}
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
return new TextDecoder().decode(Buffer.concat(chunks));
}
size += value.byteLength;
if (size > MAX_HTML_BYTES) {
await reader.cancel();
return null;
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
}
function isRuncRunUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname.toLowerCase();
return hostname === "runc.run" || hostname.endsWith(".runc.run");
} catch {
return false;
}
}
function findRuncIntroImage(html: string, baseUrl: string): string | null {
const introMatch = html.match(/<div\b[^>]*class=["'][^"']*\brun-intro__image\b[^"']*["'][^>]*>[\s\S]*?<img\b[^>]*>/i);
if (!introMatch) {
return null;
}
const src = getAttribute(introMatch[0], "src");
return src ? toHttpUrl(src, baseUrl) : null;
}
function findMetaImage(html: string, baseUrl: string): string | null {
const tags = html.match(/<meta\b[^>]*>/gi) ?? [];
for (const tag of tags) {
const key = (getAttribute(tag, "property") || getAttribute(tag, "name") || "").toLowerCase();
if (!IMAGE_META_KEYS.has(key)) {
continue;
}
const content = getAttribute(tag, "content");
if (!content) {
continue;
}
const imageUrl = toHttpUrl(content, baseUrl);
if (imageUrl) {
return imageUrl;
}
}
return null;
}
export function extractRaceCoverImageFromHtml(html: string, pageUrl: string): string | null {
if (isRuncRunUrl(pageUrl)) {
const runcImage = findRuncIntroImage(html, pageUrl);
if (runcImage) {
return runcImage;
}
}
return findMetaImage(html, pageUrl);
}
export async function extractRaceCoverImage(officialUrl: string): Promise<string | null> {
const normalizedUrl = toHttpUrl(officialUrl, officialUrl);
if (!normalizedUrl) {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
let pageUrl = normalizedUrl;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
if (!(await isSafePublicHttpUrl(pageUrl))) {
return null;
}
const response = await fetch(pageUrl, { redirect: "manual", signal: controller.signal });
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
const nextUrl = location ? toHttpUrl(location, pageUrl) : null;
if (!nextUrl) {
return null;
}
pageUrl = nextUrl;
continue;
}
if (!response.ok) {
return null;
}
const html = await readHtml(response);
return html == null ? null : extractRaceCoverImageFromHtml(html, pageUrl);
}
return null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}