feat: Add support for Parse.File.setDirectory, setMetadata, setTags with stream-based file upload (#10092)

This commit is contained in:
Manuel
2026-03-04 17:22:26 +00:00
committed by GitHub
parent 9c9a40d1b1
commit ca666b02fc
4 changed files with 242 additions and 8 deletions
+7 -7
View File
@@ -38,7 +38,7 @@
"mongodb": "7.1.0",
"mustache": "4.2.0",
"otpauth": "9.4.0",
"parse": "8.3.0",
"parse": "8.4.0",
"path-to-regexp": "8.3.0",
"pg-monitor": "3.1.0",
"pg-promise": "12.6.0",
@@ -18402,9 +18402,9 @@
}
},
"node_modules/parse": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse/-/parse-8.3.0.tgz",
"integrity": "sha512-llyOFZfxXZcgy1EpLoxNmClT1+nwp/RmF5mgYQ0/Nj5sl6Acw4oLSjFfckk1gJLAl7FRDsI8TGhhiIJYq41bAQ==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/parse/-/parse-8.4.0.tgz",
"integrity": "sha512-Pfb0Oedh9PHU0ZQ54EupCgh9zZR0OqitiXSHJ6oyGIqCsaOZ+ENAuw6Nr06T0FqzYb/fYkq4cC4hdte2QmjjNA==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "7.28.6",
@@ -35512,9 +35512,9 @@
}
},
"parse": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse/-/parse-8.3.0.tgz",
"integrity": "sha512-llyOFZfxXZcgy1EpLoxNmClT1+nwp/RmF5mgYQ0/Nj5sl6Acw4oLSjFfckk1gJLAl7FRDsI8TGhhiIJYq41bAQ==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/parse/-/parse-8.4.0.tgz",
"integrity": "sha512-Pfb0Oedh9PHU0ZQ54EupCgh9zZR0OqitiXSHJ6oyGIqCsaOZ+ENAuw6Nr06T0FqzYb/fYkq4cC4hdte2QmjjNA==",
"requires": {
"@babel/runtime": "7.28.6",
"@babel/runtime-corejs3": "7.29.0",
+1 -1
View File
@@ -48,7 +48,7 @@
"mongodb": "7.1.0",
"mustache": "4.2.0",
"otpauth": "9.4.0",
"parse": "8.3.0",
"parse": "8.4.0",
"path-to-regexp": "8.3.0",
"pg-monitor": "3.1.0",
"pg-promise": "12.6.0",
+202
View File
@@ -2311,6 +2311,208 @@ describe('Parse.File testing', () => {
expect(b.url).toBeDefined();
});
it('saves file with directory via streaming upload (header)', async () => {
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'X-Parse-Upload-Mode': 'stream',
'X-Parse-File-Directory': 'stream-dir-test',
};
const response = await request({
method: 'POST',
headers,
url: 'http://localhost:8378/1/files/stream-header.txt',
body: 'stream directory header content',
});
const b = response.data;
expect(b.name).toMatch(/^stream-dir-test\/.*_stream-header.txt$/);
expect(b.url).toBeDefined();
});
it('rejects directory header without master key for streaming upload', async () => {
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'X-Parse-Upload-Mode': 'stream',
'X-Parse-File-Directory': 'no-master',
};
try {
await request({
method: 'POST',
headers,
url: 'http://localhost:8378/1/files/stream-header.txt',
body: 'should fail',
});
fail('should have thrown');
} catch (error) {
expect(error.data.code).toEqual(Parse.Error.OPERATION_FORBIDDEN);
}
});
it('validates directory header for streaming upload', async () => {
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'X-Parse-Upload-Mode': 'stream',
'X-Parse-File-Directory': '../etc',
};
try {
await request({
method: 'POST',
headers,
url: 'http://localhost:8378/1/files/stream-header.txt',
body: 'should fail',
});
fail('should have thrown');
} catch (error) {
expect(error.data.code).toEqual(Parse.Error.INVALID_FILE_NAME);
}
});
it('saves file with metadata and tags via streaming upload headers', async () => {
spyOn(FilesController.prototype, 'createFile').and.callThrough();
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'X-Parse-Upload-Mode': 'stream',
'X-Parse-File-Metadata': JSON.stringify({ key1: 'value1' }),
'X-Parse-File-Tags': JSON.stringify({ tag1: 'tagValue1' }),
};
const response = await request({
method: 'POST',
headers,
url: 'http://localhost:8378/1/files/stream-meta.txt',
body: 'stream with metadata content',
});
const b = response.data;
expect(b.name).toMatch(/_stream-meta.txt$/);
expect(b.url).toBeDefined();
const options = FilesController.prototype.createFile.calls.argsFor(0)[4];
expect(options.metadata).toEqual({ key1: 'value1' });
expect(options.tags).toEqual({ tag1: 'tagValue1' });
});
it('saves file with directory, metadata, and tags via streaming upload headers', async () => {
spyOn(FilesController.prototype, 'createFile').and.callThrough();
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'X-Parse-Upload-Mode': 'stream',
'X-Parse-File-Directory': 'uploads',
'X-Parse-File-Metadata': JSON.stringify({ author: 'test' }),
'X-Parse-File-Tags': JSON.stringify({ env: 'test' }),
};
const response = await request({
method: 'POST',
headers,
url: 'http://localhost:8378/1/files/stream-all.txt',
body: 'stream with all file data',
});
const b = response.data;
expect(b.name).toMatch(/^uploads\/.*_stream-all.txt$/);
expect(b.url).toBeDefined();
const options = FilesController.prototype.createFile.calls.argsFor(0)[4];
expect(options.metadata).toEqual({ author: 'test' });
expect(options.tags).toEqual({ env: 'test' });
});
it('rejects invalid JSON in metadata header', async () => {
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'X-Parse-Upload-Mode': 'stream',
'X-Parse-File-Metadata': 'not-json',
};
try {
await request({
method: 'POST',
headers,
url: 'http://localhost:8378/1/files/stream-bad.txt',
body: 'should fail',
});
fail('should have thrown');
} catch (error) {
expect(error.data.code).toEqual(Parse.Error.INVALID_JSON);
}
});
it('rejects invalid JSON in tags header', async () => {
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'X-Parse-Upload-Mode': 'stream',
'X-Parse-File-Tags': '{bad',
};
try {
await request({
method: 'POST',
headers,
url: 'http://localhost:8378/1/files/stream-bad.txt',
body: 'should fail',
});
fail('should have thrown');
} catch (error) {
expect(error.data.code).toEqual(Parse.Error.INVALID_JSON);
}
});
it('rejects non-object metadata header', async () => {
const invalidValues = ['"a string"', '[1,2]', 'null', '42', 'true'];
for (const value of invalidValues) {
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'X-Parse-Upload-Mode': 'stream',
'X-Parse-File-Metadata': value,
};
try {
await request({
method: 'POST',
headers,
url: 'http://localhost:8378/1/files/stream-bad.txt',
body: 'should fail',
});
fail(`should have thrown for metadata: ${value}`);
} catch (error) {
expect(error.data.code).toEqual(Parse.Error.INVALID_JSON);
expect(error.data.error).toBe('Invalid JSON in X-Parse-File-Metadata header.');
}
}
});
it('rejects non-object tags header', async () => {
const invalidValues = ['"a string"', '[1,2]', 'null', '42', 'true'];
for (const value of invalidValues) {
const headers = {
'Content-Type': 'text/plain',
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'X-Parse-Upload-Mode': 'stream',
'X-Parse-File-Tags': value,
};
try {
await request({
method: 'POST',
headers,
url: 'http://localhost:8378/1/files/stream-bad.txt',
body: 'should fail',
});
fail(`should have thrown for tags: ${value}`);
} catch (error) {
expect(error.data.code).toEqual(Parse.Error.INVALID_JSON);
expect(error.data.error).toBe('Invalid JSON in X-Parse-File-Tags header.');
}
}
});
it('validates directory - rejects trailing slash', async () => {
const file = new Parse.File('hello.txt', data, 'text/plain');
file.setDirectory('trailing/');
+32
View File
@@ -314,6 +314,38 @@ export class FilesRouter {
}
}
// For streaming uploads, read file data from headers since the body is the raw stream
if (req.get('X-Parse-Upload-Mode') === 'stream') {
req.fileData = {};
if (req.get('X-Parse-File-Directory')) {
req.fileData.directory = req.get('X-Parse-File-Directory');
}
if (req.get('X-Parse-File-Metadata')) {
try {
const parsed = JSON.parse(req.get('X-Parse-File-Metadata'));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error();
}
req.fileData.metadata = parsed;
} catch {
next(new Parse.Error(Parse.Error.INVALID_JSON, 'Invalid JSON in X-Parse-File-Metadata header.'));
return;
}
}
if (req.get('X-Parse-File-Tags')) {
try {
const parsed = JSON.parse(req.get('X-Parse-File-Tags'));
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error();
}
req.fileData.tags = parsed;
} catch {
next(new Parse.Error(Parse.Error.INVALID_JSON, 'Invalid JSON in X-Parse-File-Tags header.'));
return;
}
}
}
// Validate directory option (requires master key)
const directory = req.fileData?.directory;
if (directory !== undefined) {