feat: Add support for Parse.File.setDirectory() with master key to save file in directory (#10076)

This commit is contained in:
Manuel
2026-02-26 00:32:29 +00:00
committed by GitHub
parent 5d94d2b83a
commit 17d987c95a
6 changed files with 380 additions and 21 deletions
+66 -7
View File
@@ -38,7 +38,7 @@
"mongodb": "7.0.0",
"mustache": "4.2.0",
"otpauth": "9.4.0",
"parse": "8.2.0",
"parse": "8.3.0",
"path-to-regexp": "8.3.0",
"pg-monitor": "3.1.0",
"pg-promise": "12.6.0",
@@ -4171,6 +4171,44 @@
"node": "20 || 22 || 24"
}
},
"node_modules/@parse/push-adapter/node_modules/parse": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/parse/-/parse-8.2.0.tgz",
"integrity": "sha512-jSx4zIqCja6O2HKhkzZ6JTm4fBUQS6sQpvFCAsqzaU4XlEhoRLm9mM1tZeYhvxTVA6zymvj/EJZ4YDOXCTRbmA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "7.28.6",
"@babel/runtime-corejs3": "7.29.0",
"crypto-js": "4.2.0",
"idb-keyval": "6.2.2",
"react-native-crypto-js": "1.0.0",
"ws": "8.19.0"
},
"engines": {
"node": ">=20.19.0 <21 || >=22.12.0 <23 || >=24.1.0 <25"
}
},
"node_modules/@parse/push-adapter/node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -18363,9 +18401,9 @@
}
},
"node_modules/parse": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/parse/-/parse-8.2.0.tgz",
"integrity": "sha512-jSx4zIqCja6O2HKhkzZ6JTm4fBUQS6sQpvFCAsqzaU4XlEhoRLm9mM1tZeYhvxTVA6zymvj/EJZ4YDOXCTRbmA==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse/-/parse-8.3.0.tgz",
"integrity": "sha512-llyOFZfxXZcgy1EpLoxNmClT1+nwp/RmF5mgYQ0/Nj5sl6Acw4oLSjFfckk1gJLAl7FRDsI8TGhhiIJYq41bAQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "7.28.6",
@@ -25594,6 +25632,27 @@
"npmlog": "7.0.1",
"parse": "8.2.0",
"web-push": "3.6.7"
},
"dependencies": {
"parse": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/parse/-/parse-8.2.0.tgz",
"integrity": "sha512-jSx4zIqCja6O2HKhkzZ6JTm4fBUQS6sQpvFCAsqzaU4XlEhoRLm9mM1tZeYhvxTVA6zymvj/EJZ4YDOXCTRbmA==",
"requires": {
"@babel/runtime": "7.28.6",
"@babel/runtime-corejs3": "7.29.0",
"crypto-js": "4.2.0",
"idb-keyval": "6.2.2",
"react-native-crypto-js": "1.0.0",
"ws": "8.19.0"
}
},
"ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"requires": {}
}
}
},
"@pkgjs/parseargs": {
@@ -35452,9 +35511,9 @@
}
},
"parse": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/parse/-/parse-8.2.0.tgz",
"integrity": "sha512-jSx4zIqCja6O2HKhkzZ6JTm4fBUQS6sQpvFCAsqzaU4XlEhoRLm9mM1tZeYhvxTVA6zymvj/EJZ4YDOXCTRbmA==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse/-/parse-8.3.0.tgz",
"integrity": "sha512-llyOFZfxXZcgy1EpLoxNmClT1+nwp/RmF5mgYQ0/Nj5sl6Acw4oLSjFfckk1gJLAl7FRDsI8TGhhiIJYq41bAQ==",
"requires": {
"@babel/runtime": "7.28.6",
"@babel/runtime-corejs3": "7.29.0",
+1 -1
View File
@@ -48,7 +48,7 @@
"mongodb": "7.0.0",
"mustache": "4.2.0",
"otpauth": "9.4.0",
"parse": "8.2.0",
"parse": "8.3.0",
"path-to-regexp": "8.3.0",
"pg-monitor": "3.1.0",
"pg-promise": "12.6.0",
+207 -2
View File
@@ -771,7 +771,7 @@ describe('Parse.File testing', () => {
url: 'http://localhost:8378/1/files/invalid-id/invalid-file.txt',
}).catch(e => e);
expect(res1.status).toBe(403);
expect(res1.data).toEqual({ code: 119, error: 'Invalid application ID.' });
expect(res1.data).toEqual({ error: 'Permission denied' });
// Ensure server did not crash
const res2 = await request({ url: 'http://localhost:8378/1/health' });
expect(res2.status).toEqual(200);
@@ -783,7 +783,7 @@ describe('Parse.File testing', () => {
url: 'http://localhost:8378/1/files/invalid-id//invalid-path/%20/invalid-file.txt',
}).catch(e => e);
expect(res1.status).toBe(403);
expect(res1.data).toEqual({ error: 'unauthorized' });
expect(res1.data).toEqual({ error: 'Permission denied' });
// Ensure server did not crash
const res2 = await request({ url: 'http://localhost:8378/1/health' });
expect(res2.status).toEqual(200);
@@ -2153,4 +2153,209 @@ describe('Parse.File testing', () => {
expect(getResponse.text).toEqual('legacy content');
});
});
describe('file directory', () => {
it('saves file with directory using master key', async () => {
spyOn(FilesController.prototype, 'createFile').and.callThrough();
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory('user-uploads/avatars');
const result = await file.save({ useMasterKey: true });
expect(result.name()).toMatch(/^user-uploads\/avatars\/.*_hello.txt$/);
expect(result.url()).toBeDefined();
// directory is consumed (deleted) from options by FilesController.createFile
// and prepended to the filename, which is verified above via result.name()
expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({
metadata: {},
});
});
it('rejects directory without master key', async () => {
await reconfigureServer({
fileUpload: {
enableForPublic: true,
},
});
try {
const response = await request({
method: 'POST',
url: 'http://localhost:8378/1/files/hello.txt',
body: JSON.stringify({
_ApplicationId: 'test',
_JavaScriptKey: 'test',
_ContentType: 'text/plain',
base64: Buffer.from('Hello World!').toString('base64'),
fileData: {
directory: 'some-dir',
metadata: {},
tags: {},
},
}),
});
fail('should have thrown');
expect(response).toBeUndefined();
} catch (error) {
expect(error.data.code).toEqual(Parse.Error.OPERATION_FORBIDDEN);
expect(error.data.error).toEqual('Directory can only be set using the Master Key.');
}
});
it('validates directory - rejects path traversal', async () => {
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory('some/../etc');
try {
await file.save({ useMasterKey: true });
fail('should have thrown');
} catch (error) {
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
expect(error.message).toContain('..');
}
});
it('validates directory - rejects leading slash', async () => {
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory('/absolute-path');
try {
await file.save({ useMasterKey: true });
fail('should have thrown');
} catch (error) {
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
}
});
it('validates directory - rejects invalid characters', async () => {
const invalidDirs = ['dir with spaces', '~root', '$HOME/files', 'dir%00name', '.hidden', 'foo\\bar'];
for (const dir of invalidDirs) {
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory(dir);
try {
await file.save({ useMasterKey: true });
fail(`should have thrown for directory: ${dir}`);
} catch (error) {
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
expect(error.message).toContain('invalid characters');
}
}
});
it('validates directory - rejects consecutive slashes', async () => {
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory('dir//subdir');
try {
await file.save({ useMasterKey: true });
fail('should have thrown');
} catch (error) {
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
expect(error.message).toContain('consecutive slashes');
}
});
it('saves and retrieves file with nested directory', async () => {
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory('photos/2024/january');
const result = await file.save({ useMasterKey: true });
expect(result.name()).toMatch(/^photos\/2024\/january\/.*_hello.txt$/);
expect(result.url()).toBeDefined();
// Retrieve the file via its URL
const response = await request({ url: result.url() });
expect(response.text).toEqual(str);
});
it('allows beforeSaveFile trigger to set directory', async () => {
Parse.Cloud.beforeSave(Parse.File, req => {
req.file.setDirectory('trigger-dir');
});
spyOn(FilesController.prototype, 'createFile').and.callThrough();
const file = new Parse.File('hello.txt', data, 'text/plain');
const result = await file.save();
expect(result.name()).toMatch(/^trigger-dir\/.*_hello.txt$/);
// directory is consumed (deleted) from options by FilesController.createFile
// and prepended to the filename, which is verified above via result.name()
expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({
metadata: {},
});
});
it('deletes file with directory path', async () => {
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory('delete-test');
const result = await file.save({ useMasterKey: true });
expect(result.name()).toMatch(/^delete-test\/.*_hello.txt$/);
await result.destroy({ useMasterKey: true });
// Verify file is gone
try {
await request({ url: result.url() });
fail('should have thrown');
} catch (error) {
expect(error.status).toBe(404);
}
});
it('saves file with directory via streaming upload (trigger)', async () => {
Parse.Cloud.beforeSave(Parse.File, req => {
req.file.setDirectory('stream-uploads');
});
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Upload-Mode': 'stream',
};
const response = await request({
method: 'POST',
headers,
url: 'http://localhost:8378/1/files/stream-dir.txt',
body: 'stream directory content',
});
const b = response.data;
expect(b.name).toMatch(/^stream-uploads\/.*_stream-dir.txt$/);
expect(b.url).toBeDefined();
});
it('validates directory - rejects trailing slash', async () => {
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory('trailing/');
try {
await file.save({ useMasterKey: true });
fail('should have thrown');
} catch (error) {
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
expect(error.message).toContain('start or end with');
}
});
it('validates directory - rejects too long path', async () => {
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory('a'.repeat(257));
try {
await file.save({ useMasterKey: true });
fail('should have thrown');
} catch (error) {
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
expect(error.message).toContain('too long');
}
});
it('validates directory - rejects reserved segment "metadata"', async () => {
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory('metadata/docs');
try {
await file.save({ useMasterKey: true });
fail('should have thrown');
} catch (error) {
expect(error.code).toEqual(Parse.Error.INVALID_FILE_NAME);
expect(error.message).toContain('reserved segment');
}
});
it('saves file without directory (no change to existing behavior)', async () => {
spyOn(FilesController.prototype, 'createFile').and.callThrough();
const file = new Parse.File('hello.txt', data, 'text/plain');
const result = await file.save();
expect(result.name()).not.toContain('/');
expect(result.url()).toBeDefined();
expect(FilesController.prototype.createFile.calls.argsFor(0)[4]).toEqual({
metadata: {},
});
});
});
});
+2 -1
View File
@@ -220,7 +220,8 @@ export class GridFSBucketAdapter extends FilesAdapter {
}
getFileLocation(config, filename) {
return config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename);
const encodedFilename = filename.split('/').map(encodeURIComponent).join('/');
return config.mount + '/files/' + config.applicationId + '/' + encodedFilename;
}
async getMetadata(filename) {
+6
View File
@@ -29,6 +29,12 @@ export class FilesController extends AdaptableController {
filename = randomHexString(32) + '_' + filename;
}
// Prepend directory if provided
if (options && options.directory) {
filename = options.directory + '/' + filename;
delete options.directory;
}
// Fallback: buffer stream for adapters that don't support streaming
if (typeof data?.pipe === 'function' && !this.adapter.supportsStreaming) {
data = await new Promise((resolve, reject) => {
+98 -10
View File
@@ -6,6 +6,7 @@ import logger from '../logger';
const triggers = require('../triggers');
const Utils = require('../Utils');
import { Readable } from 'stream';
import { createSanitizedHttpError } from '../Error';
/**
* Wraps a readable stream in a Readable that enforces a byte size limit.
@@ -79,11 +80,17 @@ export function createSizeLimitedStream(source, maxBytes) {
return output;
}
// Segments that conflict with sub-routes under GET /files/:appId/*. If a file
// directory starts with one of these, its URL would match the wrong route
// handler. Update this list when adding new sub-routes to expressRouter().
export const RESERVED_DIRECTORY_SEGMENTS = ['metadata'];
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);
// Metadata route must come before the catch-all GET route
router.get('/files/:appId/metadata/*filepath', this.metadataHandler);
router.get('/files/:appId/*filepath', this.getHandler);
router.post('/files', function (req, res, next) {
next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Filename not provided.'));
@@ -98,7 +105,7 @@ export class FilesRouter {
);
router.delete(
'/files/:filename',
'/files/*filepath',
Middlewares.handleParseHeaders,
Middlewares.handleParseSession,
Middlewares.enforceMasterKeyAccess,
@@ -107,15 +114,63 @@ export class FilesRouter {
return router;
}
static _getFilenameFromParams(req) {
const parts = req.params.filepath;
return Array.isArray(parts) ? parts.join('/') : parts;
}
static validateDirectory(directory) {
if (typeof directory !== 'string') {
return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must be a string.');
}
if (directory.length === 0) {
return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must not be empty.');
}
if (directory.length > 256) {
return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory path is too long.');
}
if (directory.includes('..')) {
return new Parse.Error(Parse.Error.INVALID_FILE_NAME, 'Directory must not contain "..".');
}
if (directory.startsWith('/') || directory.endsWith('/')) {
return new Parse.Error(
Parse.Error.INVALID_FILE_NAME,
'Directory must not start or end with "/".'
);
}
if (directory.includes('//')) {
return new Parse.Error(
Parse.Error.INVALID_FILE_NAME,
'Directory must not contain consecutive slashes.'
);
}
const firstSegment = directory.split('/')[0];
if (RESERVED_DIRECTORY_SEGMENTS.includes(firstSegment)) {
return new Parse.Error(
Parse.Error.INVALID_FILE_NAME,
`Directory must not start with reserved segment "${firstSegment}".`
);
}
const dirRegex = /^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/;
if (!dirRegex.test(directory)) {
return new Parse.Error(
Parse.Error.INVALID_FILE_NAME,
'Directory contains invalid characters.'
);
}
return null;
}
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.' });
const error = createSanitizedHttpError(403, 'Invalid application ID.', config);
res.status(error.status);
res.json({ error: error.message });
return;
}
let filename = req.params.filename;
let filename = FilesRouter._getFilenameFromParams(req);
try {
const filesController = config.filesController;
const mime = (await import('mime')).default;
@@ -259,6 +314,25 @@ export class FilesRouter {
}
}
// Validate directory option (requires master key)
const directory = req.fileData?.directory;
if (directory !== undefined) {
if (!isMaster) {
next(
new Parse.Error(
Parse.Error.OPERATION_FORBIDDEN,
'Directory can only be set using the Master Key.'
)
);
return;
}
const directoryError = FilesRouter.validateDirectory(directory);
if (directoryError) {
next(directoryError);
return;
}
}
// Dispatch to the appropriate handler based on whether the body was buffered
if (req.body instanceof Buffer) {
return this._handleBufferedUpload(req, res, next);
@@ -279,7 +353,7 @@ export class FilesRouter {
const base64 = req.body.toString('base64');
const file = new Parse.File(filename, { base64 }, contentType);
const { metadata = {}, tags = {} } = req.fileData || {};
const { metadata = {}, tags = {}, directory } = req.fileData || {};
try {
// Scan request data for denied keywords
Utils.checkProhibitedKeywords(config, metadata);
@@ -290,6 +364,9 @@ export class FilesRouter {
}
file.setTags(tags);
file.setMetadata(metadata);
if (directory) {
file.setDirectory(directory);
}
const fileSize = Buffer.byteLength(req.body);
const fileObject = { file, fileSize };
try {
@@ -332,6 +409,10 @@ export class FilesRouter {
const fileTags =
Object.keys(fileObject.file._tags).length > 0 ? { tags: fileObject.file._tags } : {};
Object.assign(fileOptions, fileTags);
// include directory if set (from client request or beforeSaveFile trigger)
if (fileObject.file._directory) {
fileOptions.directory = fileObject.file._directory;
}
// save file
const createFileResult = await filesController.createFile(
config,
@@ -400,7 +481,7 @@ export class FilesRouter {
// Build a Parse.File with no _data (streaming mode)
const file = new Parse.File(filename, { base64: '' }, contentType);
const { metadata = {}, tags = {} } = req.fileData || {};
const { metadata = {}, tags = {}, directory } = req.fileData || {};
// Validate metadata and tags for prohibited keywords
try {
@@ -414,6 +495,9 @@ export class FilesRouter {
file.setTags(tags);
file.setMetadata(metadata);
if (directory) {
file.setDirectory(directory);
}
const fileSize = req.get('Content-Length')
? parseInt(req.get('Content-Length'), 10)
@@ -452,6 +536,10 @@ export class FilesRouter {
const fileTags =
Object.keys(fileObject.file._tags).length > 0 ? { tags: fileObject.file._tags } : {};
Object.assign(fileOptions, fileTags);
// include directory if set (from client request or beforeSaveFile trigger)
if (fileObject.file._directory) {
fileOptions.directory = fileObject.file._directory;
}
// Pass stream directly to filesController — it will buffer if adapter doesn't support streaming
const sourceType = fileObject.file._source?.type || contentType;
@@ -498,7 +586,7 @@ export class FilesRouter {
async deleteHandler(req, res, next) {
try {
const { filesController } = req.config;
const { filename } = req.params;
const filename = FilesRouter._getFilenameFromParams(req);
// run beforeDeleteFile trigger
const file = new Parse.File(filename);
file._url = await filesController.adapter.getFileLocation(req.config, filename);
@@ -535,7 +623,7 @@ export class FilesRouter {
try {
const config = Config.get(req.params.appId);
const { filesController } = config;
const { filename } = req.params;
const filename = FilesRouter._getFilenameFromParams(req);
const data = await filesController.getMetadata(filename);
res.status(200);
res.json(data);