246 lines
9.2 KiB
JavaScript
246 lines
9.2 KiB
JavaScript
// From nestarz/remove-background (api/transformer.js), without background removal: same API as
|
||
// sharp.elias.systems. server.js runs this handler on Node's http server.
|
||
import { URL } from "node:url";
|
||
import { randomUUID } from "node:crypto";
|
||
import { createWriteStream } from "node:fs";
|
||
import { readFile, rm } from "node:fs/promises";
|
||
import { tmpdir } from "node:os";
|
||
import { Readable, Transform } from "node:stream";
|
||
import { pipeline } from "node:stream/promises";
|
||
import { join } from "node:path";
|
||
|
||
import { lookup } from "mrmime";
|
||
import sharp from "sharp";
|
||
|
||
// Callers keep the results (sharp/ in S3, browser cache): no reuse here, and little RAM on this host.
|
||
sharp.cache(false);
|
||
sharp.concurrency(1);
|
||
|
||
// Sources are staged in /tmp and read by path, so libvips decodes sequentially
|
||
// from disk and RAM stays flat regardless of source size. Stream input would buffer ~2× the bytes.
|
||
const MAX_SOURCE_BYTES = 256 * 1024 * 1024;
|
||
const MAX_INPUT_PIXELS = 100_000_000;
|
||
const allowedSourceHosts = (process.env.ALLOWED_SOURCE_HOSTS ?? "")
|
||
.split(",")
|
||
.map((host) => host.trim())
|
||
.filter(Boolean);
|
||
const validateSource = (url) => {
|
||
if (
|
||
url.protocol !== "https:" ||
|
||
url.username ||
|
||
url.password ||
|
||
(url.port && url.port !== "443") ||
|
||
!allowedSourceHosts.some((host) =>
|
||
host.startsWith("*.") ? url.hostname.endsWith(host.slice(1)) : url.hostname === host,
|
||
)
|
||
) {
|
||
throw Object.assign(new Error("Image source is not allowed"), { statusCode: 403 });
|
||
}
|
||
};
|
||
|
||
const allowCors = (fn) => async (req, res) => {
|
||
// Anonymous images must keep the same CORS response across cache hits and origins.
|
||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||
res.setHeader("Cache-Control", "no-store");
|
||
res.setHeader("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS");
|
||
res.setHeader("Access-Control-Max-Age", "86400");
|
||
res.setHeader(
|
||
"Access-Control-Allow-Headers",
|
||
"X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version",
|
||
);
|
||
if (req.method === "OPTIONS") {
|
||
res.status(200).end();
|
||
return;
|
||
}
|
||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||
res.setHeader("Allow", "GET, HEAD, OPTIONS");
|
||
return res.status(405).send("Method not allowed");
|
||
}
|
||
return await fn(req, res);
|
||
};
|
||
|
||
const parseJSON = (value) => {
|
||
if (value === undefined || value === "") return null;
|
||
try {
|
||
return JSON.parse(value);
|
||
} catch {
|
||
throw Object.assign(new Error("Invalid image arguments"), { statusCode: 400 });
|
||
}
|
||
};
|
||
|
||
const bufferToStream = (buffer) => {
|
||
const stream = new Readable();
|
||
stream.push(buffer);
|
||
stream.push(null);
|
||
return stream;
|
||
};
|
||
|
||
// Header-only read (no pixel decode): rejects non-images and oversized dimensions with a status
|
||
// before any response bytes are sent, and bounds HEIC before the WASM decoder allocates RGBA pixels.
|
||
const probeSource = async (path, inputOptions) => {
|
||
try {
|
||
await sharp(path, { ...inputOptions, limitInputPixels: MAX_INPUT_PIXELS }).metadata();
|
||
} catch (error) {
|
||
throw error.message.includes("pixel limit")
|
||
? Object.assign(new Error("Image source is too large"), { statusCode: 413 })
|
||
: Object.assign(new Error("Unsupported image source"), { statusCode: 415 });
|
||
}
|
||
};
|
||
|
||
const decodeHeic = async (path) => {
|
||
const { default: heicDecode } = await import("heic-decode");
|
||
// Hand the decoded pixels straight to Sharp; no intermediate (pure-JS) JPEG encode.
|
||
const { width, height, data } = await heicDecode({ buffer: await readFile(path) });
|
||
return { input: data, options: { raw: { width, height, channels: 4 } } };
|
||
};
|
||
|
||
const convertToIco = async (stream) => {
|
||
const input = await stream
|
||
.toFormat("png")
|
||
.toColorspace("srgb")
|
||
.png({ palette: false })
|
||
.toBuffer();
|
||
const { default: icoEndec } = await import("ico-endec");
|
||
const icoBuffer = await icoEndec.encode([input]);
|
||
return bufferToStream(icoBuffer);
|
||
};
|
||
|
||
export default allowCors(async (req, res) => {
|
||
const { url: src, etag, options, to_ico, ...queryOptions } = req.query;
|
||
if (!src) return res.status(400).send("Image URL is required");
|
||
if (Object.values(req.query).some((value) => typeof value !== "string")) {
|
||
return res.status(400).send("Image arguments must be single values");
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const onClose = () => {
|
||
if (!res.writableFinished) controller.abort();
|
||
};
|
||
res.once("close", onClose);
|
||
let source;
|
||
let sourcePath;
|
||
let deadline;
|
||
try {
|
||
let url;
|
||
try {
|
||
url = new URL(src);
|
||
} catch {
|
||
return res.status(400).send("Invalid image URL");
|
||
}
|
||
validateSource(url);
|
||
const inputOptions = parseJSON(options);
|
||
const operations = Object.entries(queryOptions).map(([name, value]) => [
|
||
name,
|
||
parseJSON(value),
|
||
]);
|
||
const filename = url.pathname.split("/").pop() || "image";
|
||
const createPipeline = (image) => {
|
||
const instanceOptions = {
|
||
...inputOptions,
|
||
...image?.options,
|
||
limitInputPixels: MAX_INPUT_PIXELS,
|
||
};
|
||
const instance = image ? sharp(image.input, instanceOptions) : sharp(instanceOptions);
|
||
// Decoded HEIC pixels are opaque RGBA; keep the RGB output of the former JPEG intermediate.
|
||
if (image?.options.raw) instance.removeAlpha();
|
||
for (const [name, value] of operations) {
|
||
if (typeof instance[name] === "function") {
|
||
const args = Array.isArray(value) ? value : value ? [value] : [];
|
||
try {
|
||
instance[name](...args);
|
||
} catch {
|
||
throw Object.assign(new Error("Invalid image operation"), { statusCode: 400 });
|
||
}
|
||
}
|
||
}
|
||
return instance;
|
||
};
|
||
// Validate operations before fetching; the real pipeline reads the staged source file.
|
||
createPipeline();
|
||
|
||
deadline = setTimeout(() => controller.abort(new Error("Image source timed out")), 20_000);
|
||
let response;
|
||
for (let redirects = 0; ; redirects++) {
|
||
validateSource(url);
|
||
response = await fetch(url, { signal: controller.signal, redirect: "manual" });
|
||
if (![301, 302, 303, 307, 308].includes(response.status)) break;
|
||
// Release redirect bodies before validating/fetching the next location.
|
||
await response.body?.cancel();
|
||
const location = response.headers.get("location");
|
||
if (!location || redirects === 5) throw new Error("Invalid source redirect");
|
||
url = new URL(location, url);
|
||
}
|
||
if (!response.ok || !response.body) {
|
||
await response.body?.cancel();
|
||
throw new Error("Image source request failed");
|
||
}
|
||
const { headers } = response;
|
||
const stream = Readable.fromWeb(response.body);
|
||
source = stream;
|
||
if (Number(headers.get("content-length")) > MAX_SOURCE_BYTES) {
|
||
throw Object.assign(new Error("Image source is too large"), { statusCode: 413 });
|
||
}
|
||
let bytes = 0;
|
||
const bounded = new Transform({
|
||
transform(chunk, _encoding, callback) {
|
||
bytes += chunk.length;
|
||
callback(
|
||
bytes > MAX_SOURCE_BYTES
|
||
? Object.assign(new Error("Image source is too large"), { statusCode: 413 })
|
||
: null,
|
||
chunk,
|
||
);
|
||
},
|
||
});
|
||
sourcePath = join(tmpdir(), `source-${randomUUID()}`);
|
||
// The deadline covers the full download.
|
||
await pipeline(stream, bounded, createWriteStream(sourcePath), { signal: controller.signal });
|
||
clearTimeout(deadline);
|
||
await probeSource(sourcePath, inputOptions);
|
||
let image = { input: sourcePath, options: {} };
|
||
if (headers.get("content-type")?.includes("image/hei") || /\.hei(c|f)$/i.test(filename)) {
|
||
image = await decodeHeic(sourcePath);
|
||
}
|
||
if (controller.signal.aborted) throw controller.signal.reason;
|
||
const sharpInstance = createPipeline(image);
|
||
const formatOut = to_ico ? "ico" : sharpInstance.options.formatOut;
|
||
const newFilename =
|
||
formatOut === "input" ? filename : `${filename.replace(/\.[^.]*$/, "")}.${formatOut}`;
|
||
const output = to_ico ? await convertToIco(sharpInstance) : sharpInstance;
|
||
res.setHeader(
|
||
"Content-Type",
|
||
// mrmime has no entry for .ico.
|
||
to_ico ? "image/x-icon" : (lookup(newFilename) ?? "application/octet-stream"),
|
||
);
|
||
// An etag versions the URL, so browsers never need to revalidate it (not even on reload).
|
||
res.setHeader(
|
||
"Cache-Control",
|
||
etag ? "public, max-age=2592000, immutable" : "public, max-age=3600",
|
||
);
|
||
res.setHeader(
|
||
"Content-Disposition",
|
||
`inline; filename="${newFilename.replace(/["\\\r\n]/g, "_")}"`,
|
||
);
|
||
if (req.method === "HEAD") {
|
||
// Drain processing so source/encoder errors are observed before returning success.
|
||
for await (const _ of output) {
|
||
/* no response body for HEAD */
|
||
}
|
||
res.status(200).end();
|
||
} else {
|
||
await pipeline(output, res, { signal: controller.signal });
|
||
}
|
||
} catch (error) {
|
||
if (!res.headersSent && !res.destroyed) {
|
||
res.setHeader("Cache-Control", "no-store");
|
||
const status = error.statusCode ?? (controller.signal.aborted ? 504 : 502);
|
||
res.status(status).send(status < 500 ? error.message : "Error processing image");
|
||
} else if (!res.destroyed) res.destroy(error);
|
||
} finally {
|
||
clearTimeout(deadline);
|
||
source?.destroy();
|
||
controller.abort();
|
||
if (sourcePath) await rm(sourcePath, { force: true }).catch(() => {});
|
||
res.off("close", onClose);
|
||
}
|
||
});
|