Skip to content
25 changes: 25 additions & 0 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,35 @@ module.exports = {
// --- Archive extraction ---
MAX_EXTRACTED_ARCHIVE_SIZE: process.env.MAX_EXTRACTED_ARCHIVE_SIZE?.trim() || null,
MAX_ARCHIVE_ENTRIES: Number(process.env.MAX_ARCHIVE_ENTRIES) || 100000,
MAX_BROWSABLE_ARCHIVE_SIZE: process.env.MAX_BROWSABLE_ARCHIVE_SIZE?.trim() || null,
ARCHIVE_CACHE_MAX_SIZE: process.env.ARCHIVE_CACHE_MAX_SIZE?.trim() || null,
ARCHIVE_EXTENSIONS: process.env.ARCHIVE_EXTENSIONS || '',
// --- Folder size index ---
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 +120,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
59 changes: 59 additions & 0 deletions backend/src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,23 @@ const archives = (() => {
return Number.isFinite(parsed) && parsed > 0 ? parsed : 32 * 1024 * 1024 * 1024;
})(),
maxEntries: env.MAX_ARCHIVE_ENTRIES,
// A compound archive — a .tar.gz and its family — is two archives, and the
// inner one has to be decompressed before anything inside it can be named.
// Above this it is not: browsing a backup by unpacking it first would
// betray the whole point, and extracting it is the operation that exists
// for that. The number is the inner archive's own declared size, so the
// refusal comes before anything is written.
browseMaxBytes: (() => {
const parsed = parseByteSize(env.MAX_BROWSABLE_ARCHIVE_SIZE);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 1024 * 1024 * 1024;
})(),
// What those decompressed copies may take up altogether. They are a
// convenience and are made again whenever they are missing, so the least
// recently opened goes first when this is passed.
cacheMaxBytes: (() => {
const parsed = parseByteSize(env.ARCHIVE_CACHE_MAX_SIZE);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 8 * 1024 * 1024 * 1024;
})(),
};
if (!raw) return { extensions: DEFAULT_ARCHIVE_EXTENSIONS, ...limits };
// 'zip,iso' replaces the default list; '+udf,squashfs' extends it.
Expand Down Expand Up @@ -661,7 +678,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
10 changes: 10 additions & 0 deletions backend/src/openapi/paths/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ module.exports = {
},
}),
},
'/api/files/recent-destinations': {
get: op({
id: 'listRecentDestinations',
summary: 'Folders this account recently copied or moved into',
description: 'Only those it can still reach.',
tag: TAG,
access: 'account',
responses: { 200: json(obj({ items: arrayOf(str()) }, ['items'])), ...errors(401) },
}),
},
'/api/files/delete-impact': {
post: op({
id: 'describeDeletion',
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
43 changes: 43 additions & 0 deletions backend/src/routes/files/transfer.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,41 @@
const { transferItems } = require('../../services/fileTransferService');
const recentDestinations = require('../../services/recentDestinationsService');
const { ACTIONS, authorizeAndResolve } = require('../../services/authorizationService');
const fs = require('node:fs/promises');
const asyncHandler = require('../../utils/asyncHandler');

const router = require('express').Router();

/**
* Where this user has recently moved or copied things.
*
* Filtered against what they can reach right now: a folder can be deleted or
* have its access revoked long after it was last used, and offering it as a
* destination would only produce a failure at the end of the flow. Anything
* gone is forgotten on the way out, so the list heals itself.
*/
router.get(
'/files/recent-destinations',
asyncHandler(async (req, res) => {
const paths = await recentDestinations.list(req.user?.id);
const context = { user: req.user, guestSession: req.guestSession };

const reachable = [];
for (const relativePath of paths) {
const { allowed, resolved } = await authorizeAndResolve(context, relativePath, ACTIONS.write);
const stats = resolved ? await fs.stat(resolved.absolutePath).catch(() => null) : null;

if (allowed && stats?.isDirectory()) {
reachable.push(relativePath);
} else {
await recentDestinations.forget(req.user?.id, relativePath);
}
}

res.json({ items: reachable });
})
);

router.post(
'/files/copy',
asyncHandler(async (req, res) => {
Expand All @@ -11,6 +44,11 @@ router.post(
user: req.user,
guestSession: req.guestSession,
});
// Recorded from the transfer itself rather than asked of the client, so every
// route into a folder counts — the picker, a drag onto a favorite, a paste —
// and the list reflects where things really go.
await recentDestinations.record(req.user?.id, result.destination);

res.json({ success: true, ...result });
})
);
Expand All @@ -23,6 +61,11 @@ router.post(
user: req.user,
guestSession: req.guestSession,
});
// Recorded from the transfer itself rather than asked of the client, so every
// route into a folder counts — the picker, a drag onto a favorite, a paste —
// and the list reflects where things really go.
await recentDestinations.record(req.user?.id, result.destination);

res.json({ success: true, ...result });
})
);
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
Loading
Loading