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
23 changes: 23 additions & 0 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,28 @@ module.exports = {
FOLDER_SIZE_MODE: process.env.FOLDER_SIZE_MODE?.trim().toLowerCase() || 'off',
FOLDER_SIZE_MODE_SET:
typeof process.env.FOLDER_SIZE_MODE === 'string' && process.env.FOLDER_SIZE_MODE.trim() !== '',
// Lightweight process and cgroup diagnostics, off by default. When enabled the
// sampler logs only anomalous intervals unless explicitly told otherwise.
PERFORMANCE_DIAGNOSTICS_ENABLED:
normalizeBoolean(process.env.PERFORMANCE_DIAGNOSTICS_ENABLED) ?? false,
PERFORMANCE_DIAGNOSTICS_INTERVAL_MS:
process.env.PERFORMANCE_DIAGNOSTICS_INTERVAL_MS != null
? Number(process.env.PERFORMANCE_DIAGNOSTICS_INTERVAL_MS)
: 15000,
PERFORMANCE_DIAGNOSTICS_LOG_EVERY_INTERVAL:
normalizeBoolean(process.env.PERFORMANCE_DIAGNOSTICS_LOG_EVERY_INTERVAL) ?? false,
PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD:
process.env.PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD != null
? Number(process.env.PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD)
: 75,
PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB:
process.env.PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB != null
? Number(process.env.PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB)
: 768,
PERFORMANCE_DIAGNOSTICS_EVENT_LOOP_DELAY_MS:
process.env.PERFORMANCE_DIAGNOSTICS_EVENT_LOOP_DELAY_MS != null
? Number(process.env.PERFORMANCE_DIAGNOSTICS_EVENT_LOOP_DELAY_MS)
: 250,
FOLDER_SIZE_EXCLUDE_PATHS: process.env.FOLDER_SIZE_EXCLUDE_PATHS || '',
FOLDER_SIZE_CONCURRENCY: Number(process.env.FOLDER_SIZE_CONCURRENCY) || 6,
FOLDER_SIZE_NETWORK_CONCURRENCY: Number(process.env.FOLDER_SIZE_NETWORK_CONCURRENCY) || 2,
Expand All @@ -96,6 +118,7 @@ module.exports = {
SEARCH_INDEX_CPU_PERCENT: Number(process.env.SEARCH_INDEX_CPU_PERCENT) || null,
SEARCH_INDEX_MEMORY_MB: Number(process.env.SEARCH_INDEX_MEMORY_MB) || null,
SEARCH_INDEX_EXCLUDE: process.env.SEARCH_INDEX_EXCLUDE?.trim() || null,
PREVIEW_MAX_RENDER_SIZE: process.env.PREVIEW_MAX_RENDER_SIZE?.trim() || null,
SEARCH_INDEX_REBUILD: normalizeBoolean(process.env.SEARCH_INDEX_REBUILD) ?? false,
SEARCH_INDEX_RECONCILE_MS: Number(process.env.SEARCH_INDEX_RECONCILE_MS) || null,
SEARCH_TIMEOUT_MS: Number(process.env.SEARCH_TIMEOUT_MS) || null,
Expand Down
42 changes: 42 additions & 0 deletions backend/src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,49 @@ const folderSize = {
rebuild: env.FOLDER_SIZE_REBUILD,
};

// --- Runtime diagnostics ---
// --- Runtime diagnostics ---
const atLeast = (value, minimum, fallback) =>
Number.isFinite(value) && value >= minimum ? value : fallback;

const performanceDiagnostics = {
enabled: env.PERFORMANCE_DIAGNOSTICS_ENABLED,
intervalMs: atLeast(env.PERFORMANCE_DIAGNOSTICS_INTERVAL_MS, 5000, 15000),
logEveryInterval: env.PERFORMANCE_DIAGNOSTICS_LOG_EVERY_INTERVAL,
cpuThreshold: atLeast(env.PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD, 1, 75),
rssThresholdMb: atLeast(env.PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB, 1, 768),
eventLoopDelayThresholdMs: atLeast(env.PERFORMANCE_DIAGNOSTICS_EVENT_LOOP_DELAY_MS, 1, 250),
};

/**
* How much of a document the preview will render.
*
* Not the same question as what the editor will open, and the difference is
* why this is a setting of its own. The editor streams text into a code view;
* the preview parses the document, sanitises the HTML it produces and then
* hands the browser every node to lay out — all on the one thread the
* interface has. A six-megabyte markdown file opens in the editor and freezes
* the tab in the preview, on the same machine, from the same file.
*
* It was hard-coded before this, which meant someone who raised
* EDITOR_MAX_FILESIZE in good faith was refused at a number that appeared in
* no setting and no document.
*
* Generous by default because freezing is no longer the failure mode: the
* preview renders in slices of a frame and hands the browser back between
* them. What is left is the weight of the document in the tab, which is a
* reader's problem rather than an application's. And the preview reads through
* the editor's endpoint, so EDITOR_MAX_FILESIZE already caps what can reach
* it — this only bites when it is set lower than that.
*/
const previewMaxRenderBytes = (() => {
const parsed = parseByteSize(env.PREVIEW_MAX_RENDER_SIZE);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 16 * 1024 * 1024;
})();

module.exports = {
performanceDiagnostics,
preview: { maxRenderBytes: previewMaxRenderBytes },
folderSize,
webauthn,
activity,
Expand Down
12 changes: 7 additions & 5 deletions backend/src/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,15 @@ router.post(
loginLimiter,
asyncHandler(async (req, res) => {
refuseWithoutPasswordSignIn();
const { email, password, username } = req.body || {};
// Support both email and username (backward compatibility)
const emailOrUsername = email || username;
const { identifier, email, password, username } = req.body || {};
// One box on the sign-in screen, and three names for what was typed into
// it: `identifier` is what that screen sends, `email` and `username` are
// the older names a script or an older client may still use.
const typed = identifier || email || username;

let user = null;
try {
user = await attemptLocalLogin({ email: emailOrUsername, password });
user = await attemptLocalLogin({ identifier: typed, password });
} catch (e) {
if (e?.status === 423) {
throw new RateLimitError(e.message, e.until);
Expand All @@ -324,7 +326,7 @@ router.post(
action: 'sign-in',
outcome: 'refused',
// The name that was typed, not one this server confirmed exists.
actor: String(emailOrUsername || '').slice(0, 200) || 'unknown',
actor: String(typed || '').slice(0, 200) || 'unknown',
detail: { method: 'password' },
req,
});
Expand Down
3 changes: 3 additions & 0 deletions backend/src/routes/browse.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ router.get(
sourceFolderName: pathParts[pathParts.length - 1] || '',
};
}
// Listings carry transient information such as active OnlyOffice sessions.
// Keep browser and proxy caches from serving an out-of-date directory view.
res.setHeader('Cache-Control', 'private, no-store');

res.json(response);
})
Expand Down
48 changes: 38 additions & 10 deletions backend/src/routes/permissions.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const express = require('express');
const fs = require('fs/promises');
const { exec } = require('child_process');
const { execFile } = require('child_process');
const { promisify } = require('util');

const { normalizeRelativePath } = require('../utils/pathUtils');
Expand All @@ -16,7 +16,28 @@ const {
} = require('../errors/AppError');

const router = express.Router();
const execAsync = promisify(exec);
// `execFile`, not `exec`: every one of these used to build a command line with
// values from the request in it, and a shell then read that line. `owner` and
// `group` arrive from the body, so a pair of quotes was the whole difference
// between "may change ownership here" and "may run anything as the user this
// server runs as".
const execAsync = promisify(execFile);

/**
* An account or group name has to look like one.
*
* An argument list is not a free pass on its own: `chown` reads anything starting
* with a dash as an option, so `--reference=/etc/shadow` would have copied another
* file's ownership onto the target. A name starts with a letter, a digit or an
* underscore.
*/
const ACCOUNT_NAME_PATTERN = /^[a-zA-Z0-9_][a-zA-Z0-9._-]*$/;
const ensureValidAccountName = (value, label) => {
if (value === undefined || value === null || value === '') return;
if (typeof value !== 'string' || !ACCOUNT_NAME_PATTERN.test(value)) {
throw new ValidationError(`${label} is not a valid name.`);
}
};

/**
* Get file permissions, owner, and group information
Expand Down Expand Up @@ -53,15 +74,15 @@ router.get(
if (process.platform !== 'win32') {
try {
// Get owner name from uid
const { stdout: ownerOut } = await execAsync(`id -nu ${stats.uid}`);
const { stdout: ownerOut } = await execAsync('id', ['-nu', String(stats.uid)]);
owner = ownerOut.trim();
} catch (e) {
logger.debug({ err: e }, 'Failed to get owner name');
}

try {
// Get group name from gid
const { stdout: groupOut } = await execAsync(`id -gn ${stats.gid}`);
const { stdout: groupOut } = await execAsync('id', ['-gn', String(stats.gid)]);
group = groupOut.trim();
} catch (e) {
logger.debug({ err: e }, 'Failed to get group name');
Expand Down Expand Up @@ -139,7 +160,7 @@ router.post(
// Use chmod -R for recursive on Unix systems
if (process.platform !== 'win32') {
try {
await execAsync(`chmod -R ${mode} "${resolved.absolutePath}"`);
await execAsync('chmod', ['-R', String(mode), resolved.absolutePath]);
} catch (e) {
logger.error({ err: e }, 'Failed to apply recursive chmod');
throw new Error('Failed to apply permissions recursively.');
Expand Down Expand Up @@ -215,18 +236,25 @@ router.post(
// chown requires shell execution as Node.js doesn't have built-in owner/group change
// This requires elevated privileges on most systems
if (process.platform !== 'win32') {
let chownCmd = '';
ensureValidAccountName(owner, 'The owner');
ensureValidAccountName(group, 'The group');

let command = null;
let args = [];

if (owner && group) {
chownCmd = `chown "${owner}:${group}" "${resolved.absolutePath}"`;
command = 'chown';
args = [`${owner}:${group}`, resolved.absolutePath];
} else if (owner) {
chownCmd = `chown "${owner}" "${resolved.absolutePath}"`;
command = 'chown';
args = [owner, resolved.absolutePath];
} else if (group) {
chownCmd = `chgrp "${group}" "${resolved.absolutePath}"`;
command = 'chgrp';
args = [group, resolved.absolutePath];
}

try {
await execAsync(chownCmd);
if (command) await execAsync(command, args);
logger.info({ path: relativePath, owner, group }, 'Ownership changed');
} catch (e) {
logger.error({ err: e }, 'Failed to change ownership');
Expand Down
12 changes: 8 additions & 4 deletions backend/src/routes/usage.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
const express = require('express');
const { promisify } = require('util');
const { exec } = require('child_process');
const { execFile } = require('child_process');
const { normalizeRelativePath } = require('../utils/pathUtils');
const { resolvePathWithAccess } = require('../services/accessManager');
const logger = require('../utils/logger');
const asyncHandler = require('../utils/asyncHandler');
const execp = promisify(exec);
// `execFile`, not `exec`: the path goes in as an argument rather than into a
// command line. A folder whose name contains a quote used to end the quoting and
// leave the rest for the shell to run, as the user this server runs as, the moment
// somebody opened it — and any account that can make a folder could name one.
const execp = promisify(execFile);
const router = express.Router();

// Fast directory size using du command
const dirSize = async (root) => {
try {
// -sb: summarize in bytes, don't follow symlinks
// This is orders of magnitude faster than fs.stat() recursion
const { stdout } = await execp(`du -sb "${root}"`, {
const { stdout } = await execp('du', ['-sb', root], {
maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large outputs
});

Expand Down Expand Up @@ -45,7 +49,7 @@ router.get(
// Run both commands in parallel for maximum speed
const [size, dfResult] = await Promise.all([
dirSize(abs),
execp(`df -Pk "${abs}"`).catch(() => ({ stdout: '' })),
execp('df', ['-Pk', abs]).catch(() => ({ stdout: '' })),
]);

let total = 0,
Expand Down
13 changes: 13 additions & 0 deletions backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const capabilities = require('./services/capabilities');
const { installProcessFailureHandlers } = require('./utils/processFailures');
const { sweepUnreferencedLogos } = require('./services/brandingLogo');
const featureSwitches = require('./services/featureSwitches');
const { reportLegacyCache } = require('./services/legacyCacheCheck');
const performanceDiagnostics = require('./services/performanceDiagnostics');

let server = null;

Expand Down Expand Up @@ -114,6 +116,16 @@ const startServer = async () => {
expirySweep.unref?.();
void sweepExpiredRecords();

// What early releases left in the cache directory: the database and app-config.json
// lived there up to 1.1.7, and an installation that skipped the releases in between
// comes up on a new, empty app.db with its accounts and shares sitting unread.
reportLegacyCache();

// A periodic record of what the process is costing — CPU, resident memory, event-loop
// delay, and the queues that can grow. Off unless PERFORMANCE_DIAGNOSTICS_ENABLED is
// set, and then it says only the intervals that look wrong.
performanceDiagnostics.start();

// A logo left behind by a stop in the middle of a branding change, or by a
// removal that failed, is 2 MB nothing can reach. Here, where nothing is being
// placed, so a file under one of our names is a finished one.
Expand All @@ -130,6 +142,7 @@ const startServer = async () => {
folderSizeManager.stop();
trashMaintenance.stop();
searchIndexManager.stop();
performanceDiagnostics.stop();
server.close(() => {
logger.info('Server closed');
process.exit(0);
Expand Down
83 changes: 83 additions & 0 deletions backend/src/services/legacyCacheCheck.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const fs = require('fs');
const path = require('path');

const { directories } = require('../config/index');
const logger = require('../utils/logger');

/**
* What early releases left in the cache directory, said out loud at start.
*
* Up to 1.1.7 the database and app-config.json lived in the cache directory.
* 1.1.8 moved them to the config directory and left links behind in their
* place; 2.0.3 removed that move from the entrypoint. So an installation that
* started on 1.1.7 or earlier and skipped the releases in between comes up on a
* new, empty app.db in /config, with its accounts and shares sitting unread in
* /cache — and nothing said so. The links, where an installation passed through
* 1.1.8 to 2.0.2, are harmless but look like data.
*
* Nothing is moved: which of two databases holds what matters cannot be told
* from here, and guessing wrong would overwrite the one in use. The log says
* where the old file is and what to do with it.
*/

const LEGACY_NAMES = ['app.db', 'app-config.json', 'extensions'];

const readLinkOrNull = (file) => {
try {
return fs.readlinkSync(file);
} catch {
return null;
}
};

/** What is there, without following anything. */
const inspectLegacyCache = (cacheDir = directories.cache) => {
const findings = [];
for (const name of LEGACY_NAMES) {
const file = path.join(cacheDir, name);
let stats;
try {
stats = fs.lstatSync(file);
} catch {
continue;
}
if (stats.isSymbolicLink()) {
findings.push({ name, path: file, kind: 'link', target: readLinkOrNull(file) });
} else if (name === 'app.db' && stats.isFile()) {
findings.push({ name, path: file, kind: 'database', sizeBytes: stats.size });
}
}
return findings;
};

const reportLegacyCache = ({
cacheDir = directories.cache,
configDir = directories.config,
log = logger,
} = {}) => {
const findings = inspectLegacyCache(cacheDir);

const database = findings.find((finding) => finding.kind === 'database');
if (database) {
log.warn(
{
legacyDatabase: database.path,
sizeBytes: database.sizeBytes,
databaseInUse: path.join(configDir, 'app.db'),
},
'An app.db written by release 1.1.7 or earlier is in the cache directory, and nothing reads it: this server runs on the app.db in the config directory. If accounts, shares or favorites are missing, stop the container, back up both files, and copy the old one over the one in the config directory.'
);
}

const links = findings.filter((finding) => finding.kind === 'link');
if (links.length > 0) {
log.info(
{ links: links.map((link) => `${link.path} -> ${link.target}`) },
'Links left in the cache directory by releases 1.1.8 to 2.0.2 are unused and can be deleted.'
);
}

return findings;
};

module.exports = { inspectLegacyCache, reportLegacyCache };
Loading
Loading