mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
feat: Add support for Parse.File.setDirectory, setMetadata, setTags with stream-based file upload (#10092)
This commit is contained in:
Generated
+7
-7
@@ -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
@@ -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",
|
||||
|
||||
@@ -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/');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user