diff --git a/spec/GridFSBucketStorageAdapter.spec.js b/spec/GridFSBucketStorageAdapter.spec.js index 57f94c2dab..6a274125bc 100644 --- a/spec/GridFSBucketStorageAdapter.spec.js +++ b/spec/GridFSBucketStorageAdapter.spec.js @@ -476,6 +476,31 @@ describe_only_db('mongo')('GridFSBucket', () => { } }); + it('reports supportsStreaming as true', () => { + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + expect(gfsAdapter.supportsStreaming).toBe(true); + }); + + it('creates file from Readable stream', async () => { + const { Readable } = require('stream'); + const gfsAdapter = new GridFSBucketAdapter(databaseURI); + const data = Buffer.from('streamed file content'); + const stream = Readable.from(data); + await gfsAdapter.createFile('streamFile.txt', stream); + const result = await gfsAdapter.getFileData('streamFile.txt'); + expect(result.toString('utf8')).toBe('streamed file content'); + }); + + it('creates encrypted file from Readable stream (buffers for encryption)', async () => { + const { Readable } = require('stream'); + const gfsAdapter = new GridFSBucketAdapter(databaseURI, {}, 'test-encryption-key'); + const data = Buffer.from('encrypted streamed content'); + const stream = Readable.from(data); + await gfsAdapter.createFile('encryptedStream.txt', stream); + const result = await gfsAdapter.getFileData('encryptedStream.txt'); + expect(result.toString('utf8')).toBe('encrypted streamed content'); + }); + describe('MongoDB Client Metadata', () => { it('should not pass metadata to MongoClient by default', async () => { const gfsAdapter = new GridFSBucketAdapter(databaseURI); diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 46496a0768..da69edc416 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -1878,4 +1878,279 @@ describe('Parse.File testing', () => { ).toBeRejectedWith(jasmine.objectContaining({ status: 400 })); }); }); + + describe('streaming binary uploads', () => { + afterEach(() => { + Parse.Cloud._removeAllHooks(); + }); + + describe('createSizeLimitedStream', () => { + const { createSizeLimitedStream } = require('../lib/Routers/FilesRouter'); + const { Readable } = require('stream'); + + it('passes data through when under limit', async () => { + const input = Readable.from(Buffer.from('hello')); + const limited = createSizeLimitedStream(input, 100); + const chunks = []; + for await (const chunk of limited) { + chunks.push(chunk); + } + expect(Buffer.concat(chunks).toString()).toBe('hello'); + }); + + it('destroys stream when data exceeds limit', async () => { + const input = Readable.from(Buffer.from('hello world, this is too long')); + const limited = createSizeLimitedStream(input, 5); + const chunks = []; + try { + for await (const chunk of limited) { + chunks.push(chunk); + } + fail('should have thrown'); + } catch (e) { + expect(e.message).toContain('exceeds'); + } + }); + + }); + + it('streams binary upload with X-Parse-Upload-Mode header', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + let response; + try { + response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/stream-test.txt', + body: 'streaming file content', + }); + } catch (e) { + fail('Request failed: status=' + e.status + ' text=' + e.text + ' data=' + JSON.stringify(e.data)); + return; + } + const b = response.data; + expect(b.name).toMatch(/_stream-test.txt$/); + expect(b.url).toMatch(/stream-test\.txt$/); + const getResponse = await request({ url: b.url }); + expect(getResponse.text).toEqual('streaming file content'); + }); + + it('infers content type from extension when Content-Type header is missing', async () => { + const headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/inferred.txt', + body: 'inferred content type', + }); + const b = response.data; + expect(b.name).toMatch(/_inferred.txt$/); + const getResponse = await request({ url: b.url }); + expect(getResponse.text).toEqual('inferred content type'); + }); + + it('uses buffered path without X-Parse-Upload-Mode header', async () => { + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/buffered-test.txt', + body: 'buffered file content', + }); + const b = response.data; + expect(b.name).toMatch(/_buffered-test.txt$/); + const getResponse = await request({ url: b.url }); + expect(getResponse.text).toEqual('buffered file content'); + }); + + it('rejects streaming upload exceeding size limit', async () => { + await reconfigureServer({ maxUploadSize: '10b' }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/big-file.txt', + body: 'this content is definitely longer than 10 bytes', + }); + fail('should have thrown'); + } catch (response) { + expect(response.data.code).toBe(Parse.Error.FILE_SAVE_ERROR); + expect(response.data.error).toContain('exceeds'); + } + }); + + it('rejects streaming upload with Content-Length exceeding limit', async () => { + await reconfigureServer({ maxUploadSize: '10b' }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + 'Content-Length': '99999', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/big-file.txt', + body: 'hi', + }); + fail('should have thrown'); + } catch (response) { + expect(response.data.code).toBe(Parse.Error.FILE_SAVE_ERROR); + expect(response.data.error).toContain('exceeds'); + } + }); + + it('fires beforeSave trigger with request.stream = true on streaming upload', async () => { + let receivedStream; + let receivedData; + Parse.Cloud.beforeSave(Parse.File, (request) => { + receivedStream = request.stream; + receivedData = request.file._data; + request.file.addMetadata('source', 'stream'); + request.file.addTag('env', 'test'); + }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/trigger-test.txt', + body: 'trigger test content', + }); + expect(response.data.name).toMatch(/_trigger-test.txt$/); + expect(receivedStream).toBe(true); + expect(receivedData).toBeFalsy(); + const getResponse = await request({ url: response.data.url }); + expect(getResponse.text).toEqual('trigger test content'); + }); + + it('rejects streaming upload when beforeSave trigger throws', async () => { + Parse.Cloud.beforeSave(Parse.File, () => { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Upload rejected'); + }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + try { + await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/rejected.txt', + body: 'rejected content', + }); + fail('should have thrown'); + } catch (response) { + expect(response.data.code).toBe(Parse.Error.SCRIPT_FAILED); + expect(response.data.error).toBe('Upload rejected'); + } + }); + + it('skips save when beforeSave trigger returns Parse.File with URL on streaming upload', async () => { + Parse.Cloud.beforeSave(Parse.File, () => { + return Parse.File.fromJSON({ + __type: 'File', + name: 'existing.txt', + url: 'http://example.com/existing.txt', + }); + }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/skip-save.txt', + body: 'should not be saved', + }); + expect(response.data.url).toBe('http://example.com/existing.txt'); + expect(response.data.name).toBe('existing.txt'); + }); + + it('fires afterSave trigger with request.stream = true on streaming upload', async () => { + let afterSaveStream; + let afterSaveData; + let afterSaveUrl; + Parse.Cloud.afterSave(Parse.File, (request) => { + afterSaveStream = request.stream; + afterSaveData = request.file._data; + afterSaveUrl = request.file._url; + }); + const headers = { + 'Content-Type': 'application/octet-stream', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + }; + const response = await request({ + method: 'POST', + headers: headers, + url: 'http://localhost:8378/1/files/after-save.txt', + body: 'after save content', + }); + expect(response.data.name).toMatch(/_after-save.txt$/); + expect(afterSaveStream).toBe(true); + expect(afterSaveData).toBeFalsy(); + expect(afterSaveUrl).toBeTruthy(); + }); + + it('verifies FilesAdapter default supportsStreaming is false', () => { + const { FilesAdapter } = require('../lib/Adapters/Files/FilesAdapter'); + const adapter = new FilesAdapter(); + expect(adapter.supportsStreaming).toBe(false); + }); + + it('legacy JSON-wrapped upload still works', async () => { + await reconfigureServer({ + fileUpload: { + enableForPublic: true, + fileExtensions: ['*'], + }, + }); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/files/legacy.txt', + body: JSON.stringify({ + _ApplicationId: 'test', + _JavaScriptKey: 'test', + _ContentType: 'text/plain', + base64: Buffer.from('legacy content').toString('base64'), + }), + }); + const b = response.data; + expect(b.name).toMatch(/_legacy.txt$/); + const getResponse = await request({ url: b.url }); + expect(getResponse.text).toEqual('legacy content'); + }); + }); }); diff --git a/spec/Utils.spec.js b/spec/Utils.spec.js index a473064376..853ddc9b3c 100644 --- a/spec/Utils.spec.js +++ b/spec/Utils.spec.js @@ -175,6 +175,81 @@ describe('Utils', () => { }); }); + describe('parseSizeToBytes', () => { + it('parses megabyte string', () => { + expect(Utils.parseSizeToBytes('20mb')).toBe(20 * 1024 * 1024); + }); + + it('parses Mb string (case-insensitive)', () => { + expect(Utils.parseSizeToBytes('20Mb')).toBe(20 * 1024 * 1024); + }); + + it('parses kilobyte string', () => { + expect(Utils.parseSizeToBytes('512kb')).toBe(512 * 1024); + }); + + it('parses gigabyte string', () => { + expect(Utils.parseSizeToBytes('1gb')).toBe(1 * 1024 * 1024 * 1024); + }); + + it('parses bytes suffix', () => { + expect(Utils.parseSizeToBytes('100b')).toBe(100); + }); + + it('parses plain number as bytes', () => { + expect(Utils.parseSizeToBytes(1048576)).toBe(1048576); + }); + + it('parses numeric string as bytes', () => { + expect(Utils.parseSizeToBytes('1048576')).toBe(1048576); + }); + + it('parses decimal value and floors result', () => { + expect(Utils.parseSizeToBytes('1.5mb')).toBe(Math.floor(1.5 * 1024 * 1024)); + }); + + it('trims whitespace around value', () => { + expect(Utils.parseSizeToBytes(' 20mb ')).toBe(20 * 1024 * 1024); + }); + + it('allows whitespace between number and unit', () => { + expect(Utils.parseSizeToBytes('20 mb')).toBe(20 * 1024 * 1024); + }); + + it('parses zero', () => { + expect(Utils.parseSizeToBytes('0')).toBe(0); + expect(Utils.parseSizeToBytes(0)).toBe(0); + }); + + it('throws on invalid string', () => { + expect(() => Utils.parseSizeToBytes('abc')).toThrow(); + }); + + it('throws on negative value', () => { + expect(() => Utils.parseSizeToBytes('-5mb')).toThrow(); + }); + + it('throws on empty string', () => { + expect(() => Utils.parseSizeToBytes('')).toThrow(); + }); + + it('throws on unsupported unit', () => { + expect(() => Utils.parseSizeToBytes('10tb')).toThrow(); + }); + + it('throws on NaN', () => { + expect(() => Utils.parseSizeToBytes(NaN)).toThrow(); + }); + + it('throws on Infinity', () => { + expect(() => Utils.parseSizeToBytes(Infinity)).toThrow(); + }); + + it('throws on negative number', () => { + expect(() => Utils.parseSizeToBytes(-1)).toThrow(); + }); + }); + describe('createSanitizedError', () => { it('should return "Permission denied" when enableSanitizedErrorResponse is true', () => { const config = { enableSanitizedErrorResponse: true }; diff --git a/src/Adapters/Files/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js index 0e9b555853..0ada581069 100644 --- a/src/Adapters/Files/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -26,7 +26,7 @@ export class FilesAdapter { /** Responsible for storing the file in order to be retrieved later by its filename * * @param {string} filename - the filename to save - * @param {*} data - the buffer of data from the file + * @param {Buffer|import('stream').Readable} data - the file data as a Buffer, or a Readable stream if the adapter supports streaming (see supportsStreaming) * @param {string} contentType - the supposed contentType * @discussion the contentType can be undefined if the controller was not able to determine it * @param {object} options - (Optional) options to be passed to file adapter (S3 File Adapter Only) @@ -38,6 +38,16 @@ export class FilesAdapter { */ createFile(filename: string, data, contentType: string, options: Object): Promise {} + /** Whether this adapter supports receiving Readable streams in createFile(). + * If false (default), streams are buffered to a Buffer before being passed. + * Override and return true to receive Readable streams directly. + * + * @return {boolean} + */ + get supportsStreaming() { + return false; + } + /** Responsible for deleting the specified file * * @param {string} filename - the filename to delete diff --git a/src/Adapters/Files/GridFSBucketAdapter.js b/src/Adapters/Files/GridFSBucketAdapter.js index 8e1a849030..18c4eaa3d6 100644 --- a/src/Adapters/Files/GridFSBucketAdapter.js +++ b/src/Adapters/Files/GridFSBucketAdapter.js @@ -45,6 +45,10 @@ export class GridFSBucketAdapter extends FilesAdapter { this._mongoOptions = _mongoOptions; } + get supportsStreaming() { + return true; + } + _connect() { if (!this._connectionPromise) { // Only use driverInfo if clientMetadata option is set @@ -77,6 +81,32 @@ export class GridFSBucketAdapter extends FilesAdapter { const stream = await bucket.openUploadStream(filename, { metadata: options.metadata, }); + + // If data is a stream and encryption is enabled, buffer first + // (AES-256-GCM needs complete data for format: [encrypted][IV][authTag]) + if (typeof data?.pipe === 'function' && this._encryptionKey !== null) { + data = await new Promise((resolve, reject) => { + const chunks = []; + data.on('data', chunk => chunks.push(chunk)); + data.on('end', () => resolve(Buffer.concat(chunks))); + data.on('error', reject); + }); + } + + if (typeof data?.pipe === 'function') { + // Pipe readable stream directly into GridFS upload stream + return new Promise((resolve, reject) => { + data.pipe(stream); + stream.on('finish', resolve); + stream.on('error', reject); + data.on('error', (err) => { + stream.destroy(err); + reject(err); + }); + }); + } + + // Buffer path (existing behavior) if (this._encryptionKey !== null) { try { const iv = crypto.randomBytes(16); diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index 15551a6e38..610b48b3fd 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -48,6 +48,11 @@ export class AdaptableController { // Makes sure the prototype matches const mismatches = Object.getOwnPropertyNames(Type.prototype).reduce((obj, key) => { + // Skip getters — they provide optional defaults that adapters don't need to implement + const descriptor = Object.getOwnPropertyDescriptor(Type.prototype, key); + if (descriptor && typeof descriptor.get === 'function') { + return obj; + } const adapterType = typeof adapter[key]; const expectedType = typeof Type.prototype[key]; if (adapterType !== expectedType) { diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 39db972fe0..e21ea7c1a3 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -29,6 +29,16 @@ export class FilesController extends AdaptableController { filename = randomHexString(32) + '_' + filename; } + // Fallback: buffer stream for adapters that don't support streaming + if (typeof data?.pipe === 'function' && !this.adapter.supportsStreaming) { + data = await new Promise((resolve, reject) => { + const chunks = []; + data.on('data', chunk => chunks.push(chunk)); + data.on('end', () => resolve(Buffer.concat(chunks))); + data.on('error', reject); + }); + } + const location = await this.adapter.getFileLocation(config, filename); await this.adapter.createFile(filename, data, contentType, options); return { diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index f0bb483d7b..f53e1c9d87 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -5,6 +5,79 @@ import Config from '../Config'; import logger from '../logger'; const triggers = require('../triggers'); const Utils = require('../Utils'); +import { Readable } from 'stream'; + +/** + * Wraps a readable stream in a Readable that enforces a byte size limit. + * Data flow is lazy: the source is not read until a consumer starts reading + * from the returned stream (via pipe or 'data' listener). This ensures the + * consumer's error listener is attached before any data (or error) is emitted. + */ +export function createSizeLimitedStream(source, maxBytes) { + let totalBytes = 0; + let started = false; + let sourceEnded = false; + let onData, onEnd, onError; + + const output = new Readable({ + read() { + if (!started) { + started = true; + + onData = (chunk) => { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + output.destroy( + new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File size exceeds maximum allowed: ${maxBytes} bytes.` + ) + ); + return; + } + if (!output.push(chunk)) { + source.pause(); + } + }; + + onEnd = () => { + sourceEnded = true; + output.push(null); + }; + + onError = (err) => output.destroy(err); + + source.on('data', onData); + source.on('end', onEnd); + source.on('error', onError); + } + + // Resume source in case it was paused due to backpressure + if (!sourceEnded) { + source.resume(); + } + }, + destroy(err, callback) { + if (onData) { + source.removeListener('data', onData); + } + if (onEnd) { + source.removeListener('end', onEnd); + } + if (onError) { + source.removeListener('error', onError); + } + // Suppress errors emitted during drain (e.g. client disconnect) + source.on('error', () => {}); + if (!sourceEnded) { + source.resume(); + } + callback(err); + } + }); + + return output; +} export class FilesRouter { expressRouter({ maxUploadSize = '20Mb' } = {}) { @@ -18,15 +91,10 @@ export class FilesRouter { router.post( '/files/:filename', - express.raw({ - type: () => { - return true; - }, - limit: maxUploadSize, - }), // Allow uploads without Content-Type, or with any Content-Type. + this._bodyParsingMiddleware(maxUploadSize), Middlewares.handleParseHeaders, Middlewares.handleParseSession, - this.createHandler + this.createHandler.bind(this) ); router.delete( @@ -111,6 +179,20 @@ export class FilesRouter { } } + _bodyParsingMiddleware(maxUploadSize) { + const rawParser = express.raw({ + type: () => true, + limit: maxUploadSize, + }); + return (req, res, next) => { + if (req.get('X-Parse-Upload-Mode') === 'stream') { + req._maxUploadSizeBytes = Utils.parseSizeToBytes(maxUploadSize); + return next(); + } + return rawParser(req, res, next); + }; + } + async createHandler(req, res, next) { const config = req.config; const user = req.auth.user; @@ -139,11 +221,6 @@ export class FilesRouter { const { filename } = req.params; const contentType = req.get('Content-type'); - if (!req.body || !req.body.length) { - next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.')); - return; - } - const error = filesController.validateFilename(filename); if (error) { next(error); @@ -182,6 +259,24 @@ export class FilesRouter { } } + // Dispatch to the appropriate handler based on whether the body was buffered + if (req.body instanceof Buffer) { + return this._handleBufferedUpload(req, res, next); + } + return this._handleStreamUpload(req, res, next); + } + + async _handleBufferedUpload(req, res, next) { + const config = req.config; + const filesController = config.filesController; + const { filename } = req.params; + const contentType = req.get('Content-type'); + + if (!req.body || !req.body.length) { + next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Invalid file upload.')); + return; + } + const base64 = req.body.toString('base64'); const file = new Parse.File(filename, { base64 }, contentType); const { metadata = {}, tags = {} } = req.fileData || {}; @@ -221,7 +316,12 @@ export class FilesRouter { // if the file returned by the trigger has already been saved skip saving anything if (!saveResult) { // update fileSize - const bufferData = Buffer.from(fileObject.file._data, 'base64'); + let bufferData; + if (fileObject.file._source?.format === 'buffer') { + bufferData = fileObject.file._source.buffer; + } else { + bufferData = Buffer.from(fileObject.file._data, 'base64'); + } fileObject.fileSize = Buffer.byteLength(bufferData); // prepare file options const fileOptions = { @@ -265,6 +365,136 @@ export class FilesRouter { } } + async _handleStreamUpload(req, res, next) { + const config = req.config; + const filesController = config.filesController; + const { filename } = req.params; + let contentType = req.get('Content-Type'); + const maxBytes = req._maxUploadSizeBytes; + let stream; + + try { + // Early rejection via Content-Length header + const contentLength = req.get('Content-Length'); + if (contentLength && parseInt(contentLength, 10) > maxBytes) { + req.resume(); + next(new Parse.Error( + Parse.Error.FILE_SAVE_ERROR, + `File size exceeds maximum allowed: ${maxBytes} bytes.` + )); + return; + } + + const mime = (await import('mime')).default; + + // Infer content type from extension or add extension from content type + const hasExtension = filename && filename.includes('.'); + if (hasExtension && !contentType) { + contentType = mime.getType(filename); + } else if (!hasExtension && contentType) { + // extension will be added by filesController.createFile + } + + // Create size-limited stream wrapping the request + stream = createSizeLimitedStream(req, maxBytes); + + // Build a Parse.File with no _data (streaming mode) + const file = new Parse.File(filename, { base64: '' }, contentType); + const { metadata = {}, tags = {} } = req.fileData || {}; + + // Validate metadata and tags for prohibited keywords + try { + Utils.checkProhibitedKeywords(config, metadata); + Utils.checkProhibitedKeywords(config, tags); + } catch (error) { + stream.destroy(); + next(new Parse.Error(Parse.Error.INVALID_KEY_NAME, error)); + return; + } + + file.setTags(tags); + file.setMetadata(metadata); + + const fileSize = req.get('Content-Length') + ? parseInt(req.get('Content-Length'), 10) + : null; + const fileObject = { file, fileSize, stream: true }; + + // Run beforeSaveFile trigger + const triggerResult = await triggers.maybeRunFileTrigger( + triggers.Types.beforeSave, + fileObject, + config, + req.auth + ); + + let saveResult; + // If a new ParseFile is returned, check if it's an already saved file + if (triggerResult instanceof Parse.File) { + fileObject.file = triggerResult; + if (triggerResult.url()) { + fileObject.fileSize = null; + saveResult = { + url: triggerResult.url(), + name: triggerResult._name, + }; + // Destroy stream to remove listeners and drain request + stream.destroy(); + } + } + + // If the file returned by the trigger has already been saved, skip saving + if (!saveResult) { + // Prepare file options + const fileOptions = { + metadata: fileObject.file._metadata, + }; + const fileTags = + Object.keys(fileObject.file._tags).length > 0 ? { tags: fileObject.file._tags } : {}; + Object.assign(fileOptions, fileTags); + + // Pass stream directly to filesController — it will buffer if adapter doesn't support streaming + const sourceType = fileObject.file._source?.type || contentType; + const createFileResult = await filesController.createFile( + config, + fileObject.file._name, + stream, + sourceType, + fileOptions + ); + + // Update file with new data + fileObject.file._name = createFileResult.name; + fileObject.file._url = createFileResult.url; + fileObject.file._requestTask = null; + fileObject.file._previousSave = Promise.resolve(fileObject.file); + saveResult = { + url: createFileResult.url, + name: createFileResult.name, + }; + } + + // Run afterSaveFile trigger + await triggers.maybeRunFileTrigger(triggers.Types.afterSave, fileObject, config, req.auth); + res.status(201); + res.set('Location', saveResult.url); + res.json(saveResult); + } catch (e) { + // Destroy stream to remove listeners and drain request, or resume directly + if (stream) { + stream.destroy(); + } else { + req.resume(); + } + logger.error('Error creating a file: ', e); + const error = triggers.resolveError(e, { + code: Parse.Error.FILE_SAVE_ERROR, + message: `Could not store file: ${filename}.`, + }); + next(error); + } + } + async deleteHandler(req, res, next) { try { const { filesController } = req.config; diff --git a/src/Utils.js b/src/Utils.js index 0eca833552..1e072725d7 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -469,6 +469,40 @@ class Utils { } return current; } + + /** + * Parses a human-readable size string into a byte count. + * @param {number | string} size - A number (floored to an integer), a numeric string + * (treated as bytes), or a string with a unit suffix: `b`, `kb`, `mb`, `gb` + * (case-insensitive). Examples: `'20mb'`, `'512kb'`, `'1.5gb'`, `1048576`. + * @returns {number} The size in bytes, floored to the nearest integer. + * @throws {Error} If the string does not match the expected format. + */ + static parseSizeToBytes(size) { + if (typeof size === 'number') { + if (!Number.isFinite(size) || size < 0) { + throw new Error(`Invalid size value: ${size}`); + } + return Math.floor(size); + } + const str = String(size).trim().toLowerCase(); + const match = str.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/); + if (!match) { + throw new Error(`Invalid size value: ${size}`); + } + const num = parseFloat(match[1]); + const unit = match[2]; + switch (unit) { + case 'kb': + return Math.floor(num * 1024); + case 'mb': + return Math.floor(num * 1024 * 1024); + case 'gb': + return Math.floor(num * 1024 * 1024 * 1024); + default: + return Math.floor(num); + } + } } module.exports = Utils;