BREAKING CHANGE: This upgrades the internally used Express framework from version 4 to 5, which may be a breaking change. If Parse Server is set up to be mounted on an Express application, we recommend to also use version 5 of the Express framework to avoid any compatibility issues. Note that even if there are no issues after upgrading, future releases of Parse Server may introduce issues if Parse Server internally relies on Express 5-specific features which are unsupported by the Express version on which it is mounted. See the Express [migration guide](https://expressjs.com/en/guide/migrating-5.html) and [release announcement](https://expressjs.com/2024/10/15/v5-release.html#breaking-changes) for more info.
318 lines
10 KiB
JavaScript
318 lines
10 KiB
JavaScript
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 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',
|
|
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);
|
|
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 mime = (await import('mime')).default;
|
|
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(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) {
|
|
// 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 = 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 (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'
|
|
);
|
|
}
|