sync
This commit is contained in:
@@ -1,29 +1,54 @@
|
||||
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) => {
|
||||
@@ -147,6 +172,130 @@ export function createGatewayServer(config: GatewayConfig) {
|
||||
},
|
||||
)
|
||||
|
||||
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))
|
||||
@@ -170,6 +319,10 @@ type ImageQuery = {
|
||||
w?: string
|
||||
}
|
||||
|
||||
type RemoteImageQuery = ImageQuery & {
|
||||
src?: string
|
||||
}
|
||||
|
||||
type ImageCacheKeyInput = {
|
||||
assetId: string
|
||||
format: ActualImageFormat
|
||||
@@ -187,6 +340,22 @@ type ImageParams = {
|
||||
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
|
||||
@@ -258,6 +427,143 @@ function buildImageCacheKey(input: ImageCacheKeyInput) {
|
||||
].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) }
|
||||
|
||||
Reference in New Issue
Block a user