UNPKG

@tiledesk/tiledesk-server

Version:
584 lines (492 loc) 22.4 kB
const express = require('express'); const router = express.Router(); const pathlib = require('path'); const mongoose = require('mongoose'); const multer = require('multer'); const passport = require('passport'); const mime = require('mime-types'); const path = require('path'); const sharp = require('sharp'); const verifyFileContent = require('../middleware/file-type.js'); require('../middleware/passport.js')(passport); const validtoken = require('../middleware/valid-token.js') const roleChecker = require('../middleware/has-role.js'); const winston = require('../config/winston.js'); const FileGridFsService = require('../services/fileGridFsService.js'); const roleConstants = require('../models/roleConstants.js'); const faq_kb = require('../models/faq_kb'); const project_user = require('../models/project_user'); const fileService = new FileGridFsService("files"); const fallbackFileService = new FileGridFsService("images");; let MAX_UPLOAD_FILE_SIZE = process.env.MAX_UPLOAD_FILE_SIZE || 1024000; // 1MB let uploadlimits = undefined; if (MAX_UPLOAD_FILE_SIZE) { uploadlimits = { fileSize: parseInt(MAX_UPLOAD_FILE_SIZE) } ; winston.info("Max upload file size is : " + MAX_UPLOAD_FILE_SIZE); } else { winston.info("Max upload file size is infinity"); } /** * Default: '2592000' (30 days) * Examples: * - '30' (30 seconds) */ const chatFileExpirationTime = parseInt(process.env.CHAT_FILE_EXPIRATION_TIME || '2592000', 10); /** * Default: ".jpg,.jpeg,.png,.gif,.pdf,.txt" * Examples: * - '* /*' without spaces (all extension) * Deprecated: "application/pdf,image/png,..." */ const default_chat_allowed_extensions = process.env.CHAT_FILES_ALLOW_LIST || ".jpg,.jpeg,.png,.gif,.pdf,.txt"; const default_assets_allowed_extensions = process.env.ASSETS_FILES_ALLOW_LIST || ".jpg,.jpeg,.png,.gif,.pdf,.txt,.csv,.doc,.docx"; //,.xls,.xlsx,.ppt,.pptx,.zip,.rar const images_extensions = [ ".png", ".jpg", ".jpeg", ".gif" ]; const fileFilter = (extensionsSource = 'chat') => { return (req, file, cb) => { const project = req.project; const pu = req.projectuser; let allowed_extensions; let allowed_mime_types; if (extensionsSource === 'avatar') { // Avatar only accepts image extensions allowed_extensions = images_extensions.join(','); } else if (extensionsSource === 'assets') { allowed_extensions = default_assets_allowed_extensions; } else if (pu.roleType === 2 || pu.role === roleConstants.GUEST) { allowed_extensions = project?.widget?.allowedUploadExtentions || default_chat_allowed_extensions; } else { allowed_extensions = project?.settings?.allowed_upload_extentions || default_chat_allowed_extensions; } if (allowed_extensions !== "*/*") { allowed_mime_types = getMimeTypes(allowed_extensions); if (!file.originalname) { return cb(new Error("File original name is required")); } const ext = path.extname(file.originalname).toLowerCase(); if (!allowed_extensions.includes(ext)) { const error = new Error(`File extension ${ext} is not allowed${extensionsSource === 'avatar' ? ' for avatar' : ''}`); error.status = 403; return cb(error); } const expectedMimeType = mime.lookup(ext); if (expectedMimeType && !areMimeTypesEquivalent(file.mimetype, expectedMimeType)) { const error = new Error(`File content does not match mimetype. Detected: ${file.mimetype}, provided: ${expectedMimeType}`); error.status = 403; return cb(error); } return cb(null, true); } else { return cb(null, true); } } } function getMimeTypes(allowed_extension) { const extension = allowed_extension.split(',').map(e => e.trim().toLowerCase()); const allowedMimeTypes = extension.map(ext => mime.lookup(ext)).filter(Boolean); return allowedMimeTypes; } /** * Checks if two MIME types are equivalent, accepting common aliases * Examples: * - audio/wav === audio/wave * - audio/x-wav === audio/wave * - image/jpeg === image/jpg */ function areMimeTypesEquivalent(mimeType1, mimeType2) { if (!mimeType1 || !mimeType2) return false; if (mimeType1 === mimeType2) return true; // Normalize to lowercase for comparison const m1 = mimeType1.toLowerCase(); const m2 = mimeType2.toLowerCase(); if (m1 === m2) return true; // Common MIME type aliases const aliases = { 'audio/wav': ['audio/wave', 'audio/x-wav', 'audio/vnd.wave'], 'audio/wave': ['audio/wav', 'audio/x-wav', 'audio/vnd.wave'], 'audio/x-wav': ['audio/wav', 'audio/wave', 'audio/vnd.wave'], 'audio/vnd.wave': ['audio/wav', 'audio/wave', 'audio/x-wav'], 'audio/mpeg': ['audio/opus', 'audio/mp3', 'audio/webm'], 'audio/mp3': ['audio/mpeg', 'audio/opus', 'audio/webm'], 'audio/opus': ['audio/mpeg', 'audio/mp3', 'audio/webm'], 'audio/webm': ['audio/mpeg', 'audio/mp3', 'audio/opus'], 'image/jpeg': ['image/jpg'], 'image/jpg': ['image/jpeg'], 'application/x-zip-compressed': ['application/zip'], 'application/zip': ['application/x-zip-compressed'], }; // Check if m1 is an alias of m2 or vice versa if (aliases[m1] && aliases[m1].includes(m2)) return true; if (aliases[m2] && aliases[m2].includes(m1)) return true; return false; } const uploadChat = multer({ storage: fileService.getStorage("files"), fileFilter: fileFilter('chat'), limits: uploadlimits }).single('file'); const uploadAssets = multer({ storage: fileService.getStorageProjectAssets("files"), fileFilter: fileFilter('assets'), limits: uploadlimits }).single('file'); const uploadAvatar = multer({ storage: fileService.getStorageAvatarFiles("files"), fileFilter: fileFilter('avatar'), limits: uploadlimits }).single('file'); // *********************** // // ****** Endpoints ****** // // *********************** // router.post('/chat', [ passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, roleChecker.hasRoleOrTypes('guest', ['bot','subscription']) ], async (req, res) => { const expireAt = new Date(Date.now() + chatFileExpirationTime * 1000); req.expireAt = expireAt; uploadChat(req, res, async (err) => { if (err instanceof multer.MulterError) { // A Multer error occurred when uploading winston.error(`Multer replied with code ${err?.code} and message "${err?.message}"`); let status = 400; if (err?.code === 'LIMIT_FILE_SIZE') { status = 413; } return res.status(status).send({ success: false, error: err?.message || 'An error occurred while uploading the file', code: err.code }); } else if (err) { // An unknown error occurred when uploading. winston.error(`Multer replied with status ${err?.status} and message "${err?.message}"`); let status = err?.status || 400; return res.status(status).send({ success: false, error: err.message || "An error occurred while uploading the file" }) } try { const buffer = await fileService.getFileDataAsBuffer(req.file.filename); await verifyFileContent(buffer, req.file.mimetype); await mongoose.connection.db.collection('files.chunks').updateMany( { files_id: req.file.id }, { $set: { "metadata.expireAt": req.file.metadata.expireAt }} ) return res.status(201).send({ message: "File uploaded successfully", filename: req.file.filename }) } catch (err) { if (err?.source === "FileContentVerification") { let error_message = err?.message || "Content verification failed"; winston.warn("File content verification failed. Message: ", error_message); return res.status(403).send({ success: false, error: error_message }) } winston.error("Error saving file: ", err); return res.status(500).send({ success: false, error: "Error updating file chunks" }); } }) }) router.post('/assets', [ passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, roleChecker.hasRoleOrTypes('admin', ['bot','subscription']) ], async (req, res) => { // Assets have no retention by default, but can be set via query parameter let customExpiration = parseInt(req.query?.expiration, 10); if (customExpiration && !isNaN(customExpiration) && customExpiration > 0) { req.expireAt = new Date(Date.now() + customExpiration * 1000); } uploadAssets(req, res, async (err) => { if (err instanceof multer.MulterError) { // A Multer error occurred when uploading winston.error(`Multer replied with code ${err?.code} and message "${err?.message}"`); let status = 400; if (err?.code === 'LIMIT_FILE_SIZE') { status = 413; } return res.status(status).send({ success: false, error: err?.message || 'An error occurred while uploading the file', code: err.code }); } else if (err) { // An unknown error occurred when uploading. winston.error(`Multer replied with status ${err?.status} and message "${err?.message}"`); let status = err?.status || 400; return res.status(status).send({ success: false, error: err.message || "An error occurred while uploading the file" }) } try { const buffer = await fileService.getFileDataAsBuffer(req.file.filename); await verifyFileContent(buffer, req.file.mimetype); if (req.file.metadata && req.file.metadata.expireAt) { await mongoose.connection.db.collection('files.chunks').updateMany( { files_id: req.file.id }, { $set: { "metadata.expireAt": req.file.metadata.expireAt }} ); } const ext = path.extname(req.file.originalname).toLowerCase(); let thumbnail; // Generate thumbnail for images if (images_extensions.includes(ext)) { const buffer = await fileService.getFileDataAsBuffer(req.file.filename); const thumbFilename = req.file.filename.replace(/([^/]+)$/, "thumbnails_200_200-$1"); const resized = await sharp(buffer).resize(200, 200).toBuffer(); const thumbMetadata = req.expireAt ? { metadata: { expireAt: req.expireAt } } : undefined; // Use the same contentType as the original file for the thumbnail await fileService.createFile(thumbFilename, resized, undefined, req.file.mimetype, thumbMetadata); if (req.expireAt) { await mongoose.connection.db.collection('files.chunks').updateMany( { files_id: ( await fileService.find(thumbFilename))._id }, { $set: { "metadata.expireAt": req.expireAt }} ); } thumbnail = thumbFilename; } return res.status(201).send({ message: 'File uploaded successfully', filename: encodeURIComponent(req.file.filename), thumbnail: thumbnail ? encodeURIComponent(thumbnail) : undefined }) } catch (err) { if (err?.source === "FileContentVerification") { let error_message = err?.message || "Content verification failed"; winston.warn("File content verification failed. Message: ", error_message); return res.status(403).send({ success: false, error: error_message }) } winston.error("Error uploading asset", err); return res.status(500).send({ success: false, error: "Error uploading asset" }); } }) }) /** * Upload user profile photo or bot avatar * Path: uploads/users/{user_id|bot_id}/images/photo.jpg * This maintains compatibility with clients that expect fixed paths. * Profile photos/avatars have no retention. */ router.post('/users/photo', [ passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, roleChecker.hasRoleOrTypes('agent', ['bot','subscription']) ], async (req, res) => { uploadAvatar(req, res, async (err) => { if (err instanceof multer.MulterError) { // A Multer error occurred when uploading winston.error(`Multer replied with code ${err?.code} and message "${err?.message}"`); let status = 400; if (err?.code === 'LIMIT_FILE_SIZE') { status = 413; } return res.status(status).send({ success: false, error: err?.message || 'An error occurred while uploading the file', code: err.code }); } else if (err) { // An unknown error occurred when uploading. winston.error(`Multer replied with status ${err?.status} and message "${err?.message}"`); let status = err?.status || 400; return res.status(status).send({ success: false, error: err.message || "An error occurred while uploading the file" }) } try { winston.debug("/users/photo"); if (!req.file) { return res.status(400).send({ success: false, error: 'No file uploaded' }); } let userid = req.user.id; let bot_id; let entity_id = userid; if (req.query.bot_id) { bot_id = req.query.bot_id; let chatbot = await faq_kb.findById(bot_id).catch((err) => { winston.error("Error finding bot ", err); return res.status(500).send({ success: false, error: "Unable to find chatbot with id " + bot_id }); }); if (!chatbot) { return res.status(404).send({ success: false, error: "Chatbot not found" }); } let id_project = chatbot.id_project; let puser = await project_user.findOne({ id_user: userid, id_project: id_project }).catch((err) => { winston.error("Error finding project user: ", err); return res.status(500).send({ success: false, error: "Unable to find project user for user " + userid + "in project " + id_project }); }); if (!puser) { winston.warn("User " + userid + " doesn't belong to the project " + id_project); return res.status(401).send({ success: false, error: "You don't belong to the chatbot's project" }); } if ((puser.role !== roleConstants.ADMIN) && (puser.role !== roleConstants.OWNER)) { winston.warn("User with role " + puser.role + " can't modify the chatbot"); return res.status(403).send({ success: false, error: "You don't have the role required to modify the chatbot" }); } entity_id = bot_id; } var destinationFolder = 'uploads/users/' + entity_id + "/images/"; winston.debug("destinationFolder:" + destinationFolder); var thumFilename = destinationFolder + 'thumbnails_200_200-photo.jpg'; winston.debug("req.file.filename:" + req.file.filename); const buffer = await fileService.getFileDataAsBuffer(req.file.filename); try { const resizeImage = await sharp(buffer).resize(200, 200).toBuffer(); // Use the same contentType as the original file for the thumbnail await fileService.createFile(thumFilename, resizeImage, undefined, req.file.mimetype); let thumFile = await fileService.find(thumFilename); winston.debug("thumFile", thumFile); return res.status(201).json({ message: 'Image uploaded successfully', filename: encodeURIComponent(req.file.filename), thumbnail: encodeURIComponent(thumFilename) }); } catch (thumbErr) { winston.error("Error generating or creating thumbnail", thumbErr); // Still return success for the main file, but log thumbnail error return res.status(201).json({ message: 'Image uploaded successfully', filename: encodeURIComponent(req.file.filename), thumbnail: undefined }); } } catch (error) { winston.error('Error uploading user image.', error); return res.status(500).send({ success: false, error: 'Error uploading user image.' }); } }) }) router.get("/", [ passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, ], async (req, res) => { winston.debug('path', req.query.path); if (req.query.as_attachment) { res.set({ "Content-Disposition": "attachment; filename=\""+req.query.path+"\"" }); } let fService = fileService; try { let file = await fileService.find(req.query.path); res.set({ "Content-Length": file.length}); res.set({ "Content-Type": file.contentType}); } catch (e) { if (e.code == "ENOENT") { winston.debug(`File ${req.query.path} not found on primary file service. Fallback to secondary.`) // To instantiate fallbackFileService here where needed you need to wait for the open event. // Instance moved on top // await new Promise(r => fallbackFileService.conn.once("open", r)); try { let file = await fallbackFileService.find(req.query.path) res.set({ "Content-Length": file.length }); res.set({ "Content-Type": file.contentType }); fService = fallbackFileService; } catch (e) { if (e.code == "ENOENT") { winston.debug(`File ${req.query.path} not found on seconday file service.`) return res.status(404).send({ success: false, error: 'File not found.' }); }else { winston.error('Error getting file: ', e); return res.status(500).send({success: false, error: 'Error getting file.'}); } } } else { winston.error('Error getting file', e); return res.status(500).send({success: false, error: 'Error getting file.'}); } } fService.getFileDataAsStream(req.query.path).pipe(res); }); router.get("/download", [ passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, ], (req, res) => { winston.debug('path', req.query.path); let filename = pathlib.basename(req.query.path); winston.debug("filename:"+filename); res.attachment(filename); fileService.getFileDataAsStream(req.query.path).pipe(res); }); /** * Delete a file (and its thumbnail if it's an image) * Works for both profile photos/avatars and project assets * * Example: * curl -v -X DELETE -u user:pass \ * http://localhost:3000/filesp?path=uploads%2Fusers%2F65c5f3599faf2d04cd7da528%2Fimages%2Fphoto.jpg * * curl -v -X DELETE -u user:pass \ * http://localhost:3000/filesp?path=uploads%2Fprojects%2F65c5f3599faf2d04cd7da528%2Ffiles%2Fuuid%2Flogo.png */ router.delete("/", [ passport.authenticate(['basic', 'jwt'], { session: false }), validtoken, ], async (req, res) => { try { winston.debug("delete file"); let filePath = req.query.path; if (!filePath) { return res.status(400).send({ success: false, error: 'Path parameter is required' }); } winston.debug("path:" + filePath); let filename = pathlib.basename(filePath); winston.debug("filename:" + filename); if (!filename) { winston.warn('Error deleting file. No filename specified:' + filePath); return res.status(400).send({ success: false, error: 'No filename specified in path' }); } // Determine which service to use based on path // Try primary service first (files bucket) let fService = fileService; let fileExists = false; try { await fileService.find(filePath); fileExists = true; } catch (e) { if (e.code == "ENOENT") { winston.debug(`File ${filePath} not found on primary file service. Trying fallback.`); try { await fallbackFileService.find(filePath); fService = fallbackFileService; fileExists = true; } catch (e2) { if (e2.code == "ENOENT") { winston.debug(`File ${filePath} not found on fallback file service.`); return res.status(404).send({ success: false, error: 'File not found.' }); } else { winston.error('Error checking file on fallback service: ', e2); return res.status(500).send({ success: false, error: 'Error checking file existence.' }); } } } else { winston.error('Error checking file on primary service: ', e); return res.status(500).send({ success: false, error: 'Error checking file existence.' }); } } // Delete the main file try { const deletedFile = await fService.deleteFile(filePath); winston.debug("File deleted successfully:", deletedFile.filename); // Check if this is an image and try to delete thumbnail // Thumbnail pattern: thumbnails_200_200-{filename} // For profile photos: thumbnails_200_200-photo.jpg // For assets: thumbnails_200_200-{original_filename} const isImage = images_extensions.some(ext => filename.toLowerCase().endsWith(ext)); if (isImage) { let thumbFilename = 'thumbnails_200_200-' + filename; let thumbPath = filePath.replace(filename, thumbFilename); winston.debug("thumbPath:" + thumbPath); try { // Try to delete thumbnail from the same service await fService.deleteFile(thumbPath); winston.debug("Thumbnail deleted successfully:" + thumbPath); } catch (thumbErr) { // Thumbnail might not exist or be in different service, try fallback if (thumbErr.code == "ENOENT" || thumbErr.msg == "File not found") { winston.debug(`Thumbnail ${thumbPath} not found on ${fService === fileService ? 'primary' : 'fallback'} service. Trying other service.`); const otherService = fService === fileService ? fallbackFileService : fileService; try { await otherService.deleteFile(thumbPath); winston.debug("Thumbnail deleted from fallback service:" + thumbPath); } catch (fallbackThumbErr) { // Thumbnail doesn't exist, that's ok winston.debug(`Thumbnail ${thumbPath} not found on fallback service either. Skipping.`); } } else { winston.error('Error deleting thumbnail:', thumbErr); // Don't fail the whole request if thumbnail deletion fails } } } return res.status(200).json({ message: 'File deleted successfully', filename: encodeURIComponent(deletedFile.filename) }); } catch (deleteErr) { winston.error('Error deleting file:', deleteErr); return res.status(500).send({ success: false, error: 'Error deleting file.' }); } } catch (error) { winston.error('Error in delete endpoint:', error); return res.status(500).send({ success: false, error: 'Error deleting file.' }); } }); module.exports = router;