Files
image-platform/apps/gateway/src/server.ts
2026-05-12 07:54:32 +03:00

604 lines
17 KiB
TypeScript

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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#191927"/><circle cx="24" cy="24" r="9" fill="#7b4cff"/><path d="M12 48 28 32l10 10 6-7 10 13H12Z" fill="#ffffff"/></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<ImageCacheKeyInput, "assetId" | "version"> & {
project: string
sourceUrl: string
}
type RemoteSourceValidationOptions = {
allowedHosts: ReadonlySet<string>
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<typeof selectFormatForAccept>[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<typeof normalizeImageTransform>[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}`
}