Input validation, WebSocket handler, and file upload middleware
This commit is contained in:
35
src/middleware/upload.js
Normal file
35
src/middleware/upload.js
Normal 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
113
src/utils/validators.js
Normal 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
58
src/utils/ws.js
Normal 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();
|
||||
Reference in New Issue
Block a user