mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85afe91818 | ||
|
|
17d987c95a | ||
|
|
5d94d2b83a | ||
|
|
dbc31c43f0 | ||
|
|
257a73c87a |
@@ -1,3 +1,10 @@
|
||||
# [9.4.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.3.1...9.4.0-alpha.1) (2026-02-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add support for `Parse.File.setDirectory()` with master key to save file in directory ([#10076](https://github.com/parse-community/parse-server/issues/10076)) ([17d987c](https://github.com/parse-community/parse-server/commit/17d987c95accdb2d75f63aed25abd919b0999589))
|
||||
|
||||
## [9.3.1-alpha.4](https://github.com/parse-community/parse-server/compare/9.3.1-alpha.3...9.3.1-alpha.4) (2026-02-23)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
## [9.3.1](https://github.com/parse-community/parse-server/compare/9.3.0...9.3.1) (2026-02-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* GraphQL introspection disabled in `NODE_ENV=production` even with master key ([#10071](https://github.com/parse-community/parse-server/issues/10071)) ([a5269f0](https://github.com/parse-community/parse-server/commit/a5269f077666537fad1d2eeefee82a36a148255c))
|
||||
* JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) ([#10072](https://github.com/parse-community/parse-server/issues/10072)) ([9d5942d](https://github.com/parse-community/parse-server/commit/9d5942d50e55c822924c27b05aa98f1393e7a330))
|
||||
* Remove obsolete Parse Server option `pages.enableRouter` ([#10070](https://github.com/parse-community/parse-server/issues/10070)) ([00b3b72](https://github.com/parse-community/parse-server/commit/00b3b7297d806b4b40d7c08dd987b748e018e4b6))
|
||||
* Type error in docs creation ([#10069](https://github.com/parse-community/parse-server/issues/10069)) ([02a277f](https://github.com/parse-community/parse-server/commit/02a277f1e937fd3e6bd85bdb49870bf3f47678a0))
|
||||
|
||||
# [9.3.0](https://github.com/parse-community/parse-server/compare/9.2.0...9.3.0) (2026-02-21)
|
||||
|
||||
|
||||
|
||||
Generated
+68
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.3.1-alpha.4",
|
||||
"version": "9.4.0-alpha.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "parse-server",
|
||||
"version": "9.3.1-alpha.4",
|
||||
"version": "9.4.0-alpha.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -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",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "parse-server",
|
||||
"version": "9.3.1-alpha.4",
|
||||
"version": "9.4.0-alpha.1",
|
||||
"description": "An express module providing a Parse-compatible API server",
|
||||
"main": "lib/index.js",
|
||||
"repository": {
|
||||
@@ -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
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user