From cebe7bb0d25dc9f30d28601a2aacaecb5bfd65dc Mon Sep 17 00:00:00 2001 From: Walusimbi Silver Date: Tue, 17 Mar 2026 13:02:26 +0300 Subject: [PATCH] Input validation, WebSocket handler, and file upload middleware --- src/middleware/upload.js | 35 ++++++++++++ src/utils/validators.js | 113 +++++++++++++++++++++++++++++++++++++++ src/utils/ws.js | 58 ++++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 src/middleware/upload.js create mode 100644 src/utils/validators.js create mode 100644 src/utils/ws.js diff --git a/src/middleware/upload.js b/src/middleware/upload.js new file mode 100644 index 0000000..010d375 --- /dev/null +++ b/src/middleware/upload.js @@ -0,0 +1,35 @@ +const multer = require("multer"); +const path = require("path"); +const { nanoid } = require("nanoid"); +const config = require("../config"); + +const ALLOWED_MIMES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/avif", + "image/tiff", + "image/gif", + "image/svg+xml", +]); + +const storage = multer.diskStorage({ + destination: config.storage.uploadDir, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + cb(null, `${nanoid(12)}${ext}`); + }, +}); + +const upload = multer({ + storage, + limits: { fileSize: config.storage.maxFileSize }, + fileFilter: (_req, file, cb) => { + if (!ALLOWED_MIMES.has(file.mimetype)) { + return cb(new Error(`Unsupported format: ${file.mimetype}`)); + } + cb(null, true); + }, +}); + +module.exports = upload; diff --git a/src/utils/validators.js b/src/utils/validators.js new file mode 100644 index 0000000..971c2a4 --- /dev/null +++ b/src/utils/validators.js @@ -0,0 +1,113 @@ +/** + * Lightweight validation for job operations. + * Each operation has required/optional params and a validator. + */ + +const OPERATIONS = { + resize: { + description: "Resize image to given dimensions", + validate: (params) => { + if (!params.width && !params.height) { + return "resize requires at least width or height"; + } + if (params.width && (params.width < 1 || params.width > 10000)) { + return "width must be 1-10000"; + } + if (params.height && (params.height < 1 || params.height > 10000)) { + return "height must be 1-10000"; + } + if (params.fit && !["cover", "contain", "fill", "inside", "outside"].includes(params.fit)) { + return "fit must be one of: cover, contain, fill, inside, outside"; + } + return null; + }, + }, + + convert: { + description: "Convert image format", + validate: (params) => { + const allowed = ["jpeg", "png", "webp", "avif", "tiff"]; + if (!params.format || !allowed.includes(params.format)) { + return `format must be one of: ${allowed.join(", ")}`; + } + return null; + }, + }, + + compress: { + description: "Compress image with quality setting", + validate: (params) => { + if (params.quality !== undefined && (params.quality < 1 || params.quality > 100)) { + return "quality must be 1-100"; + } + return null; + }, + }, + + watermark: { + description: "Overlay text watermark on image", + validate: (params) => { + if (!params.text) return "watermark requires text"; + if (params.text.length > 200) return "watermark text max 200 chars"; + return null; + }, + }, + + "strip-meta": { + description: "Remove EXIF/metadata from image", + validate: () => null, // no params needed + }, + + blur: { + description: "Apply Gaussian blur", + validate: (params) => { + if (params.sigma !== undefined && (params.sigma < 0.3 || params.sigma > 100)) { + return "sigma must be 0.3-100"; + } + return null; + }, + }, + + grayscale: { + description: "Convert to grayscale", + validate: () => null, + }, + + rotate: { + description: "Rotate image by degrees", + validate: (params) => { + if (params.angle === undefined) return "rotate requires angle"; + return null; + }, + }, +}; + +/** + * Validate an array of operations. + * @param {Array<{op: string, params?: object}>} operations + * @returns {string|null} error message or null + */ +function validateOperations(operations) { + if (!Array.isArray(operations) || operations.length === 0) { + return "operations must be a non-empty array"; + } + + if (operations.length > 10) { + return "max 10 operations per job"; + } + + for (let i = 0; i < operations.length; i++) { + const { op, params = {} } = operations[i]; + + if (!OPERATIONS[op]) { + return `operations[${i}]: unknown operation "${op}". Available: ${Object.keys(OPERATIONS).join(", ")}`; + } + + const err = OPERATIONS[op].validate(params); + if (err) return `operations[${i}] (${op}): ${err}`; + } + + return null; +} + +module.exports = { OPERATIONS, validateOperations }; diff --git a/src/utils/ws.js b/src/utils/ws.js new file mode 100644 index 0000000..2552496 --- /dev/null +++ b/src/utils/ws.js @@ -0,0 +1,58 @@ +const { WebSocketServer } = require("ws"); + +class WsManager { + constructor() { + /** @type {Map>} jobId → clients */ + this.subs = new Map(); + } + + /** + * Attach to an existing HTTP server. + * Client connects: ws://host:port/ws?jobId=xxx + */ + attach(server) { + this.wss = new WebSocketServer({ server, path: "/ws" }); + + this.wss.on("connection", (ws, req) => { + const jobId = new URL(req.url, "http://localhost").searchParams.get("jobId"); + + if (!jobId) { + ws.close(4000, "Missing jobId query param"); + return; + } + + // Subscribe this socket to the job + if (!this.subs.has(jobId)) this.subs.set(jobId, new Set()); + this.subs.get(jobId).add(ws); + + ws.on("close", () => { + const clients = this.subs.get(jobId); + if (clients) { + clients.delete(ws); + if (clients.size === 0) this.subs.delete(jobId); + } + }); + + // Acknowledge subscription + ws.send(JSON.stringify({ type: "subscribed", jobId })); + }); + } + + /** + * Broadcast progress to all clients watching a job. + * @param {string} jobId + * @param {object} payload { type, progress, stage, result, error } + */ + notify(jobId, payload) { + const clients = this.subs.get(jobId); + if (!clients) return; + + const msg = JSON.stringify({ jobId, ...payload }); + for (const ws of clients) { + if (ws.readyState === 1) ws.send(msg); + } + } +} + +// Singleton +module.exports = new WsManager();