import multer from "multer";
import path from "path";
import fs from "fs";
import os from "os"; // Import the os module
import Joi from "joi";
import crypto from "crypto";
import { createContextLogger } from "../utils/logger.js";
import { uploadImage as uploadGhostImage } from "../services/ghostService.js"; // Assuming uploadImage is in ghostService
import { processImage } from "../services/imageProcessingService.js"; // Import the processing service
// --- Use OS temporary directory for uploads ---
const uploadDir = os.tmpdir(); // Use the OS default temp directory
// We generally don't need to create os.tmpdir(), it should exist
// if (!fs.existsSync(uploadDir)){
// fs.mkdirSync(uploadDir);
// }
// Validation schema for uploaded files (excluding size - validated by multer limits)
const fileValidationSchema = Joi.object({
originalname: Joi.string().max(255).required(),
mimetype: Joi.string().pattern(/^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i).required()
});
// Post-upload validation schema (when file.size is available)
const uploadedFileValidationSchema = Joi.object({
originalname: Joi.string().max(255).required(),
mimetype: Joi.string().pattern(/^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i).required(),
size: Joi.number().max(10 * 1024 * 1024).required(), // 10MB max
path: Joi.string().required()
});
// Safe filename generation
const generateSafeFilename = (originalName) => {
const ext = path.extname(originalName);
// Validate extension against whitelist
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
const normalizedExt = ext.toLowerCase();
if (!allowedExtensions.includes(normalizedExt)) {
throw new Error('Invalid file extension');
}
// Generate cryptographically secure random filename
const randomBytes = crypto.randomBytes(16).toString('hex');
const timestamp = Date.now();
return `mcp-upload-${timestamp}-${randomBytes}${normalizedExt}`;
};
const storage = multer.diskStorage({
destination: function (req, file, cb) {
// Ensure we're using the temp directory, no user input for path
cb(null, uploadDir);
},
filename: function (req, file, cb) {
try {
// Generate safe filename that prevents path traversal
const safeFilename = generateSafeFilename(file.originalname);
cb(null, safeFilename);
} catch (error) {
cb(error);
}
},
});
// Enhanced filter for image files with validation
const imageFileFilter = (req, file, cb) => {
// Validate file properties (excluding size - not available at this stage)
const validation = fileValidationSchema.validate({
originalname: file.originalname,
mimetype: file.mimetype
});
if (validation.error) {
return cb(new Error(`File validation failed: ${validation.error.details[0].message}`), false);
}
// Additional security checks
const filename = file.originalname;
// Check for path traversal attempts
if (filename.includes('../') || filename.includes('..\\') || path.isAbsolute(filename)) {
return cb(new Error('Invalid filename: Path traversal detected'), false);
}
// Check for null bytes
if (filename.includes('\0')) {
return cb(new Error('Invalid filename: Null byte detected'), false);
}
cb(null, true);
};
const upload = multer({
storage: storage,
fileFilter: imageFileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
files: 1 // Only allow 1 file per request
}
});
/**
* Extracts a base filename without extension or unique identifiers.
* Example: 'mcp-upload-1678886400000-123456789.jpg' -> 'image' (if original was image.jpg)
* Note: This might be simplified depending on how original filename is best accessed.
* Multer's `file.originalname` is the best source.
*/
const getDefaultAltText = (originalName) => {
try {
// Use the original filename directly instead of a file path to avoid path traversal
// Validate the input is a string and not a path
if (!originalName || typeof originalName !== 'string') {
return "Uploaded image";
}
// Ensure no path separators are present (defense in depth)
const sanitizedName = originalName.replace(/[/\\:]/g, '');
const originalFilename = sanitizedName
.split(".")
.slice(0, -1)
.join(".");
// Attempt to remove common prefixes/suffixes added during upload/processing
const nameWithoutIds = originalFilename.replace(
/^(processed-|mcp-upload-)\d+-\d+-?/,
""
);
return nameWithoutIds.replace(/[-_]/g, " ") || "Uploaded image";
} catch (e) {
return "Uploaded image"; // Fallback
}
};
/**
* Controller to handle image uploads.
* Processes the image and includes alt text in the response.
*/
const handleImageUpload = async (req, res, next) => {
const logger = createContextLogger('image-controller');
let originalPath = null;
let processedPath = null;
try {
if (!req.file) {
return res.status(400).json({ message: "No image file uploaded." });
}
// Post-upload validation with complete file information
const fileValidation = uploadedFileValidationSchema.validate({
originalname: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size,
path: req.file.path
});
if (fileValidation.error) {
// Delete the uploaded file since validation failed
// Validate file path is within upload directory before deletion
const filePath = req.file.path;
const resolvedFilePath = path.resolve(filePath);
const resolvedUploadDir = path.resolve(uploadDir);
if (resolvedFilePath.startsWith(resolvedUploadDir)) {
fs.unlink(filePath, () => {});
}
return res.status(400).json({
message: `File validation failed: ${fileValidation.error.details[0].message}`
});
}
// Validate the file path is within our temp directory (defense in depth)
originalPath = req.file.path;
const resolvedPath = path.resolve(originalPath);
const resolvedUploadDir = path.resolve(uploadDir);
if (!resolvedPath.startsWith(resolvedUploadDir)) {
logger.error('Security violation: File path outside upload directory', {
filePath: path.basename(originalPath),
uploadDir: path.basename(uploadDir)
});
throw new Error('Security violation: File path outside of upload directory');
}
logger.info('Image received for processing', {
originalName: req.file.originalname,
size: req.file.size,
mimetype: req.file.mimetype,
tempFile: path.basename(originalPath)
});
// Process Image (output directory is still the temp dir)
processedPath = await processImage(originalPath, uploadDir);
// --- Handle Alt Text ---
// Validate and sanitize alt text from the request body
const altSchema = Joi.string().max(500).allow('').optional();
const { error, value: sanitizedAlt } = altSchema.validate(req.body.alt);
if (error) {
return res.status(400).json({ message: `Invalid alt text: ${error.details[0].message}` });
}
const providedAlt = sanitizedAlt;
// Generate a default alt text from the original filename if none provided
const defaultAlt = getDefaultAltText(req.file.originalname);
const altText = providedAlt || defaultAlt;
logger.debug('Alt text determined', {
provided: !!providedAlt,
generated: !providedAlt,
altText
});
// --- End Alt Text Handling ---
// Call ghostService to upload the processed image
const uploadResult = await uploadGhostImage(processedPath);
logger.info('Image uploaded to Ghost successfully', {
ghostUrl: uploadResult.url,
processedFile: path.basename(processedPath)
});
// Respond with the URL and the determined alt text
res.status(200).json({ url: uploadResult.url, alt: altText });
} catch (error) {
logger.error('Image upload controller error', {
error: error.message,
stack: error.stack,
originalFile: originalPath ? path.basename(originalPath) : null,
processedFile: processedPath ? path.basename(processedPath) : null
});
// If it's a multer error (e.g., file filter), it might need specific handling
if (error instanceof multer.MulterError) {
return res.status(400).json({ message: error.message });
}
// Pass other errors to the global handler
next(error);
} finally {
// Cleanup: Delete temporary files with path validation
if (originalPath) {
const resolvedOriginalPath = path.resolve(originalPath);
const resolvedUploadDir = path.resolve(uploadDir);
if (resolvedOriginalPath.startsWith(resolvedUploadDir)) {
fs.unlink(originalPath, (err) => {
if (err)
logger.warn('Failed to delete original temp file', {
file: path.basename(originalPath),
error: err.message
});
});
}
}
if (processedPath && processedPath !== originalPath) {
const resolvedProcessedPath = path.resolve(processedPath);
const resolvedUploadDir = path.resolve(uploadDir);
if (resolvedProcessedPath.startsWith(resolvedUploadDir)) {
fs.unlink(processedPath, (err) => {
if (err)
logger.warn('Failed to delete processed temp file', {
file: path.basename(processedPath),
error: err.message
});
});
}
}
}
};
export { upload, handleImageUpload }; // Export upload middleware and controller