Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
87 changes: 87 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,85 @@ 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',
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID?.trim() || null,
WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME?.trim() || null,
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
116 changes: 109 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 All @@ -345,6 +443,16 @@ if (onlyoffice.serverUrl && !env.ONLYOFFICE_SECRET) {
// Derived from the session secret, so it lasts as long as that does; when even
// that could not be stored, a restart only means already-loaded pages fetch
// their thumbnails again through the API, which re-runs the access check.
// --- Passkeys (WebAuthn) ---
// The relying party: the name a browser shows when it asks for a passkey, and
// the domain the key is bound to. The domain is left unset by default and taken
// from the request's own origin — a deployment reached by several names would
// otherwise bind every key to one of them.
const webauthn = {
rpId: env.WEBAUTHN_RP_ID,
rpName: env.WEBAUTHN_RP_NAME || 'NextExplorer',
};

const thumbnailAccess = {
secret: deriveSecret('thumbnails'),
};
Expand All @@ -365,13 +473,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 Expand Up @@ -547,6 +648,7 @@ const folderSize = {

module.exports = {
folderSize,
webauthn,
archives,
port: env.PORT,
address: env.ADDRESS,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/errors/errorCodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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',
AUTH_PASSKEY_REJECTED: 'AUTH_PASSKEY_REJECTED',

// 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