2026-03-22 03:47:54 +08:00
|
|
|
import { randomUUID } from "node:crypto";
|
2026-06-13 10:17:13 +08:00
|
|
|
import { extname } from "node:path";
|
2026-03-25 09:27:12 +08:00
|
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
2026-04-11 23:27:44 +08:00
|
|
|
import sharp from "sharp";
|
2026-05-12 22:13:58 +08:00
|
|
|
import { readImageDimensions } from "../lib/exiftool.js";
|
2026-03-22 03:47:54 +08:00
|
|
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
2026-03-23 11:46:45 +08:00
|
|
|
import { sanitizeFilename } from "../lib/filename.js";
|
2026-04-21 09:59:57 +08:00
|
|
|
import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
|
2026-04-11 23:27:44 +08:00
|
|
|
import { decodeHeic } from "../lib/heic-converter.js";
|
2026-06-13 10:17:13 +08:00
|
|
|
import { getObjectSize, getObjectStream, putObject } from "../lib/object-storage.js";
|
2026-04-30 16:11:33 +08:00
|
|
|
import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
|
2026-03-22 03:47:54 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Guard against path traversal in URL params.
|
|
|
|
|
*/
|
|
|
|
|
function isPathTraversal(segment: string): boolean {
|
|
|
|
|
return (
|
|
|
|
|
segment.includes("..") ||
|
|
|
|
|
segment.includes("/") ||
|
|
|
|
|
segment.includes("\\") ||
|
|
|
|
|
segment.includes("\0")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fileRoutes(app: FastifyInstance): Promise<void> {
|
|
|
|
|
// ── POST /api/v1/upload ────────────────────────────────────────
|
2026-05-13 21:33:50 +08:00
|
|
|
app.post(
|
|
|
|
|
"/api/v1/upload",
|
|
|
|
|
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
|
|
|
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
|
|
|
const jobId = randomUUID();
|
2026-03-22 03:47:54 +08:00
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
const uploadedFiles: Array<{
|
|
|
|
|
name: string;
|
|
|
|
|
size: number;
|
|
|
|
|
format: string;
|
|
|
|
|
}> = [];
|
2026-03-22 03:47:54 +08:00
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
const parts = request.parts();
|
2026-03-22 03:47:54 +08:00
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
for await (const part of parts) {
|
|
|
|
|
// Skip non-file fields
|
|
|
|
|
if (part.type !== "file") continue;
|
2026-03-22 03:47:54 +08:00
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
// Consume buffer from the stream
|
|
|
|
|
const chunks: Buffer[] = [];
|
|
|
|
|
for await (const chunk of part.file) {
|
|
|
|
|
chunks.push(chunk);
|
|
|
|
|
}
|
|
|
|
|
const buffer = Buffer.concat(chunks);
|
2026-03-22 03:47:54 +08:00
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
// Skip empty parts (e.g. empty file field)
|
|
|
|
|
if (buffer.length === 0) continue;
|
2026-03-22 03:47:54 +08:00
|
|
|
|
2026-06-16 15:48:07 +08:00
|
|
|
// Try image validation; non-image files are accepted with format from extension
|
|
|
|
|
const validation = await validateImageBuffer(buffer, part.filename).catch(() => null);
|
|
|
|
|
const isValidImage = validation?.valid === true;
|
2026-05-13 21:33:50 +08:00
|
|
|
|
|
|
|
|
// Sanitize SVG uploads to prevent XXE, SSRF, and script injection
|
2026-06-16 15:48:07 +08:00
|
|
|
const safeBuffer = isValidImage && isSvgBuffer(buffer) ? sanitizeSvg(buffer) : buffer;
|
2026-05-13 21:33:50 +08:00
|
|
|
|
2026-06-13 10:17:13 +08:00
|
|
|
// Sanitize filename (canonical; do NOT re-sanitize downstream)
|
2026-05-13 21:33:50 +08:00
|
|
|
const safeName = sanitizeFilename(part.filename ?? "upload");
|
|
|
|
|
|
2026-06-13 10:17:13 +08:00
|
|
|
// Write to object storage uploads prefix
|
|
|
|
|
await putObject(`uploads/${jobId}/${safeName}`, safeBuffer);
|
2026-05-13 21:33:50 +08:00
|
|
|
|
2026-06-16 15:48:07 +08:00
|
|
|
const fileExt = safeName.split(".").pop()?.toLowerCase() ?? "";
|
2026-05-13 21:33:50 +08:00
|
|
|
uploadedFiles.push({
|
|
|
|
|
name: safeName,
|
|
|
|
|
size: safeBuffer.length,
|
2026-06-16 15:48:07 +08:00
|
|
|
format: isValidImage ? validation.format : fileExt,
|
2026-03-22 03:47:54 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
if (uploadedFiles.length === 0) {
|
|
|
|
|
return reply.status(400).send({ error: "No valid files uploaded" });
|
|
|
|
|
}
|
2026-04-30 16:11:33 +08:00
|
|
|
|
2026-05-13 21:33:50 +08:00
|
|
|
return reply.send({
|
|
|
|
|
jobId,
|
|
|
|
|
files: uploadedFiles,
|
2026-03-22 03:47:54 +08:00
|
|
|
});
|
2026-05-13 21:33:50 +08:00
|
|
|
},
|
|
|
|
|
);
|
2026-03-22 03:47:54 +08:00
|
|
|
|
|
|
|
|
// ── GET /api/v1/download/:jobId/:filename ──────────────────────
|
|
|
|
|
app.get(
|
|
|
|
|
"/api/v1/download/:jobId/:filename",
|
|
|
|
|
async (
|
|
|
|
|
request: FastifyRequest<{
|
|
|
|
|
Params: { jobId: string; filename: string };
|
|
|
|
|
}>,
|
|
|
|
|
reply: FastifyReply,
|
|
|
|
|
) => {
|
|
|
|
|
const { jobId, filename } = request.params;
|
|
|
|
|
|
|
|
|
|
// Guard against path traversal
|
|
|
|
|
if (isPathTraversal(jobId) || isPathTraversal(filename)) {
|
|
|
|
|
return reply.status(400).send({ error: "Invalid path" });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 10:17:13 +08:00
|
|
|
// Resolve from object storage: outputs/ first, then uploads/
|
|
|
|
|
let key = `outputs/${jobId}/${filename}`;
|
|
|
|
|
let size: number;
|
2026-03-22 03:47:54 +08:00
|
|
|
try {
|
2026-06-13 10:17:13 +08:00
|
|
|
size = await getObjectSize(key);
|
2026-03-22 03:47:54 +08:00
|
|
|
} catch {
|
2026-06-13 10:17:13 +08:00
|
|
|
key = `uploads/${jobId}/${filename}`;
|
2026-03-22 03:47:54 +08:00
|
|
|
try {
|
2026-06-13 10:17:13 +08:00
|
|
|
size = await getObjectSize(key);
|
2026-03-22 03:47:54 +08:00
|
|
|
} catch {
|
|
|
|
|
return reply.status(404).send({ error: "File not found" });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ext = extname(filename).toLowerCase().replace(/^\./, "");
|
|
|
|
|
const contentType = getContentType(ext);
|
|
|
|
|
|
2026-06-13 10:17:13 +08:00
|
|
|
// Check Range header before setting content headers (a 416 must not
|
|
|
|
|
// carry the attachment Content-Type that would confuse serialization).
|
|
|
|
|
reply.header("Accept-Ranges", "bytes");
|
|
|
|
|
|
|
|
|
|
const range = request.headers.range;
|
|
|
|
|
if (range) {
|
|
|
|
|
const m = range.match(/^bytes=(\d+)-(\d*)$/);
|
|
|
|
|
const start = m ? Number.parseInt(m[1], 10) : Number.NaN;
|
|
|
|
|
const end = m?.[2] ? Number.parseInt(m[2], 10) : size - 1;
|
|
|
|
|
if (!m || Number.isNaN(start) || start >= size || end < start) {
|
|
|
|
|
return reply
|
|
|
|
|
.code(416)
|
|
|
|
|
.header("Content-Range", `bytes */${size}`)
|
|
|
|
|
.send({ error: "Range not satisfiable" });
|
|
|
|
|
}
|
|
|
|
|
const clampedEnd = Math.min(end, size - 1);
|
|
|
|
|
return reply
|
|
|
|
|
.code(206)
|
|
|
|
|
.header("Content-Type", contentType)
|
|
|
|
|
.header(
|
|
|
|
|
"Content-Disposition",
|
|
|
|
|
`attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
|
|
|
)
|
|
|
|
|
.header("Content-Range", `bytes ${start}-${clampedEnd}/${size}`)
|
|
|
|
|
.header("Content-Length", String(clampedEnd - start + 1))
|
|
|
|
|
.send(await getObjectStream(key, { start, end: clampedEnd }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reply
|
2026-03-22 03:47:54 +08:00
|
|
|
.header("Content-Type", contentType)
|
2026-06-06 16:05:59 +08:00
|
|
|
.header(
|
|
|
|
|
"Content-Disposition",
|
|
|
|
|
`attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
|
|
|
)
|
2026-06-13 10:17:13 +08:00
|
|
|
.header("Content-Length", String(size));
|
|
|
|
|
return reply.send(await getObjectStream(key));
|
2026-03-22 03:47:54 +08:00
|
|
|
},
|
|
|
|
|
);
|
2026-04-11 23:27:44 +08:00
|
|
|
|
|
|
|
|
// ── POST /api/v1/preview ──────────────────────────────────────
|
|
|
|
|
// Returns a WebP preview for formats browsers can't display (HEIC/HEIF).
|
|
|
|
|
app.post("/api/v1/preview", async (request: FastifyRequest, reply: FastifyReply) => {
|
|
|
|
|
const data = await request.file();
|
|
|
|
|
if (!data) {
|
|
|
|
|
return reply.status(400).send({ error: "No file provided" });
|
|
|
|
|
}
|
2026-05-12 22:13:58 +08:00
|
|
|
const originalBuffer = await data.toBuffer();
|
|
|
|
|
let buffer = originalBuffer;
|
|
|
|
|
const ext = data.filename?.split(".").pop()?.toLowerCase();
|
2026-04-11 23:27:44 +08:00
|
|
|
|
2026-04-21 09:59:57 +08:00
|
|
|
const validation = await validateImageBuffer(buffer, data.filename);
|
2026-04-11 23:27:44 +08:00
|
|
|
if (!validation.valid) {
|
|
|
|
|
return reply.status(400).send({ error: validation.reason });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Decode HEIC/HEIF via system decoder
|
|
|
|
|
if (validation.format === "heif") {
|
|
|
|
|
try {
|
|
|
|
|
buffer = await decodeHeic(buffer);
|
|
|
|
|
} catch {
|
|
|
|
|
return reply.status(422).send({ error: "Failed to decode HEIC/HEIF file" });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 09:59:57 +08:00
|
|
|
// Decode CLI-decoded formats (RAW, PSD, TGA, EXR, HDR) via external tools
|
|
|
|
|
if (needsCliDecode(validation.format)) {
|
|
|
|
|
try {
|
|
|
|
|
buffer = await decodeToSharpCompat(buffer, validation.format);
|
|
|
|
|
} catch {
|
2026-05-11 18:00:59 +08:00
|
|
|
// CLI decoder unavailable -- try Sharp directly as fallback for preview
|
|
|
|
|
try {
|
|
|
|
|
await sharp(buffer).metadata();
|
|
|
|
|
} catch {
|
|
|
|
|
return reply.status(422).send({
|
|
|
|
|
error: `Failed to decode ${validation.format.toUpperCase()} file`,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-21 09:59:57 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 18:00:59 +08:00
|
|
|
try {
|
2026-05-12 22:13:58 +08:00
|
|
|
const preMeta = await sharp(buffer).metadata();
|
|
|
|
|
let origWidth = preMeta.width ?? 0;
|
|
|
|
|
let origHeight = preMeta.height ?? 0;
|
|
|
|
|
|
|
|
|
|
if (validation.format === "raw") {
|
|
|
|
|
const dims = await readImageDimensions(originalBuffer, ext);
|
|
|
|
|
if (dims) {
|
|
|
|
|
origWidth = dims.width;
|
|
|
|
|
origHeight = dims.height;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 18:00:59 +08:00
|
|
|
const webp = await sharp(buffer)
|
|
|
|
|
.resize(1200, 1200, { fit: "inside", withoutEnlargement: true })
|
|
|
|
|
.webp({ quality: 80 })
|
|
|
|
|
.toBuffer();
|
2026-05-12 22:13:58 +08:00
|
|
|
return reply
|
|
|
|
|
.header("Content-Type", "image/webp")
|
|
|
|
|
.header("X-Original-Width", String(origWidth))
|
|
|
|
|
.header("X-Original-Height", String(origHeight))
|
|
|
|
|
.send(webp);
|
2026-05-11 18:00:59 +08:00
|
|
|
} catch {
|
|
|
|
|
return reply.status(422).send({
|
|
|
|
|
error: `Failed to generate preview for ${validation.format.toUpperCase()} file`,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-11 23:27:44 +08:00
|
|
|
});
|
2026-03-22 03:47:54 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getContentType(ext: string): string {
|
|
|
|
|
const map: Record<string, string> = {
|
|
|
|
|
jpg: "image/jpeg",
|
|
|
|
|
jpeg: "image/jpeg",
|
|
|
|
|
png: "image/png",
|
|
|
|
|
webp: "image/webp",
|
|
|
|
|
gif: "image/gif",
|
|
|
|
|
bmp: "image/bmp",
|
|
|
|
|
tiff: "image/tiff",
|
|
|
|
|
tif: "image/tiff",
|
|
|
|
|
avif: "image/avif",
|
|
|
|
|
svg: "image/svg+xml",
|
2026-03-22 04:21:10 +08:00
|
|
|
pdf: "application/pdf",
|
|
|
|
|
zip: "application/zip",
|
|
|
|
|
ico: "image/x-icon",
|
|
|
|
|
json: "application/json",
|
2026-07-03 09:54:02 +08:00
|
|
|
csv: "text/csv",
|
|
|
|
|
tsv: "text/tab-separated-values",
|
|
|
|
|
txt: "text/plain",
|
|
|
|
|
md: "text/markdown",
|
|
|
|
|
markdown: "text/markdown",
|
|
|
|
|
html: "text/html",
|
|
|
|
|
htm: "text/html",
|
|
|
|
|
xml: "application/xml",
|
|
|
|
|
yaml: "application/yaml",
|
|
|
|
|
yml: "application/yaml",
|
|
|
|
|
mp4: "video/mp4",
|
|
|
|
|
m4v: "video/mp4",
|
|
|
|
|
mov: "video/quicktime",
|
|
|
|
|
webm: "video/webm",
|
|
|
|
|
mkv: "video/x-matroska",
|
|
|
|
|
avi: "video/x-msvideo",
|
|
|
|
|
"3gp": "video/3gpp",
|
|
|
|
|
flv: "video/x-flv",
|
|
|
|
|
wmv: "video/x-ms-wmv",
|
|
|
|
|
mpg: "video/mpeg",
|
|
|
|
|
mpeg: "video/mpeg",
|
|
|
|
|
ts: "video/mp2t",
|
|
|
|
|
mts: "video/mp2t",
|
|
|
|
|
m2ts: "video/mp2t",
|
|
|
|
|
ogv: "video/ogg",
|
|
|
|
|
mp3: "audio/mpeg",
|
|
|
|
|
wav: "audio/wav",
|
|
|
|
|
flac: "audio/flac",
|
|
|
|
|
aac: "audio/aac",
|
|
|
|
|
m4a: "audio/mp4",
|
|
|
|
|
ogg: "audio/ogg",
|
|
|
|
|
opus: "audio/opus",
|
|
|
|
|
wma: "audio/x-ms-wma",
|
|
|
|
|
aiff: "audio/aiff",
|
|
|
|
|
amr: "audio/amr",
|
|
|
|
|
ac3: "audio/ac3",
|
|
|
|
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
|
|
|
odt: "application/vnd.oasis.opendocument.text",
|
|
|
|
|
rtf: "application/rtf",
|
|
|
|
|
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
|
|
|
odp: "application/vnd.oasis.opendocument.presentation",
|
|
|
|
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
|
|
|
ods: "application/vnd.oasis.opendocument.spreadsheet",
|
|
|
|
|
epub: "application/epub+zip",
|
|
|
|
|
srt: "application/x-subrip",
|
|
|
|
|
vtt: "text/vtt",
|
2026-04-21 09:59:57 +08:00
|
|
|
jxl: "image/jxl",
|
|
|
|
|
dng: "image/x-adobe-dng",
|
|
|
|
|
cr2: "image/x-canon-cr2",
|
|
|
|
|
nef: "image/x-nikon-nef",
|
|
|
|
|
arw: "image/x-sony-arw",
|
|
|
|
|
orf: "image/x-olympus-orf",
|
|
|
|
|
rw2: "image/x-panasonic-rw2",
|
|
|
|
|
tga: "image/x-tga",
|
|
|
|
|
psd: "image/vnd.adobe.photoshop",
|
|
|
|
|
exr: "image/x-exr",
|
|
|
|
|
hdr: "image/vnd.radiance",
|
|
|
|
|
heic: "image/heic",
|
|
|
|
|
heif: "image/heif",
|
2026-03-22 03:47:54 +08:00
|
|
|
};
|
|
|
|
|
return map[ext] ?? "application/octet-stream";
|
|
|
|
|
}
|