Input validation, WebSocket handler, and file upload middleware

This commit is contained in:
Walusimbi Silver
2026-03-17 13:02:26 +03:00
parent 0382f813a8
commit cebe7bb0d2
3 changed files with 206 additions and 0 deletions

35
src/middleware/upload.js Normal file
View File

@@ -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;

113
src/utils/validators.js Normal file
View File

@@ -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 };

58
src/utils/ws.js Normal file
View File

@@ -0,0 +1,58 @@
const { WebSocketServer } = require("ws");
class WsManager {
constructor() {
/** @type {Map<string, Set<import('ws').WebSocket>>} 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();