head-vps/sharp/transformer.js

246 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}
});