All files / routes images.ts

100% Statements 42/42
98.41% Branches 62/63
100% Functions 3/3
100% Lines 42/42

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147                1x       1x                                 9x 9x             7x 1x   6x             1x   5x                     1x   4x             1x     3x                     2x   1x     1x 17x   17x 1x         16x           2x     14x 14x 14x   13x 1x               12x 17x 17x 7x 7x     7x     12x 12x 12x 12x   12x 12x 12x   12x   1x 1x          
// Image + media serving from R2 (local dev only)
// In deployed environments, assets are served directly from the R2 custom domain.
 
import { Hono } from "hono";
import { logger } from "../lib/logger";
 
type Env = { Bindings: CloudflareBindings };
 
const images = new Hono<Env>();
 
// Extension → content-type fallback when R2 metadata is missing.
// Avoids serving an MP3 as image/jpeg (silent decode failure in <audio>).
const MIME_BY_EXTENSION: Record<string, string> = {
	jpg: "image/jpeg",
	jpeg: "image/jpeg",
	png: "image/png",
	webp: "image/webp",
	gif: "image/gif",
	avif: "image/avif",
	svg: "image/svg+xml",
	mp3: "audio/mpeg",
	m4a: "audio/mp4",
	wav: "audio/wav",
	ogg: "audio/ogg",
	mp4: "video/mp4",
	webm: "video/webm",
};
 
function inferContentType(path: string): string | null {
	const ext = path.split(".").pop()?.toLowerCase() ?? "";
	return MIME_BY_EXTENSION[ext] ?? null;
}
 
// Magic-byte sniffing for content-addressed seed keys (`seed/<sha256>`)
// where neither R2 metadata nor a file extension is available. Limited to
// the formats the seed pool produces.
function sniffImageMime(bytes: Uint8Array): string | null {
	if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
		return "image/jpeg";
	}
	if (
		bytes.length >= 8 &&
		bytes[0] === 0x89 &&
		bytes[1] === 0x50 &&
		bytes[2] === 0x4e &&
		bytes[3] === 0x47
	) {
		return "image/png";
	}
	if (
		bytes.length >= 12 &&
		bytes[0] === 0x52 &&
		bytes[1] === 0x49 &&
		bytes[2] === 0x46 &&
		bytes[3] === 0x46 &&
		bytes[8] === 0x57 &&
		bytes[9] === 0x45 &&
		bytes[10] === 0x42 &&
		bytes[11] === 0x50
	) {
		return "image/webp";
	}
	if (
		bytes.length >= 6 &&
		bytes[0] === 0x47 &&
		bytes[1] === 0x49 &&
		bytes[2] === 0x46 &&
		bytes[3] === 0x38
	) {
		return "image/gif";
	}
	// AVIF: ISO-BMFF "ftyp" box at offset 4, major brand "avif" or "avis" at offset 8.
	if (
		bytes.length >= 12 &&
		bytes[4] === 0x66 &&
		bytes[5] === 0x74 &&
		bytes[6] === 0x79 &&
		bytes[7] === 0x70 &&
		bytes[8] === 0x61 &&
		bytes[9] === 0x76 &&
		bytes[10] === 0x69 &&
		(bytes[11] === 0x66 || bytes[11] === 0x73)
	) {
		return "image/avif";
	}
	return null;
}
 
images.get("/*", async (c) => {
	const path = c.req.path.replace("/api/images/", "");
 
	if (!path) {
		return c.json({ error: "Image path required" }, 400);
	}
 
	// Prevent path traversal attacks
	// Reject paths with .. sequences, absolute paths, or encoded traversals
	if (
		path.includes("..") ||
		path.startsWith("/") ||
		path.includes("%2e%2e") ||
		path.includes("%2E%2E")
	) {
		return c.json({ error: "Invalid path" }, 400);
	}
 
	try {
		const r2 = c.env.R2;
		const object = await r2.get(path);
 
		if (!object) {
			return c.json({ error: "Image not found" }, 404);
		}
 
		// Prefer R2 metadata, then extension lookup. If both miss (CAS-style
		// `seed/<sha256>` keys with no httpMetadata), buffer the body and
		// sniff magic bytes — `X-Content-Type-Options: nosniff` below blocks
		// browser-side detection, so the server must commit to a real type.
		let contentType =
			object.httpMetadata?.contentType || inferContentType(path);
		let body: ReadableStream | ArrayBuffer = object.body;
		if (!contentType) {
			const buf = await object.arrayBuffer();
			contentType =
				sniffImageMime(new Uint8Array(buf, 0, Math.min(16, buf.byteLength))) ||
				"application/octet-stream";
			body = buf;
		}
 
		const headers = new Headers();
		headers.set("Content-Type", contentType);
		headers.set("Cache-Control", "public, max-age=86400"); // Cache for 1 day
		headers.set("ETag", object.httpEtag);
		// Vary by Accept header so CDN caches different formats separately
		headers.set("Vary", "Accept");
		headers.set("X-Content-Type-Options", "nosniff");
		headers.set("Content-Disposition", "inline");
 
		return new Response(body, { headers });
	} catch (error) {
		logger.error("R2 image fetch error:", error);
		return c.json({ error: "Failed to retrieve image" }, 502);
	}
});
 
export default images;