Skip to content

Commit ca666b0

Browse files
authored
feat: Add support for Parse.File.setDirectory, setMetadata, setTags with stream-based file upload (#10092)
1 parent 9c9a40d commit ca666b0

4 files changed

Lines changed: 242 additions & 8 deletions

File tree

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"mongodb": "7.1.0",
4949
"mustache": "4.2.0",
5050
"otpauth": "9.4.0",
51-
"parse": "8.3.0",
51+
"parse": "8.4.0",
5252
"path-to-regexp": "8.3.0",
5353
"pg-monitor": "3.1.0",
5454
"pg-promise": "12.6.0",

spec/ParseFile.spec.js

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2311,6 +2311,208 @@ describe('Parse.File testing', () => {
23112311
expect(b.url).toBeDefined();
23122312
});
23132313

2314+
it('saves file with directory via streaming upload (header)', async () => {
2315+
const headers = {
2316+
'Content-Type': 'text/plain',
2317+
'X-Parse-Application-Id': 'test',
2318+
'X-Parse-Master-Key': 'test',
2319+
'X-Parse-Upload-Mode': 'stream',
2320+
'X-Parse-File-Directory': 'stream-dir-test',
2321+
};
2322+
const response = await request({
2323+
method: 'POST',
2324+
headers,
2325+
url: 'http://localhost:8378/1/files/stream-header.txt',
2326+
body: 'stream directory header content',
2327+
});
2328+
const b = response.data;
2329+
expect(b.name).toMatch(/^stream-dir-test\/.*_stream-header.txt$/);
2330+
expect(b.url).toBeDefined();
2331+
});
2332+
2333+
it('rejects directory header without master key for streaming upload', async () => {
2334+
const headers = {
2335+
'Content-Type': 'text/plain',
2336+
'X-Parse-Application-Id': 'test',
2337+
'X-Parse-REST-API-Key': 'rest',
2338+
'X-Parse-Upload-Mode': 'stream',
2339+
'X-Parse-File-Directory': 'no-master',
2340+
};
2341+
try {
2342+
await request({
2343+
method: 'POST',
2344+
headers,
2345+
url: 'http://localhost:8378/1/files/stream-header.txt',
2346+
body: 'should fail',
2347+
});
2348+
fail('should have thrown');
2349+
} catch (error) {
2350+
expect(error.data.code).toEqual(Parse.Error.OPERATION_FORBIDDEN);
2351+
}
2352+
});
2353+
2354+
it('validates directory header for streaming upload', async () => {
2355+
const headers = {
2356+
'Content-Type': 'text/plain',
2357+
'X-Parse-Application-Id': 'test',
2358+
'X-Parse-Master-Key': 'test',
2359+
'X-Parse-Upload-Mode': 'stream',
2360+
'X-Parse-File-Directory': '../etc',
2361+
};
2362+
try {
2363+
await request({
2364+
method: 'POST',
2365+
headers,
2366+
url: 'http://localhost:8378/1/files/stream-header.txt',
2367+
body: 'should fail',
2368+
});
2369+
fail('should have thrown');
2370+
} catch (error) {
2371+
expect(error.data.code).toEqual(Parse.Error.INVALID_FILE_NAME);
2372+
}
2373+
});
2374+
2375+
it('saves file with metadata and tags via streaming upload headers', async () => {
2376+
spyOn(FilesController.prototype, 'createFile').and.callThrough();
2377+
const headers = {
2378+
'Content-Type': 'text/plain',
2379+
'X-Parse-Application-Id': 'test',
2380+
'X-Parse-Master-Key': 'test',
2381+
'X-Parse-Upload-Mode': 'stream',
2382+
'X-Parse-File-Metadata': JSON.stringify({ key1: 'value1' }),
2383+
'X-Parse-File-Tags': JSON.stringify({ tag1: 'tagValue1' }),
2384+
};
2385+
const response = await request({
2386+
method: 'POST',
2387+
headers,
2388+
url: 'http://localhost:8378/1/files/stream-meta.txt',
2389+
body: 'stream with metadata content',
2390+
});
2391+
const b = response.data;
2392+
expect(b.name).toMatch(/_stream-meta.txt$/);
2393+
expect(b.url).toBeDefined();
2394+
const options = FilesController.prototype.createFile.calls.argsFor(0)[4];
2395+
expect(options.metadata).toEqual({ key1: 'value1' });
2396+
expect(options.tags).toEqual({ tag1: 'tagValue1' });
2397+
});
2398+
2399+
it('saves file with directory, metadata, and tags via streaming upload headers', async () => {
2400+
spyOn(FilesController.prototype, 'createFile').and.callThrough();
2401+
const headers = {
2402+
'Content-Type': 'text/plain',
2403+
'X-Parse-Application-Id': 'test',
2404+
'X-Parse-Master-Key': 'test',
2405+
'X-Parse-Upload-Mode': 'stream',
2406+
'X-Parse-File-Directory': 'uploads',
2407+
'X-Parse-File-Metadata': JSON.stringify({ author: 'test' }),
2408+
'X-Parse-File-Tags': JSON.stringify({ env: 'test' }),
2409+
};
2410+
const response = await request({
2411+
method: 'POST',
2412+
headers,
2413+
url: 'http://localhost:8378/1/files/stream-all.txt',
2414+
body: 'stream with all file data',
2415+
});
2416+
const b = response.data;
2417+
expect(b.name).toMatch(/^uploads\/.*_stream-all.txt$/);
2418+
expect(b.url).toBeDefined();
2419+
const options = FilesController.prototype.createFile.calls.argsFor(0)[4];
2420+
expect(options.metadata).toEqual({ author: 'test' });
2421+
expect(options.tags).toEqual({ env: 'test' });
2422+
});
2423+
2424+
it('rejects invalid JSON in metadata header', async () => {
2425+
const headers = {
2426+
'Content-Type': 'text/plain',
2427+
'X-Parse-Application-Id': 'test',
2428+
'X-Parse-Master-Key': 'test',
2429+
'X-Parse-Upload-Mode': 'stream',
2430+
'X-Parse-File-Metadata': 'not-json',
2431+
};
2432+
try {
2433+
await request({
2434+
method: 'POST',
2435+
headers,
2436+
url: 'http://localhost:8378/1/files/stream-bad.txt',
2437+
body: 'should fail',
2438+
});
2439+
fail('should have thrown');
2440+
} catch (error) {
2441+
expect(error.data.code).toEqual(Parse.Error.INVALID_JSON);
2442+
}
2443+
});
2444+
2445+
it('rejects invalid JSON in tags header', async () => {
2446+
const headers = {
2447+
'Content-Type': 'text/plain',
2448+
'X-Parse-Application-Id': 'test',
2449+
'X-Parse-Master-Key': 'test',
2450+
'X-Parse-Upload-Mode': 'stream',
2451+
'X-Parse-File-Tags': '{bad',
2452+
};
2453+
try {
2454+
await request({
2455+
method: 'POST',
2456+
headers,
2457+
url: 'http://localhost:8378/1/files/stream-bad.txt',
2458+
body: 'should fail',
2459+
});
2460+
fail('should have thrown');
2461+
} catch (error) {
2462+
expect(error.data.code).toEqual(Parse.Error.INVALID_JSON);
2463+
}
2464+
});
2465+
2466+
it('rejects non-object metadata header', async () => {
2467+
const invalidValues = ['"a string"', '[1,2]', 'null', '42', 'true'];
2468+
for (const value of invalidValues) {
2469+
const headers = {
2470+
'Content-Type': 'text/plain',
2471+
'X-Parse-Application-Id': 'test',
2472+
'X-Parse-Master-Key': 'test',
2473+
'X-Parse-Upload-Mode': 'stream',
2474+
'X-Parse-File-Metadata': value,
2475+
};
2476+
try {
2477+
await request({
2478+
method: 'POST',
2479+
headers,
2480+
url: 'http://localhost:8378/1/files/stream-bad.txt',
2481+
body: 'should fail',
2482+
});
2483+
fail(`should have thrown for metadata: ${value}`);
2484+
} catch (error) {
2485+
expect(error.data.code).toEqual(Parse.Error.INVALID_JSON);
2486+
expect(error.data.error).toBe('Invalid JSON in X-Parse-File-Metadata header.');
2487+
}
2488+
}
2489+
});
2490+
2491+
it('rejects non-object tags header', async () => {
2492+
const invalidValues = ['"a string"', '[1,2]', 'null', '42', 'true'];
2493+
for (const value of invalidValues) {
2494+
const headers = {
2495+
'Content-Type': 'text/plain',
2496+
'X-Parse-Application-Id': 'test',
2497+
'X-Parse-Master-Key': 'test',
2498+
'X-Parse-Upload-Mode': 'stream',
2499+
'X-Parse-File-Tags': value,
2500+
};
2501+
try {
2502+
await request({
2503+
method: 'POST',
2504+
headers,
2505+
url: 'http://localhost:8378/1/files/stream-bad.txt',
2506+
body: 'should fail',
2507+
});
2508+
fail(`should have thrown for tags: ${value}`);
2509+
} catch (error) {
2510+
expect(error.data.code).toEqual(Parse.Error.INVALID_JSON);
2511+
expect(error.data.error).toBe('Invalid JSON in X-Parse-File-Tags header.');
2512+
}
2513+
}
2514+
});
2515+
23142516
it('validates directory - rejects trailing slash', async () => {
23152517
const file = new Parse.File('hello.txt', data, 'text/plain');
23162518
file.setDirectory('trailing/');

src/Routers/FilesRouter.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,38 @@ export class FilesRouter {
314314
}
315315
}
316316

317+
// For streaming uploads, read file data from headers since the body is the raw stream
318+
if (req.get('X-Parse-Upload-Mode') === 'stream') {
319+
req.fileData = {};
320+
if (req.get('X-Parse-File-Directory')) {
321+
req.fileData.directory = req.get('X-Parse-File-Directory');
322+
}
323+
if (req.get('X-Parse-File-Metadata')) {
324+
try {
325+
const parsed = JSON.parse(req.get('X-Parse-File-Metadata'));
326+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
327+
throw new Error();
328+
}
329+
req.fileData.metadata = parsed;
330+
} catch {
331+
next(new Parse.Error(Parse.Error.INVALID_JSON, 'Invalid JSON in X-Parse-File-Metadata header.'));
332+
return;
333+
}
334+
}
335+
if (req.get('X-Parse-File-Tags')) {
336+
try {
337+
const parsed = JSON.parse(req.get('X-Parse-File-Tags'));
338+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
339+
throw new Error();
340+
}
341+
req.fileData.tags = parsed;
342+
} catch {
343+
next(new Parse.Error(Parse.Error.INVALID_JSON, 'Invalid JSON in X-Parse-File-Tags header.'));
344+
return;
345+
}
346+
}
347+
}
348+
317349
// Validate directory option (requires master key)
318350
const directory = req.fileData?.directory;
319351
if (directory !== undefined) {

0 commit comments

Comments
 (0)