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