import express from 'express'; import BodyParser from 'body-parser'; import * as Middlewares from '../middlewares'; import Parse from 'parse/node'; import Config from '../Config'; import mime from 'mime'; import logger from '../logger'; const triggers = require('../triggers'); const http = require('http'); const Utils = require('../Utils'); const downloadFileFromURI = uri => { return new Promise((res, rej) => { http .get(uri, response => { response.setDefaultEncoding('base64'); let body = `data:${response.headers['content-type']};base64,`; response.on('data', data => (body += data)); response.on('end', () => res(body)); }) .on('error', e => { rej(`Error downloading file from ${uri}: ${e.message}`); }); }); }; const addFileDataIfNeeded = async file => { if (file._source.format === 'uri') { const base64 = await downloadFileFromURI(file._source.uri); file._previousSave = file; file._data = base64; file._requestTask = null; } return file; }; 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', BodyParser.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; } getHandler(req, res) { const config = Config.get(req.params.appId); if (!config) { res.status(403); const err = new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Invalid application ID.'); res.json({ code: err.code, error: err.message }); return; } const filesController = config.filesController; const filename = req.params.filename; const 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.'); }); } else { filesController .getFileData(config, filename) .then(data => { res.status(200); res.set('Content-Type', contentType); res.set('Content-Length', data.length); res.end(data); }) .catch(() => { res.status(404); res.set('Content-Type', 'text/plain'); res.end('File not found.'); }); } } 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(fileExtensions); if (regex.test(extension)) { return true; } }); }; let extension = contentType; if (filename && filename.includes('.')) { extension = filename.split('.')[1]; } else if (contentType && contentType.includes('/')) { extension = contentType.split('/')[1]; } extension = extension.split(' ').join(''); if (!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 { 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) { // if the ParseFile returned is type uri, download the file before saving it await addFileDataIfNeeded(fileObject.file); // 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 = 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 (e) { 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' ); }