From 5250671b010f2831c284698457a72d348282da5a Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:56:09 -0500 Subject: [PATCH] fix: range headers in file.js Range header support was mostly incorrect, which brought to surface undefined behavior in OnlyOffice which resulted in a request hanging. This was the cause of the `pdf-editor` app hanging for a while when opening it with a new file. --- src/backend/src/routers/file.js | 173 ++++++++++---------- src/backend/tools/.test-webhook-config.json | 6 + 2 files changed, 90 insertions(+), 89 deletions(-) create mode 100644 src/backend/tools/.test-webhook-config.json diff --git a/src/backend/src/routers/file.js b/src/backend/src/routers/file.js index f84fba483..3c1ca5c76 100644 --- a/src/backend/src/routers/file.js +++ b/src/backend/src/routers/file.js @@ -125,111 +125,106 @@ router.get('/file', async (req, res, next) => { }); const fileSize = fsentry[0].size; - //-------------------------------------------------- - // No range - //-------------------------------------------------- - if ( ! range ) { - // set content-type, if available - if ( contentType !== null ) { - res.setHeader('Content-Type', contentType); + res.setHeader('Accept-Ranges', 'bytes'); + + const parseRangeHeader = (rangeHeader) => { + // Check if this is a multipart range request + if ( rangeHeader.includes(',') ) { + // For now, we'll only serve the first range in multipart requests + // as the underlying storage layer doesn't support multipart responses + const firstRange = rangeHeader.split(',')[0].trim(); + const matches = firstRange.match(/bytes=(\d+)-(\d*)/); + if ( ! matches ) return null; + + const start = parseInt(matches[1], 10); + const end = matches[2] ? parseInt(matches[2], 10) : null; + + return { start, end, isMultipart: true }; } - const svc_filesystem = req.services.get('filesystem'); + // Single range request + const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/); + if ( ! matches ) return null; - // stream data from S3 - try { - /* eslint-disable */ - const fsNode = await svc_filesystem.node( - new NodeRawEntrySelector(fsentry[0]), - ); - /* eslint-enable */ - const ll_read = new LLRead(); - const stream = await ll_read.run({ - no_acl: true, - actor: req.actor ?? ownerActor, - fsNode, - }); + const start = parseInt(matches[1], 10); + const end = matches[2] ? parseInt(matches[2], 10) : null; + + return { start, end, isMultipart: false }; + }; - return stream.pipe(res); - } catch (e) { - errors.report('read from storage', { - source: e, - trace: true, - alarm: true, - }); - return res.type('application/json').status(500).send({ message: 'There was an internal problem reading the file.' }); - } - } //-------------------------------------------------- // Range //-------------------------------------------------- - else { - const total = fsentry[0].size; - const user_agent = req.get('User-Agent'); + if ( range ) { + res.status(206); + const rangeInfo = parseRangeHeader(req.headers['range']); + if ( rangeInfo ) { + const { start, end, isMultipart } = rangeInfo; - let start, end, chunkSize = 5000000; - let is_safari = false; + // For open-ended ranges, we need to calculate the actual end byte + let actualEnd = end; + let fileSize = null; - // Parse range header - var parts = range.replace(/bytes=/, '').split('-'); - var partialstart = parts[0]; - var partialend = parts[1]; + try { + fileSize = fsentry[0].size; + if ( end === null ) { + actualEnd = fileSize - 1; // File size is 1-based, end byte is 0-based + } + } catch (e) { + // If we can't get file size, we'll let the storage layer handle it + // and not set Content-Range header + actualEnd = null; + fileSize = null; + } - start = parseInt(partialstart, 10); - end = partialend ? parseInt(partialend, 10) : total - 1; + if ( actualEnd !== null ) { + const totalSize = fileSize !== null ? fileSize : '*'; + const contentRange = `bytes ${start}-${actualEnd}/${totalSize}`; + res.set('Content-Range', contentRange); + } - if ( user_agent && user_agent.toLowerCase().includes('safari') && !user_agent.includes('Chrome') ) { - // Safari - is_safari = true; - chunkSize = (end - start) + 1; - } else { - // All other user agents - end = Math.min(start + chunkSize, fileSize - 1); + // If this was a multipart request, modify the range header to only include the first range + if ( isMultipart ) { + req.headers['range'] = end !== null + ? `bytes=${start}-${end}` + : `bytes=${start}-`; + } } + } - // Create headers - const headers = { - 'Content-Range': `bytes ${start}-${end}/${fileSize}`, - 'Accept-Ranges': 'bytes', - 'Content-Length': is_safari ? chunkSize : (end - start + 1), - }; + //-------------------------------------------------- + // No range + //-------------------------------------------------- + // set content-type, if available + if ( contentType !== null ) { + res.setHeader('Content-Type', contentType); + } - // Set Content-Type, if available - if ( contentType ) { - headers['Content-Type'] = contentType; - } + const svc_filesystem = req.services.get('filesystem'); - // HTTP Status 206 for Partial Content - res.writeHead(206, headers); + // stream data from S3 + try { + /* eslint-disable */ + const fsNode = await svc_filesystem.node( + new NodeRawEntrySelector(fsentry[0]), + ); + /* eslint-enable */ + const ll_read = new LLRead(); + const stream = await ll_read.run({ + range, + no_acl: true, + actor: req.actor ?? ownerActor, + fsNode, + }); - try { - const svc_filesystem = req.services.get('filesystem'); - /* eslint-disable */ - const fsNode = await svc_filesystem.node( - new NodeRawEntrySelector(fsentry[0]), - ); - /* eslint-enable */ - const ll_read = new LLRead(); - const stream = await ll_read.run({ - no_acl: true, - actor: req.actor ?? ownerActor, - fsNode, - ...(req.headers['range'] ? { range: req.headers['range'] } : { }), - }); - // const storage = Context.get('storage'); - // let stream = await storage.create_read_stream(fsentry[0].uuid, { - // bucket: fsentry[0].bucket, - // bucket_region: fsentry[0].bucket_region, - // }); - return stream.pipe(res); - } catch (e) { - errors.report('read from storage', { - source: e, - trace: true, - alarm: true, - }); - return res.type('application/json').status(500).send({ message: 'There was an internal problem reading the file.' }); - } + return stream.pipe(res); + } catch (e) { + errors.report('read from storage', { + source: e, + trace: true, + alarm: true, + }); + return res.type('application/json').status(500).send({ message: 'There was an internal problem reading the file.' }); } }); diff --git a/src/backend/tools/.test-webhook-config.json b/src/backend/tools/.test-webhook-config.json new file mode 100644 index 000000000..b3adc8fd6 --- /dev/null +++ b/src/backend/tools/.test-webhook-config.json @@ -0,0 +1,6 @@ +{ + "key": "test-webhook-66999928605bc47b", + "webhook_secret": "9c193c4e111780a42b3d27661779adebba03c132078847490eb61639ce73288c", + "nonce": 13, + "instance_url": "http://api.puter.localhost:4100" +} \ No newline at end of file