Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e8f47fd
Hand these commands their arguments instead of a command line
Sep 27, 2026
bfa04d4
Tell a failed OIDC sign-in apart, and come back to the right address
Sep 26, 2026
3698d6c
Say what the cache directory holds, and what the process is costing
Sep 26, 2026
31ed5c4
Let the preview's ceiling be set, and stop a proxy keeping a listing
Sep 26, 2026
dd0bf16
Open the database once, and compile a statement once
Sep 26, 2026
6e2d75c
Remember a folder's sort and view as rows, not as one value per account
Sep 26, 2026
1b34959
Ask where to put something, and offer the folders somebody actually uses
Sep 26, 2026
d87820f
Give the archive bounds a number, and read a size written with a capi…
Sep 26, 2026
d545ab4
Ask the filesystem how full it is, instead of asking a shell
Sep 26, 2026
cf736a6
What a share permits, and what its owner can see of it
Sep 27, 2026
86d1434
Catalogue a folder named build, and say how a result was found
Sep 27, 2026
f340db3
Hand chown its arguments instead of a command line, and let a rule hi…
Sep 27, 2026
704f62e
Say that an account is locked out, and let an administrator unlock it
Sep 27, 2026
ac7b648
Say what kind of file this is, and what a folder weighs
Sep 27, 2026
9d4d8ee
Move what pointed at a folder with the folder, and forget it with the…
Sep 27, 2026
6cd2332
A copy that says how far it has got, and can be stopped
Sep 27, 2026
e3e64fb
Fall back to chunks when the direct upload is refused, and only then
Sep 27, 2026
2ef2a28
Write a folder's address with its slashes, and keep each guard a func…
Sep 27, 2026
d4673f3
Restore somewhere else, and put a version back without losing the file
Sep 27, 2026
c65bf3d
Render a long Markdown document in pieces, and keep the scroll where …
Sep 27, 2026
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
30 changes: 30 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 Expand Up @@ -159,6 +184,11 @@ module.exports = {
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),
// Direct first, chunked only when the direct attempt fails — a reverse proxy
// with a body limit is the usual reason, and it is not something the server
// can know in advance. Mutually exclusive with UPLOAD_CHUNKED_ENABLED, which
// says to chunk everything from the start.
UPLOAD_CHUNKED_AUTO_FALLBACK: normalizeBoolean(process.env.UPLOAD_CHUNKED_AUTO_FALLBACK),
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,
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
14 changes: 12 additions & 2 deletions backend/src/openapi/paths/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ module.exports = {
access: 'account',
body: body(obj({ items: arrayOf(itemRef), destination: str() }, ['items', 'destination'])),
responses: {
200: json(transferDone, 'What landed where, once it has all landed.'),
200: ndjson(transferDone, 'Progress as it goes, and what landed where.'),
...errors(400, 401, 403, 404, 507),
},
}),
Expand All @@ -127,11 +127,21 @@ module.exports = {
access: 'account',
body: body(obj({ items: arrayOf(itemRef), destination: str() }, ['items', 'destination'])),
responses: {
200: json(transferDone, 'What landed where, once it has all landed.'),
200: ndjson(transferDone, 'Progress as it goes, and what landed where.'),
...errors(400, 401, 403, 404, 507),
},
}),
},
'/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
Loading
Loading