Skip to content
Merged
4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"license": "ISC",
"dependencies": {
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
"@tus/file-store": "^2.1.0",
"@tus/server": "^2.4.1",
"adm-zip": "^0.5.16",
"archiver": "^6.0.2",
"axios": "^1.7.7",
Expand All @@ -36,8 +38,8 @@
"fluent-ffmpeg": "^2.1.2",
"jsonwebtoken": "^9.0.2",
"memorystore": "^1.6.7",
"p-limit": "^3.1.0",
"multer": "^2.0.2",
"p-limit": "^3.1.0",
"p-queue": "^7.4.1",
"pino": "^10.1.0",
"pino-http": "^11.0.0",
Expand Down
7 changes: 5 additions & 2 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const { bootstrap } = require('./utils/bootstrap');
const { configureSession } = require('./middleware/session');
const logger = require('./utils/logger');
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
const { uploads } = require('./config');

/**
* Creates and configures the Express application.
Expand Down Expand Up @@ -49,8 +50,10 @@ const createApp = async (options = {}) => {
configureHttpLogging(app);

configureCors(app);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Large enough to carry back whatever the text editor was allowed to open;
// see the reasoning beside the two limits in the configuration.
app.use(express.json({ limit: uploads.maxJsonBodyBytes }));
app.use(express.urlencoded({ extended: true, limit: uploads.maxJsonBodyBytes }));
app.use(cookieParser());
app.use(requestContextMiddleware);
logger.debug('Mounted cookie parser middleware');
Expand Down
85 changes: 85 additions & 0 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ module.exports = {
ONLYOFFICE_SECRET: process.env.ONLYOFFICE_SECRET || null,
ONLYOFFICE_LANG: process.env.ONLYOFFICE_LANG?.trim() || 'en',
ONLYOFFICE_FORCE_SAVE: normalizeBoolean(process.env.ONLYOFFICE_FORCE_SAVE) || false,
ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS: Number(process.env.ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS) || 10000,
// 0 disables proactive writes to the external storage. A bounded interval
// keeps the Document Server's internal autosave from becoming a full document
// conversion on every edit.
ONLYOFFICE_AUTO_SAVE_INTERVAL_MS: (() => {
const value = Number(process.env.ONLYOFFICE_AUTO_SAVE_INTERVAL_MS);
return Number.isFinite(value) && value >= 0 ? value : 30000;
})(),
ONLYOFFICE_FILE_EXTENSIONS: process.env.ONLYOFFICE_FILE_EXTENSIONS || '',
ONLYOFFICE_DOWNLOAD_ORIGINS: process.env.ONLYOFFICE_DOWNLOAD_ORIGINS || '',

Expand Down Expand Up @@ -139,6 +147,83 @@ module.exports = {
// Uploads (direct, non-chunked)
MAX_DIRECT_UPLOAD_SIZE: process.env.MAX_DIRECT_UPLOAD_SIZE?.trim() || null,
UPLOAD_STORAGE_RESERVE: process.env.UPLOAD_STORAGE_RESERVE?.trim() || '64M',
UPLOAD_CHUNK_SIZE: process.env.UPLOAD_CHUNK_SIZE,
UPLOAD_CHUNKED_ENABLED: normalizeBoolean(process.env.UPLOAD_CHUNKED_ENABLED),
MAX_CHUNK_SIZE_MIB: process.env.MAX_CHUNK_SIZE_MIB,
UPLOAD_INACTIVITY_TIMEOUT: process.env.UPLOAD_INACTIVITY_TIMEOUT,
THUMBNAILS_ENABLED: normalizeBoolean(process.env.THUMBNAILS_ENABLED) ?? true,
THUMBNAIL_CACHE_MAX_FILES:
process.env.THUMBNAIL_CACHE_MAX_FILES != null
? Number(process.env.THUMBNAIL_CACHE_MAX_FILES)
: 3000,
THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS:
process.env.THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS != null
? Number(process.env.THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS)
: 60 * 60 * 1000,
THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE:
process.env.THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE != null
? Number(process.env.THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE)
: 500,
THUMBNAIL_CACHE_TTL_DAYS:
process.env.THUMBNAIL_CACHE_TTL_DAYS != null
? Number(process.env.THUMBNAIL_CACHE_TTL_DAYS)
: 30,
// ExifTool from the machine rather than the one in the archive: 23 MB of
// Perl somebody who already has it would rather not carry twice (#9).
// Empty means the bundled copy, which is the default and needs nothing.
EXIFTOOL_PATH: process.env.EXIFTOOL_PATH || '',
// Embedded RAW previews are full-size JPEGs, far larger than a thumbnail, and
// a new one is extracted whenever a RAW file changes.
RAW_PREVIEW_CACHE_MAX_FILES:
process.env.RAW_PREVIEW_CACHE_MAX_FILES != null
? Number(process.env.RAW_PREVIEW_CACHE_MAX_FILES)
: 500,
THUMBNAIL_SHARP_CACHE_MEMORY_MB:
process.env.THUMBNAIL_SHARP_CACHE_MEMORY_MB != null
? Number(process.env.THUMBNAIL_SHARP_CACHE_MEMORY_MB)
: 0,
THUMBNAIL_VIDEO_CONCURRENCY:
process.env.THUMBNAIL_VIDEO_CONCURRENCY != null
? Number(process.env.THUMBNAIL_VIDEO_CONCURRENCY)
: 3,
THUMBNAIL_VIDEO_SEEK_SECONDS:
process.env.THUMBNAIL_VIDEO_SEEK_SECONDS != null
? Number(process.env.THUMBNAIL_VIDEO_SEEK_SECONDS)
: 5,
THUMBNAIL_VIDEO_SEEK_PERCENT:
process.env.THUMBNAIL_VIDEO_SEEK_PERCENT != null &&
process.env.THUMBNAIL_VIDEO_SEEK_PERCENT.trim() !== ''
? Number(process.env.THUMBNAIL_VIDEO_SEEK_PERCENT)
: null,
THUMBNAIL_VIDEO_THREADS:
process.env.THUMBNAIL_VIDEO_THREADS != null ? Number(process.env.THUMBNAIL_VIDEO_THREADS) : 2,
THUMBNAIL_VIDEO_SCALE_FLAGS: process.env.THUMBNAIL_VIDEO_SCALE_FLAGS?.trim() || 'fast_bilinear',
THUMBNAIL_BACKGROUND_QUEUE_LIMIT:
process.env.THUMBNAIL_BACKGROUND_QUEUE_LIMIT != null
? Number(process.env.THUMBNAIL_BACKGROUND_QUEUE_LIMIT)
: 16,
THUMBNAIL_DIAGNOSTICS_ENABLED:
normalizeBoolean(process.env.THUMBNAIL_DIAGNOSTICS_ENABLED) ?? false,
THUMBNAIL_DIAGNOSTICS_INTERVAL_MS:
process.env.THUMBNAIL_DIAGNOSTICS_INTERVAL_MS != null
? Number(process.env.THUMBNAIL_DIAGNOSTICS_INTERVAL_MS)
: 30000,
THUMBNAIL_SLOW_JOB_MS:
process.env.THUMBNAIL_SLOW_JOB_MS != null ? Number(process.env.THUMBNAIL_SLOW_JOB_MS) : 10000,
// How long one ffmpeg may take over a single thumbnail before it is killed.
// Not a deadline anything waits on — the queue has given up long before —
// but the only thing that ends a process that has stopped making progress.
THUMBNAIL_FFMPEG_TIMEOUT_MS:
process.env.THUMBNAIL_FFMPEG_TIMEOUT_MS != null
? Number(process.env.THUMBNAIL_FFMPEG_TIMEOUT_MS)
: 5 * 60 * 1000,
// Niceness applied to child ffmpeg/convert processes (0 = disabled, 1-19 lowers
// their CPU priority so the Node event loop stays responsive during generation).
THUMBNAIL_PROCESS_NICE:
process.env.THUMBNAIL_PROCESS_NICE != null ? Number(process.env.THUMBNAIL_PROCESS_NICE) : 10,
TUS_UPLOAD_DIR: process.env.TUS_UPLOAD_DIR?.trim() || null,
TUS_INCOMPLETE_UPLOAD_TTL_MS: process.env.TUS_INCOMPLETE_UPLOAD_TTL_MS,
TUS_CLEANUP_INTERVAL_MS: process.env.TUS_CLEANUP_INTERVAL_MS,
MAX_FILES_PER_UPLOAD: Number(process.env.MAX_FILES_PER_UPLOAD) || 50,

// Editor
Expand Down
105 changes: 98 additions & 7 deletions backend/src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const env = require('./env');
const constants = require('./constants');
const loggingConfig = require('./logging');
const { parseByteSize } = require('../utils/env');
// logger reads config/logging, never this file — requiring it here makes no cycle.
const logger = require('../utils/logger');

const parseCommaOrSpaceList = (raw) => {
if (!raw) return [];
Expand Down Expand Up @@ -277,16 +279,110 @@ const searchMaxFileSizeBytes = (() => {
})();

// --- Uploads ---
// --- Editor ---
/**
* What the inline text editor opens, and what a JSON request body may weigh.
*
* They are one decision rather than two. The editor sends a file back through a
* JSON body when it saves it, so a body limit under the size the editor opens
* produces a file that opens and cannot be saved — answered with "request
* entity too large", which names neither setting. Express's own default is
* 100 kB, against an editor that opens two megabytes.
*
* Escaping is why the body has to be worth more than the file: in the worst
* case every character of the content is a quote, a backslash or a newline and
* becomes two, and the path travels in the same body. A file whose bytes would
* expand further than that is one the editor refuses to open anyway, as binary.
*/
const JSON_ESCAPE_WORST_CASE = 2;
const JSON_BODY_OVERHEAD_BYTES = 64 * 1024;
const DEFAULT_JSON_BODY_BYTES = 8 * 1024 * 1024;

const bodyNeededFor = (fileBytes) => fileBytes * JSON_ESCAPE_WORST_CASE + JSON_BODY_OVERHEAD_BYTES;
const fileAllowedBy = (bodyBytes) =>
Math.max(0, Math.floor((bodyBytes - JSON_BODY_OVERHEAD_BYTES) / JSON_ESCAPE_WORST_CASE));

const { editorMaxFileSizeBytes, maxJsonBodyBytes } = (() => {
const parsedEditor = parseByteSize(env.EDITOR_MAX_FILESIZE);
// Default: 2 MiB if not configured or invalid
const editorAsked =
Number.isFinite(parsedEditor) && parsedEditor > 0 ? parsedEditor : 2 * 1024 * 1024;

const parsedBody = parseByteSize(env.MAX_JSON_BODY_SIZE);
const bodyWasChosen = Number.isFinite(parsedBody) && parsedBody > 0;

// A body limit someone set is a ceiling they meant — it is a guard, not a
// detail — so it is never raised from here. The editor is what gives way, and
// it gives way by refusing to open what it could not save back.
if (bodyWasChosen) {
const allowed = fileAllowedBy(parsedBody);
if (editorAsked > allowed) {
logger.warn(
{ editorAsked, loweredTo: allowed, maxJsonBodyBytes: parsedBody },
'EDITOR_MAX_FILESIZE is larger than MAX_JSON_BODY_SIZE can carry back and has been ' +
'lowered to match; the editor would otherwise open files it could not save'
);
}
return { editorMaxFileSizeBytes: Math.min(editorAsked, allowed), maxJsonBodyBytes: parsedBody };
}

// Nobody chose the body limit, so the editor's size is the only wish there is
// to honour: the default body limit rises to carry it.
const needed = bodyNeededFor(editorAsked);
if (needed > DEFAULT_JSON_BODY_BYTES) {
logger.info(
{ editorMaxFileSizeBytes: editorAsked, maxJsonBodyBytes: needed },
'Raised the JSON body limit above its default so the text editor can save what it opens'
);
}

return {
editorMaxFileSizeBytes: editorAsked,
maxJsonBodyBytes: Math.max(DEFAULT_JSON_BODY_BYTES, needed),
};
})();

// Ceilings for direct (non-chunked) uploads. They exist so a single request
// cannot stream until the disk is full; they are generous on purpose, since
// large files are a normal use of a file manager. Chunked uploads have their
// own storage guard in the TUS service.
// How long a direct upload may go without a byte arriving before it is given
// up on. A client that goes away mid-body otherwise holds the request, and the
// half-written file with it, until the socket itself times out.
const uploadInactivityTimeoutMs = (() => {
const value = Number(env.UPLOAD_INACTIVITY_TIMEOUT);
return Number.isFinite(value) && value >= 0 ? value : 120000;
})();

// Where a chunked upload's parts live until the whole file is there, and how
// long an unfinished one is kept. Under the cache rather than beside the
// destination: a part file is not a file anybody asked for, and a volume should
// never show one.
const tusUploadDir = env.TUS_UPLOAD_DIR
? path.resolve(env.TUS_UPLOAD_DIR)
: path.join(cacheDir, 'tus-uploads');

const tusIncompleteUploadTtlMs = (() => {
const value = Number(env.TUS_INCOMPLETE_UPLOAD_TTL_MS);
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 60 * 60 * 1000;
})();

const tusCleanupIntervalMs = (() => {
const value = Number(env.TUS_CLEANUP_INTERVAL_MS);
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 10 * 60 * 1000;
})();

const uploads = {
maxJsonBodyBytes,
maxDirectUploadBytes: (() => {
const parsed = parseByteSize(env.MAX_DIRECT_UPLOAD_SIZE);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 64 * 1024 * 1024 * 1024;
})(),
maxFilesPerRequest: env.MAX_FILES_PER_UPLOAD,
inactivityTimeoutMs: uploadInactivityTimeoutMs,
tusUploadDir,
tusIncompleteUploadTtlMs,
tusCleanupIntervalMs,
// Free space kept in reserve when accepting writes, so a full volume never
// takes the database down with it. The trash gives space back before this
// floor is crossed.
Expand Down Expand Up @@ -320,6 +416,8 @@ const onlyoffice = {
secret: env.ONLYOFFICE_SECRET || deriveSecret('onlyoffice'),
lang: env.ONLYOFFICE_LANG,
forceSave: env.ONLYOFFICE_FORCE_SAVE,
forceSaveTimeoutMs: Math.min(30000, Math.max(7000, env.ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS)),
autoSaveIntervalMs: Math.min(300000, Math.max(0, env.ONLYOFFICE_AUTO_SAVE_INTERVAL_MS)),
extensions: env.ONLYOFFICE_FILE_EXTENSIONS.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
Expand Down Expand Up @@ -365,13 +463,6 @@ const collabora = {
.filter(Boolean),
};

// --- Editor ---
const editorMaxFileSizeBytes = (() => {
const parsed = parseByteSize(env.EDITOR_MAX_FILESIZE);
// Default: 2 MiB if not configured or invalid
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 1024 * 1024;
})();

const editor = {
extensions: parseExtensionList(env.EDITOR_EXTENSIONS),
maxFileSizeBytes: editorMaxFileSizeBytes,
Expand Down
1 change: 1 addition & 0 deletions backend/src/errors/errorCodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const ErrorCodes = {
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
AUTH_ACCOUNT_LOCKED: 'AUTH_ACCOUNT_LOCKED',
AUTH_PASSWORD_INCORRECT: 'AUTH_PASSWORD_INCORRECT',
AUTH_INVALID_TOTP_CODE: 'AUTH_INVALID_TOTP_CODE',

// Validation (400)
VALIDATION_EMAIL_REQUIRED: 'VALIDATION_EMAIL_REQUIRED',
Expand Down
4 changes: 4 additions & 0 deletions backend/src/middleware/errorHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,8 @@ const notFoundHandler = (req, res, next) => {
module.exports = {
errorHandler,
notFoundHandler,
// Used by the routes that stream their progress: an NDJSON stream has already
// answered 200 by the time something fails, so it says why in a line of its
// own rather than through the error handler — and says it the same way.
sanitizeClientMessage,
};
36 changes: 36 additions & 0 deletions backend/src/middleware/multipartRefusals.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
const multer = require('multer');

/**
* A size as a person reads it: "2 MB", "64 GB".
*/
const describeBytes = (bytes) => {
const units = ['bytes', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${Math.round(value * 10) / 10} ${units[unit]}`;
};

/**
* Run a multer middleware whose refusals say what this route's limits are.
*
* multer names the limit a request met and not its value — "File too large" —
* and only the route knows the value, and the setting that governs it. The
* sentence for a code is attached here; the status comes from the error
* handler, which knows every code whichever route raised it.
*
* @param {import('express').RequestHandler} middleware what `upload.single()` or `.fields()` returned
* @param {Record<string, string>} sentences what to tell the client, by multer error code
*/
const explainMultipartRefusals = (middleware, sentences) => (req, res, next) =>
middleware(req, res, (error) => {
if (error instanceof multer.MulterError && sentences[error.code]) {
error.clientMessage = sentences[error.code];
}
next(error);
});

module.exports = { describeBytes, explainMultipartRefusals };
Loading
Loading