import express from 'express'; import * as Middlewares from '../middlewares'; import Parse from 'parse/node'; import Config from '../Config'; import logger from '../logger'; const triggers = require('../triggers'); const Utils = require('../Utils'); export class FilesRouter { expressRouter({ maxUploadSize = '20Mb' } = {}) { var router = express.Router(); router.get('/files/:appId/:filename', this.getHandler); router.get('/files/:appId/metadata/:filename', this.metadataHandler); router.post('/files', function (req, res, next) { next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename not provided.')); }); router.post( '/files/:filename', express.raw({ type: () => { return true; }, limit: maxUploadSize, }), // Allow uploads without Content-Type, or with any Content-Type. Middlewares.handleParseHeaders, Middlewares.handleParseSession, this.createHandler ); router.delete( '/files/:filename', Middlewares.handleParseHeaders, Middlewares.handleParseSession, Middlewares.enforceMasterKeyAccess, this.deleteHandler ); return router; } async getHandler(req, res) { const config = Config.get(req.params.appId); if (!config) { res.status(403); res.json({ code: Parse.Error.OPERATION_FORBIDDEN, error: 'Invalid application ID.' }); return; } let filename = req.params.filename; try { const filesController = config.filesController; const mime = (await import('mime')).default; let contentType = mime.getType(filename); let file = new Parse.File(filename, { base64: '' }, contentType); const triggerResult = await triggers.maybeRunFileTrigger( triggers.Types.beforeFind, { file }, config, req.auth ); if (triggerResult?.file?._name) { filename = triggerResult?.file?._name; contentType = mime.getType(filename); } if (isFileStreamable(req, filesController)) { filesController.handleFileStream(config, filename, req, res, contentType).catch(() => { res.status(404); res.set('Content-Type', 'text/plain'); res.end('File not found.'); }); return; } let data = await filesController.getFileData(config, filename).catch(() => { res.status(404); res.set('Content-Type', 'text/plain'); res.end('File not found.'); }); if (!data) { return; } file = new Parse.File(filename, { base64: data.toString('base64') }, contentType); const afterFind = await triggers.maybeRunFileTrigger( triggers.Types.afterFind, { file, forceDownload: false }, config, req.auth ); if (afterFind?.file) { contentType = mime.getType(afterFind.file._name); data = Buffer.from(afterFind.file._data, 'base64'); } res.status(200); res.set('Content-Type', contentType); res.set('Content-Length', data.length); if (afterFind.forceDownload) { res.set('Content-Disposition', `attachment;filename=${afterFind.file._name}`); } res.end(data); } catch (e) { const err = triggers.resolveError(e, { code: Parse.Error.SCRIPT_FAILED, message: `Could not find file: ${filename}.`, }); res.status(403); res.json({ code: err.code, error: err.message }); } } async createHandler(req, res, next) { const config = req.config; const user = req.auth.user; const isMaster = req.auth.isMaster; const isLinked = user && Parse.AnonymousUtils.isLinked(user); if (!isMaster && !config.fileUpload.enableForAnonymousUser && isLinked) { next( new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by anonymous user is disabled.') ); return; } if (!isMaster && !config.fileUpload.enableForAuthenticatedUser && !isLinked && user) { next( new Parse.Error( Parse.Error.FILE_SAVE_ERROR, 'File upload by authenticated user is disabled.' ) ); return; } if (!isMaster && !config.fileUpload.enableForPublic && !user) { next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'File upload by public is disabled.')); return; } const filesController = config.filesController; const { filename } = req.params; const contentType = req.get('Content-type'); if (!req.body || !req.body.length) { next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.')); return; } const error = filesController.validateFilename(filename); if (error) { next(error); return; } const fileExtensions = config.fileUpload?.fileExtensions; if (!isMaster && fileExtensions) { const isValidExtension = extension => { return fileExtensions.some(ext => { if (ext === '*') { return true; } const regex = new RegExp(ext); if (regex.test(extension)) { return true; } }); }; let extension = contentType; if (filename && filename.includes('.')) { extension = filename.substring(filename.lastIndexOf('.') + 1); } else if (contentType && contentType.includes('/')) { extension = contentType.split('/')[1]; } extension = extension?.split(' ')?.join(''); if (extension && !isValidExtension(extension)) { next( new Parse.Error( Parse.Error.FILE_SAVE_ERROR, `File upload of extension ${extension} is disabled.` ) ); return; } } const base64 = req.body.toString('base64'); const file = new Parse.File(filename, { base64 }, contentType); const { metadata = {}, tags = {} } = req.fileData || {}; try { // Scan request data for denied keywords Utils.checkProhibitedKeywords(config, metadata); Utils.checkProhibitedKeywords(config, tags); } catch (error) { next(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error)); return; } file.setTags(tags); file.setMetadata(metadata); const fileSize = Buffer.byteLength(req.body); const fileObject = { file, fileSize }; try { // run beforeSaveFile trigger const triggerResult = await triggers.maybeRunFileTrigger( triggers.Types.beforeSave, fileObject, config, req.auth ); let saveResult; // if a new ParseFile is returned check if it's an already saved file if (triggerResult instanceof Parse.File) { fileObject.file = triggerResult; if (triggerResult.url()) { // set fileSize to null because we wont know how big it is here fileObject.fileSize = null; saveResult = { url: triggerResult.url(), name: triggerResult._name, }; } } // if the file returned by the trigger has already been saved skip saving anything if (!saveResult) { // update fileSize const bufferData = Buffer.from(fileObject.file._data, 'base64'); fileObject.fileSize = Buffer.byteLength(bufferData); // prepare file options const fileOptions = { metadata: fileObject.file._metadata, }; // some s3-compatible providers (DigitalOcean, Linode) do not accept tags // so we do not include the tags option if it is empty. const fileTags = Object.keys(fileObject.file._tags).length > 0 ? { tags: fileObject.file._tags } : {}; Object.assign(fileOptions, fileTags); // save file const createFileResult = await filesController.createFile( config, fileObject.file._name, bufferData, fileObject.file._source.type, fileOptions ); // update file with new data fileObject.file._name = createFileResult.name; fileObject.file._url = createFileResult.url; fileObject.file._requestTask = null; fileObject.file._previousSave = Promise.resolve(fileObject.file); saveResult = { url: createFileResult.url, name: createFileResult.name, }; } // run afterSaveFile trigger await triggers.maybeRunFileTrigger(triggers.Types.afterSave, fileObject, config, req.auth); res.status(201); res.set('Location', saveResult.url); res.json(saveResult); } catch (e) { logger.error('Error creating a file: ', e); const error = triggers.resolveError(e, { code: Parse.Error.FILE_SAVE_ERROR, message: `Could not store file: ${fileObject.file._name}.`, }); next(error); } } async deleteHandler(req, res, next) { try { const { filesController } = req.config; const { filename } = req.params; // run beforeDeleteFile trigger const file = new Parse.File(filename); file._url = await filesController.adapter.getFileLocation(req.config, filename); const fileObject = { file, fileSize: null }; await triggers.maybeRunFileTrigger( triggers.Types.beforeDelete, fileObject, req.config, req.auth ); // delete file await filesController.deleteFile(req.config, filename); // run afterDeleteFile trigger await triggers.maybeRunFileTrigger( triggers.Types.afterDelete, fileObject, req.config, req.auth ); res.status(200); // TODO: return useful JSON here? res.end(); } catch (e) { logger.error('Error deleting a file: ', e); const error = triggers.resolveError(e, { code: Parse.Error.FILE_DELETE_ERROR, message: 'Could not delete file.', }); next(error); } } async metadataHandler(req, res) { try { const config = Config.get(req.params.appId); const { filesController } = config; const { filename } = req.params; const data = await filesController.getMetadata(filename); res.status(200); res.json(data); } catch { res.status(200); res.json({}); } } } function isFileStreamable(req, filesController) { const range = (req.get('Range') || '/-/').split('-'); const start = Number(range[0]); const end = Number(range[1]); return ( (!isNaN(start) || !isNaN(end)) && typeof filesController.adapter.handleFileStream === 'function' ); }