191 lines
5.2 KiB
TypeScript
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);
|
|
}
|
|
}
|