UNPKG

express-controller-sets

Version:

Unified package for Express.js controller sets, dynamic routing, and S3 file uploads.

404 lines (347 loc) 13.6 kB
import path from "path"; import { ACTIVE_CONTENT_TYPES, INLINE_SAFE_TYPES, detectFileType, sharpFormatFor, } from "./utils/fileType.js"; /** * Conservative default. A consumer who needs more can widen it explicitly, which * makes accepting active content a decision someone made rather than a default * nobody noticed. */ const DEFAULT_ALLOWED_MIME_TYPES = [ "image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "application/pdf", ]; /** Formats sharp can re-encode without changing the container. */ const OPTIMIZABLE_FORMATS = ["jpeg", "png", "webp"]; const VALID_LEVELS = ["low", "medium", "med", "high"]; const DEFAULTS = { uploadPath: "files/", fields: [{ name: "file", maxCount: 1 }], acl: "private", allowedMimeTypes: DEFAULT_ALLOWED_MIME_TYPES, maxFileSize: 10 * 1024 * 1024, maxFiles: 10, imgOptimizations: undefined, allowClientImageOptions: false, }; // Cached module handles: these are heavy (sharp ships a native binary, the AWS SDK // is large) and must not load for consumers who only use the CRUD routes. let modulesPromise = null; const loadModules = () => { modulesPromise ??= Promise.all([ import("@aws-sdk/client-s3"), import("multer"), ]).then(([s3, multer]) => ({ s3, multer: multer.default ?? multer })); return modulesPromise; }; let sharpPromise = null; const loadSharp = () => { sharpPromise ??= import("sharp").then((mod) => mod.default ?? mod); return sharpPromise; }; /** * Reads S3 configuration at call time. * * Deliberately not cached at module scope: an app that loads its environment after * importing this package would otherwise be locked into a permanent 503 by a * snapshot taken before the variables existed. This module also does not call * `dotenv.config()` — loading environment is the host application's decision, not * a side effect a library imposes on it. */ const readConfig = () => { const config = { endpoint: process.env.S3_ENDPOINT, accessKeyId: process.env.S3_SPACES_KEY, secretAccessKey: process.env.S3_SPACES_SECRET, bucket: process.env.S3_BUCKET_NAME, region: process.env.S3_REGION || "us-east-1", }; const missing = ["endpoint", "accessKeyId", "secretAccessKey", "bucket"].filter( (key) => !config[key], ); return { config, missing }; }; let cachedClient = null; let cachedClientKey = null; const getClient = async (config) => { const key = `${config.endpoint}|${config.region}|${config.accessKeyId}`; if (cachedClient && cachedClientKey === key) return cachedClient; const { s3 } = await loadModules(); cachedClient = new s3.S3Client({ endpoint: config.endpoint, forcePathStyle: true, region: config.region, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, }, }); cachedClientKey = key; return cachedClient; }; /** * Utility to generate a backward-compatible public S3 URL of an object. */ function getS3Url(bucket, key) { const endpoint = process.env.S3_ENDPOINT || ""; const cleanEndpoint = endpoint.endsWith("/") ? endpoint.slice(0, -1) : endpoint; return `${cleanEndpoint}/${bucket}/${key}`; } /** * Iterative image compression. * Binary-searches encoder quality to land within a target size ratio. * * Accepts either a mime type (`image/jpeg`) or a sharp format name (`jpeg`). * Formats outside `OPTIMIZABLE_FORMATS` are returned untouched rather than * transcoded: silently turning a GIF into JPEG bytes loses animation and * desynchronises the stored extension from the actual content. */ async function compressImage(buffer, mimetypeOrFormat, level) { const input = String(mimetypeOrFormat ?? ""); const format = input.includes("/") ? sharpFormatFor(input) : input; if (!OPTIMIZABLE_FORMATS.includes(format)) return buffer; const originalSize = buffer.length; const normLevel = String(level).toLowerCase().trim(); let minRatio, maxRatio, startQuality; if (normLevel === "low") { minRatio = 0.75; maxRatio = 0.8; startQuality = 80; } else if (normLevel === "medium" || normLevel === "med") { minRatio = 0.6; maxRatio = 0.65; startQuality = 65; } else if (normLevel === "high") { if (originalSize > 1 * 1024 * 1024) { // For files > 1MB, scale target ratios so 1MB targets 400-450KB and 5MB targets 600-700KB const sizeInMB = originalSize / (1024 * 1024); const targetMinKB = 400 + (sizeInMB - 1) * ((600 - 400) / (5 - 1)); const targetMaxKB = 450 + (sizeInMB - 1) * ((700 - 450) / (5 - 1)); minRatio = (targetMinKB * 1024) / originalSize; maxRatio = (targetMaxKB * 1024) / originalSize; startQuality = 30; } else { minRatio = 0.4; maxRatio = 0.45; startQuality = 45; } } else { return buffer; } const sharp = await loadSharp(); const minQuality = 25; const maxQuality = 95; let lowQ = minQuality; let highQ = maxQuality; let currentQuality = startQuality; let bestBuffer = buffer; let bestDiff = Infinity; for (let i = 0; i < 7; i++) { let compressed; try { const pipeline = sharp(buffer); if (format === "png") { compressed = await pipeline.png({ quality: currentQuality, palette: true }).toBuffer(); } else if (format === "webp") { compressed = await pipeline.webp({ quality: currentQuality }).toBuffer(); } else { compressed = await pipeline.jpeg({ quality: currentQuality }).toBuffer(); } } catch (err) { console.warn( `[ControllerSet-S3] Unable to optimize '${format}': ${err.message}. Uploading original instead.`, ); return buffer; } const ratio = compressed.length / originalSize; const diffToMid = Math.abs(ratio - (minRatio + maxRatio) / 2); if (ratio >= minRatio && ratio <= maxRatio) { bestBuffer = compressed; break; } if (diffToMid < bestDiff) { bestDiff = diffToMid; bestBuffer = compressed; } if (ratio > maxRatio) { highQ = currentQuality - 1; const next = Math.floor((currentQuality + lowQ) / 2); if (next === currentQuality) break; currentQuality = next; } else { lowQ = currentQuality + 1; const next = Math.ceil((currentQuality + highQ) / 2); if (next === currentQuality) break; currentQuality = next; } if (currentQuality < minQuality || currentQuality > maxQuality) break; } return bestBuffer; } /** Supports both `(…, options)` and the legacy `(…, uploadPath, fields, imgOptimizations)`. */ function normalizeUploadOptions(rest) { const [first, second, third] = rest; const isOptionsObject = first && typeof first === "object" && !Array.isArray(first); const provided = isOptionsObject ? first : { uploadPath: first, fields: second, imgOptimizations: third }; const merged = { ...DEFAULTS }; for (const [key, value] of Object.entries(provided)) { if (value !== undefined) merged[key] = value; } return merged; } /** * Resolves the optimization level. * * Server configuration wins by default. The level drives up to seven sequential * re-encodes of a multi-megabyte buffer, so letting a client set it turns any * upload endpoint into CPU amplification. */ function resolveImageLevel(req, options) { if (!options.allowClientImageOptions) return options.imgOptimizations; const requested = req.query?.imgOptimizations ?? req.body?.imgOptimizations ?? req.headers?.["x-img-optimizations"]; if (typeof requested === "string" && VALID_LEVELS.includes(requested.toLowerCase().trim())) { return requested.toLowerCase().trim(); } return options.imgOptimizations; } const fail = (res, status, error) => res.status(status).json({ success: false, error }); /** * S3 upload middleware. * * Populates `req.body` with the resulting object URLs so the CRUD controller can * persist them. */ const fileUploadMiddleware = (req, res, next, ...rest) => { const options = normalizeUploadOptions(rest); runUpload(req, res, next, options).catch((err) => { console.error(`[ControllerSet-S3] Unexpected upload failure:`, err); if (!res.headersSent) { fail(res, 500, "Failed to process S3 upload."); } }); }; async function runUpload(req, res, next, options) { const { config, missing } = readConfig(); if (missing.length > 0) { console.error( `[ControllerSet-S3] Missing required S3 environment variables: ${missing.join(", ")}`, ); return fail(res, 503, "S3 service is not properly configured on the server."); } const { s3, multer } = await loadModules(); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: options.maxFileSize, files: options.maxFiles }, }); const runMulter = () => new Promise((resolve, reject) => { const done = (err) => (err ? reject(err) : resolve()); const { fields } = options; if (fields.length === 1 && fields[0].maxCount === 1) { upload.single(fields[0].name)(req, res, done); } else { upload.fields(fields)(req, res, done); } }); try { await runMulter(); } catch (err) { console.error(`[ControllerSet-S3] Multer error: ${err.message}`); const isMulterError = err.name === "MulterError"; return fail( res, isMulterError ? 400 : 500, isMulterError ? `File upload error: ${err.message}` : "Failed to process S3 upload.", ); } const level = resolveImageLevel(req, options); const filesToUpload = collectFiles(req, options.fields); const client = await getClient(config); for (const file of filesToUpload) { // The declared mimetype is a claim by the client; the bytes are the fact. const detected = detectFileType(file.buffer); if (!detected) { return fail(res, 400, `Unsupported or unrecognised file type for '${file.fieldname}'.`); } if (!options.allowedMimeTypes.includes(detected.mime)) { return fail(res, 400, `File type '${detected.mime}' is not allowed.`); } let buffer = file.buffer; const format = sharpFormatFor(detected.mime); if (format && level) { buffer = await compressImage(buffer, format, level); } // Re-detect after compression so the stored extension and Content-Type // describe the bytes actually being written, whatever the encoder produced. const final = detectFileType(buffer) ?? detected; const uniqueId = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; const key = path.posix.join(sanitizePrefix(options.uploadPath), `${uniqueId}${final.ext}`); const inline = INLINE_SAFE_TYPES.includes(final.mime); await client.send( new s3.PutObjectCommand({ Bucket: config.bucket, Key: key, Body: buffer, ContentType: final.mime, // Active content is never rendered by the browser on the bucket origin, // even when a consumer has explicitly allowlisted the type. ContentDisposition: inline && !ACTIVE_CONTENT_TYPES.includes(final.mime) ? "inline" : "attachment", ACL: options.acl, }), ); file.buffer = buffer; file.size = buffer.length; file.key = key; file.location = getS3Url(config.bucket, key); } populateBody(req, options.fields); if (typeof next === "function") next(); } /** Normalizes the configured prefix so it cannot escape into an absolute or parent path. */ function sanitizePrefix(prefix) { const normalized = path.posix.normalize(String(prefix ?? "")); return normalized.replace(/^(\.\.(\/|$))+/, "").replace(/^\/+/, ""); } function filesForField(req, name) { if (!req.files) return []; return Array.isArray(req.files) ? req.files.filter((f) => f.fieldname === name) : (req.files[name] ?? []); } function collectFiles(req, fields) { if (req.file) return [req.file]; return fields.flatMap((field) => filesForField(req, field.name)); } function populateBody(req, fields) { if (req.file) { const field = fields.find((f) => f.name === req.file.fieldname) ?? fields[0]; req.body[field.name] = field.formatToUrlObject ? { url: req.file.location } : req.file.location; return; } if (!req.files) return; for (const field of fields) { const found = filesForField(req, field.name); if (found.length === 0) continue; const locations = found.map((f) => field.formatToUrlObject ? { url: f.location } : f.location, ); req.body[field.name] = field.maxCount === 1 ? locations[0] : locations; } } export { compressImage, fileUploadMiddleware };