diff --git a/server-ce/nginx/overleaf.conf b/server-ce/nginx/overleaf.conf index db63a58d88..873e996733 100644 --- a/server-ce/nginx/overleaf.conf +++ b/server-ce/nginx/overleaf.conf @@ -10,16 +10,21 @@ server { } # File upload endpoints: extended timeouts for large files on slow connections. - # client_body_timeout 15m is set globally in nginx.conf.template; repeated - # here explicitly and client_max_body_size raised to 550m for this path. + # proxy_request_buffering off: forward the request body to Node.js immediately + # rather than buffering first, so Node.js can send a keepalive response byte + # before the full body arrives (preventing upstream proxy "first-byte" timeouts). + # proxy_buffering off: forward that keepalive byte to Traefik/LB without delay. location ~ ^/project/[^/]+/upload$ { proxy_pass http://127.0.0.1:4000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_request_buffering off; + proxy_buffering off; proxy_read_timeout 15m; proxy_send_timeout 15m; + send_timeout 15m; client_body_timeout 15m; client_max_body_size 550m; } diff --git a/services/web/app/src/Features/Uploads/ProjectUploadController.mjs b/services/web/app/src/Features/Uploads/ProjectUploadController.mjs index cecbac5ea2..b4a08c4221 100644 --- a/services/web/app/src/Features/Uploads/ProjectUploadController.mjs +++ b/services/web/app/src/Features/Uploads/ProjectUploadController.mjs @@ -24,6 +24,15 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs' const defaultsDeep = lodash.defaultsDeep +// Send a JSON response compatible with both normal mode and streaming mode +// (where startStreamingResponse already sent HTTP 200 + chunked headers). +function sendUploadResponse(res, statusCode, body) { + if (res.headersSent) { + return res.end(JSON.stringify(body)) + } + return res.status(statusCode).json(body) +} + const upload = multer( defaultsDeep( { @@ -92,7 +101,7 @@ async function uploadFile(req, res, next) { await fsPromises.unlink(path).catch(unlinkErr => { logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file') }) - return res.status(422).json({ + return sendUploadResponse(res, 422, { success: false, error: 'invalid_filename', }) @@ -119,7 +128,7 @@ async function uploadFile(req, res, next) { await fsPromises.unlink(path).catch(unlinkErr => { logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file') }) - throw error + return sendUploadResponse(res, 500, { success: false }) } return FileSystemImportManager.addEntity( @@ -134,22 +143,22 @@ async function uploadFile(req, res, next) { timer.done() if (error != null) { if (error.name === 'InvalidNameError') { - return res.status(422).json({ + return sendUploadResponse(res, 422, { success: false, error: 'invalid_filename', }) } else if (error instanceof DuplicateNameError) { - return res.status(422).json({ + return sendUploadResponse(res, 422, { success: false, error: 'duplicate_file_name', }) } else if (error.message === 'project_has_too_many_files') { - return res.status(422).json({ + return sendUploadResponse(res, 422, { success: false, error: 'project_has_too_many_files', }) } else if (error.message === 'folder_not_found') { - return res.status(422).json({ + return sendUploadResponse(res, 422, { success: false, error: 'folder_not_found', }) @@ -164,10 +173,10 @@ async function uploadFile(req, res, next) { }, 'error uploading file' ) - return res.status(422).json({ success: false }) + return sendUploadResponse(res, 422, { success: false }) } } else { - return res.json({ + return sendUploadResponse(res, 200, { success: true, entity_id: entity?._id, entity_type: entity?.type, @@ -273,25 +282,28 @@ async function importDocument(req, res, next) { */ function multerMiddleware(req, res, next) { if (upload == null) { - return res - .status(500) - .json({ success: false, error: req.i18n.translate('upload_failed') }) + return sendUploadResponse(res, 500, { + success: false, + error: req.i18n.translate('upload_failed'), + }) } return upload.single('qqfile')( req, res, /** @param {any} err */ function (err) { if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { - return res - .status(422) - .json({ success: false, error: req.i18n.translate('file_too_large') }) + return sendUploadResponse(res, 422, { + success: false, + error: req.i18n.translate('file_too_large'), + }) } if (err) return next(err) if (!req.file?.path) { logger.info({ req }, 'missing req.file.path on upload') - return res - .status(400) - .json({ success: false, error: 'invalid_upload_request' }) + return sendUploadResponse(res, 400, { + success: false, + error: 'invalid_upload_request', + }) } next() } diff --git a/services/web/app/src/Features/Uploads/UploadsRouter.mjs b/services/web/app/src/Features/Uploads/UploadsRouter.mjs index 070cc90801..9bed1068b2 100644 --- a/services/web/app/src/Features/Uploads/UploadsRouter.mjs +++ b/services/web/app/src/Features/Uploads/UploadsRouter.mjs @@ -6,6 +6,27 @@ import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs' import Settings from '@overleaf/settings' import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs' +// Sends HTTP 200 + first chunk immediately so upstream proxies (Traefik, cloud +// LBs) don't timeout waiting for the first response byte during large uploads. +// Must come *after* auth/rate-limit middleware (those still return proper codes) +// but *before* multer so it fires while the request body is still streaming in. +// The upload handler ends the response with the actual JSON result as the final +// chunk; the client's getResponseData trims leading whitespace before parsing. +function startStreamingResponse(req, res, next) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Transfer-Encoding': 'chunked', + 'X-Accel-Buffering': 'no', + }) + res.write('\n') + const heartbeat = setInterval(() => { + if (!res.writableEnded) res.write('\n') + }, 30000) + res.on('finish', () => clearInterval(heartbeat)) + res.on('close', () => clearInterval(heartbeat)) + next() +} + const rateLimiters = { projectUpload: new RateLimiter('project-upload', { points: 20, @@ -62,6 +83,7 @@ export default { fileUploadRateLimit, AsyncLocalStorage.middleware, AuthorizationMiddleware.ensureUserCanWriteProjectContent, + startStreamingResponse, ProjectUploadController.multerMiddleware, ProjectUploadController.uploadFile ) @@ -72,6 +94,7 @@ export default { AuthenticationController.requireLogin(), AsyncLocalStorage.middleware, AuthorizationMiddleware.ensureUserCanWriteProjectContent, + startStreamingResponse, ProjectUploadController.multerMiddleware, ProjectUploadController.uploadFile ) diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx index 812c481b26..eea5315bc7 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/modes/file-tree-upload-doc.tsx @@ -173,6 +173,24 @@ export default function FileTreeUploadDoc() { // limit: maxConnections || 1, limit: 1, fieldName: 'qqfile', // "qqfile" field inherited from FineUploader + // The server sends HTTP 200 + a keepalive '\n' byte immediately to + // prevent upstream proxy timeouts on slow connections, then streams + // the actual JSON result as the final chunk. Trim before parsing. + getResponseData: (responseText: string) => { + try { + return JSON.parse(responseText.trim()) + } catch { + return {} + } + }, + validateStatus: (statusCode: number, responseText: string) => { + if (statusCode < 200 || statusCode >= 300) return false + try { + return JSON.parse(responseText.trim()).success === true + } catch { + return false + } + }, }) // close the modal when all the uploads completed successfully .on('complete', result => {