Skip to main content
Glama

Ghost MCP Server

by jgardner04
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

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jgardner04/Ghost-MCP-Server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server