import Fastify, { type FastifyReply } from "fastify" import { ImageTransformConfigError, isAllowedSourceHost, loadAllowedSourceHostsFromEnv, normalizeImageTransform, parseBooleanFlag, selectFormatForAccept, type ActualImageFormat, type ResizeMode, } from "@image-platform/image-config" import { createHash } from "node:crypto" import { isIP } from "node:net" import type { GatewayConfig } from "./config.js" import { ImageMemoryCache, type CachedImage } from "./image-cache.js" import { proxyToUpstream } from "./proxy.js" const FAVICON_SVG = `` export function createGatewayServer(config: GatewayConfig) { const app = Fastify({ logger: true, }) const imageCache = new ImageMemoryCache(config.l1MaxEntries, config.l1TtlMs) const allowCustomTransforms = parseBooleanFlag(process.env.IMAGE_ALLOW_CUSTOM_TRANSFORMS, false) const allowedSourceHosts = loadAllowedSourceHostsFromEnv() const allowUnregisteredHosts = parseBooleanFlag(process.env.SOURCE_HOST_ALLOW_ALL, false) const allowPrivateSourceNetworks = parseBooleanFlag(process.env.SOURCE_ALLOW_PRIVATE_NETWORKS, false) app.get("/health", async () => ({ service: "image-platform-gateway", status: "ok", })) app.get("/favicon.ico", async (_request, reply) => reply .code(200) .header("cache-control", "no-store") .header("content-type", "image/svg+xml; charset=utf-8") .send(FAVICON_SVG), ) app.get("/favicon.svg", async (_request, reply) => reply .code(200) .header("cache-control", "no-store") .header("content-type", "image/svg+xml; charset=utf-8") .send(FAVICON_SVG), ) app.get<{ Params: ImageParams; Querystring: ImageQuery }>( "/images/:assetId/:version/:preset", async (request, reply) => { const version = parseVersionParam(request.params.version) if (version === null) { return reply.code(400).send({ message: "image version must use v{number} format", statusCode: 400, }) } const width = parseOptionalInteger(request.query.w) const height = parseOptionalNonNegativeInteger(request.query.h) const quality = parseOptionalInteger(request.query.q) const resize = parseResizeMode(request.query.fit) if ( (request.query.w !== undefined && width === null) || (request.query.h !== undefined && height === null) || (request.query.q !== undefined && quality === null) ) { return reply.code(400).send({ message: "w, h and q query params must be positive integers", statusCode: 400, }) } if (request.query.fit !== undefined && resize === null) { return reply.code(400).send({ message: "fit query param must be fit or fill", statusCode: 400, }) } const format = selectFormat({ acceptHeader: request.headers.accept, allowCustomTransforms, preset: request.params.preset, requestedFormat: request.query.f ?? "auto", }) if (!format.ok) { return reply.code(400).send({ message: format.message, statusCode: 400, }) } const transform = normalizeTransform({ allowCustomTransforms, format: format.value.format, height, preset: request.params.preset, quality, requestedFormat: format.value.requestedFormat, resize, width, }) if (!transform.ok) { return reply.code(400).send({ message: transform.message, statusCode: 400, }) } const cacheKey = buildImageCacheKey({ assetId: request.params.assetId, format: transform.value.format, height: transform.value.height, preset: transform.value.preset, quality: transform.value.quality, resize: transform.value.resize, version, width: transform.value.width, }) const cached = imageCache.get(cacheKey) const vary = transform.value.requestedFormat === "auto" ? "Accept" : null if (cached) { return sendImage(reply, cached, "HIT", vary) } const backendResponse = await fetch(new URL("/api/internal/images/ensure", config.backendUpstream), { body: JSON.stringify({ assetId: request.params.assetId, format: transform.value.format, height: transform.value.height, preset: transform.value.preset, quality: transform.value.quality, requestedFormat: transform.value.requestedFormat, resize: transform.value.resize, version, width: transform.value.width, }), headers: { "content-type": "application/json" }, method: "POST", }) if (!backendResponse.ok) { const body = await backendResponse.text() return reply .code(backendResponse.status) .header("cache-control", "no-store") .header("content-type", backendResponse.headers.get("content-type") ?? "text/plain; charset=utf-8") .send(body) } const image: CachedImage = { body: Buffer.from(await backendResponse.arrayBuffer()), cacheControl: backendResponse.headers.get("cache-control") ?? "public, max-age=31536000, immutable", contentType: backendResponse.headers.get("content-type") ?? contentTypeForFormat(transform.value.format), etag: backendResponse.headers.get("etag"), } imageCache.set(cacheKey, image) return sendImage(reply, image, "MISS", vary) }, ) app.get<{ Params: RemoteImageParams; Querystring: RemoteImageQuery }>( "/p/:project/remote/:preset", async (request, reply) => { if (!isSafeProjectSlug(request.params.project)) { return reply.code(400).send({ message: "project must be 3-128 chars and contain only letters, digits, _ or -", statusCode: 400, }) } const source = normalizeRemoteSourceUrl(request.query.src, { allowedHosts: allowedSourceHosts, allowPrivateNetworks: allowPrivateSourceNetworks, allowUnregisteredHosts, }) if (!source.ok) { return reply.code(400).send({ message: source.message, statusCode: 400, }) } const width = parseOptionalInteger(request.query.w) const height = parseOptionalNonNegativeInteger(request.query.h) const quality = parseOptionalInteger(request.query.q) const resize = parseResizeMode(request.query.fit) if ( (request.query.w !== undefined && width === null) || (request.query.h !== undefined && height === null) || (request.query.q !== undefined && quality === null) ) { return reply.code(400).send({ message: "w, h and q query params must be positive integers", statusCode: 400, }) } if (request.query.fit !== undefined && resize === null) { return reply.code(400).send({ message: "fit query param must be fit or fill", statusCode: 400, }) } const format = selectFormat({ acceptHeader: request.headers.accept, allowCustomTransforms, preset: request.params.preset, requestedFormat: request.query.f ?? "auto", }) if (!format.ok) { return reply.code(400).send({ message: format.message, statusCode: 400, }) } const transform = normalizeTransform({ allowCustomTransforms, format: format.value.format, height, preset: request.params.preset, quality, requestedFormat: format.value.requestedFormat, resize, width, }) if (!transform.ok) { return reply.code(400).send({ message: transform.message, statusCode: 400, }) } const cacheKey = buildRemoteImageCacheKey({ format: transform.value.format, height: transform.value.height, preset: transform.value.preset, project: request.params.project, quality: transform.value.quality, resize: transform.value.resize, sourceUrl: source.value, width: transform.value.width, }) const cached = imageCache.get(cacheKey) const vary = transform.value.requestedFormat === "auto" ? "Accept" : null if (cached) { return sendImage(reply, cached, "HIT", vary) } const imgproxyUrl = buildImgproxyUrl(config.imgproxyUpstream, source.value, { format: transform.value.format, height: transform.value.height, quality: transform.value.quality, resize: transform.value.resize, width: transform.value.width, }) const imgproxyResponse = await fetch(imgproxyUrl) if (!imgproxyResponse.ok) { return reply.code(502).header("cache-control", "no-store").send({ message: `imgproxy returned ${imgproxyResponse.status}`, statusCode: 502, }) } const image: CachedImage = { body: Buffer.from(await imgproxyResponse.arrayBuffer()), cacheControl: imgproxyResponse.headers.get("cache-control") ?? config.remoteCacheControl, contentType: imgproxyResponse.headers.get("content-type") ?? contentTypeForFormat(transform.value.format), etag: imgproxyResponse.headers.get("etag"), } imageCache.set(cacheKey, image) return sendImage(reply, image, "MISS", vary) }, ) app.all("/api/*", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream)) app.all("/docs", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream)) app.all("/docs/*", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream)) app.all("/docs-json", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream)) app.setNotFoundHandler(async (_request, reply) => { return reply.code(404).send({ message: "route not found", statusCode: 404, }) }) return app } type ImageQuery = { f?: string fit?: string h?: string q?: string w?: string } type RemoteImageQuery = ImageQuery & { src?: string } type ImageCacheKeyInput = { assetId: string format: ActualImageFormat height: number preset: string quality: number resize: ResizeMode version: number width: number } type ImageParams = { assetId: string preset: string version: string } type RemoteImageParams = { preset: string project: string } type RemoteImageCacheKeyInput = Omit & { project: string sourceUrl: string } type RemoteSourceValidationOptions = { allowedHosts: ReadonlySet allowPrivateNetworks: boolean allowUnregisteredHosts: boolean } function parseVersionParam(value: string) { if (!value.startsWith("v")) { return null } const parsed = Number.parseInt(value.slice(1), 10) return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null } function parseOptionalInteger(value: string | undefined) { if (!value) { return null } if (!/^\d+$/.test(value)) { return null } const parsed = Number.parseInt(value, 10) return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null } function parseOptionalNonNegativeInteger(value: string | undefined) { if (!value) { return null } if (!/^\d+$/.test(value)) { return null } const parsed = Number.parseInt(value, 10) return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null } function parseResizeMode(value: string | undefined): ResizeMode | null { if (value === undefined) { return null } return value === "fit" || value === "fill" ? value : null } function selectFormat(input: Parameters[0]) { try { return { ok: true as const, value: selectFormatForAccept(input) } } catch (error) { if (error instanceof ImageTransformConfigError) { return { message: error.message, ok: false as const } } throw error } } function buildImageCacheKey(input: ImageCacheKeyInput) { return [ input.assetId, input.version, input.preset, input.width, input.height, input.resize, input.quality, input.format, ].join(":") } function buildRemoteImageCacheKey(input: RemoteImageCacheKeyInput) { const sourceHash = createHash("sha256").update(input.sourceUrl).digest("hex").slice(0, 32) return [ "remote", input.project, sourceHash, input.preset, input.width, input.height, input.resize, input.quality, input.format, ].join(":") } function normalizeRemoteSourceUrl(value: string | undefined, options: RemoteSourceValidationOptions) { if (!value) { return { message: "src query param is required", ok: false as const } } if (value.length > 4096) { return { message: "src query param is too long", ok: false as const } } let url: URL try { url = new URL(value) } catch { return { message: "src must be an absolute http or https URL", ok: false as const } } if (url.protocol !== "http:" && url.protocol !== "https:") { return { message: "src must use http or https protocol", ok: false as const } } if (url.username || url.password) { return { message: "src must not contain credentials", ok: false as const } } if (!options.allowPrivateNetworks && isPrivateSourceHostname(url.hostname)) { return { message: "src host must not be localhost or private network address", ok: false as const } } if (!options.allowUnregisteredHosts && !isAllowedSourceHost(url.hostname, options.allowedHosts)) { return { message: "src host is not allowed", ok: false as const } } url.hash = "" return { ok: true as const, value: url.toString() } } function isSafeProjectSlug(value: string) { return /^[a-zA-Z0-9_-]{3,128}$/.test(value) } function isPrivateSourceHostname(hostname: string) { const normalized = hostname.toLowerCase() if (normalized === "localhost" || normalized.endsWith(".localhost")) { return true } if (isIP(normalized) === 4) { return isPrivateIpv4(normalized) } if (isIP(normalized) === 6) { return isPrivateIpv6(normalized) } return false } function isPrivateIpv4(value: string) { const parts = value.split(".").map((part) => Number.parseInt(part, 10)) const [first, second] = parts if (first === undefined || second === undefined) { return true } return ( first === 0 || first === 10 || first === 127 || (first === 169 && second === 254) || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) ) } function isPrivateIpv6(value: string) { const normalized = value.toLowerCase() return normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") } function buildImgproxyUrl( upstream: URL, sourceUrl: string, options: { format: ActualImageFormat height: number quality: number resize: ResizeMode width: number }, ) { const url = new URL(upstream) const encodedSource = Buffer.from(sourceUrl).toString("base64url") url.pathname = joinUrlPath( url.pathname, "insecure", `rs:${options.resize}:${options.width}:${options.height}`, `q:${options.quality}`, `${encodedSource}.${options.format}`, ) return url } function joinUrlPath(...segments: string[]) { return segments .flatMap((segment) => segment.split("/")) .filter(Boolean) .map(encodePathSegment) .join("/") .replace(/^/, "/") } function encodePathSegment(segment: string) { return segment.includes(":") ? segment : encodeURIComponent(segment) } function normalizeTransform(input: Parameters[0]) { try { return { ok: true as const, value: normalizeImageTransform(input) } } catch (error) { if (error instanceof ImageTransformConfigError) { return { message: error.message, ok: false as const } } throw error } } function sendImage(reply: FastifyReply, image: CachedImage, cacheStatus: string, vary: string | null) { if (image.etag) { reply.header("etag", image.etag) } if (vary) { reply.header("vary", vary) } return reply .code(200) .header("cache-control", image.cacheControl) .header("content-length", image.body.length.toString()) .header("content-type", image.contentType) .header("x-image-platform-l1", cacheStatus) .send(image.body) } function contentTypeForFormat(format: ActualImageFormat) { if (format === "jpg") { return "image/jpeg" } return `image/${format}` }