From e8f47fd98fa43633efcb6d69af8fb84b95871cdd Mon Sep 17 00:00:00 2001 From: Benjy Date: Sun, 27 Sep 2026 19:00:43 +0200 Subject: [PATCH 01/15] Hand these commands their arguments instead of a command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two routes built a command line with values from the request in it and gave the line to a shell. `routes/usage.js` pasted a folder's path into `du -sb "…"` and `df -Pk "…"`; `routes/permissions.js` pasted an owner, a group, a mode and a path into `chown`, `chgrp`, `chmod -R` and two `id` lookups. A name is not a shell string. Anything that closes the quoting leaves the rest for `/bin/sh` to run, as the user the server runs as. The usage one needs no privilege at all: any account that can create a folder can name one, and where AUTH_ENABLED is false that is anybody who can reach the server. `execFile` takes the arguments as a list, so there is no line for a shell to read and no shell. The commands, their output and the answers are unchanged — this is deliberately the smallest change that closes it, and not the rewrite that stands in nxzai#450 and nxzai#453. An argument list is not a free pass on its own: `chown` reads a leading dash as an option, so `--reference=/etc/shadow` would have copied another file's ownership onto the target. An account or group name has to look like one. `tests/routes/no-shell.test.js` — three cases. Two of them name a folder, and an owner, with a payload that creates a file, and check the file is not there; the third asks for `--reference=/etc/shadow` and expects a refusal. All three fail against the routes as they are, the first two by running the command. The payload only ever touches the working directory, and it is removed whatever an assertion does, so a run that does execute leaves nothing behind. --- backend/src/routes/permissions.js | 48 ++++++++--- backend/src/routes/usage.js | 12 ++- backend/tests/routes/no-shell.test.js | 117 ++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 backend/tests/routes/no-shell.test.js diff --git a/backend/src/routes/permissions.js b/backend/src/routes/permissions.js index 0d005f38a..0c264164c 100644 --- a/backend/src/routes/permissions.js +++ b/backend/src/routes/permissions.js @@ -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'); @@ -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 @@ -53,7 +74,7 @@ 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'); @@ -61,7 +82,7 @@ router.get( 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'); @@ -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.'); @@ -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'); diff --git a/backend/src/routes/usage.js b/backend/src/routes/usage.js index f7c5060e8..4f7f9a022 100644 --- a/backend/src/routes/usage.js +++ b/backend/src/routes/usage.js @@ -1,11 +1,15 @@ 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 @@ -13,7 +17,7 @@ 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 }); @@ -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, diff --git a/backend/tests/routes/no-shell.test.js b/backend/tests/routes/no-shell.test.js new file mode 100644 index 000000000..cc8092240 --- /dev/null +++ b/backend/tests/routes/no-shell.test.js @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import request from 'supertest'; +import { setupTestEnv, createTestApp } from '../helpers/env-test-utils.js'; + +/** + * A name from a request is not a shell string. + * + * Two routes built command lines with values from the request pasted into them and + * handed the line to `/bin/sh`. A folder name, an owner, a group: anything that + * closed the quoting left the rest for the shell to run, as the user this server + * runs as. The folder one needs no privilege at all — any account that can make a + * folder can name one, and with AUTH_ENABLED=false that is anybody who can reach + * the server. + * + * The payloads below only create a file inside the test's own temporary directory, + * and what each case asserts is that the file is not there. + */ + +let env; + +// Where a payload would land: a folder name cannot hold a slash, so it writes into +// the working directory of the process running this. +const PROOF = path.join(process.cwd(), 'a-shell-ran-here'); +const shellRan = async () => + fs + .access(PROOF) + .then(() => true) + .catch(() => false); + +afterEach(async () => { + // Whatever an assertion did, this happens: a run that does execute must not leave + // the file behind for the next one to find. + await fs.rm(PROOF, { force: true }); + if (env) { + await env.cleanup(); + env = null; + } +}); + +describe('asking how full a volume is', () => { + it('does not run what a folder is called', async () => { + env = await setupTestEnv({ + tag: 'usage-no-shell-', + modules: ['src/routes/usage', 'src/services/accessManager', 'src/utils/pathUtils'], + }); + + // A name that closes the quoting the route used to open around it. + const folder = 'Vol";touch a-shell-ran-here;echo "'; + await fs.mkdir(path.join(env.volumeDir, folder)); + + const app = createTestApp({ + router: env.requireFresh('src/routes/usage'), + mountPath: '/api', + user: { id: 'admin-user', roles: ['admin'] }, + }); + + const response = await request(app).get(`/api/usage/${encodeURIComponent(folder)}`); + + expect(response.status).toBe(200); + expect(await shellRan()).toBe(false); + }); +}); + +describe('changing an owner', () => { + // With the error handler, so a refusal arrives as the sentence it is meant to be + // rather than an empty body with a status on it. + const appFor = () => + createTestApp({ + router: env.requireFresh('src/routes/permissions'), + mountPath: '/api', + user: { id: 'admin-user', roles: ['admin'] }, + errorHandler: env.requireFresh('src/middleware/errorHandler').errorHandler, + }); + + const setup = async (tag) => { + env = await setupTestEnv({ + tag, + modules: [ + 'src/routes/permissions', + 'src/middleware/errorHandler', + 'src/services/accessManager', + 'src/utils/pathUtils', + ], + }); + await fs.mkdir(path.join(env.volumeDir, 'Vol'), { recursive: true }); + await fs.writeFile(path.join(env.volumeDir, 'Vol', 'file.txt'), 'x'); + }; + + it('does not run what an owner is called', async () => { + await setup('chown-no-shell-'); + const response = await request(appFor()) + .post('/api/permissions/chown') + .send({ path: 'Vol/file.txt', owner: 'root";touch a-shell-ran-here;echo "' }); + + // Refused as a name, or attempted as one argument and failed — either way the + // command inside it is not a command. + expect(await shellRan()).toBe(false); + expect(response.status).toBeGreaterThanOrEqual(400); + }); + + it('refuses a name that would be read as an option', async () => { + await setup('chown-option-'); + + const response = await request(appFor()) + .post('/api/permissions/chown') + .send({ path: 'Vol/file.txt', owner: '--reference=/etc/shadow' }); + + // Refused as a name rather than attempted as one: a 400 from the route, and a + // sentence that says which field and that it is a name. Not the exact wording — + // a test that pins a sentence breaks when somebody improves it. + expect(response.status).toBe(400); + expect(JSON.stringify(response.body)).toMatch(/owner/i); + expect(JSON.stringify(response.body)).toMatch(/(invalid|not a valid).{0,20}name/i); + }); +}); From bfa04d40271fa5973ffebc2490d428be57983380 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 19:04:27 +0200 Subject: [PATCH 02/15] Tell a failed OIDC sign-in apart, and come back to the right address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things about OIDC that this fixes. **Which of two failures it was.** A sign-in that cannot start has one of two causes: nothing was configured, or what was configured could not be made to work. The first is answered by filling in OIDC_ISSUER and the rest; the second is answered by looking at the provider. Both answered 404 "OIDC is not configured", so an administrator whose provider was unreachable was sent to change a configuration that was already right. `configureOidc` now records what it concluded and why, and the routes read it: 404 AUTH_OIDC_NOT_CONFIGURED for the first, 503 AUTH_OIDC_PROVIDER_UNAVAILABLE for the second, and neither carries the network's own words — no ENOTFOUND, no internal host name, in the body or in the address bar. A browser asking for one of those addresses is looking at a page, not reading JSON, so it is sent back to the sign-in screen with the code beside the sentence. The screen can then say what the code means in the reader's own language. **Where the callback comes back to.** The return address was built from a fixed `baseURL`, so a deployment reached through a reverse proxy on a different name sent people back to the wrong origin. It is now resolved from the request — but only from a forwarded host behind a trusted proxy, and only when the result exactly matches an origin the operator configured. A redirect target that a request header could choose is an open redirect with extra steps. The path is still held to a relative, same-site one, as before. Also here, because it is the same files: - `claimsFromIdToken` in the middleware and `uniqueOrigins` in the routes were local copies of things `utils/idToken.js` and the new `utils/oidcRedirect.js` do; both go. - The guest-session cookie was cleared on `/api` only, at five sign-in and sign-out paths. A guest session left on `/` outlived the sign-in that should have ended it. - `ServiceUnavailableError` (503) and the two AUTH_OIDC_* codes, which nothing had yet. The account lockout that `routes/auth.js` also differs in is deliberately not here: it is a different subject and goes with releasing a locked account. ## Checks Seven test files, 107 tests, including three this fork had and `main` did not: `oidc-middleware`, `oidcOrigin` and `auth-oidc-routes`. Neutralising `getOidcAvailability` so it reports one verdict for both causes turns three of them red — the three that tell the two apart. Whole backend suite: 2 391 passed, 2 failed, the two that fail on `main` on its own (`auth.test.js` on the current password, `browse-hidden-files.test.js`). `npm run lint` reports 146 against `main`'s 143, the three being the parse error on `backend/tests/**` that 141 of `main`'s own test files already draw. Formatting clean. Frontend builds, backend loads, documentation site builds. One test assertion was rewritten rather than ported as it stood: it matched the wording `express-openid-connect` uses for a callback with no sign-in in progress, and that wording differs between 2.19 and 2.20. It now asserts the refusal. One box, one name for it ------------------------ The sign-in box takes an email address or a username, so it is neither: it is whatever was typed, and this calls it `identifier` from the screen to the route. The screen and the client were renamed and the store and the route were not, so the store passed `email` to a client expecting `identifier`. `JSON.stringify` drops a key whose value is undefined, and the request went out carrying a password and nobody to sign in. The answer was "invalid credentials", which is what a wrong password looks like — so nothing about it read as a defect. `attemptLocalLogin` already took `identifier`; the route is what had not caught up. `email` and `username` still work, for a script or an older client that sends them. The identifier was the half that failed loudly. The screen also reads `totpPending`, `oidcStatus`, `cancelTotp`, `ensureStatus` and `forgetSession` off the store, and none of them were there: `totpPending` read undefined, so the box for the code from the authenticator never appeared, and a correct password on an account with a second factor landed on a screen that looked like it had done nothing. Undefined is not an error in a template — it is a `v-if` that is false. The store is here in full, with the code step read back from the server on every start so a reload in the middle of one lands back on the code. tests/routes/sign-in-identifier.test.js signs in with each of the three names, refuses a wrong password, and reads the three frontend files to check they all use the one name — the chain is four files long and three of them have no runner here, which is how it broke silently in the middle. And what a refusal says ----------------------- A code that names the kind of refusal — FORBIDDEN, NOT_FOUND, CONFLICT, RATE_LIMIT_EXCEEDED — is translated for the reader, and the server's own sentence, which says *which* refusal, went underneath rather than being lost. A lock that arrives with a duration gets a sentence of its own rather than a placeholder in the plain one, so it can never read "{minutes}". And the handler no longer asks vue-i18n for a key before checking it has it, which was a console warning for every refusal the catalogue has no entry for, twice. --- backend/src/routes/auth.js | 12 +- .../tests/routes/sign-in-identifier.test.js | 119 ++++++++++++++++++ frontend/src/api/errorHandler.js | 51 ++++++-- frontend/src/i18n/locales/de.json | 1 + frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/es.json | 1 + frontend/src/i18n/locales/fr.json | 1 + frontend/src/i18n/locales/hi.json | 1 + frontend/src/i18n/locales/it.json | 1 + frontend/src/i18n/locales/ko.json | 1 + frontend/src/i18n/locales/nl.json | 1 + frontend/src/i18n/locales/pl.json | 1 + frontend/src/i18n/locales/pt-BR.json | 1 + frontend/src/i18n/locales/ro.json | 1 + frontend/src/i18n/locales/ru.json | 1 + frontend/src/i18n/locales/sv.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + frontend/src/i18n/locales/zh-TW.json | 1 + frontend/src/stores/auth.js | 108 ++++++++++++---- 19 files changed, 263 insertions(+), 42 deletions(-) create mode 100644 backend/tests/routes/sign-in-identifier.test.js diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 1df37e6ca..d0bc1de10 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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); @@ -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, }); diff --git a/backend/tests/routes/sign-in-identifier.test.js b/backend/tests/routes/sign-in-identifier.test.js new file mode 100644 index 000000000..4e4dd44b7 --- /dev/null +++ b/backend/tests/routes/sign-in-identifier.test.js @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import request from 'supertest'; + +import { createTestApp, modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * One box on the sign-in screen, and the same name for it all the way down. + * + * The box takes an email address or a username, so it is neither: it is + * whatever was typed. That name has to hold from the screen to the route, and + * when it did not, nothing said so. The screen sent `identifier`, the store + * passed on `email`, and `JSON.stringify` drops a key whose value is undefined + * — so the request went out carrying a password and nobody to sign in, and the + * answer was "invalid credentials", which is what a wrong password looks like. + * + * Both halves are asserted here: the route takes the name the screen sends, and + * the screen, the client and the store all use that one name. The second half + * is read off the frontend sources, because the chain is four files long and + * three of them have no runner here — and a chain that breaks silently in the + * middle is exactly what this is for. + */ + +const FRONTEND = path.join(__dirname, '..', '..', '..', 'frontend', 'src'); +const read = (relative) => fs.readFileSync(path.join(FRONTEND, relative), 'utf8'); + +describe('the name for what was typed into the sign-in box', () => { + let env; + let app; + + beforeEach(async () => { + env = await setupTestEnv({ + tag: 'sign-in-identifier-', + modules: ['src/services/db', 'src/routes/auth', 'src/middleware/errorHandler'], + envOverrides: { AUTH_ENABLED: 'true' }, + }); + await env.requireFresh('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'correct horse battery', + roles: ['admin'], + }); + app = createTestApp({ + router: env.requireFresh('src/routes/auth'), + mountPath: '/api/auth', + errorHandler: env.requireFresh('src/middleware/errorHandler').errorHandler, + }); + }); + + afterEach(async () => { + await env.cleanup(); + }); + + const signIn = (body) => request(app).post('/api/auth/login').send(body); + + it('signs in with the name the screen sends', async () => { + const response = await signIn({ + identifier: 'alice@example.com', + password: 'correct horse battery', + }); + + expect(response.status).toBe(200); + expect(response.body.user?.email).toBe('alice@example.com'); + }); + + it('takes a username in the same box', async () => { + const response = await signIn({ identifier: 'alice', password: 'correct horse battery' }); + + expect(response.status).toBe(200); + expect(response.body.user?.username).toBe('alice'); + }); + + it.each(['email', 'username'])('still takes the older name %s', async (name) => { + const response = await signIn({ + [name]: name === 'email' ? 'alice@example.com' : 'alice', + password: 'correct horse battery', + }); + + expect(response.status).toBe(200); + }); + + it('refuses a password that is wrong, and says nothing about which half', async () => { + const response = await signIn({ identifier: 'alice', password: 'not it' }); + + expect(response.status).toBe(401); + expect(response.body.error?.code).toBe('AUTH_INVALID_CREDENTIALS'); + }); + + /** + * The screen, the client and the store. A rename that stops at one of them + * leaves the next passing undefined, which is not an error anywhere — the key + * simply vanishes from the request body. + */ + it('is the name the screen, the client and the store all use', () => { + expect(read('views/AuthLoginView.vue')).toMatch(/auth\.login\(\{\s*identifier:/); + expect(read('api/auth.api.js')).toMatch(/const login = \(\{ identifier, password \}\)/); + expect(read('stores/auth.js')).toMatch(/const login = async \(\{ identifier, password \}\)/); + expect(read('stores/auth.js')).toMatch(/loginApi\(\{ identifier, password \}\)/); + }); + + /** + * Everything else the sign-in screen reads off the store. + * + * The same rename went through this screen and stopped before the store, and + * the identifier was only the half that failed loudly. `totpPending` reads + * undefined, so the box for the code from the authenticator never appears: + * a correct password on an account with a second factor lands on a screen + * that looks like it did nothing. Undefined is not an error in a template — + * it is a `v-if` that is false — so there is nothing to see but the absence. + */ + it.each(['totpPending', 'oidcStatus', 'cancelTotp', 'ensureStatus', 'forgetSession'])( + 'is on the store, because the screen reads it: %s', + (member) => { + expect(read('stores/auth.js')).toContain(member); + } + ); +}); diff --git a/frontend/src/api/errorHandler.js b/frontend/src/api/errorHandler.js index 9a333b3fb..9b10bf203 100644 --- a/frontend/src/api/errorHandler.js +++ b/frontend/src/api/errorHandler.js @@ -1,32 +1,61 @@ +/** + * Codes that say what kind of refusal it was, not which one. The catalogue + * gives the kind in the reader's language; the server's own sentence, which + * says which refusal, goes underneath rather than being lost. + */ +const GENERIC_CODES = new Set(['FORBIDDEN', 'NOT_FOUND', 'CONFLICT', 'RATE_LIMIT_EXCEEDED']); + export function createErrorHandler(notificationsStore, i18n) { - return (errorInfo) => { + // Asked before translating: vue-i18n warns in the console for every key it + // is asked for and does not have, twice with a fallback locale, and most + // codes the server sends have no entry. + const knows = (key) => i18n.global.te(key) || i18n.global.te(key, 'en'); + + /** + * @param {object} errorInfo what the server refused with + * @param {object} [options] + * @param {boolean} [options.quiet] translate it, but raise no notification: + * the screen that asked is about to say it itself, under the field it + * belongs to, which is a better place for it than a toast in the corner. + */ + return (errorInfo, { quiet = false } = {}) => { const { code, message, requestId, statusCode, details } = errorInfo; let heading = message || 'An error occurred'; + let explanation = null; // Translate if we have a code if (code) { const key = `serverErrors.${code}`; - const translated = i18n.global.t(key); - if (translated !== key) { + if (knows(key)) { // Handle rate limit pluralization if (code.startsWith('RATE_LIMIT_') && details?.retryAfter) { const minutes = Math.ceil(details.retryAfter / 60); heading = i18n.global.t(key, { minutes }, minutes); + } else if (code === 'AUTH_ACCOUNT_LOCKED' && details?.retryAfter) { + // A sentence of its own rather than a placeholder in the plain one: + // a lock that arrives without a duration must never read "{minutes}". + const minutes = Math.ceil(details.retryAfter / 60); + heading = i18n.global.t('serverErrors.AUTH_ACCOUNT_LOCKED_RETRY', { minutes }, minutes); } else { - heading = translated; + heading = i18n.global.t(key); } + if (GENERIC_CODES.has(code) && message) explanation = message; } } - notificationsStore.addNotification({ - type: 'error', - heading, - body: details ? JSON.stringify(details) : '', - requestId, - statusCode, - }); + const body = [explanation, details ? JSON.stringify(details) : null].filter(Boolean).join('\n'); + + if (!quiet) { + notificationsStore.addNotification({ + type: 'error', + heading, + body, + requestId, + statusCode, + }); + } // Return translated message for error thrown by http.js return heading; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 871c957ed..c12addea8 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Authentifizierung erforderlich", "AUTH_INVALID_CREDENTIALS": "Ungültige E-Mail oder Passwort", "AUTH_ACCOUNT_LOCKED": "Das Konto ist aufgrund fehlgeschlagener Anmeldeversuche vorübergehend gesperrt", + "AUTH_ACCOUNT_LOCKED_RETRY": "Konto nach zu vielen fehlgeschlagenen Anmeldeversuchen gesperrt. Versuchen Sie es in {minutes} Minute erneut | Konto nach zu vielen fehlgeschlagenen Anmeldeversuchen gesperrt. Versuchen Sie es in {minutes} Minuten erneut", "AUTH_PASSWORD_INCORRECT": "Das aktuelle Passwort ist falsch", "VALIDATION_EMAIL_REQUIRED": "E-Mail ist erforderlich", "VALIDATION_PASSWORD_REQUIRED": "Passwort ist erforderlich", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 916db9e90..52268b689 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Authentication required", "AUTH_INVALID_CREDENTIALS": "Invalid email or password", "AUTH_ACCOUNT_LOCKED": "Account is temporarily locked due to failed login attempts", + "AUTH_ACCOUNT_LOCKED_RETRY": "Account locked after too many failed sign-in attempts. Try again in {minutes} minute | Account locked after too many failed sign-in attempts. Try again in {minutes} minutes", "AUTH_PASSWORD_INCORRECT": "Current password is incorrect", "VALIDATION_EMAIL_REQUIRED": "Email is required", "VALIDATION_PASSWORD_REQUIRED": "Password is required", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 16aab9ac8..b5e3c40db 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Se requiere autenticación", "AUTH_INVALID_CREDENTIALS": "Correo electrónico o contraseña no válidos", "AUTH_ACCOUNT_LOCKED": "La cuenta está bloqueada temporalmente debido a intentos fallidos de inicio de sesión", + "AUTH_ACCOUNT_LOCKED_RETRY": "Cuenta bloqueada tras demasiados intentos fallidos de inicio de sesión. Inténtalo de nuevo en {minutes} minuto | Cuenta bloqueada tras demasiados intentos fallidos de inicio de sesión. Inténtalo de nuevo en {minutes} minutos", "AUTH_PASSWORD_INCORRECT": "La contraseña actual es incorrecta", "VALIDATION_EMAIL_REQUIRED": "El correo electrónico es obligatorio", "VALIDATION_PASSWORD_REQUIRED": "La contraseña es obligatoria", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index bec144287..d0c22af94 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Authentification requise", "AUTH_INVALID_CREDENTIALS": "E-mail ou mot de passe invalide", "AUTH_ACCOUNT_LOCKED": "Le compte est temporairement verrouillé en raison de tentatives de connexion échouées", + "AUTH_ACCOUNT_LOCKED_RETRY": "Compte verrouillé après trop de tentatives de connexion échouées. Réessayez dans {minutes} minute | Compte verrouillé après trop de tentatives de connexion échouées. Réessayez dans {minutes} minutes", "AUTH_PASSWORD_INCORRECT": "Le mot de passe actuel est incorrect", "VALIDATION_EMAIL_REQUIRED": "L'e-mail est obligatoire", "VALIDATION_PASSWORD_REQUIRED": "Le mot de passe est obligatoire", diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 952c440f9..529d4a561 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "प्रमाणीकरण आवश्यक है", "AUTH_INVALID_CREDENTIALS": "अमान्य ईमेल या पासवर्ड", "AUTH_ACCOUNT_LOCKED": "असफल लॉगिन प्रयासों के कारण खाता अस्थायी रूप से लॉक है", + "AUTH_ACCOUNT_LOCKED_RETRY": "बहुत अधिक असफल साइन-इन प्रयासों के बाद खाता लॉक है। {minutes} मिनट बाद फिर से प्रयास करें", "AUTH_PASSWORD_INCORRECT": "वर्तमान पासवर्ड गलत है", "VALIDATION_EMAIL_REQUIRED": "ईमेल आवश्यक है", "VALIDATION_PASSWORD_REQUIRED": "पासवर्ड आवश्यक है", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 09d08f192..51fc1e1d6 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Autenticazione richiesta", "AUTH_INVALID_CREDENTIALS": "Email o password non validi", "AUTH_ACCOUNT_LOCKED": "L'account è temporaneamente bloccato a causa di tentativi di accesso falliti", + "AUTH_ACCOUNT_LOCKED_RETRY": "Account bloccato dopo troppi tentativi di accesso falliti. Riprova tra {minutes} minuto | Account bloccato dopo troppi tentativi di accesso falliti. Riprova tra {minutes} minuti", "AUTH_PASSWORD_INCORRECT": "La password corrente è errata", "VALIDATION_EMAIL_REQUIRED": "L'email è obbligatoria", "VALIDATION_PASSWORD_REQUIRED": "La password è obbligatoria", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 4a2975c5f..47ed5f923 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "인증이 필요합니다", "AUTH_INVALID_CREDENTIALS": "이메일 혹은 비밀번호가 일치하지 않습니다", "AUTH_ACCOUNT_LOCKED": "로그인 실패로 인해 계정이 일시 잠금 처리되었습니다", + "AUTH_ACCOUNT_LOCKED_RETRY": "로그인 실패가 너무 많아 계정이 잠겼습니다. {minutes}분 후 다시 시도해주세요 | 로그인 실패가 너무 많아 계정이 잠겼습니다. {minutes}분 후 다시 시도해주세요", "AUTH_PASSWORD_INCORRECT": "현재 비밀번호가 일치하지 않습니다", "VALIDATION_EMAIL_REQUIRED": "이메일 주소가 필요합니다", "VALIDATION_PASSWORD_REQUIRED": "비밀번호가 필요합니다", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 482029f16..939b4d139 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Authenticatie vereist", "AUTH_INVALID_CREDENTIALS": "Ongeldige aanmeldgegevens", "AUTH_ACCOUNT_LOCKED": "Account is tijdelijk geblokkeerd vanwege mislukte inlogpogingen", + "AUTH_ACCOUNT_LOCKED_RETRY": "Account vergrendeld na te veel mislukte aanmeldpogingen. Probeer het over {minutes} minuut opnieuw | Account vergrendeld na te veel mislukte aanmeldpogingen. Probeer het over {minutes} minuten opnieuw", "AUTH_PASSWORD_INCORRECT": "Huidig wachtwoord is onjuist", "VALIDATION_EMAIL_REQUIRED": "E-mailadres is vereist", "VALIDATION_PASSWORD_REQUIRED": "Wachtwoord is vereist", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 6fe0405e0..9e78b67c6 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Wymagana autentykacja", "AUTH_INVALID_CREDENTIALS": "Nieprawidłowy adres e-mail lub hasło", "AUTH_ACCOUNT_LOCKED": "Konto jest tymczasowo zablokowane z powodu nieudanych prób logowania", + "AUTH_ACCOUNT_LOCKED_RETRY": "Konto zablokowane po zbyt wielu nieudanych próbach logowania. Spróbuj ponownie za {minutes} minutę | Konto zablokowane po zbyt wielu nieudanych próbach logowania. Spróbuj ponownie za {minutes} minuty | Konto zablokowane po zbyt wielu nieudanych próbach logowania. Spróbuj ponownie za {minutes} minut", "AUTH_PASSWORD_INCORRECT": "Obecne hasło jest nieprawidłowe", "VALIDATION_EMAIL_REQUIRED": "Adres e-mail jest wymagany", "VALIDATION_PASSWORD_REQUIRED": "Hasło jest wymagane", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 6c5eea037..fcaaba56f 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Autenticação necessária", "AUTH_INVALID_CREDENTIALS": "E-mail ou senha inválidos", "AUTH_ACCOUNT_LOCKED": "A conta está temporariamente bloqueada devido a tentativas de login com falha", + "AUTH_ACCOUNT_LOCKED_RETRY": "Conta bloqueada após muitas tentativas de login com falha. Tente novamente em {minutes} minuto | Conta bloqueada após muitas tentativas de login com falha. Tente novamente em {minutes} minutos", "AUTH_PASSWORD_INCORRECT": "A senha atual está incorreta", "VALIDATION_EMAIL_REQUIRED": "O e-mail é obrigatório", "VALIDATION_PASSWORD_REQUIRED": "A senha é obrigatória", diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 6460935b2..3e55e03cd 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Autentificare necesară", "AUTH_INVALID_CREDENTIALS": "Email sau parolă invalidă", "AUTH_ACCOUNT_LOCKED": "Contul este blocat temporar din cauza încercărilor eșuate de autentificare", + "AUTH_ACCOUNT_LOCKED_RETRY": "Cont blocat după prea multe încercări eșuate de autentificare. Încercați din nou peste {minutes} minut | Cont blocat după prea multe încercări eșuate de autentificare. Încercați din nou peste {minutes} minute", "AUTH_PASSWORD_INCORRECT": "Parola curentă este incorectă", "VALIDATION_EMAIL_REQUIRED": "Email-ul este obligatoriu", "VALIDATION_PASSWORD_REQUIRED": "Parola este obligatorie", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 01612b6e2..7d300f90d 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Требуется аутентификация", "AUTH_INVALID_CREDENTIALS": "Неверная электронная почта или пароль", "AUTH_ACCOUNT_LOCKED": "Учетная запись временно заблокирована из-за неудачных попыток входа", + "AUTH_ACCOUNT_LOCKED_RETRY": "Учетная запись заблокирована после слишком многих неудачных попыток входа. Повторите попытку через {minutes} минуту | Учетная запись заблокирована после слишком многих неудачных попыток входа. Повторите попытку через {minutes} минуты | Учетная запись заблокирована после слишком многих неудачных попыток входа. Повторите попытку через {minutes} минут", "AUTH_PASSWORD_INCORRECT": "Текущий пароль неверен", "VALIDATION_EMAIL_REQUIRED": "Требуется электронная почта", "VALIDATION_PASSWORD_REQUIRED": "Требуется пароль", diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 4e6d8f496..3ef628f70 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "Autentisering krävs", "AUTH_INVALID_CREDENTIALS": "Ogiltig e-post eller lösenord", "AUTH_ACCOUNT_LOCKED": "Kontot är tillfälligt låst på grund av misslyckade inloggningsförsök", + "AUTH_ACCOUNT_LOCKED_RETRY": "Kontot är låst efter för många misslyckade inloggningsförsök. Försök igen om {minutes} minut | Kontot är låst efter för många misslyckade inloggningsförsök. Försök igen om {minutes} minuter", "AUTH_PASSWORD_INCORRECT": "Det nuvarande lösenordet är felaktigt", "VALIDATION_EMAIL_REQUIRED": "E-post krävs", "VALIDATION_PASSWORD_REQUIRED": "Lösenord krävs", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 5ac5665f2..e8b5b7588 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "需要身份验证", "AUTH_INVALID_CREDENTIALS": "电子邮件或密码无效", "AUTH_ACCOUNT_LOCKED": "由于登录尝试失败,账户已被临时锁定", + "AUTH_ACCOUNT_LOCKED_RETRY": "登录失败次数过多,账户已被锁定。请在{minutes}分钟后重试", "AUTH_PASSWORD_INCORRECT": "当前密码不正确", "VALIDATION_EMAIL_REQUIRED": "电子邮件为必填项", "VALIDATION_PASSWORD_REQUIRED": "密码为必填项", diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 3dfba8d63..7f6cb7860 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -175,6 +175,7 @@ "AUTH_REQUIRED": "需要身份驗證", "AUTH_INVALID_CREDENTIALS": "電子郵件或密碼無效", "AUTH_ACCOUNT_LOCKED": "由於登入嘗試失敗,帳戶已暫時鎖定", + "AUTH_ACCOUNT_LOCKED_RETRY": "登入失敗次數過多,帳戶已被鎖定。請在{minutes}分鐘後重試", "AUTH_PASSWORD_INCORRECT": "目前密碼不正確", "VALIDATION_EMAIL_REQUIRED": "電子郵件為必填", "VALIDATION_PASSWORD_REQUIRED": "密碼為必填", diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 43bf8ee43..d4bd2abc7 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -5,8 +5,8 @@ import { fetchAuthStatus, setupAccount as setupAccountApi, login as loginApi, - submitTotpCode as submitTotpCodeApi, signInWithPasskey as signInWithPasskeyApi, + submitTotpCode as submitTotpCodeApi, logout as logoutApi, fetchCurrentUser, } from '@/api'; @@ -18,16 +18,22 @@ export const useAuthStore = defineStore('auth', () => { const strategies = ref({ local: true, oidc: false, + passkey: false, }); + // What the server's configuration pass concluded about single sign-on: + // 'ready', 'not-configured' or 'unavailable'. The sign-in screen shows the + // last two rather than sending somebody to a provider that cannot answer. + const oidcStatus = ref('ready'); const currentUser = ref(null); - const isLoading = ref(false); /** - * Whether a sign-in is waiting for a code from an authenticator. + * The password was right, and the account asks for a code as well. * - * The password was right; nothing about who they are is known here, and the - * server is holding that. + * Read back from the server on every start, not only set when a sign-in + * happens here: a reload in the middle of one lands back on the code rather + * than on a password screen that would start the whole thing again. */ - const totpRequired = ref(false); + const totpPending = ref(false); + const isLoading = ref(false); const hasStatus = ref(false); const lastError = ref(null); let initPromise = null; @@ -60,11 +66,10 @@ export const useAuthStore = defineStore('auth', () => { requiresSetup.value = enabled ? Boolean(status.requiresSetup) : false; authEnabled.value = enabled; authMode.value = typeof status?.authMode === 'string' ? status.authMode : 'local'; - strategies.value = status?.strategies || { local: true, oidc: false }; + strategies.value = status?.strategies || { local: true, oidc: false, passkey: false }; + oidcStatus.value = status?.oidc?.status || 'ready'; currentUser.value = status?.user || null; - // A reload in the middle of signing in lands back on the code rather - // than on a password screen that would start the whole thing again. - totpRequired.value = Boolean(status?.totpPending); + totpPending.value = Boolean(status?.totpPending); // Clear guest session if user is now authenticated if (currentUser.value) { @@ -96,15 +101,20 @@ export const useAuthStore = defineStore('auth', () => { sessionStorage.removeItem('guestSessionId'); }; - const login = async ({ email, password }) => { + const login = async ({ identifier, password }) => { lastError.value = null; - const response = await loginApi({ email, password }); + const response = await loginApi({ identifier, password }); + hasStatus.value = true; + + // Halfway: the password was right and a code is wanted as well. Nobody is + // signed in until it arrives, so nothing here says anybody is. if (response?.totpRequired) { - totpRequired.value = true; + totpPending.value = true; + currentUser.value = null; return { totpRequired: true }; } - totpRequired.value = false; - hasStatus.value = true; + + totpPending.value = false; currentUser.value = response?.user || null; // Clear guest session when user logs in @@ -113,10 +123,11 @@ export const useAuthStore = defineStore('auth', () => { }; /** - * Signing in with a passkey. + * Sign in with a passkey, which names nobody. * - * Nobody is named: the authenticator offers what it holds for this site, and - * the server works out whose key it is from the key itself. + * The same two endings as a password: signed in, or waiting for a code. A + * passkey that was unlocked — a fingerprint, a face, a PIN — is already the + * second factor, so only one that was not lands here waiting. */ const signInWithPasskey = async () => { lastError.value = null; @@ -124,26 +135,48 @@ export const useAuthStore = defineStore('auth', () => { hasStatus.value = true; if (response?.totpRequired) { - totpRequired.value = true; + totpPending.value = true; currentUser.value = null; return { totpRequired: true }; } - totpRequired.value = false; + totpPending.value = false; currentUser.value = response?.user || null; sessionStorage.removeItem('guestSessionId'); return { totpRequired: false }; }; - /** The second step. Which account this is remains the server's to know. */ + /** + * Finish a sign-in with the code from the phone, or one off the paper. + * + * @returns {Promise<{usedRecoveryCode: boolean, recoveryCodesLeft: number|null}>} + */ const submitTotpCode = async (code) => { lastError.value = null; const response = await submitTotpCodeApi(code); - totpRequired.value = false; - hasStatus.value = true; + totpPending.value = false; currentUser.value = response?.user || null; sessionStorage.removeItem('guestSessionId'); - return response; + return { + usedRecoveryCode: Boolean(response?.usedRecoveryCode), + recoveryCodesLeft: response?.recoveryCodesLeft ?? null, + }; + }; + + /** + * Back to the password, when somebody gives up on finding their phone. + * + * The server is told, rather than only the screen: it is the one holding the + * half-open sign-in, and a page reload would otherwise come back to the code + * for a step this person has already walked away from. + */ + const cancelTotp = async () => { + totpPending.value = false; + try { + await logoutApi(); + } catch (_) { + // Nothing was signed in; a server that cannot be reached changes that. + } }; const logout = async () => { @@ -155,6 +188,26 @@ export const useAuthStore = defineStore('auth', () => { } hasStatus.value = true; currentUser.value = null; + totpPending.value = false; + }; + + /** + * Drop the session locally, without telling the server. + * + * For a session that has already expired: there is nothing left to end, and + * asking the server to end it would be one more request answered 401 — or, + * where an identity provider is involved, a redirect to it that a fetch + * cannot follow. `hasStatus` stays true so the navigation guard sends the + * person to the login screen rather than pausing to ask the server who they + * are, which is the question that just failed. + */ + const forgetSession = () => { + currentUser.value = null; + // A session that is over is not one halfway through: whatever was waiting + // for a code is gone with it, and the screen starts at the password. + totpPending.value = false; + hasStatus.value = true; + lastError.value = null; }; const clearError = () => { @@ -181,16 +234,19 @@ export const useAuthStore = defineStore('auth', () => { authEnabled, authMode, strategies, + oidcStatus, currentUser, + totpPending, lastError, initialize, ensureStatus: initialize, setupAccount, login, - totpRequired, - submitTotpCode, signInWithPasskey, + submitTotpCode, + cancelTotp, logout, + forgetSession, clearError, refreshCurrentUser, }; From 3698d6c170f12f55037e850cd7f3837c727a7edd Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 20:37:52 +0200 Subject: [PATCH 03/15] Say what the cache directory holds, and what the process is costing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reports the server makes about itself at start, neither of which existed. **What releases up to 1.1.7 left in the cache.** The database and app-config.json lived in CACHE_DIR until 1.1.8, which moved them to CONFIG_DIR and left links behind; 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_DIR with its accounts, shares and settings sitting unread in the cache. Nothing said so: the server started, the sign-in page offered to create the first administrator, and the answer looked like a fresh installation rather than a lost one. It is a notice and nothing more — nothing is moved, nothing is deleted. It names what it found and where, and says what to do with it. **What the process is costing.** `services/performanceDiagnostics.js` samples CPU, resident memory as the cgroup sees it rather than as the host does, event-loop delay at p99, and the queues that can grow: thumbnails, folder sizes, transfers. Off unless PERFORMANCE_DIAGNOSTICS_ENABLED is set, and then it reports only the intervals that pass a threshold — a diagnostic that logs every interval by default is a diagnostic that fills a disk. PERFORMANCE_DIAGNOSTICS_LOG_EVERY_INTERVAL asks for all of them. An interval below its floor is held to the default: a sampler on a 1 ms interval costs more than whatever it was meant to diagnose, and 1 ms is what an emptied field sends. Each queue reports itself through an optional call. One that has no report is a queue this installation has nothing to say about, not a reason for the whole record to fail — a diagnostic that throws says nothing at the moment it is most wanted. ## Checks `legacy-cache-check.test.js`, 3 tests: a cache holding the old names is named, one holding the links 1.1.8 left is named differently, and an ordinary cache says nothing. Making the inspection always see an empty cache turns all three red. `performance-diagnostics.test.js`, 7 tests: silent unless asked for, says what it will watch and by which thresholds, holds an interval below its floor to the default, reports nothing of an ordinary interval, reports one that passes a threshold and says which kind it was, reports every interval when told to, and samples the machine rather than guessing. Making it start whether or not it was asked for turns the first red. Whole backend suite: 2 556 passed, 2 failed — the two that fail on `main` on its own. --- backend/src/config/env.js | 22 +++ backend/src/config/index.js | 15 ++ backend/src/server.js | 13 ++ backend/src/services/legacyCacheCheck.js | 83 ++++++++ .../src/services/performanceDiagnostics.js | 178 ++++++++++++++++++ .../tests/services/legacy-cache-check.test.js | 86 +++++++++ .../services/performance-diagnostics.test.js | 153 +++++++++++++++ 7 files changed, 550 insertions(+) create mode 100644 backend/src/services/legacyCacheCheck.js create mode 100644 backend/src/services/performanceDiagnostics.js create mode 100644 backend/tests/services/legacy-cache-check.test.js create mode 100644 backend/tests/services/performance-diagnostics.test.js diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 988d9bf83..1001f3d91 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -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, diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 4bc9da340..7994d8b38 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -661,7 +661,22 @@ 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), +}; + module.exports = { + performanceDiagnostics, folderSize, webauthn, activity, diff --git a/backend/src/server.js b/backend/src/server.js index 4aa8dc520..db5bbaf44 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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; @@ -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. @@ -130,6 +142,7 @@ const startServer = async () => { folderSizeManager.stop(); trashMaintenance.stop(); searchIndexManager.stop(); + performanceDiagnostics.stop(); server.close(() => { logger.info('Server closed'); process.exit(0); diff --git a/backend/src/services/legacyCacheCheck.js b/backend/src/services/legacyCacheCheck.js new file mode 100644 index 000000000..0a9f67950 --- /dev/null +++ b/backend/src/services/legacyCacheCheck.js @@ -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 }; diff --git a/backend/src/services/performanceDiagnostics.js b/backend/src/services/performanceDiagnostics.js new file mode 100644 index 000000000..0b3d65faa --- /dev/null +++ b/backend/src/services/performanceDiagnostics.js @@ -0,0 +1,178 @@ +const fs = require('fs/promises'); +const { monitorEventLoopDelay, performance } = require('perf_hooks'); + +const { performanceDiagnostics: config } = require('../config'); +const logger = require('../utils/logger'); +const thumbnailService = require('./thumbnailService'); +const folderSizeManager = require('./folderSizeManager'); +const fileTransferService = require('./fileTransferService'); + +let timer = null; +let previousSample = null; +let eventLoopDelay = null; + +const toMb = (bytes) => Math.round((Number(bytes) || 0) / 1024 / 1024); + +const readText = async (filePath) => { + try { + return (await fs.readFile(filePath, 'utf8')).trim(); + } catch (_) { + return null; + } +}; + +const readNumber = async (filePath) => { + const value = await readText(filePath); + if (value == null || value === 'max') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +}; + +const readKeyValueFile = async (filePath, allowedKeys) => { + const content = await readText(filePath); + if (!content) return null; + const result = {}; + for (const line of content.split('\n')) { + const [key, rawValue] = line.trim().split(/\s+/, 2); + if (!allowedKeys.has(key)) continue; + const value = Number(rawValue); + if (Number.isFinite(value)) result[key] = toMb(value); + } + return result; +}; + +const readCgroupMemory = async () => { + const v2Current = await readNumber('/sys/fs/cgroup/memory.current'); + const v2Limit = await readNumber('/sys/fs/cgroup/memory.max'); + const isV2 = v2Current != null; + const current = isV2 + ? v2Current + : await readNumber('/sys/fs/cgroup/memory/memory.usage_in_bytes'); + const limit = isV2 ? v2Limit : await readNumber('/sys/fs/cgroup/memory/memory.limit_in_bytes'); + const stat = await readKeyValueFile( + isV2 ? '/sys/fs/cgroup/memory.stat' : '/sys/fs/cgroup/memory/memory.stat', + new Set(['anon', 'file', 'slab', 'slab_reclaimable', 'slab_unreclaimable', 'cache', 'rss']) + ); + + if (current == null && !stat) return null; + return { + currentMb: toMb(current), + ...(limit != null ? { limitMb: toMb(limit) } : {}), + ...(stat ? { statMb: stat } : {}), + }; +}; + +const activeResourceCounts = () => { + if (typeof process.getActiveResourcesInfo !== 'function') return undefined; + return process.getActiveResourcesInfo().reduce((counts, name) => { + counts[name] = (counts[name] || 0) + 1; + return counts; + }, {}); +}; + +const sample = async () => { + const now = performance.now(); + const cpu = process.cpuUsage(); + const memory = process.memoryUsage(); + const [cgroupMemory, thumbnail, folderSize, transfers] = await Promise.all([ + readCgroupMemory(), + // Each queue reports itself when it can. One that does not is a queue this + // installation has no report for, not a reason for the whole record to fail — a + // diagnostic that throws is a diagnostic that says nothing at the moment it is + // most wanted. + Promise.resolve(thumbnailService.getDiagnosticsSnapshot?.() ?? null), + Promise.resolve(folderSizeManager.getDiagnosticsSnapshot?.() ?? null), + Promise.resolve(fileTransferService.getDiagnosticsSnapshot?.() ?? null), + ]); + + const elapsedMs = previousSample ? Math.max(1, now - previousSample.at) : null; + const cpuDeltaUs = previousSample + ? cpu.user - previousSample.cpu.user + (cpu.system - previousSample.cpu.system) + : null; + const cpuPercent = + elapsedMs != null && cpuDeltaUs != null + ? Math.round((cpuDeltaUs / 1000 / elapsedMs) * 100) + : null; + const loopDelayMs = eventLoopDelay + ? Number(eventLoopDelay.percentile(99) / 1e6).toFixed(1) + : null; + const eventLoopUtilization = previousSample?.eventLoopUtilization + ? performance.eventLoopUtilization(previousSample.eventLoopUtilization) + : null; + + previousSample = { + at: now, + cpu, + eventLoopUtilization: performance.eventLoopUtilization(), + }; + eventLoopDelay?.reset(); + + return { + cpuPercent, + ...(eventLoopUtilization + ? { eventLoopUtilizationPercent: Math.round(eventLoopUtilization.utilization * 100) } + : {}), + ...(loopDelayMs != null ? { eventLoopP99DelayMs: Number(loopDelayMs) } : {}), + memoryMb: { + rss: toMb(memory.rss), + heapUsed: toMb(memory.heapUsed), + heapTotal: toMb(memory.heapTotal), + external: toMb(memory.external), + arrayBuffers: toMb(memory.arrayBuffers), + }, + cgroupMemory, + resources: activeResourceCounts(), + thumbnail, + folderSize, + transfers, + }; +}; + +const isPressure = (snapshot) => + (snapshot.cpuPercent ?? 0) >= config.cpuThreshold || + snapshot.memoryMb.rss >= config.rssThresholdMb || + (snapshot.eventLoopP99DelayMs ?? 0) >= config.eventLoopDelayThresholdMs; + +const start = () => { + if (!config.enabled || timer) return; + + eventLoopDelay = monitorEventLoopDelay({ resolution: 20 }); + eventLoopDelay.enable(); + logger.info( + { + intervalMs: config.intervalMs, + cpuThreshold: config.cpuThreshold, + rssThresholdMb: config.rssThresholdMb, + eventLoopDelayThresholdMs: config.eventLoopDelayThresholdMs, + logEveryInterval: config.logEveryInterval, + }, + 'Performance diagnostics enabled' + ); + + const tick = () => { + sample() + .then((snapshot) => { + if (config.logEveryInterval || isPressure(snapshot)) { + logger.info( + { reason: isPressure(snapshot) ? 'resource-pressure' : 'interval', ...snapshot }, + 'Performance diagnostics' + ); + } + }) + .catch((err) => logger.debug({ err }, 'Performance diagnostics sample failed')); + }; + + tick(); + timer = setInterval(tick, config.intervalMs); + if (typeof timer.unref === 'function') timer.unref(); +}; + +const stop = () => { + if (timer) clearInterval(timer); + timer = null; + eventLoopDelay?.disable(); + eventLoopDelay = null; + previousSample = null; +}; + +module.exports = { start, stop, sample }; diff --git a/backend/tests/services/legacy-cache-check.test.js b/backend/tests/services/legacy-cache-check.test.js new file mode 100644 index 000000000..5dcfa6ecc --- /dev/null +++ b/backend/tests/services/legacy-cache-check.test.js @@ -0,0 +1,86 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * What early releases left in the cache directory. + * + * An installation that started on 1.1.7 or earlier kept its database in /cache; + * the move to /config that 1.1.8 made was removed in 2.0.3, so one that skipped + * the releases in between comes up on an empty app.db with its accounts unread + * in /cache. Nothing said so. Now the start does — and moves nothing, since + * which file holds what matters cannot be told from here. + */ + +let envContext; + +afterEach(async () => { + if (envContext) await envContext.cleanup(); + envContext = null; +}); + +const setup = async () => { + envContext = await setupTestEnv({ tag: 'legacy-cache-' }); + const check = envContext.requireFresh('src/services/legacyCacheCheck'); + const log = { warn: vi.fn(), info: vi.fn() }; + const report = () => + check.reportLegacyCache({ + cacheDir: envContext.cacheDir, + configDir: envContext.configDir, + log, + }); + return { check, log, report, cache: envContext.cacheDir, config: envContext.configDir }; +}; + +describe('an app.db left in the cache directory', () => { + it('is reported as a warning naming both files, and left where it is', async () => { + const { log, report, cache, config } = await setup(); + fs.writeFileSync(path.join(cache, 'app.db'), 'SQLite format 3\0 with the old accounts'); + + const findings = report(); + + expect(findings).toEqual([expect.objectContaining({ name: 'app.db', kind: 'database' })]); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn.mock.calls[0][0]).toMatchObject({ + legacyDatabase: path.join(cache, 'app.db'), + databaseInUse: path.join(config, 'app.db'), + }); + expect(fs.existsSync(path.join(cache, 'app.db'))).toBe(true); + }); +}); + +describe('links left in the cache directory', () => { + it('are mentioned as unused, not warned about, and not followed', async () => { + const { log, report, cache, config } = await setup(); + fs.writeFileSync(path.join(config, 'app-config.json'), '{}'); + fs.symlinkSync(path.join(config, 'app.db'), path.join(cache, 'app.db')); + fs.symlinkSync(path.join(config, 'app-config.json'), path.join(cache, 'app-config.json')); + fs.symlinkSync(path.join(config, 'extensions'), path.join(cache, 'extensions')); + + const findings = report(); + + expect(findings.map((finding) => [finding.name, finding.kind])).toEqual([ + ['app.db', 'link'], + ['app-config.json', 'link'], + ['extensions', 'link'], + ]); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.info).toHaveBeenCalledTimes(1); + expect(log.info.mock.calls[0][0].links).toHaveLength(3); + }); +}); + +describe('a cache directory with nothing from early releases', () => { + it('says nothing', async () => { + const { log, report, cache } = await setup(); + fs.mkdirSync(path.join(cache, 'thumbnails'), { recursive: true }); + fs.writeFileSync(path.join(cache, 'index.db'), 'SQLite format 3\0'); + + expect(report()).toEqual([]); + expect(log.warn).not.toHaveBeenCalled(); + expect(log.info).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/services/performance-diagnostics.test.js b/backend/tests/services/performance-diagnostics.test.js new file mode 100644 index 000000000..2e3b7c010 --- /dev/null +++ b/backend/tests/services/performance-diagnostics.test.js @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * The periodic record of what the process is costing. + * + * It exists for the case nobody can reproduce: an installation that goes slow after + * hours, on storage nobody here has, with a load nobody here makes. The only useful + * answer is what the process was costing at the time, so the sampler reports CPU, + * resident memory as the cgroup sees it, event-loop delay, and the queues that grow. + * + * What is worth testing is not the numbers — they are the machine's — but the three + * decisions around them: that it says nothing at all unless it was asked for, that it + * then reports only the intervals that look wrong, and that it can be told to report + * every one. A diagnostic that logs on every interval by accident is a diagnostic that + * fills a disk. + */ + +let env; + +afterEach(async () => { + vi.restoreAllMocks(); + if (env) { + env.requireFresh('src/services/performanceDiagnostics').stop(); + await env.cleanup(); + } + env = null; +}); + +const load = async (extraEnv = {}) => { + env = await setupTestEnv({ tag: 'perf-diagnostics-', env: extraEnv }); + // The logger first, and spied on before the service is loaded: the service keeps + // whichever logger it was given at require time, so spying on a fresh one afterwards + // watches an object nothing writes to. + const logger = env.requireFresh('src/utils/logger'); + const said = vi.spyOn(logger, 'info'); + const diagnostics = env.requireFresh('src/services/performanceDiagnostics'); + return { diagnostics, said }; +}; + +/** Every message a logger spy was given, as one string. */ +const messages = (spy) => spy.mock.calls.map((call) => String(call[1] ?? call[0])).join('\n'); + +describe('the performance record', () => { + it('is silent unless somebody asked for it', async () => { + const { diagnostics, said } = await load(); + + diagnostics.start(); + + expect(messages(said)).not.toContain('Performance diagnostics'); + }); + + it('says what it will watch, and by which thresholds, when it is on', async () => { + const { diagnostics, said } = await load({ + PERFORMANCE_DIAGNOSTICS_ENABLED: 'true', + PERFORMANCE_DIAGNOSTICS_INTERVAL_MS: '60000', + PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD: '90', + }); + + diagnostics.start(); + + expect(messages(said)).toContain('Performance diagnostics enabled'); + const announced = said.mock.calls.find(([, message]) => /enabled/.test(String(message)))[0]; + expect(announced.intervalMs).toBe(60000); + expect(announced.cpuThreshold).toBe(90); + }); + + it('holds an interval below its floor to the default, rather than sampling constantly', async () => { + // What an emptied or mistyped field sends. A sampler on a 1 ms interval costs more + // than whatever it was meant to diagnose. + const { diagnostics, said } = await load({ + PERFORMANCE_DIAGNOSTICS_ENABLED: 'true', + PERFORMANCE_DIAGNOSTICS_INTERVAL_MS: '1', + }); + + diagnostics.start(); + + const announced = said.mock.calls.find(([, message]) => /enabled/.test(String(message)))[0]; + expect(announced.intervalMs).toBe(15000); + }); + + it('reports nothing of an interval that looks ordinary', async () => { + const { diagnostics, said } = await load({ + PERFORMANCE_DIAGNOSTICS_ENABLED: 'true', + // Thresholds nothing here will reach. + PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD: '100000', + PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB: '100000', + PERFORMANCE_DIAGNOSTICS_EVENT_LOOP_DELAY_MS: '100000', + }); + + diagnostics.start(); + await vi.waitFor(() => expect(messages(said)).toContain('enabled')); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const records = said.mock.calls.filter(([, message]) => message === 'Performance diagnostics'); + expect(records).toEqual([]); + }); + + it('reports one that passes a threshold, and says which kind of interval it was', async () => { + const { diagnostics, said } = await load({ + PERFORMANCE_DIAGNOSTICS_ENABLED: 'true', + // A memory threshold of nothing: every interval is past it. + PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB: '1', + }); + + diagnostics.start(); + + await vi.waitFor(() => { + const records = said.mock.calls.filter( + ([, message]) => message === 'Performance diagnostics' + ); + expect(records.length).toBeGreaterThan(0); + expect(records[0][0].reason).toBe('resource-pressure'); + }); + }); + + it('reports every interval when it is told to', async () => { + const { diagnostics, said } = await load({ + PERFORMANCE_DIAGNOSTICS_ENABLED: 'true', + PERFORMANCE_DIAGNOSTICS_LOG_EVERY_INTERVAL: 'true', + PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD: '100000', + PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB: '100000', + PERFORMANCE_DIAGNOSTICS_EVENT_LOOP_DELAY_MS: '100000', + }); + + diagnostics.start(); + + await vi.waitFor(() => { + const records = said.mock.calls.filter( + ([, message]) => message === 'Performance diagnostics' + ); + expect(records.length).toBeGreaterThan(0); + // Nothing was under pressure: it is reporting because it was asked to. + expect(records[0][0].reason).toBe('interval'); + }); + }); + + it('samples the machine rather than guessing at it', async () => { + const { diagnostics } = await load({ PERFORMANCE_DIAGNOSTICS_ENABLED: 'true' }); + + const snapshot = await diagnostics.sample(); + + // `toMb` rounds, and this process is small enough to round to zero on some + // machines, so what is asserted is that the numbers came from somewhere rather + // than what they are. + expect(typeof snapshot.memoryMb.rss).toBe('number'); + expect(snapshot.memoryMb.heapTotal).toBeGreaterThan(0); + // And the queues each answered, or said they had nothing to answer with. + expect(snapshot).toHaveProperty('resources'); + expect(snapshot).toHaveProperty('cpuPercent'); + }); +}); From 31ed5c4287dc5ef335d022b63625eee98a06afc3 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 20:39:58 +0200 Subject: [PATCH 04/15] Let the preview's ceiling be set, and stop a proxy keeping a listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/features` already offers `preview.maxRenderBytes` to the screen, and the configuration had no such value, so it answered `null` and the preview rendered whatever it was given. A document large enough to render slowly renders slowly for everybody on the machine, and nobody could raise or lower the point at which it stops trying. PREVIEW_MAX_RENDER_SIZE sets it; 16 MB when it is not set, and a value below zero or unparseable is that default rather than a ceiling of nothing. And the listing: `GET /api/browse` answers with what is true at that moment — which documents somebody has open in an editor, what a folder weighs, whether a write would be refused. A GET with no cache header is cacheable by default, so a proxy or a browser was free to keep it and serve a folder as it was: a deleted file still listed, a document shown as open by somebody who closed it an hour ago. `private, no-store` says what it is. ## Checks `browse-caching.test.js` asserts both halves of the header, and taking the header away turns it red. The ceiling is read through `/api/features`, which has offered the field since the batch that brought the features route and was answering `null` for it. Whole backend suite: 2 556 passed, 2 failed — the two that fail on `main` on its own. --- backend/src/config/env.js | 1 + backend/src/config/index.js | 27 ++++++++++++ backend/src/routes/browse.js | 3 ++ backend/tests/routes/browse-caching.test.js | 49 +++++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 backend/tests/routes/browse-caching.test.js diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 1001f3d91..1fad07b0b 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -118,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, diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 7994d8b38..dff388378 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -675,8 +675,35 @@ const performanceDiagnostics = { 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, diff --git a/backend/src/routes/browse.js b/backend/src/routes/browse.js index 1038237c8..5943ea983 100644 --- a/backend/src/routes/browse.js +++ b/backend/src/routes/browse.js @@ -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); }) diff --git a/backend/tests/routes/browse-caching.test.js b/backend/tests/routes/browse-caching.test.js new file mode 100644 index 000000000..56d2c75bb --- /dev/null +++ b/backend/tests/routes/browse-caching.test.js @@ -0,0 +1,49 @@ +import express from 'express'; +import request from 'supertest'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * A listing is not a document, and must not be cached as one. + * + * `GET /api/browse` carries what is true at that moment: which documents somebody has + * open in an editor, what a folder weighs, whether a write would be refused. None of + * that is worth remembering, and a proxy or a browser that remembers it serves a view + * of a folder as it was — a file that was deleted still listed, a document shown as + * open by somebody who closed it an hour ago. + * + * No header said so, and the answer to a GET with none is cacheable by default. + */ + +let env; + +afterEach(async () => { + if (env) await env.cleanup(); + env = null; +}); + +const app = () => { + const server = express(); + server.use((req, _res, next) => { + req.user = { id: 'admin-1', roles: ['admin'] }; + next(); + }); + server.use('/api', env.requireFresh('src/routes/browse')); + server.use(env.requireFresh('src/middleware/errorHandler').errorHandler); + return server; +}; + +describe('the answer to a listing', () => { + it('is not to be kept by a browser or a proxy', async () => { + env = await setupTestEnv({ tag: 'browse-caching-' }); + + const response = await request(app()).get('/api/browse/'); + + expect(response.status).toBe(200); + // `private` keeps a shared proxy out of it; `no-store` keeps the browser from + // answering the next navigation from what it already has. + expect(response.headers['cache-control']).toContain('no-store'); + expect(response.headers['cache-control']).toContain('private'); + }); +}); From dd0bf163f80564f5c63bd793f217b5395c84c34a Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 20:50:51 +0200 Subject: [PATCH 05/15] Open the database once, and compile a statement once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getDb` checks whether a connection is already open and opens one when it is not. Every caller that arrives before the first has finished sees "not open" and opens another — and everything that starts with the server asks at once: the session store, the settings, the trash sweep, the search index, the favourites. So a start opened app.db four times, ran `migrate` over the same file four times in parallel, and whichever finished last became the one everybody used. One opening is shared now. `openDb` does the work, `getDb` hands every caller the same promise while it is in flight and the same connection afterwards, and `closeDb` lets a test take it away again — which is what a suite opening a database per case needs. And `prepared(db, sql)`: `db.prepare` compiles the SQL every time it is called, and the hot paths called it per row — a listing asking whether each of a thousand entries is a favourite compiled the same statement a thousand times. Kept in a WeakMap keyed on the connection, so the cache goes when the connection does and a statement is never handed to a connection that did not compile it. ## Checks `db-single-open.test.js`, 5 tests. Five callers in one turn get one connection — counted on the connection objects rather than on anything the code says about itself. A later caller gets the one already open. After `closeDb` the next caller gets a new, working one. The same SQL twice compiles once, and the same SQL against a new connection compiles again. Putting the per-caller opening back turns the first red. Whole backend suite: 2 561 passed, 2 failed — the two that fail on `main` on its own. Two files were in this batch and are not: `betterSqliteSessionStore.js` and `bootstrap.js`. `main`'s versions are the newer ones — it opens the session database on first use rather than when the module is required, which is what keeps `require('./backend/src/app.js')` working where the cache directory does not exist yet. Bringing the fork's versions over them would have broken that check. --- backend/src/services/db.js | 64 +++++++++++- backend/tests/services/db-single-open.test.js | 98 +++++++++++++++++++ 2 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 backend/tests/services/db-single-open.test.js diff --git a/backend/src/services/db.js b/backend/src/services/db.js index 09b8189cd..86ebc0b77 100644 --- a/backend/src/services/db.js +++ b/backend/src/services/db.js @@ -866,9 +866,7 @@ const ensureAnonymousUser = (db) => { } }; -const getDb = async () => { - if (dbInstance) return dbInstance; - +const openDb = async () => { const dbDir = directories.config; await ensureDir(dbDir); const dbPath = getDbPath(); @@ -938,11 +936,67 @@ const getDb = async () => { logger.warn({ err }, '[DB] Failed to ensure the ONLYOFFICE editor session table'); } ensureAnonymousUser(db); - dbInstance = db; - return dbInstance; + return db; +}; + +/** + * The application database, opened on first use. + * + * Everything that starts with the server asks for it at once. Each caller passed the + * check for an open database before the first one had finished opening it, and went on + * to open app.db again and run the migrations over it in parallel: four connections at + * every start, four sets of `CREATE TABLE IF NOT EXISTS`, and whichever finished last + * became the one everybody used. One opening is shared instead. + */ +let dbOpening = null; +const getDb = async () => { + if (dbInstance) return dbInstance; + if (!dbOpening) { + dbOpening = openDb() + .then((db) => { + dbInstance = db; + return db; + }) + .finally(() => { + dbOpening = null; + }); + } + return dbOpening; +}; + +const closeDb = () => { + if (!dbInstance) return; + dbInstance.close(); + dbInstance = null; +}; + +/** + * A statement prepared once per database and kept. + * + * `db.prepare` compiles the SQL every time it is called, and the hot paths — a listing + * asking whether each of a thousand rows is a favourite — called it per row. Kept in a + * WeakMap so the cache goes when the connection does, which is what a test that opens a + * database per case needs. + */ +const statementCache = new WeakMap(); + +const prepared = (db, sql) => { + let cache = statementCache.get(db); + if (!cache) { + cache = new Map(); + statementCache.set(db, cache); + } + let statement = cache.get(sql); + if (!statement) { + statement = db.prepare(sql); + cache.set(sql, statement); + } + return statement; }; module.exports = { + closeDb, + prepared, getDb, getDbPath, // The index database keeps its own copy of this table, so it needs the same diff --git a/backend/tests/services/db-single-open.test.js b/backend/tests/services/db-single-open.test.js new file mode 100644 index 000000000..4cf226b4f --- /dev/null +++ b/backend/tests/services/db-single-open.test.js @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * How many times `app.db` is opened, and how many times a statement is compiled. + * + * Everything that starts with the server asks for the database at once: the session + * store, the settings, the trash sweep, the search index, the favourites. `getDb` + * checked whether a connection was already open and opened one when it was not — and + * every caller that arrived before the first one had finished saw "not open" and opened + * another. Four connections at every start, four runs of the migrations over the same + * file in parallel, and whichever finished last became the one everybody used. + * + * The number that matters is therefore 1, and it is the constructor that is counted + * rather than anything the code says about itself. + */ + +let env; + +afterEach(async () => { + vi.restoreAllMocks(); + if (env) { + env.requireFresh('src/services/db').closeDb?.(); + await env.cleanup(); + } + env = null; +}); + +describe('opening the application database', () => { + it('happens once, however many callers ask at the same moment', async () => { + env = await setupTestEnv({ tag: 'db-single-open-' }); + const db = env.requireFresh('src/services/db'); + + // Five callers in the same turn, as the start does. + const handles = await Promise.all([db.getDb(), db.getDb(), db.getDb(), db.getDb(), db.getDb()]); + + // The same connection, not five that happen to point at the same file. + expect(new Set(handles).size).toBe(1); + }); + + it('gives every later caller the connection it already has', async () => { + env = await setupTestEnv({ tag: 'db-single-open-' }); + const db = env.requireFresh('src/services/db'); + + const first = await db.getDb(); + const second = await db.getDb(); + + expect(second).toBe(first); + }); + + it('opens again after it has been closed', async () => { + env = await setupTestEnv({ tag: 'db-single-open-' }); + const db = env.requireFresh('src/services/db'); + + const first = await db.getDb(); + db.closeDb(); + const second = await db.getDb(); + + expect(second).not.toBe(first); + // And the new one works, which a closed handle would not. + expect(second.prepare('SELECT 1 AS one').get().one).toBe(1); + }); +}); + +describe('a statement asked for twice', () => { + it('is compiled once', async () => { + env = await setupTestEnv({ tag: 'db-prepared-' }); + const db = await env.requireFresh('src/services/db').getDb(); + const { prepared } = env.requireFresh('src/services/db'); + const compile = vi.spyOn(db, 'prepare'); + + const sql = 'SELECT COUNT(*) AS total FROM users WHERE id = ?'; + const a = prepared(db, sql); + const b = prepared(db, sql); + + expect(b).toBe(a); + expect(compile).toHaveBeenCalledTimes(1); + // And it is a working statement, not a cached object that only looks like one. + expect(a.get('nobody').total).toBe(0); + }); + + it('is compiled again for a different connection', async () => { + env = await setupTestEnv({ tag: 'db-prepared-' }); + const service = env.requireFresh('src/services/db'); + const sql = 'SELECT COUNT(*) AS total FROM users'; + + const first = await service.getDb(); + const one = service.prepared(first, sql); + service.closeDb(); + const second = await service.getDb(); + const two = service.prepared(second, sql); + + // A statement belongs to the connection that compiled it; handing the old one to a + // new connection is how a cache keyed on the SQL alone breaks. + expect(two).not.toBe(one); + }); +}); From 6e2d75cd8461ea4a6d44d314414ca85fd89c1044 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 22:38:30 +0200 Subject: [PATCH 06/15] Remember a folder's sort and view as rows, not as one value per account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each account's per-folder choices were two JSON values under `user_settings`: one map of sorts, one of views, read and rewritten whole on every change. Three things followed, and all three are gone here. Two tabs on different folders overwrote each other. Each save sent the whole map, so whichever tab saved last won and the other folder's choice was lost. A preference is now written one folder at a time, through `PATCH /api/settings` with `{ folderSort: { path, sort } }`, and the row's UPSERT keeps the half it was not given: setting a folder's view no longer erases its sort. The map had a ceiling of a hundred folders, because it shipped entire on every load and was rewritten entire on every change; the hundred-and-first folder silently forgot the oldest. Rows have no ceiling. And nothing could ever clean it up. A folder deleted or renamed left its preferences behind on every account that had ever opened it, on a path that no longer existed. As rows they can be removed with the folder they describe. Schema 20 creates `folder_preferences` and carries the old values over: one row per account and folder, merged under the later of the two times, and the values it read are removed so nothing can read them back. A value it cannot parse costs that folder its remembered sort rather than failing the startup. Also here, because they are the same screen and the same service: - A default view for folders that have none of their own. `null` means the built-in one; a mode there is no such thing as is refused rather than read as null, which used to put every folder back to the built-in view. - Markdown opens in the editor, for whoever mostly writes it, instead of going through the preview and clicking Edit every time. Only markdown: it is the one kind of file that has both, so it is the only one where opening it is a choice. - An access rule that cannot be stored is refused with its reason instead of being dropped from the answer. Saving `../Secret` used to answer 200 with a list the page then adopted, and an administrator was left believing a folder was hidden that never was. Worse, a permission that was not one of the three became `rw`, so a mistyped `readonly` opened a folder for writing. - Every section of a save is checked before any of it is written. A payload carrying a valid section and a refused one used to store the first and answer 400 — a request reported as refused that had changed something. - One list of what a preference is. It was three: the keys the route allowed, the chain of if/else that sanitised them, and the defaults in the client store. A key in one and missing from another was accepted, silently dropped, and answered with its previous value, which the client applied — so the switch flicked itself back off. `markdownOpensInEditor` did exactly that. `PATCH /api/settings` now answers with the settings read back from storage rather than an echo of what was sent, so what the caller applies to its own state is what a later request would read. `tests/routes/settings-preferences.js` was asserting the echo, and now asserts the value of each preference it saved. `USER_SETTING_KEYS` is `WRITABLE_USER_SETTINGS`, which says what it holds. tests/services/folder-preferences-as-rows.test.js covers the eight claims above. Each was put back to the old behaviour to check the right one goes red: overwriting instead of keeping the other half, deleting the account's rows on every write, reading without the owner, a LIMIT of 100, accepting any word as a view mode, and dropping the carry-over. Whole suite: 2685 passed, 2 failed — the two that already fail on main (auth.test.js on the wrong current password, browse-hidden-files.test.js on a FOREIGN KEY). Built, started, and driven in a browser: a folder keeps the view it was left in while its neighbour keeps the default, and markdown goes to the editor only when the preference says so. --- backend/src/routes/settings.js | 599 +++++---- backend/src/services/db.js | 118 ++ backend/src/services/settingsService.js | 1198 +++++++++++------ backend/tests/helpers/substitute-module.js | 36 + backend/tests/routes/account-language.test.js | 4 +- .../routes/settings-concurrent-saves.test.js | 137 ++ .../tests/routes/settings-exclusions.test.js | 124 ++ .../routes/settings-partial-updates.test.js | 587 ++++++++ .../tests/routes/settings-preferences.test.js | 30 +- .../routes/settings-user-preferences.test.js | 323 +++++ .../routes/settings-write-boundary.test.js | 189 +++ .../folder-preferences-as-rows.test.js | 189 +++ .../services/settings-without-json.test.js | 130 ++ backend/tests/services/settings.test.js | 188 ++- backend/tests/services/trash-settings.test.js | 6 +- frontend/src/api/settings.api.js | 11 + frontend/src/composables/navigation.js | 20 +- frontend/src/i18n/locales/de.json | 13 +- frontend/src/i18n/locales/en.json | 13 +- frontend/src/i18n/locales/es.json | 13 +- frontend/src/i18n/locales/fr.json | 13 +- frontend/src/i18n/locales/hi.json | 13 +- frontend/src/i18n/locales/it.json | 13 +- frontend/src/i18n/locales/ko.json | 13 +- frontend/src/i18n/locales/nl.json | 13 +- frontend/src/i18n/locales/pl.json | 13 +- frontend/src/i18n/locales/pt-BR.json | 13 +- frontend/src/i18n/locales/ro.json | 13 +- frontend/src/i18n/locales/ru.json | 13 +- frontend/src/i18n/locales/sv.json | 13 +- frontend/src/i18n/locales/zh-CN.json | 13 +- frontend/src/i18n/locales/zh-TW.json | 13 +- frontend/src/stores/appSettings.js | 225 +++- frontend/src/stores/fileStore.js | 4 + frontend/src/stores/folderPreference.js | 107 ++ frontend/src/stores/settings.js | 191 ++- .../settings/SettingsUserPreferences.vue | 150 ++- 37 files changed, 3831 insertions(+), 930 deletions(-) create mode 100644 backend/tests/helpers/substitute-module.js create mode 100644 backend/tests/routes/settings-concurrent-saves.test.js create mode 100644 backend/tests/routes/settings-exclusions.test.js create mode 100644 backend/tests/routes/settings-partial-updates.test.js create mode 100644 backend/tests/routes/settings-user-preferences.test.js create mode 100644 backend/tests/routes/settings-write-boundary.test.js create mode 100644 backend/tests/services/folder-preferences-as-rows.test.js create mode 100644 backend/tests/services/settings-without-json.test.js create mode 100644 frontend/src/stores/folderPreference.js diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 7063be9c0..c3b32010f 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -3,54 +3,27 @@ const { getPublicSettings, getSettingsForUser, setUserSetting, - USER_SETTING_KEYS, - setSystemSetting, - getSettings, + setUserFolderSort, + setUserFolderView, + checkSystemSection, + mergeSystemSection, + replaceBranding, + WRITABLE_USER_SETTINGS, } = require('../services/settingsService'); +const { forgetReplacedLogo, replaceLogo } = require('../services/brandingLogo'); const activityLog = require('../services/activityLog'); +const asyncHandler = require('../utils/asyncHandler'); const { ensureAdmin } = require('../middleware/ensureAdmin'); -const { checkRulePath } = require('../services/accessControlService'); +const multer = require('multer'); const { ValidationError } = require('../errors/AppError'); +const { describeBytes, explainMultipartRefusals } = require('../middleware/multipartRefusals'); const folderSizeManager = require('../services/folderSizeManager'); const searchIndexManager = require('../services/searchIndexManager'); -const asyncHandler = require('../utils/asyncHandler'); -const multer = require('multer'); const featureSwitches = require('../services/featureSwitches'); -const { explainMultipartRefusals, describeBytes } = require('../middleware/multipartRefusals'); -const { replaceLogo, forgetReplacedLogo } = require('../services/brandingLogo'); - -/** - * A number somebody chose. - * - * Every numeric setting here has a floor above zero, and every one of them is - * a field on a form: emptied, it arrives as 0. Stored, the sanitizer lifts it - * to the floor — so clearing the trash retention used to leave a trash that - * keeps one day and sweeps everything older within the hour, and clearing the - * share of a volume left one percent. Nothing arriving means nothing chosen, - * and what is stored stays. - * - * One reading for all of them rather than a condition per field, so a section - * added later cannot be the one that forgot. - */ -const chosenNumber = (value) => Number.isFinite(value) && value > 0; +const { checkRulePath } = require('../services/accessControlService'); const router = express.Router(); -// Middleware to check if user is admin -const keepValid = (section, fields) => { - const update = {}; - for (const [name, isAcceptable] of Object.entries(fields)) { - if (isAcceptable(section[name])) update[name] = section[name]; - } - return update; -}; - -const isBoolean = (value) => typeof value === 'boolean'; - -// An application name of spaces is no name: the header and the sign-in page showed -// nothing where it belonged. -const isName = (value) => typeof value === 'string' && value.trim() !== ''; - const LOGO_MAX_BYTES = 2 * 1024 * 1024; // Configure multer for logo uploads @@ -72,6 +45,7 @@ const upload = multer({ const acceptLogo = explainMultipartRefusals(upload.single('logo'), { LIMIT_FILE_SIZE: `A logo can be at most ${describeBytes(LOGO_MAX_BYTES)}.`, }); + /** * GET /api/branding * Returns public branding settings (no auth required) @@ -169,277 +143,314 @@ router.post( * - Users can update their own user settings (user.*) * - Admins can update system settings (thumbnails, access, branding) */ -router.patch( - '/settings', - asyncHandler(async (req, res) => { - const payload = req.body || {}; - const user = req.user; - const isAdmin = user && Array.isArray(user.roles) && user.roles.includes('admin'); - const updated = {}; - - // User settings (all authenticated users can update) - if (payload.user && typeof payload.user === 'object' && user && user.id) { - const userUpdates = {}; - for (const [key, value] of Object.entries(payload.user)) { - // Which keys are preferences is the settings service's to say: this - // route used to keep a second list of its own, and a preference added - // to one and not the other was silently dropped here. - if (USER_SETTING_KEYS.has(key)) { - userUpdates[key] = await setUserSetting(user.id, key, value); - } - } - if (Object.keys(userUpdates).length > 0) { - updated.user = userUpdates; - } +/** + * Keep the fields of a section that arrived in a shape worth storing. + * + * A field nobody sent is not a field set to nothing, and a size that is not a + * number is a size nobody chose: both are left out, so the stored value stays + * what it was rather than becoming something the caller never asked for. + */ +const keepValid = (section, fields) => { + const update = {}; + for (const [name, isAcceptable] of Object.entries(fields)) { + if (isAcceptable(section[name])) update[name] = section[name]; + } + return update; +}; + +const isBoolean = (value) => typeof value === 'boolean'; +const isText = (value) => typeof value === 'string'; + +// A size or a count of nothing, or of less than nothing, is what an emptied or +// mistyped field sends, not a value anyone chose. The service would bring it up +// to its lowest bound — a chunk size of 0 became 1 MiB — which replaced what +// was stored with something nobody asked for. A positive value outside the +// bounds is still brought within them there. +const isPositiveNumber = (value) => Number.isFinite(value) && value > 0; + +// An application name of spaces is no name: the header and the sign-in page +// showed nothing where it belonged. +const isName = (value) => typeof value === 'string' && value.trim() !== ''; + +/** + * Merge an update over what is stored, and give back the whole section. + * + * The merge is the service's, which reads the stored section and writes it + * back without yielding in between. Merging over the settings read at the + * start of the request, as this did, left two awaits between the read and the + * write: two saves of one section at once both started from the same stored + * value, and the second wrote over the first's field while telling the person + * who set it that it was saved. Branding already had its own reason for a + * read and a write in one step; every section has this one. + * + * @returns {Promise} null when there was nothing to change, so a + * caller can tell "no valid field" from "field set to its current value". + */ +const mergeSection = async (category, key, update) => { + if (Object.keys(update).length === 0) return null; + return mergeSystemSection(category, key, update); +}; + +/** A person's own preferences, which they may change whatever their role. */ +const applyUserPreferences = async (user, section) => { + const updates = {}; + + for (const [key, value] of Object.entries(section)) { + if (key === 'folderSort') { + const folderSorts = await setUserFolderSort(user.id, value?.path, value?.sort); + if (folderSorts) updates.folderSorts = folderSorts; + } else if (key === 'folderView') { + const folderViews = await setUserFolderView(user.id, value?.path, value?.view); + if (folderViews) updates.folderViews = folderViews; + } else if (WRITABLE_USER_SETTINGS.has(key)) { + updates[key] = await setUserSetting(user.id, key, value); } + } - // System settings (admin only) - if (isAdmin) { - const systemUpdates = {}; - - // Thumbnails settings - if (payload.thumbnails && typeof payload.thumbnails === 'object') { - const thumbnailsUpdate = {}; - if (payload.thumbnails.enabled != null) { - thumbnailsUpdate.enabled = Boolean(payload.thumbnails.enabled); - } - if (chosenNumber(payload.thumbnails.size)) { - thumbnailsUpdate.size = payload.thumbnails.size; - } - if (chosenNumber(payload.thumbnails.quality)) { - thumbnailsUpdate.quality = payload.thumbnails.quality; - } - if (chosenNumber(payload.thumbnails.concurrency)) { - thumbnailsUpdate.concurrency = payload.thumbnails.concurrency; - } - if (Object.keys(thumbnailsUpdate).length > 0) { - const current = await getSettings(); - await setSystemSetting('system', 'thumbnails', { - ...current.thumbnails, - ...thumbnailsUpdate, - }); - systemUpdates.thumbnails = { ...current.thumbnails, ...thumbnailsUpdate }; - } - } + return Object.keys(updates).length > 0 ? updates : null; +}; - // Access control rules, and whether they hold administrators. The two are - // saved apart on the settings page, so each is merged over what is stored - // rather than replacing the section: saving the rules used to drop the - // setting above them, and saving the setting used to drop the rules. - if (payload.access && typeof payload.access === 'object') { - const accessUpdate = {}; - if (Array.isArray(payload.access.rules)) accessUpdate.rules = payload.access.rules; - if (typeof payload.access.applyToAdmins === 'boolean') { - accessUpdate.applyToAdmins = payload.access.applyToAdmins; - } - if (Object.keys(accessUpdate).length > 0) { - const current = await getSettings(); - const merged = await setSystemSetting('system', 'access', { - ...current.access, - ...accessUpdate, - }); - systemUpdates.access = merged; - } - } +/** + * A section checked in one step and written in another. + * + * Both halves exist because one save carries several sections: a refusal in + * the third must not leave the first two stored. `check` answers what is to be + * written, or null when the section sends nothing this route stores, and it is + * where a refusal comes from. `write` stores it, and cannot refuse. + */ +const merging = (key, fields) => ({ + check: (section) => keepValid(section, fields), + write: (update) => mergeSection('system', key, update), +}); - // Trash settings: only the fields that arrived in a usable shape are - // merged over what is stored; setSystemSetting sanitizes and clamps them. - if (payload.trash && typeof payload.trash === 'object') { - const trashUpdate = {}; - if (typeof payload.trash.enabled === 'boolean') { - trashUpdate.enabled = payload.trash.enabled; - } - if (chosenNumber(payload.trash.retentionDays)) { - trashUpdate.retentionDays = payload.trash.retentionDays; - } - if (chosenNumber(payload.trash.maxPercent)) { - trashUpdate.maxPercent = payload.trash.maxPercent; - } - if (payload.trash.maxBytes === null || chosenNumber(payload.trash.maxBytes)) { - trashUpdate.maxBytes = payload.trash.maxBytes; - } - if (Object.keys(trashUpdate).length > 0) { - const current = await getSettings(); - const merged = await setSystemSetting('system', 'trash', { - ...current.trash, - ...trashUpdate, - }); - systemUpdates.trash = merged; - } - } +const thumbnailsSection = merging('thumbnails', { + // Anything but a boolean used to be read as "on": "false" switched + // thumbnails on for everybody. + enabled: isBoolean, + size: isPositiveNumber, + quality: isPositiveNumber, + concurrency: isPositiveNumber, +}); - // Upload settings: whether uploads go out in chunks, and how big one is. - if (payload.uploads && typeof payload.uploads === 'object') { - const uploadsUpdate = {}; - if (typeof payload.uploads.chunkedEnabled === 'boolean') { - uploadsUpdate.chunkedEnabled = payload.uploads.chunkedEnabled; - } - if (chosenNumber(payload.uploads.chunkSizeBytes)) { - uploadsUpdate.chunkSizeBytes = payload.uploads.chunkSizeBytes; - } - if (Object.keys(uploadsUpdate).length > 0) { - const current = await getSettings(); - const merged = await setSystemSetting('system', 'uploads', { - ...current.uploads, - ...uploadsUpdate, - }); - systemUpdates.uploads = merged; - } - } +const uploadsSection = merging('uploads', { + chunkedEnabled: isBoolean, + chunkedAutoFallback: isBoolean, + chunkSizeBytes: isPositiveNumber, +}); - // File-version settings: only the fields that arrived usable are merged; - // setSystemSetting sanitizes and keeps them consistent. - if (payload.versions && typeof payload.versions === 'object') { - const versionsUpdate = {}; - if (typeof payload.versions.enabled === 'boolean') { - versionsUpdate.enabled = payload.versions.enabled; - } - for (const key of [ - 'keepAllHours', - 'hourlyDays', - 'dailyDays', - 'maxPerFile', - 'sessionCheckpointMinutes', - ]) { - if (chosenNumber(payload.versions[key])) versionsUpdate[key] = payload.versions[key]; - } - if (Object.keys(versionsUpdate).length > 0) { - const current = await getSettings(); - const merged = await setSystemSetting('system', 'versions', { - ...current.versions, - ...versionsUpdate, - }); - systemUpdates.versions = merged; - } - } +// The trash's size cap is the one field where nothing is a value: null removes +// the cap. Zero is not that — it is what an emptied field sends, and the +// service read it as "no cap given" and put the default back. +const isPositiveNumberOrNull = (value) => value === null || isPositiveNumber(value); + +// A retention of no days, or of fewer than none, is what an emptied or +// mistyped field sends. The service brought each up to its lowest bound — a +// retention of 0 became one day, of -5 became one day — in place of the ninety +// the administrator had. The settings page refuses them with the same bounds; +// this is what an API client used to see instead. +const trashSection = merging('trash', { + enabled: isBoolean, + retentionDays: isPositiveNumber, + maxPercent: isPositiveNumber, + maxBytes: isPositiveNumberOrNull, +}); - // Activity log settings: the switch, and how long a line is kept. - if (payload.activity && typeof payload.activity === 'object') { - const activityUpdate = {}; - if (typeof payload.activity.enabled === 'boolean') { - activityUpdate.enabled = payload.activity.enabled; - } - if (chosenNumber(payload.activity.retentionDays)) { - activityUpdate.retentionDays = payload.activity.retentionDays; - } - if (Object.keys(activityUpdate).length > 0) { - const current = await getSettings(); - const merged = await setSystemSetting('system', 'activity', { - ...current.activity, - ...activityUpdate, - }); - systemUpdates.activity = merged; - } - } +const versionsSection = merging('versions', { + enabled: isBoolean, + keepAllHours: isPositiveNumber, + hourlyDays: isPositiveNumber, + dailyDays: isPositiveNumber, + maxPerFile: isPositiveNumber, + sessionCheckpointMinutes: isPositiveNumber, +}); - // The folders each background worker leaves alone. The list is stored - // and handed to the worker, which answers with the list it is really - // applying — the stored one plus whatever the environment set, which an - // administrator cannot take away from here. - for (const [key, manager] of [ - ['folderSize', folderSizeManager], - ['searchIndex', searchIndexManager], - ]) { - const section = payload[key]; - if (!section || typeof section !== 'object') continue; - - // Whether the worker runs at all, which was decided by SEARCH_INDEX and - // FOLDER_SIZE_MODE alone: turning either on meant editing a file on the host - // and restarting, while every other setting beside them was a click (#9). - // - // The environment stays the floor. A variable somebody set decides, and a - // switch sent for it is refused in words naming the variable, rather than - // accepted and quietly ignored — "false" is a decision too, so an - // installation that turned the index off in its file has not left it to - // whoever next opens the page. - const field = key === 'searchIndex' ? 'enabled' : 'mode'; - if (Object.prototype.hasOwnProperty.call(section, field)) { - const variable = key === 'searchIndex' ? 'SEARCH_INDEX' : 'FOLDER_SIZE_MODE'; - if (featureSwitches.snapshot()[key].lockedBy) { - throw new ValidationError( - `${variable} is set in the environment, so this is decided there and not here.` - ); - } - const requested = - key === 'searchIndex' - ? typeof section.enabled === 'boolean' - ? section.enabled - : undefined - : featureSwitches.FOLDER_SIZE_MODES.includes(section.mode) - ? section.mode - : undefined; - if (requested === undefined) { - throw new ValidationError(`${field} is not a value ${key} takes.`); - } - const current = await getSettings(); - systemUpdates[key] = await setSystemSetting('system', key, { - ...current[key], - [field]: requested, - }); - if (key === 'searchIndex') await featureSwitches.setSearchIndex(requested); - else await featureSwitches.setFolderSizeMode(requested); - } - - if (!Array.isArray(section.excludedPaths)) continue; - const current = await getSettings(); - const merged = await setSystemSetting('system', key, { - ...current[key], - excludedPaths: section.excludedPaths, - }); - await manager.setAdminExclusions(merged.excludedPaths); - systemUpdates[key] = merged; - } +const activitySection = merging('activity', { + enabled: isBoolean, + retentionDays: isPositiveNumber, +}); - // Branding settings - let previousLogoUrl; - if (payload.branding && typeof payload.branding === 'object') { - const brandingUpdate = {}; - if (typeof payload.branding.appName === 'string') { - brandingUpdate.appName = payload.branding.appName; - } - if (typeof payload.branding.appLogoUrl === 'string') { - brandingUpdate.appLogoUrl = payload.branding.appLogoUrl; - } - if (typeof payload.branding.showPoweredBy === 'boolean') { - brandingUpdate.showPoweredBy = payload.branding.showPoweredBy; - } - if (Object.keys(brandingUpdate).length > 0) { - const current = await getSettings(); - previousLogoUrl = current.branding?.appLogoUrl ?? null; - await setSystemSetting('branding', 'branding', { - ...current.branding, - ...brandingUpdate, - }); - systemUpdates.branding = { ...current.branding, ...brandingUpdate }; - } - } +/** + * Branding is read and written in one step rather than merged over the + * settings read at the start of the request, because a logo it replaces is + * then removed: reset to the default, or pointed elsewhere, the old file would + * otherwise stay behind with nothing to serve or remove it. + */ +const brandingSection = { + check: (section) => { + const update = keepValid(section, { + appName: isName, + appLogoUrl: isText, + showPoweredBy: isBoolean, + }); + return Object.keys(update).length > 0 ? update : null; + }, + write: async (update) => { + const { previous, current } = await replaceBranding(update); + await forgetReplacedLogo(previous.appLogoUrl, current.appLogoUrl); + }, +}; - if (Object.keys(systemUpdates).length > 0) { - Object.assign(updated, systemUpdates); - // Which settings, not what they were set to: values belong in the - // settings, and some of them are somebody's business alone. - await activityLog.record({ - action: 'admin.settings', - user, - detail: { sections: Object.keys(systemUpdates) }, - req, - }); - } +/** + * Access rules replace the list rather than merging into it, and they are the + * one section that refuses what it was sent: a rule with no folder, or a + * permission that is not one of the three, is answered rather than dropped. + * Which is why it is checked here, before any other section is written — a + * list sent as something that is not a list is still dropped, as it always + * was, because then there is nothing to store. + * + * The switch that holds administrators to every rule is saved from a control of + * its own, so each half is taken only when it was sent and merged over what is + * stored: saving the rules must not switch it off, and switching it must not + * empty the rules. + */ +const accessSection = { + check: (section) => { + const update = {}; + if (Array.isArray(section.rules)) { + update.rules = checkSystemSection('access', { rules: section.rules }).rules; + } + if (section.applyToAdmins !== undefined) { + update.applyToAdmins = checkSystemSection('access', { + applyToAdmins: section.applyToAdmins, + }).applyToAdmins; + } + return Object.keys(update).length > 0 ? update : null; + }, + write: (update) => mergeSystemSection('system', 'access', update), +}; - // The logo that was replaced is forgotten, and only once nothing points at it - // any more. Removing the files under a fixed name meant a logo could not be - // changed back, and a branding change that failed halfway took the logo in use - // with it. - if (previousLogoUrl !== undefined) { - const settingsNow = await getSettings(); - await forgetReplacedLogo(previousLogoUrl, settingsNow.branding?.appLogoUrl); +/** + * A background worker: the folders it leaves alone, and whether it runs. + * + * The list is stored and handed to the worker, which answers with the list it + * is actually applying — the stored one plus whatever the environment set, + * which an administrator cannot remove from here. + * + * The switch follows the same rule. When the environment set it, it is refused + * rather than quietly stored: an administrator who flips a switch and sees + * nothing happen deserves to be told which variable is in the way, and a page + * that shows the switch locked will not send it in the first place. + * + * @param {string} key the settings section + * @param {object} manager the worker, for its exclusions + * @param {string} field `enabled` or `mode` + * @param {(value: *) => *} valid the value to store, or undefined to refuse it + * @param {(value: *) => Promise} apply switch the worker to it + * @param {string} variable the environment variable that would lock it + */ +const background = ({ key, manager, field, valid, apply, variable }) => ({ + check: (section) => { + const update = {}; + if (Array.isArray(section.excludedPaths)) update.excludedPaths = section.excludedPaths; + + if (Object.prototype.hasOwnProperty.call(section, field)) { + if (featureSwitches.snapshot()[key].lockedBy) { + throw new ValidationError( + `${variable} is set in the environment, so this is decided there and not here.` + ); } - } else if (payload.thumbnails || payload.access || payload.branding) { - // Non-admin trying to update system settings + const value = valid(section[field]); + if (value === undefined) throw new ValidationError(`${field} is not a value ${key} takes.`); + update[field] = value; + } + + return Object.keys(update).length ? update : null; + }, + write: async (update) => { + const saved = await mergeSection('system', key, update); + if (!saved) return false; + if (update.excludedPaths) await manager.setAdminExclusions(saved.excludedPaths); + if (Object.prototype.hasOwnProperty.call(update, field)) await apply(saved[field]); + return true; + }, +}); + +const searchIndexSection = background({ + key: 'searchIndex', + manager: searchIndexManager, + field: 'enabled', + valid: (value) => (typeof value === 'boolean' ? value : undefined), + apply: (value) => featureSwitches.setSearchIndex(value), + variable: 'SEARCH_INDEX', +}); + +const folderSizeSection = background({ + key: 'folderSize', + manager: folderSizeManager, + field: 'mode', + valid: (value) => (featureSwitches.FOLDER_SIZE_MODES.includes(value) ? value : undefined), + apply: (value) => featureSwitches.setFolderSizeMode(value), + variable: 'FOLDER_SIZE_MODE', +}); + +/** Every section only an administrator may write, and what writes it. */ +const SYSTEM_SECTIONS = { + thumbnails: thumbnailsSection, + access: accessSection, + uploads: uploadsSection, + trash: trashSection, + versions: versionsSection, + activity: activitySection, + branding: brandingSection, + folderSize: folderSizeSection, + searchIndex: searchIndexSection, +}; + +router.patch( + '/settings', + asyncHandler(async (req, res) => { + const payload = req.body || {}; + const user = req.user; + const isAdmin = user && Array.isArray(user.roles) && user.roles.includes('admin'); + + // Asked before anything is written, not after. The user section used to be + // applied first and the refusal raised afterwards, so a payload carrying + // both a preference and a system setting answered 403 with the preference + // already saved — a request reported as refused that had changed something. + const wantsSystemSettings = Object.keys(SYSTEM_SECTIONS).some((name) => payload[name]); + if (!isAdmin && wantsSystemSettings) { return res.status(403).json({ error: 'Admin access required for system settings.' }); } - // Return updated settings + // Every section is checked before any of them is written. A save carrying + // a valid section and a refused one used to store the first and then answer + // 400: a request reported as refused that had changed something, and left + // the page showing settings the server had only half taken. + const toWrite = []; + if (isAdmin) { + for (const [name, section] of Object.entries(SYSTEM_SECTIONS)) { + const sent = payload[name]; + if (!sent || typeof sent !== 'object') continue; + const update = section.check(sent); + if (update !== null) toWrite.push([name, section, update]); + } + } + + if (payload.user && typeof payload.user === 'object' && user?.id) { + await applyUserPreferences(user, payload.user); + } + + // What was stored, not what was sent: a section whose every field was + // refused writes nothing, and a log line saying otherwise would send + // somebody looking for a change that never happened. + const stored = []; + for (const [name, section, update] of toWrite) { + if (await section.write(update)) stored.push(name); + } + if (stored.length) { + // Which settings, not what they were set to: values belong in the + // settings, and some of them are somebody's business alone. + await activityLog.record({ + action: 'admin.settings', + user, + detail: { sections: stored }, + req, + }); + } + + // Read back rather than assembled from what was written: the stored value + // is sanitised on its way out, so what the caller applies to its own state + // is what a later request would read. const finalSettings = await getSettingsForUser(user); res.json(finalSettings); }) diff --git a/backend/src/services/db.js b/backend/src/services/db.js index 86ebc0b77..025582ada 100644 --- a/backend/src/services/db.js +++ b/backend/src/services/db.js @@ -221,6 +221,114 @@ const generateId = () => : `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`; const DEFAULT_FAVORITE_ICON = favorites.defaultIcon; +/** + * What a user chose for one folder: how to sort it, how to show it. + * + * A row per folder rather than one JSON blob per user. The blob had to be + * capped — it was read and rewritten whole on every change, and shipped + * entire on every load — so the hundred-and-first folder silently forgot the + * oldest. More importantly, a blob cannot be cleaned up: deleting a folder + * could not remove what everyone else had chosen for it, and renaming one left + * the preferences behind on a path that no longer existed. + */ +const FOLDER_PREFERENCES_DDL = ` + CREATE TABLE IF NOT EXISTS folder_preferences ( + user_id TEXT NOT NULL, + path TEXT NOT NULL, + sort_by TEXT, + sort_order TEXT, + view_mode TEXT, + updated_at DATETIME NOT NULL, + PRIMARY KEY (user_id, path) + ); + CREATE INDEX IF NOT EXISTS idx_folder_preferences_path ON folder_preferences(path); +`; + +/** + * Carry per-folder preferences out of the JSON blob they used to live in. + * + * They were two maps under `user_settings` — one for sorting, one for the view + * mode — capped at a hundred entries each because the whole blob was rewritten + * on every change. As rows they need no cap, and they can finally be cleaned up + * when the folder they describe is deleted or renamed. + * + * Best-effort: a preference that fails to migrate costs a folder its remembered + * sort, which is not worth failing a startup over. + */ +const migrateFolderPreferencesFromUserSettings = (db) => { + let rows; + try { + rows = db + .prepare( + "SELECT user_id, key, value FROM user_settings WHERE key IN ('folderSorts', 'folderViews')" + ) + .all(); + } catch (error) { + logger.debug({ err: error }, '[DB Migration] No folder preferences to carry over'); + return; + } + + const merged = new Map(); + for (const row of rows) { + let parsed; + try { + parsed = JSON.parse(row.value); + } catch { + continue; + } + if (!parsed || typeof parsed !== 'object') continue; + + for (const [folderPath, entry] of Object.entries(parsed)) { + if (!folderPath || !entry || typeof entry !== 'object') continue; + + const key = `${row.user_id}\u0000${folderPath}`; + const current = merged.get(key) || { + userId: row.user_id, + path: folderPath, + sortBy: null, + sortOrder: null, + viewMode: null, + updatedAt: 0, + }; + + if (row.key === 'folderSorts' && typeof entry.by === 'string') { + current.sortBy = entry.by; + current.sortOrder = entry.order === 'desc' ? 'desc' : 'asc'; + } else if (row.key === 'folderViews' && typeof entry.mode === 'string') { + current.viewMode = entry.mode; + } + + const updatedAt = Number(entry.updatedAt); + if (Number.isFinite(updatedAt) && updatedAt > current.updatedAt) { + current.updatedAt = updatedAt; + } + merged.set(key, current); + } + } + + if (merged.size === 0) return; + + const insert = db.prepare( + `INSERT OR REPLACE INTO folder_preferences + (user_id, path, sort_by, sort_order, view_mode, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ); + + for (const entry of merged.values()) { + insert.run( + entry.userId, + entry.path, + entry.sortBy, + entry.sortOrder, + entry.viewMode, + new Date(entry.updatedAt || Date.now()).toISOString() + ); + } + + db.prepare("DELETE FROM user_settings WHERE key IN ('folderSorts', 'folderViews')").run(); + logger.info({ count: merged.size }, '[DB Migration] Folder preferences moved to their own table'); +}; + const migrate = (db) => { // Simple schema versioning db.exec(` @@ -665,6 +773,16 @@ const migrate = (db) => { ); version = 19; } + if (version < 20) { + logger.info('[DB Migration] Migrating to v20: per-folder preferences as rows...'); + db.exec(FOLDER_PREFERENCES_DDL); + migrateFolderPreferencesFromUserSettings(db); + db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run( + 'schema_version', + String(20) + ); + version = 20; + } })(); }; diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index b274ccd04..346b5e167 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -1,61 +1,238 @@ -const { getDb } = require('./db'); -const env = require('../config/env'); -const { parseByteSize } = require('../utils/env'); +const { getDb, prepared } = require('./db'); +const { cachedForRequest } = require('../utils/requestContext'); const { normalizeRelativePath } = require('../utils/pathUtils'); -const { ruleAppliesToAdmins } = require('../utils/accessRules'); +const { parseByteSize } = require('../utils/env'); +const env = require('../config/env'); const folderSizeExclusions = require('./folderSizeExclusions'); const searchIndexExclusions = require('./searchIndexExclusions'); -const storage = require('./storage/jsonStorage'); // Keep for backward compatibility fallback +const { generateId } = require('../utils/ids'); +const { ValidationError } = require('../errors/AppError'); +const { ruleAppliesToAdmins } = require('../utils/accessRules'); + +const MIN_UPLOAD_CHUNK_SIZE_BYTES = 1024 * 1024; +const HARD_MAX_UPLOAD_CHUNK_SIZE_MIB = 512; +const DEFAULT_UPLOAD_CHUNK_SIZE_BYTES = 8 * 1024 * 1024; + +// Per-folder preferences are kept per user, and bounded: one entry per folder +// ever visited would otherwise grow without limit. +const MAX_FOLDER_PREFERENCES = 100; +const MAX_FOLDER_PATH_LENGTH = 1024; +const MAX_SORT_FIELD_LENGTH = 128; + +// Admin-configurable upper bound (env MAX_CHUNK_SIZE_MIB), capped at the hard +// ceiling. Used to clamp both the default and any saved chunk size. +const resolveMaxChunkSizeBytes = () => { + const raw = Number(env.MAX_CHUNK_SIZE_MIB); + const mib = + Number.isFinite(raw) && raw >= 1 + ? Math.min(Math.floor(raw), HARD_MAX_UPLOAD_CHUNK_SIZE_MIB) + : HARD_MAX_UPLOAD_CHUNK_SIZE_MIB; + return Math.max(MIN_UPLOAD_CHUNK_SIZE_BYTES, mib * 1024 * 1024); +}; +const MAX_UPLOAD_CHUNK_SIZE_BYTES = resolveMaxChunkSizeBytes(); + +const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value)); + +const defaultUploadSettings = () => { + const configuredChunkSize = parseByteSize(env.UPLOAD_CHUNK_SIZE); + const chunkSizeBytes = + Number.isFinite(configuredChunkSize) && configuredChunkSize > 0 + ? configuredChunkSize + : DEFAULT_UPLOAD_CHUNK_SIZE_BYTES; -const generateId = () => { - const crypto = require('crypto'); - return typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`; + const chunkedAutoFallback = env.UPLOAD_CHUNKED_AUTO_FALLBACK ?? false; + return { + // Auto-fallback and forced chunked uploads are mutually exclusive — auto is a + // direct-with-fallback mode, so it turns forced chunking off. + chunkedEnabled: chunkedAutoFallback ? false : (env.UPLOAD_CHUNKED_ENABLED ?? false), + chunkedAutoFallback, + chunkSizeBytes: clampNumber( + Math.floor(chunkSizeBytes), + MIN_UPLOAD_CHUNK_SIZE_BYTES, + MAX_UPLOAD_CHUNK_SIZE_BYTES + ), + }; +}; + +const isValidFolderPath = (folderPath) => + typeof folderPath === 'string' && + folderPath.length > 0 && + folderPath.length <= MAX_FOLDER_PATH_LENGTH; + +const sanitizeFolderSort = (sort) => { + if ( + !sort || + typeof sort !== 'object' || + typeof sort.by !== 'string' || + sort.by.trim().length === 0 || + sort.by.length > MAX_SORT_FIELD_LENGTH || + (sort.order !== 'asc' && sort.order !== 'desc') + ) { + return null; + } + + return { + by: sort.by.trim(), + order: sort.order, + updatedAt: Number.isFinite(sort.updatedAt) ? Math.floor(sort.updatedAt) : 0, + }; +}; + +const VIEW_MODES = ['grid', 'list', 'tab', 'photos']; + +/** A remembered view mode for one folder, or null when it is not one we have. */ +const sanitizeFolderView = (view) => { + const mode = typeof view === 'string' ? view : view?.mode; + if (!VIEW_MODES.includes(mode)) return null; + + return { + mode, + updatedAt: Number.isFinite(view?.updatedAt) ? Math.floor(view.updatedAt) : 0, + }; +}; + +/** + * A map of folder path to preference, keeping only what is valid and only the + * most recently used — one entry per folder ever visited would grow forever. + */ +const sanitizeFolderPreferences = (preferences, sanitizeEntry) => { + if (!preferences || typeof preferences !== 'object' || Array.isArray(preferences)) { + return {}; + } + + return Object.fromEntries( + Object.entries(preferences) + .map(([folderPath, entry]) => { + const sanitized = sanitizeEntry(entry); + return isValidFolderPath(folderPath) && sanitized ? [folderPath, sanitized] : null; + }) + .filter(Boolean) + .sort(([, a], [, b]) => b.updatedAt - a.updatedAt) + .slice(0, MAX_FOLDER_PREFERENCES) + ); +}; + +const sanitizeFolderSorts = (folderSorts) => + sanitizeFolderPreferences(folderSorts, sanitizeFolderSort); + +const sanitizeFolderViews = (folderViews) => + sanitizeFolderPreferences(folderViews, sanitizeFolderView); + +/** + * The bounds thumbnail settings are held to, and their defaults. The settings + * page refuses a value outside them before sending it, with the same numbers + * (`SettingsFilesThumbnails.vue`). + */ +const THUMBNAIL_BOUNDS = { + size: { min: 64, max: 1024, fallback: 200 }, + quality: { min: 1, max: 100, fallback: 70 }, + concurrency: { min: 1, max: 50, fallback: 10 }, }; /** * Sanitize thumbnail settings */ const sanitizeThumbnails = (thumbnails = {}) => { + const integer = (key) => { + const { min, max, fallback } = THUMBNAIL_BOUNDS[key]; + return Number.isFinite(thumbnails[key]) + ? clampNumber(Math.floor(thumbnails[key]), min, max) + : fallback; + }; return { enabled: typeof thumbnails.enabled === 'boolean' ? thumbnails.enabled : true, - size: Number.isFinite(thumbnails.size) - ? Math.max(64, Math.min(1024, Math.floor(thumbnails.size))) - : 200, - quality: Number.isFinite(thumbnails.quality) - ? Math.max(1, Math.min(100, Math.floor(thumbnails.quality))) - : 70, - concurrency: Number.isFinite(thumbnails.concurrency) - ? Math.max(1, Math.min(50, Math.floor(thumbnails.concurrency))) - : 10, + size: integer('size'), + quality: integer('quality'), + concurrency: integer('concurrency'), }; }; +const FOLDER_SIZE_MODES = ['off', 'shallow', 'full']; + /** - * Sanitize access control rules + * What an administrator chose for the two background workers. Only a choice: + * when the environment set the same thing, the environment is what runs, and + * this is kept for the day the variable is taken away. */ -const sanitizeAccessRules = (rules = []) => { - if (!Array.isArray(rules)) return []; +const sanitizeFolderSize = (folderSize = {}) => ({ + excludedPaths: folderSizeExclusions.sanitizePaths(folderSize.excludedPaths || []), + mode: FOLDER_SIZE_MODES.includes(folderSize.mode) ? folderSize.mode : 'off', +}); + +const sanitizeSearchIndex = (searchIndex = {}) => ({ + excludedPaths: searchIndexExclusions.sanitizePaths(searchIndex.excludedPaths || []), + enabled: searchIndex.enabled === true, +}); + +const ACCESS_PERMISSIONS = ['rw', 'ro', 'hidden']; + +/** + * Sanitize access control rules. + * + * Read back (`strict: false`), a rule that cannot stand is dropped. Anything + * else would make one bad row — left by an older version, or edited into + * app.db by hand — unreadable settings, and unreadable settings are every + * hidden folder visible to everybody. + * + * Saved (`strict: true`), the same rule is refused with its reason and nothing + * is written. Dropping it silently answered 200 with a list the page then + * adopted: the row for `../Secret` disappeared the moment it was saved, and an + * administrator was left believing a folder was hidden that never was. The + * permissions were worse — anything not one of the three became `rw`, so a + * mistyped `readonly` opened a folder for writing instead of refusing the word. + */ +const sanitizeAccessRules = (rules = [], { strict = false } = {}) => { + if (!Array.isArray(rules)) { + if (strict) throw new ValidationError('The access rules have to be sent as a list.'); + return []; + } return rules - .map((rule) => { - if (!rule || typeof rule !== 'object') return null; + .map((rule, index) => { + // Numbered as the page numbers them, so the reason names the row. + const refuse = (reason) => { + if (!strict) return null; + throw new ValidationError(`Access rule ${index + 1}: ${reason}`); + }; + + if (!rule || typeof rule !== 'object' || Array.isArray(rule)) { + return refuse('this is not a rule.'); + } + + // A path of nothing but spaces normalises to itself: the rule was stored + // as it came and matched no folder — written by an administrator, listed + // on the page, and doing nothing. Refused now, and only when it is blank + // all through: a folder may legitimately be called "My Documents", or + // even " x ", so nothing here trims what somebody wrote. + if (!String(rule.path ?? '').trim()) return refuse('a rule needs the path of a folder.'); // Validate path let normalizedPath; try { normalizedPath = normalizeRelativePath(rule.path || ''); - } catch { - return null; // Invalid path + } catch (error) { + return refuse(`"${rule.path}" is not a folder path. ${error.message}`); } - if (!normalizedPath) return null; + if (!normalizedPath) return refuse('a rule needs the path of a folder.'); // Validate permissions - const permissions = ['rw', 'ro', 'hidden'].includes(rule.permissions) - ? rule.permissions - : 'rw'; + if (rule.permissions !== undefined && !ACCESS_PERMISSIONS.includes(rule.permissions)) { + return refuse( + `"${rule.permissions}" is not one of the permissions a rule gives: rw, ro or hidden.` + ); + } + const permissions = ACCESS_PERMISSIONS.includes(rule.permissions) ? rule.permissions : 'rw'; + + if (rule.recursive !== undefined && typeof rule.recursive !== 'boolean') { + return refuse(`"${rule.recursive}" does not say whether the rule covers what is inside.`); + } + + if (rule.appliesToAdmins !== undefined && typeof rule.appliesToAdmins !== 'boolean') { + return refuse( + `"${rule.appliesToAdmins}" does not say whether the rule holds administrators too.` + ); + } return { id: rule.id || `${Date.now()}-${Math.random().toString(36).slice(2)}`, @@ -63,8 +240,8 @@ const sanitizeAccessRules = (rules = []) => { recursive: Boolean(rule.recursive), permissions, // Stored as a plain yes or no, so the page shows a definite box and - // nothing downstream has to guess again. What a rule written before - // this switch existed means is decided in one place, utils/accessRules. + // nothing has to guess again. What a rule written before this switch + // existed means is decided in one place, utils/accessRules. appliesToAdmins: ruleAppliesToAdmins({ ...rule, permissions }), }; }) @@ -72,15 +249,19 @@ const sanitizeAccessRules = (rules = []) => { }; /** - * The access section: the rules, and whether they hold administrators. - * - * Kept together because the two are read together — a rule says whether it - * holds administrators, and this setting holds them to all of them at once. + * The access section: the rules, and whether every one of them also holds + * administrators. The setting is the blunt one — on, no rule lets an + * administrator through; off, each rule says for itself. */ -const sanitizeAccess = (access = {}) => { +const sanitizeAccess = (access = {}, { strict = false } = {}) => { const source = access && typeof access === 'object' && !Array.isArray(access) ? access : {}; + if (source.applyToAdmins !== undefined && typeof source.applyToAdmins !== 'boolean' && strict) { + throw new ValidationError( + 'Whether the rules hold administrators too has to be sent as true or false.' + ); + } return { - rules: sanitizeAccessRules(source.rules || []), + rules: sanitizeAccessRules(source.rules || [], { strict }), applyToAdmins: source.applyToAdmins === true, }; }; @@ -89,9 +270,12 @@ const sanitizeAccess = (access = {}) => { * Sanitize branding settings */ const sanitizeBranding = (branding = {}) => { + // A name of nothing but spaces was stored as it came, and the header and the + // sign-in page showed no name at all. One stored that way reads as the + // default, so an installation that saved one needs nothing done. + const appName = typeof branding.appName === 'string' ? branding.appName.trim().slice(0, 100) : ''; return { - appName: - typeof branding.appName === 'string' ? branding.appName.trim().slice(0, 100) : 'Explorer', + appName: appName || 'Explorer', appLogoUrl: typeof branding.appLogoUrl === 'string' ? branding.appLogoUrl.trim().slice(0, 500) @@ -100,6 +284,39 @@ const sanitizeBranding = (branding = {}) => { }; }; +/** + * Sanitize upload settings + */ +const sanitizeUploads = (uploads = {}) => { + const defaults = defaultUploadSettings(); + const rawChunkSize = + typeof uploads.chunkSizeBytes === 'string' + ? parseByteSize(uploads.chunkSizeBytes) + : uploads.chunkSizeBytes; + + const chunkedAutoFallback = + typeof uploads.chunkedAutoFallback === 'boolean' + ? uploads.chunkedAutoFallback + : defaults.chunkedAutoFallback; + const chunkedEnabled = chunkedAutoFallback + ? false // mutually exclusive with auto-fallback (auto wins) + : typeof uploads.chunkedEnabled === 'boolean' + ? uploads.chunkedEnabled + : defaults.chunkedEnabled; + + return { + chunkedEnabled, + chunkedAutoFallback, + chunkSizeBytes: Number.isFinite(rawChunkSize) + ? clampNumber( + Math.floor(rawChunkSize), + MIN_UPLOAD_CHUNK_SIZE_BYTES, + MAX_UPLOAD_CHUNK_SIZE_BYTES + ) + : defaults.chunkSizeBytes, + }; +}; + /** * The trash settings in force: on or off, how many days an item is kept, and * how much of a volume the trash may hold — a share of it, capped by a size @@ -107,14 +324,10 @@ const sanitizeBranding = (branding = {}) => { * out keeps the default the environment gave. */ const sanitizeTrash = (trash = {}) => { - // eslint-disable-next-line global-require const { trash: defaults } = require('../config/index'); - // eslint-disable-next-line global-require - const { parseByteSize } = require('../utils/env'); const source = trash && typeof trash === 'object' ? trash : {}; - const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); const integerIn = (value, min, max, fallback) => - Number.isFinite(value) ? clamp(Math.round(value), min, max) : fallback; + Number.isFinite(value) ? clampNumber(Math.round(value), min, max) : fallback; const rawMaxBytes = typeof source.maxBytes === 'string' ? parseByteSize(source.maxBytes) : source.maxBytes; @@ -131,20 +344,40 @@ const sanitizeTrash = (trash = {}) => { }; /** - * The file-version settings in force: whether a save keeps what it replaces, - * and the retention thinning (everything for a while, then hourly, then daily), - * a per-file cap and a session-checkpoint gap. Out-of-range values are clamped, - * and the windows are kept consistent (hourly covers keep-all, daily covers - * hourly), so the policy never contradicts itself. + * The activity log settings in force: on or off, and how long a line is kept. + * + * Off is the default and stays the default: a log nobody asked for is a record + * of somebody's day that nobody reads. + */ +const sanitizeActivity = (activity = {}) => { + const { activity: defaults } = require('../config/index'); + const source = activity && typeof activity === 'object' ? activity : {}; + const retentionDays = Number(source.retentionDays); + return { + enabled: typeof source.enabled === 'boolean' ? source.enabled : defaults.enabled, + retentionDays: Number.isFinite(retentionDays) + ? clampNumber(Math.round(retentionDays), 1, 3650) + : defaults.retentionDays, + }; +}; + +/** + * The file version settings in force: on or off, how long everything is kept + * before thinning starts, how long one an hour and one a day are kept, how many + * versions a file keeps at most, and how often an editing session leaves a + * checkpoint. The space they may take is the trash's: one budget per volume. + * + * The tiers are kept in order — a week of hourly versions cannot end before the + * day of keeping everything does. */ const sanitizeVersions = (versions = {}) => { - // eslint-disable-next-line global-require const { versions: defaults, VERSION_BOUNDS } = require('../config/index'); const source = versions && typeof versions === 'object' ? versions : {}; - const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); const integer = (key) => { const [min, max] = VERSION_BOUNDS[key]; - return Number.isFinite(source[key]) ? clamp(Math.round(source[key]), min, max) : defaults[key]; + return Number.isFinite(source[key]) + ? clampNumber(Math.round(source[key]), min, max) + : defaults[key]; }; const keepAllHours = integer('keepAllHours'); const hourlyDays = Math.max(integer('hourlyDays'), Math.ceil(keepAllHours / 24)); @@ -159,248 +392,146 @@ const sanitizeVersions = (versions = {}) => { }; }; -const FOLDER_SIZE_MODES = ['off', 'shallow', 'full']; - -/** - * What an administrator chose for the two background workers. Only a choice: - * when the environment set the same thing, the environment is what runs, and - * this is kept for the day the variable is taken away. - */ -const sanitizeFolderSize = (folderSize = {}) => ({ - excludedPaths: folderSizeExclusions.sanitizePaths(folderSize.excludedPaths || []), - mode: FOLDER_SIZE_MODES.includes(folderSize.mode) ? folderSize.mode : 'off', -}); - -const sanitizeSearchIndex = (searchIndex = {}) => ({ - excludedPaths: searchIndexExclusions.sanitizePaths(searchIndex.excludedPaths || []), - enabled: searchIndex.enabled === true, -}); - -/** - * The activity log settings in force: on or off, and how long a line is kept. - * - * Off is the default and stays the default: a log nobody asked for is a record - * of somebody's day that nobody reads. - */ -const sanitizeActivity = (activity = {}) => { - // eslint-disable-next-line global-require - const { activity: defaults } = require('../config/index'); - const source = activity && typeof activity === 'object' ? activity : {}; - const retentionDays = Number(source.retentionDays); - return { - enabled: typeof source.enabled === 'boolean' ? source.enabled : defaults.enabled, - retentionDays: Number.isFinite(retentionDays) - ? Math.max(1, Math.min(3650, Math.round(retentionDays))) - : defaults.retentionDays, - }; -}; - /** * Get public settings (branding only, no auth required) */ const getPublicSettings = async () => { - try { - const db = await getDb(); - const brandingRow = db - .prepare('SELECT value FROM system_settings WHERE category = ? AND key = ?') - .get('branding', 'branding'); + const db = await getDb(); + const brandingRow = db + .prepare('SELECT value FROM system_settings WHERE category = ? AND key = ?') + .get('branding', 'branding'); - if (brandingRow) { - const branding = JSON.parse(brandingRow.value); - return { - branding: sanitizeBranding(branding), - }; + let branding = {}; + if (brandingRow) { + try { + branding = JSON.parse(brandingRow.value); + } catch { + // An unreadable value is the default branding, not a failure to sign in. } - } catch (err) { - // Fallback to JSON if DB read fails - } - - // Fallback to JSON storage - try { - const data = await storage.get(); - const branding = data.settings?.branding || {}; - return { - branding: sanitizeBranding(branding), - }; - } catch (err) { - // Return defaults if all else fails - return { - branding: sanitizeBranding({}), - }; } + return { branding: sanitizeBranding(branding) }; }; /** * Get user-specific settings */ -const MIN_UPLOAD_CHUNK_SIZE_BYTES = 1024 * 1024; -const HARD_MAX_UPLOAD_CHUNK_SIZE_MIB = 512; -const DEFAULT_UPLOAD_CHUNK_SIZE_BYTES = 8 * 1024 * 1024; - -// The administrator's ceiling (MAX_CHUNK_SIZE_MIB), itself capped: a chunk is -// held whole in memory at each end, so an unbounded one is a way to run a -// server out of it. -const resolveMaxChunkSizeBytes = () => { - const raw = Number(env.MAX_CHUNK_SIZE_MIB); - const mib = - Number.isFinite(raw) && raw > 0 - ? Math.min(Math.floor(raw), HARD_MAX_UPLOAD_CHUNK_SIZE_MIB) - : HARD_MAX_UPLOAD_CHUNK_SIZE_MIB; - return Math.max(MIN_UPLOAD_CHUNK_SIZE_BYTES, mib * 1024 * 1024); -}; -const MAX_UPLOAD_CHUNK_SIZE_BYTES = resolveMaxChunkSizeBytes(); - -const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value)); - -const defaultUploadSettings = () => { - const configuredChunkSize = parseByteSize(env.UPLOAD_CHUNK_SIZE); - const chunkSizeBytes = - Number.isFinite(configuredChunkSize) && configuredChunkSize > 0 - ? configuredChunkSize - : DEFAULT_UPLOAD_CHUNK_SIZE_BYTES; - - return { - chunkedEnabled: env.UPLOAD_CHUNKED_ENABLED ?? false, - chunkSizeBytes: clampNumber( - Math.floor(chunkSizeBytes), - MIN_UPLOAD_CHUNK_SIZE_BYTES, - MAX_UPLOAD_CHUNK_SIZE_BYTES - ), - }; -}; - -const sanitizeUploads = (uploads = {}) => { - const defaults = defaultUploadSettings(); - const rawChunkSize = - typeof uploads.chunkSizeBytes === 'string' - ? parseByteSize(uploads.chunkSizeBytes) - : uploads.chunkSizeBytes; - - return { - chunkedEnabled: - typeof uploads.chunkedEnabled === 'boolean' - ? uploads.chunkedEnabled - : defaults.chunkedEnabled, - chunkSizeBytes: Number.isFinite(rawChunkSize) - ? clampNumber( - Math.floor(rawChunkSize), - MIN_UPLOAD_CHUNK_SIZE_BYTES, - MAX_UPLOAD_CHUNK_SIZE_BYTES - ) - : defaults.chunkSizeBytes, - }; -}; - const getUserSettings = async (userId) => { if (!userId) return {}; try { const db = await getDb(); - const rows = db.prepare('SELECT key, value FROM user_settings WHERE user_id = ?').all(userId); + const rows = prepared(db, 'SELECT key, value FROM user_settings WHERE user_id = ?').all(userId); const settings = {}; for (const row of rows) { try { settings[row.key] = JSON.parse(row.value); - } catch (err) { + } catch (_) { // Skip invalid JSON } } + // Per-folder preferences are rows of their own now, but the client still + // receives them among the user's settings. + Object.assign(settings, await getUserFolderPreferences(userId)); + return settings; - } catch (err) { + } catch (_) { return {}; } }; +// Through `prepared` rather than db.prepare: these run on every preference +// change, and recompiling the same three statements each time is waste the +// rest of this file already avoids. +const upsertUserSetting = (db, userId, key, value) => { + const now = new Date().toISOString(); + const valueJson = JSON.stringify(value); + const existing = prepared(db, 'SELECT id FROM user_settings WHERE user_id = ? AND key = ?').get( + userId, + key + ); + + if (existing) { + prepared( + db, + 'UPDATE user_settings SET value = ?, updated_at = ? WHERE user_id = ? AND key = ?' + ).run(valueJson, now, userId, key); + } else { + prepared( + db, + 'INSERT INTO user_settings (id, user_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?)' + ).run(generateId(), userId, key, valueJson, now); + } +}; + /** * Get system settings (admin only) */ +/** + * System settings, read from app.db and nowhere else. + * + * They used to fall back to app-config.json whenever the read failed. That file + * stopped following the settings long ago — the screens save to app.db alone — + * so a read that failed ran with whatever the file last held, often no access + * rules at all: a folder hidden by a rule opened for everyone for as long as the + * database could not be read. A read that fails now fails the request. + */ const getSystemSettings = async () => { - try { - const db = await getDb(); - const rows = db - .prepare('SELECT key, value FROM system_settings WHERE category = ?') - .all('system'); - - const thumbnails = { enabled: true, size: 200, quality: 70, concurrency: 10 }; - const access = { rules: [] }; - let trash = {}; - let versions = {}; - let uploads = {}; - let activity = {}; - let folderSize = {}; - let searchIndex = {}; - - for (const row of rows) { - try { - if (row.key === 'thumbnails') { - Object.assign(thumbnails, JSON.parse(row.value)); - } else if (row.key === 'access') { - Object.assign(access, JSON.parse(row.value)); - } else if (row.key === 'trash') { - trash = JSON.parse(row.value); - } else if (row.key === 'versions') { - versions = JSON.parse(row.value); - } else if (row.key === 'uploads') { - uploads = JSON.parse(row.value); - } else if (row.key === 'activity') { - activity = JSON.parse(row.value); - } else if (row.key === 'folderSize') { - folderSize = JSON.parse(row.value); - } else if (row.key === 'searchIndex') { - searchIndex = JSON.parse(row.value); - } - } catch (err) { - // Skip invalid JSON - } - } - - return { - thumbnails: sanitizeThumbnails(thumbnails), - access: sanitizeAccess(access), - trash: sanitizeTrash(trash), - versions: sanitizeVersions(versions), - uploads: sanitizeUploads(uploads), - activity: sanitizeActivity(activity), - folderSize: { - ...sanitizeFolderSize(folderSize), - environmentExcludedPaths: folderSizeExclusions.snapshot().environmentExcludedPaths, - }, - searchIndex: { - ...sanitizeSearchIndex(searchIndex), - environmentExcludedPaths: searchIndexExclusions.snapshot().environmentExcludedPaths, - }, - }; - } catch (err) { - // Fallback to JSON storage + const db = await getDb(); + const rows = db + .prepare('SELECT key, value FROM system_settings WHERE category = ?') + .all('system'); + + const thumbnails = { enabled: true, size: 200, quality: 70, concurrency: 10 }; + const access = { rules: [] }; + let uploads = defaultUploadSettings(); + const folderSize = { excludedPaths: [] }; + const searchIndex = { excludedPaths: [] }; + const trash = {}; + const versions = {}; + const activity = {}; + + for (const row of rows) { try { - const data = await storage.get(); - const settings = data.settings || {}; - return { - thumbnails: sanitizeThumbnails(settings.thumbnails), - access: sanitizeAccess(settings.access), - trash: sanitizeTrash(settings.trash), - versions: sanitizeVersions(settings.versions), - uploads: sanitizeUploads(settings.uploads), - activity: sanitizeActivity(settings.activity), - folderSize: sanitizeFolderSize(settings.folderSize), - searchIndex: sanitizeSearchIndex(settings.searchIndex), - }; - } catch (err2) { - // Return defaults - return { - thumbnails: sanitizeThumbnails({}), - access: sanitizeAccess({}), - trash: sanitizeTrash({}), - versions: sanitizeVersions({}), - uploads: sanitizeUploads({}), - activity: sanitizeActivity({}), - folderSize: sanitizeFolderSize({}), - searchIndex: sanitizeSearchIndex({}), - }; + if (row.key === 'thumbnails') { + Object.assign(thumbnails, JSON.parse(row.value)); + } else if (row.key === 'access') { + Object.assign(access, JSON.parse(row.value)); + } else if (row.key === 'uploads') { + uploads = { ...uploads, ...JSON.parse(row.value) }; + } else if (row.key === 'folderSize') { + Object.assign(folderSize, JSON.parse(row.value)); + } else if (row.key === 'searchIndex') { + Object.assign(searchIndex, JSON.parse(row.value)); + } else if (row.key === 'trash') { + Object.assign(trash, JSON.parse(row.value)); + } else if (row.key === 'versions') { + Object.assign(versions, JSON.parse(row.value)); + } else if (row.key === 'activity') { + Object.assign(activity, JSON.parse(row.value)); + } + } catch (_) { + // Skip invalid JSON } } + + return { + thumbnails: sanitizeThumbnails(thumbnails), + access: sanitizeAccess(access), + uploads: sanitizeUploads(uploads), + trash: sanitizeTrash(trash), + versions: sanitizeVersions(versions), + activity: sanitizeActivity(activity), + folderSize: { + ...sanitizeFolderSize(folderSize), + environmentExcludedPaths: folderSizeExclusions.snapshot().environmentExcludedPaths, + }, + searchIndex: { + ...sanitizeSearchIndex(searchIndex), + environmentExcludedPaths: searchIndexExclusions.snapshot().environmentExcludedPaths, + }, + }; }; /** @@ -418,18 +549,18 @@ const getSettingsForUser = async (user) => { if (user && user.id) { const userSettings = await getUserSettings(user.id); result.user = userSettings; + const systemSettings = await getSystemSettings(); + result.uploads = systemSettings.uploads; const isAdmin = Array.isArray(user.roles) && user.roles.includes('admin'); if (isAdmin) { - const systemSettings = await getSystemSettings(); result.thumbnails = systemSettings.thumbnails; result.access = systemSettings.access; + result.folderSize = systemSettings.folderSize; + result.searchIndex = systemSettings.searchIndex; result.trash = systemSettings.trash; result.versions = systemSettings.versions; - result.uploads = systemSettings.uploads; result.activity = systemSettings.activity; - result.folderSize = systemSettings.folderSize; - result.searchIndex = systemSettings.searchIndex; } } @@ -437,30 +568,64 @@ const getSettingsForUser = async (user) => { }; /** - * The preferences an account may set, in one place. + * Anything that is not a boolean is not an answer, and answers undefined, so + * the stored value stays. + * + * It used to be `Boolean(value)`, which has an opinion about everything: + * `'false'` — what a form field, a query string or a shell client sends — was + * true, and `0` was false. Either way the switch was set to something nobody + * had chosen, and the answer said it had been saved. + */ +const asBoolean = (value) => (typeof value === 'boolean' ? value : undefined); + +// null means "no answer of my own": for skipHome, defer to the environment. +const asNullableBoolean = (value) => { + if (value === null || value === undefined) return null; + return typeof value === 'boolean' ? value : undefined; +}; + +/** + * A default share expiry: null for none, or a whole number of at least one + * with its unit. + * + * Anything else is not an expiry, and answers undefined, so the stored one + * stays. It used to answer null, which is a value here: a default of minus + * three weeks, or of three years, silently removed the default the person had. + */ +const asShareExpiration = (value) => { + if (value === null || value === undefined) return null; + if (typeof value !== 'object') return undefined; + const validUnits = ['days', 'weeks', 'months']; + const amount = Number.isFinite(value.value) ? Math.floor(value.value) : 0; + if (amount < 1 || !validUnits.includes(value.unit)) return undefined; + return { value: amount, unit: value.unit }; +}; + +/** + * The view a folder gets when it has none of its own (#360). + * + * null is a value here, and means "use the built-in default". A mode we do not + * have is not: it used to become null too, so one unknown word put every + * folder back to the built-in view instead of being refused. + */ +const asViewMode = (value) => { + if (value === null || value === undefined) return null; + return VIEW_MODES.includes(value) ? value : undefined; +}; + +/** + * A language tag, or null to follow the browser. * - * There used to be two lists: this one, which decides how a value is - * sanitised, and another inside the settings route, which decides whether the - * key is written at all. Adding a preference to one and not the other produced - * a toggle that moved on screen, answered success, and stored nothing — so the - * two are the same list now, and the route asks here. - */ -const USER_BOOLEAN_SETTINGS = new Set([ - 'showHiddenFiles', - 'showThumbnails', - 'showVersionMarks', - 'documentsOpenInNewTab', - 'showSidebarFavorites', - 'showSidebarShares', - 'showSidebarTools', -]); - -/** - * A language tag, or null for "follow the browser". + * Checked for its shape and not against a list of the languages that exist: + * the translations are the interface's, and a second list here would be a + * second truth to keep — one locale added there and forgotten here would be + * refused for no reason anybody could see. A tag naming a translation nobody + * ships is stored and then falls back to the browser, which is what a reader + * whose language is gone should get anyway. * - * Checked for shape rather than against the list of translations: the list - * changes with a release, and a stored tag we no longer ship should fall back - * on screen, not be refused on the way in. + * Anything that is not a tag at all is refused rather than turned into null, + * as a view mode is: a typo would otherwise read as "follow the browser" and + * the choice would put itself back where it was. */ const LANGUAGE_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/; const asLocale = (value) => { @@ -470,12 +635,46 @@ const asLocale = (value) => { return LANGUAGE_TAG.test(tag) ? tag : undefined; }; -const USER_SETTING_KEYS = new Set([ - ...USER_BOOLEAN_SETTINGS, - 'defaultShareExpiration', - 'skipHome', - 'locale', -]); +/** + * Every preference a user may set, each with the coercion that belongs to it. + * + * One line per preference, in one place, because this used to be spread over + * three: a list of allowed keys in the settings route, a chain of if/else + * sanitising here, and the defaults in the client store. A key present in one + * and missing from another was accepted by the API, silently dropped, and + * answered with its previous value — which the client then applied, so the + * switch flicked itself back off. `markdownOpensInEditor` did exactly that. + * + * Adding a preference is now adding a line here. Its name and its validation + * cannot come apart, because they are the same line. + */ +const USER_SETTINGS = { + showHiddenFiles: asBoolean, + showThumbnails: asBoolean, + showSidebarFavorites: asBoolean, + showSidebarShares: asBoolean, + showSidebarTools: asBoolean, + markdownOpensInEditor: asBoolean, + documentsOpenInNewTab: asBoolean, + showVersionMarks: asBoolean, + defaultShareExpiration: asShareExpiration, + skipHome: asNullableBoolean, + defaultView: asViewMode, + locale: asLocale, +}; + +/** + * Written by the application, never straight from a request: a folder + * preference is saved one folder at a time, so that two tabs on different + * folders do not overwrite each other with whole maps. + */ +const INTERNAL_USER_SETTINGS = { + folderSorts: sanitizeFolderSorts, + folderViews: sanitizeFolderViews, +}; + +/** What PATCH /api/settings accepts under `user`. */ +const WRITABLE_USER_SETTINGS = new Set(Object.keys(USER_SETTINGS)); /** * Set a user setting @@ -484,146 +683,213 @@ const setUserSetting = async (userId, key, value) => { if (!userId) { throw new Error('User ID is required'); } - const db = await getDb(); - const now = new Date().toISOString(); - // Validate and sanitize value based on key - let sanitizedValue = value; - if (USER_BOOLEAN_SETTINGS.has(key)) { - sanitizedValue = Boolean(value); - } else if (key === 'locale') { - const tag = asLocale(value); - // `undefined` means "not a language tag": the stored value is left alone - // rather than replaced by something the interface cannot read. - if (tag === undefined) return (await getUserSettings(userId))[key]; - sanitizedValue = tag; - } else if (key === 'defaultShareExpiration') { - // Validate expiration object: { value: number, unit: 'days'|'weeks'|'months' } or null - if (value === null || value === undefined) { - sanitizedValue = null; - } else if (typeof value === 'object' && value !== null) { - const validUnits = ['days', 'weeks', 'months']; - const unit = validUnits.includes(value.unit) ? value.unit : 'weeks'; - const numValue = - Number.isFinite(value.value) && value.value > 0 ? Math.floor(value.value) : null; - sanitizedValue = numValue ? { value: numValue, unit } : null; - } else { - sanitizedValue = null; - } - } else if (key === 'skipHome') { - // Can be null (use env), true, or false - if (value === null || value === undefined) { - sanitizedValue = null; - } else { - sanitizedValue = Boolean(value); - } - } + // An unknown key is stored as it came: callers are the application itself, + // and the route only ever passes what WRITABLE_USER_SETTINGS allows. + const sanitize = USER_SETTINGS[key] || INTERNAL_USER_SETTINGS[key]; + const sanitizedValue = sanitize ? sanitize(value) : value; - const valueJson = JSON.stringify(sanitizedValue); - - // Check if setting exists - const existing = db - .prepare('SELECT id FROM user_settings WHERE user_id = ? AND key = ?') - .get(userId, key); + // What a preference cannot take is left out rather than stored as its + // default, as a section field of the wrong shape is: the stored value stays. + if (sanitizedValue === undefined) return undefined; - if (existing) { - db.prepare( - 'UPDATE user_settings SET value = ?, updated_at = ? WHERE user_id = ? AND key = ?' - ).run(valueJson, now, userId, key); - } else { - db.prepare( - 'INSERT INTO user_settings (id, user_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?)' - ).run(generateId(), userId, key, valueJson, now); - } + upsertUserSetting(db, userId, key, sanitizedValue); return sanitizedValue; }; /** - * Set a system setting (admin only) - */ -/** - * Change the branding, and answer what it was and what it is now. + * Remember one folder's preference, and return the whole map back. * - * Read and written without yielding in between — the database answers - * synchronously — so two saves at once cannot both start from the same branding: - * the logo a save replaced is the one it was the last to see, and removing it - * cannot take away the logo another save has just put in place. - * - * @returns {Promise<{previous: object, current: object}>} + * Written one folder at a time rather than by sending the map: two tabs open + * on different folders would otherwise overwrite each other with whichever + * copy was saved last. The stored map is re-read here so the entry joins what + * is already there. */ -const replaceBranding = async (update) => { - const db = await getDb(); - const row = db - .prepare('SELECT value FROM system_settings WHERE category = ? AND key = ?') - .get('branding', 'branding'); +/** Every folder preference this user has, as the client expects them. */ +const getUserFolderPreferences = async (userId) => { + if (!userId) return { folderSorts: {}, folderViews: {} }; - let stored = {}; - if (row) { - try { - stored = JSON.parse(row.value); - } catch { - // An unreadable value is the default branding. + const db = await getDb(); + const rows = prepared( + db, + 'SELECT path, sort_by, sort_order, view_mode, updated_at FROM folder_preferences WHERE user_id = ?' + ).all(userId); + + const folderSorts = {}; + const folderViews = {}; + for (const row of rows) { + const updatedAt = Date.parse(row.updated_at) || 0; + if (row.sort_by) { + folderSorts[row.path] = { + by: row.sort_by, + order: row.sort_order === 'desc' ? 'desc' : 'asc', + updatedAt, + }; + } + if (row.view_mode) { + folderViews[row.path] = { mode: row.view_mode, updatedAt }; } } - const previous = sanitizeBranding(stored); - const current = sanitizeBranding({ ...previous, ...update }); + return { folderSorts, folderViews }; +}; - const now = new Date().toISOString(); - const valueJson = JSON.stringify(current); - const existing = db - .prepare('SELECT id FROM system_settings WHERE category = ? AND key = ?') - .get('branding', 'branding'); - if (existing) { - db.prepare( - 'UPDATE system_settings SET value = ?, updated_at = ? WHERE category = ? AND key = ?' - ).run(valueJson, now, 'branding', 'branding'); - } else { - db.prepare( - 'INSERT INTO system_settings (id, category, key, value, updated_at) VALUES (?, ?, ?, ?, ?)' - ).run(generateId(), 'branding', 'branding', valueJson, now); +/** + * Remember one folder's sort or view. + * + * One row per folder, so a change touches only that folder: two tabs on + * different folders no longer overwrite each other, and there is no ceiling on + * how many folders can be remembered. The row carries both preferences, so + * setting one must not erase the other. + */ +const setUserFolderPreference = async (userId, folderPath, { sort, view }) => { + if (!userId) { + throw new Error('User ID is required'); } - return { previous, current }; + const normalizedPath = normalizeRelativePath(folderPath); + const sanitizedSort = sort === undefined ? undefined : sanitizeFolderSort(sort); + const sanitizedView = view === undefined ? undefined : sanitizeFolderView(view); + + if (!isValidFolderPath(normalizedPath) || (!sanitizedSort && !sanitizedView)) { + return null; + } + + const db = await getDb(); + const now = new Date().toISOString(); + + prepared( + db, + `INSERT INTO folder_preferences (user_id, path, sort_by, sort_order, view_mode, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, path) DO UPDATE SET + sort_by = COALESCE(excluded.sort_by, folder_preferences.sort_by), + sort_order = COALESCE(excluded.sort_order, folder_preferences.sort_order), + view_mode = COALESCE(excluded.view_mode, folder_preferences.view_mode), + updated_at = excluded.updated_at` + ).run( + userId, + normalizedPath, + sanitizedSort?.by ?? null, + sanitizedSort?.order ?? null, + sanitizedView?.mode ?? null, + now + ); + + return getUserFolderPreferences(userId); }; -const setSystemSetting = async (category, key, value) => { +const setUserFolderSort = async (userId, folderPath, sort) => { + const preferences = await setUserFolderPreference(userId, folderPath, { sort }); + return preferences?.folderSorts ?? null; +}; + +const setUserFolderView = async (userId, folderPath, view) => { + const preferences = await setUserFolderPreference(userId, folderPath, { view }); + return preferences?.folderViews ?? null; +}; + +const assertSystemCategory = (category) => { if (category !== 'branding' && category !== 'system') { throw new Error('Invalid category. Must be "branding" or "system"'); } +}; + +/** + * What a section is held to before it is stored, by key. + * + * The same shaping a read applies, so a section merged over the row itself + * comes out as it would have come out of the settings: a field nobody sent + * takes the sanitiser's default, which is the one a read would have given it. + */ +const sanitizeSystemSetting = (key, value) => { + if (key === 'thumbnails') return sanitizeThumbnails(value); + // Strict: what is being stored was just written by somebody, and a rule that + // cannot be stored as they wrote it is answered rather than dropped. + if (key === 'access') return sanitizeAccess(value, { strict: true }); + if (key === 'uploads') return sanitizeUploads(value); + if (key === 'branding') return sanitizeBranding(value); + if (key === 'folderSize') return sanitizeFolderSize(value); + // The search index had no case here, so what was stored for it was the + // merge as it came: paths with spaces around them, empty entries, the same + // folder twice. The worker was handed a sanitised copy and behaved, so only + // the stored value was wrong — and it is the one the next merge starts from. + if (key === 'searchIndex') return sanitizeSearchIndex(value); + if (key === 'trash') return sanitizeTrash(value); + if (key === 'activity') return sanitizeActivity(value); + if (key === 'versions') return sanitizeVersions(value); + return value; +}; + +/** + * What a section would be stored as, without storing it. + * + * The route checks every section of a save before writing any of them, so a + * section that refuses what it was sent refuses before another has been + * stored. + */ +const checkSystemSection = (key, value) => sanitizeSystemSetting(key, value); + +/** + * Set a system setting (admin only) + */ +const setSystemSetting = async (category, key, value) => { + assertSystemCategory(category); const db = await getDb(); - const now = new Date().toISOString(); + const sanitizedValue = sanitizeSystemSetting(key, value); + + writeSystemSetting(db, category, key, sanitizedValue); + + return sanitizedValue; +}; + +/** One section as it is stored, before any default is put around it. */ +const readStoredSection = (db, category, key) => { + const row = db + .prepare('SELECT value FROM system_settings WHERE category = ? AND key = ?') + .get(category, key); + if (!row) return {}; - // Sanitize based on key - let sanitizedValue = value; - if (key === 'thumbnails') { - sanitizedValue = sanitizeThumbnails(value); - } else if (key === 'access') { - sanitizedValue = sanitizeAccess(value); - } else if (key === 'branding') { - sanitizedValue = sanitizeBranding(value); - } else if (key === 'trash') { - sanitizedValue = sanitizeTrash(value); - } else if (key === 'versions') { - sanitizedValue = sanitizeVersions(value); - } else if (key === 'uploads') { - sanitizedValue = sanitizeUploads(value); - } else if (key === 'activity') { - sanitizedValue = sanitizeActivity(value); - } else if (key === 'folderSize') { - sanitizedValue = sanitizeFolderSize(value); - } else if (key === 'searchIndex') { - // The search index had no case here, so what was stored for it was the - // merge as it came: paths with spaces around them, empty entries, the same - // folder twice. The worker was handed a sanitised copy and behaved, so only - // the stored value was wrong — and it is the one the next merge starts from. - sanitizedValue = sanitizeSearchIndex(value); + try { + const stored = JSON.parse(row.value); + return stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {}; + } catch { + // An unreadable value is the section's defaults, exactly as a read treats it. + return {}; } +}; + +/** + * Merge an update over one stored section and write it back, and answer the + * whole section as it now stands. + * + * Read and written without yielding in between — the database answers + * synchronously — so two saves of one section at once cannot both start from + * the same stored value. The route used to merge over the settings read at the + * start of the request, with two awaits between that read and the write: a + * retention of ninety days saved in one tab disappeared when the other tab + * saved a size cap a moment later, and the person who set it was told it was + * saved. Branding was taken out of this path for the same reason, where losing + * a save also left a logo file behind with nothing to serve or remove it. + */ +const mergeSystemSection = async (category, key, update) => { + assertSystemCategory(category); + + const db = await getDb(); + const merged = sanitizeSystemSetting(key, { + ...readStoredSection(db, category, key), + ...update, + }); + writeSystemSetting(db, category, key, merged); + return merged; +}; - const valueJson = JSON.stringify(sanitizedValue); +/** Store one system setting as it is, in a single synchronous step. */ +const writeSystemSetting = (db, category, key, value, now = new Date().toISOString()) => { + const valueJson = JSON.stringify(value); // Check if setting exists const existing = db @@ -631,31 +897,71 @@ const setSystemSetting = async (category, key, value) => { .get(category, key); if (existing) { - db.prepare( + prepared( + db, 'UPDATE system_settings SET value = ?, updated_at = ? WHERE category = ? AND key = ?' ).run(valueJson, now, category, key); } else { - db.prepare( + prepared( + db, 'INSERT INTO system_settings (id, category, key, value, updated_at) VALUES (?, ?, ?, ?, ?)' ).run(generateId(), category, key, valueJson, now); } +}; - return sanitizedValue; +/** + * Change the branding, and answer what it was and what it is now. + * + * Read and written without yielding in between — the database answers + * synchronously — so two saves at once cannot both start from the same + * branding: the logo a save replaced is the one it was the last to see, and + * removing it cannot take away the logo another save has just put in place. + * + * @returns {Promise<{previous: object, current: object}>} + */ +const replaceBranding = async (update) => { + const db = await getDb(); + const row = db + .prepare('SELECT value FROM system_settings WHERE category = ? AND key = ?') + .get('branding', 'branding'); + + let stored = {}; + if (row) { + try { + stored = JSON.parse(row.value); + } catch { + // An unreadable value is the default branding. + } + } + + const previous = sanitizeBranding(stored); + const current = sanitizeBranding({ ...previous, ...update }); + writeSystemSetting(db, 'branding', 'branding', current); + return { previous, current }; }; /** * Legacy method: Get all settings (for backward compatibility) * Returns system settings + branding */ -const getSettings = async () => { - const systemSettings = await getSystemSettings(); - const publicSettings = await getPublicSettings(); +/** + * Settings, read once per request. + * + * The access rules are consulted for every path, so a bulk operation asked for + * these thousands of times over — each one several queries and a JSON parse, + * to re-read values that cannot change while a single request is running. The + * promise is memoized, not the value, so concurrent callers share one read. + */ +const getSettings = async () => + cachedForRequest('settings', 'all', async () => { + const systemSettings = await getSystemSettings(); + const publicSettings = await getPublicSettings(); - return { - ...systemSettings, - branding: publicSettings.branding, - }; -}; + return { + ...systemSettings, + branding: publicSettings.branding, + }; + }); /** * Legacy method: Set settings (for backward compatibility) @@ -667,15 +973,25 @@ const setSettings = async (partial) => { // Deep merge const merged = { thumbnails: { ...current.thumbnails, ...(partial.thumbnails || {}) }, + // Each half of the section stands on its own: saving the rules alone must + // not quietly switch off whether they hold administrators, and vice versa. access: { rules: partial.access?.rules !== undefined ? partial.access.rules : current.access.rules, - // Saved apart from the rules on the settings page, so each has to survive - // the other being saved on its own. applyToAdmins: partial.access?.applyToAdmins !== undefined ? partial.access.applyToAdmins : current.access.applyToAdmins, }, + uploads: { ...current.uploads, ...(partial.uploads || {}) }, + trash: { ...current.trash, ...(partial.trash || {}) }, + versions: { ...current.versions, ...(partial.versions || {}) }, + activity: { ...current.activity, ...(partial.activity || {}) }, + folderSize: { + excludedPaths: + partial.folderSize?.excludedPaths !== undefined + ? partial.folderSize.excludedPaths + : current.folderSize.excludedPaths, + }, branding: { ...current.branding, ...(partial.branding || {}) }, }; @@ -686,54 +1002,46 @@ const setSettings = async (partial) => { if (partial.access) { merged.access = await setSystemSetting('system', 'access', merged.access); } + if (partial.folderSize) { + merged.folderSize = await setSystemSetting('system', 'folderSize', merged.folderSize); + } if (partial.branding) { merged.branding = await setSystemSetting('branding', 'branding', merged.branding); } - - // Also update JSON for backward compatibility during transition - try { - await storage.update((data) => ({ - ...data, - settings: { - thumbnails: merged.thumbnails, - access: merged.access, - branding: merged.branding, - }, - })); - } catch (err) { - // Non-fatal, continue + if (partial.uploads) { + merged.uploads = await setSystemSetting('system', 'uploads', merged.uploads); + } + if (partial.trash) { + merged.trash = await setSystemSetting('system', 'trash', merged.trash); + } + if (partial.versions) { + merged.versions = await setSystemSetting('system', 'versions', merged.versions); + } + if (partial.activity) { + merged.activity = await setSystemSetting('system', 'activity', merged.activity); } return merged; }; -/** - * Update settings with an updater function - */ -const updateSettings = async (updater) => { - const current = await getSettings(); - const next = typeof updater === 'function' ? updater(current) : current; - return setSettings(next); -}; - module.exports = { - replaceBranding, - USER_SETTING_KEYS, - MAX_UPLOAD_CHUNK_SIZE_BYTES, + checkSystemSection, getPublicSettings, - sanitizeAccess, + getUserSettings, + getSystemSettings, sanitizeTrash, sanitizeVersions, sanitizeActivity, - sanitizeFolderSize, - sanitizeSearchIndex, - getUserSettings, - getSystemSettings, getSettingsForUser, setUserSetting, + WRITABLE_USER_SETTINGS, + setUserFolderSort, + setUserFolderView, setSystemSetting, + mergeSystemSection, + replaceBranding, + MAX_UPLOAD_CHUNK_SIZE_BYTES, // Legacy methods for backward compatibility getSettings, setSettings, - updateSettings, }; diff --git a/backend/tests/helpers/substitute-module.js b/backend/tests/helpers/substitute-module.js new file mode 100644 index 000000000..38c6b659f --- /dev/null +++ b/backend/tests/helpers/substitute-module.js @@ -0,0 +1,36 @@ +const { createRequire } = require('node:module'); + +/** + * Stand `exports` in for what `fromFile` receives when it requires `request`, + * until the returned function is called. + * + * For the rare collaborator a test cannot otherwise bring into the state it + * needs — a write that has begun and not yet finished. The request is resolved + * from `fromFile`, exactly as the code under test resolves it, so the stand-in + * is the module that code actually gets. Require the code under test after + * this, and restore once whatever it started has been stopped. + */ +const substituteModule = (fromFile, request, exports) => { + const localRequire = createRequire(fromFile); + const resolved = localRequire.resolve(request); + const previous = localRequire.cache[resolved]; + + localRequire.cache[resolved] = { + id: resolved, + filename: resolved, + loaded: true, + exports, + children: [], + paths: [], + }; + + return () => { + if (previous) { + localRequire.cache[resolved] = previous; + } else { + delete localRequire.cache[resolved]; + } + }; +}; + +module.exports = { substituteModule }; diff --git a/backend/tests/routes/account-language.test.js b/backend/tests/routes/account-language.test.js index 3a589e037..5c833192c 100644 --- a/backend/tests/routes/account-language.test.js +++ b/backend/tests/routes/account-language.test.js @@ -74,8 +74,8 @@ describe('a language on the account', () => { }); it('is a preference the settings route accepts', async () => { - const { USER_SETTING_KEYS } = settings(); + const { WRITABLE_USER_SETTINGS } = settings(); - expect(USER_SETTING_KEYS.has('locale')).toBe(true); + expect(WRITABLE_USER_SETTINGS.has('locale')).toBe(true); }); }); diff --git a/backend/tests/routes/settings-concurrent-saves.test.js b/backend/tests/routes/settings-concurrent-saves.test.js new file mode 100644 index 000000000..f1c38f344 --- /dev/null +++ b/backend/tests/routes/settings-concurrent-saves.test.js @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { fileURLToPath } from 'node:url'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; +import { substituteModule } from '../helpers/substitute-module.js'; + +/** + * Two saves of one settings section, in flight at the same moment. + * + * The page sends what changed rather than the whole document, so the server + * merges each section over what is stored. That merge used to read the + * settings at the start of the request and write the result two awaits later: + * two administrators saving at once, or one with the settings open in two + * tabs, both started from the same stored value and the second wrote over the + * first's field — while telling the person who set it that it was saved. + * Branding was taken out of that path already, where a lost save also left a + * logo file behind; every other section had the same hole. + * + * The database answers synchronously, so a read and a write with nothing + * awaited between them cannot be interleaved. That is what is pinned here: the + * moment where the second request could slip in is the `await` on the database + * handle, and both requests are held at it until both have arrived. With the + * read and the write on either side of it, the second overwrites the first; + * with both after it, the second reads what the first has just written. + */ + +const SERVICE_FILE = fileURLToPath( + new URL('../../src/services/settingsService.js', import.meta.url) +); + +let currentEnv; +let restore = null; + +afterEach(async () => { + restore?.(); + restore = null; + if (currentEnv) { + await currentEnv.cleanup(); + currentEnv = null; + } +}); + +/** + * Hold the first two callers until both have arrived, and let everything after + * them straight through. + */ +const holdFirstTwo = () => { + let arrived = 0; + let release; + const both = new Promise((resolve) => { + release = resolve; + }); + return async () => { + arrived += 1; + if (arrived > 2) return; + if (arrived === 2) release(); + await both; + }; +}; + +const buildApp = () => { + const routes = currentEnv.requireFresh('src/routes/settings'); + const { errorHandler } = currentEnv.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { id: 'u1', email: 'u@example.com', roles: ['admin'] }; + next(); + }); + app.use('/api', routes); + app.use(errorHandler); + return app; +}; + +/** + * An application whose settings service waits on the gate every time it asks + * for the database handle. + */ +const seed = async () => { + currentEnv = await setupTestEnv({ tag: 'settings-concurrent-' }); + const db = currentEnv.requireFresh('src/services/db'); + await db.getDb(); + + const gate = holdFirstTwo(); + // Substituted after the environment cleared the module registry, so the + // settings service required next is the one that receives this. + restore = substituteModule(SERVICE_FILE, './db', { + ...db, + getDb: async () => { + await gate(); + return db.getDb(); + }, + }); + + return buildApp(); +}; + +const patch = (app, payload) => request(app).patch('/api/settings').send(payload); +const readAsAdmin = async (app) => (await request(app).get('/api/settings')).body; + +describe('two saves of one settings section at once', () => { + it.each([ + ['the trash', 'trash', { retentionDays: 90 }, { maxPercent: 40 }], + ['file versions', 'versions', { maxPerFile: 7 }, { dailyDays: 60 }], + ['thumbnails', 'thumbnails', { size: 320 }, { quality: 55 }], + ['uploads', 'uploads', { chunkSizeBytes: 16 * 1024 * 1024 }, { chunkedEnabled: true }], + ])('keep both fields: %s', async (_label, section, first, second) => { + const app = await seed(); + + const answers = await Promise.all([ + patch(app, { [section]: first }), + patch(app, { [section]: second }), + ]); + + expect(answers.map((answer) => answer.status)).toEqual([200, 200]); + expect((await readAsAdmin(app))[section]).toMatchObject({ ...first, ...second }); + }); + + /** + * The exclusions are a list rather than a set of fields, so the two saves + * cannot both survive — the second replaces the list. What must hold is that + * the one the person is told about is the one that is stored. + */ + it('leaves the folder size exclusions as the last answer says they are', async () => { + const app = await seed(); + + const answers = await Promise.all([ + patch(app, { folderSize: { excludedPaths: ['Archive'] } }), + patch(app, { folderSize: { excludedPaths: ['Archive', 'Backups'] } }), + ]); + + expect(answers.map((answer) => answer.status)).toEqual([200, 200]); + const stored = (await readAsAdmin(app)).folderSize.excludedPaths; + expect(stored).toEqual(answers.at(-1).body.folderSize.excludedPaths); + }); +}); diff --git a/backend/tests/routes/settings-exclusions.test.js b/backend/tests/routes/settings-exclusions.test.js new file mode 100644 index 000000000..485d62480 --- /dev/null +++ b/backend/tests/routes/settings-exclusions.test.js @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Written at the layer the browser actually talks to. + * + * `SEARCH_INDEX_EXCLUDE` was set, the service that reads settings reported it, + * a test asserted exactly that — and the page still said "no path configured", + * because the route does not call that function. It calls one that assembles + * the admin payload field by field, and the new field was not in the list. A + * test one layer below the defect cannot see the defect. + */ +let envContext; + +const buildApp = (roles) => { + const settingsRoutes = envContext.requireFresh('src/routes/settings'); + const { errorHandler } = envContext.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { id: 'u1', email: 'u@example.com', roles }; + next(); + }); + app.use('/api', settingsRoutes); + app.use(errorHandler); + return app; +}; + +const seed = async (env) => { + envContext = await setupTestEnv({ tag: 'settings-exclusions-', env }); + const dbService = envContext.requireFresh('src/services/db'); + await dbService.getDb(); +}; + +describe('what GET /api/settings tells an administrator', () => { + it('carries the search index exclusions the environment set', async () => { + await seed({ SEARCH_INDEX: 'true', SEARCH_INDEX_EXCLUDE: 'Stacks/docker' }); + try { + const response = await request(buildApp(['admin'])).get('/api/settings'); + + expect(response.status).toBe(200); + expect(response.body.searchIndex).toBeTruthy(); + expect(response.body.searchIndex.environmentExcludedPaths).toEqual(['Stacks/docker']); + // Beside the folder-size ones, which have always been there. + expect(response.body.folderSize).toBeTruthy(); + } finally { + await envContext.cleanup(); + } + }); + + it('does not carry them to someone who is not an administrator', async () => { + await seed({ SEARCH_INDEX: 'true', SEARCH_INDEX_EXCLUDE: 'Stacks/docker' }); + try { + const response = await request(buildApp(['user'])).get('/api/settings'); + + expect(response.status).toBe(200); + expect(response.body.searchIndex).toBeUndefined(); + } finally { + await envContext.cleanup(); + } + }); + + it('takes a path an administrator adds and gives it back', async () => { + await seed({ SEARCH_INDEX: 'true' }); + try { + const app = buildApp(['admin']); + const saved = await request(app) + .patch('/api/settings') + .send({ searchIndex: { excludedPaths: ['Sauvegardes/2024'] } }); + expect(saved.status).toBe(200); + + const response = await request(app).get('/api/settings'); + expect(response.body.searchIndex.excludedPaths).toEqual(['Sauvegardes/2024']); + } finally { + await envContext.cleanup(); + } + }); +}); + +/** + * Written down is not the same as in effect. + * + * Saving the list and telling the worker about it are two separate steps, and + * a test that reads the setting back sees only the first. Skipping the second + * leaves the running indexer walking a folder an administrator has just + * excluded, with the settings page showing it excluded — which is the worst + * shape a setting can take. + */ +describe('an exclusion an administrator adds while the index is running', () => { + it('reaches the worker, not only the stored settings', async () => { + await seed({ SEARCH_INDEX: 'true' }); + try { + const exclusions = envContext.requireFresh('src/services/searchIndexExclusions'); + expect(exclusions.effectivePaths()).not.toContain('Sauvegardes/2024'); + + const response = await request(buildApp(['admin'])) + .patch('/api/settings') + .send({ searchIndex: { excludedPaths: ['Sauvegardes/2024'] } }); + expect(response.status).toBe(200); + + // The worker decides what it walks from this list, not from the database. + expect(exclusions.effectivePaths()).toContain('Sauvegardes/2024'); + } finally { + await envContext.cleanup(); + } + }); + + it('does the same for folder sizes', async () => { + await seed({ FOLDER_SIZE_MODE: 'full' }); + try { + const exclusions = envContext.requireFresh('src/services/folderSizeExclusions'); + + await request(buildApp(['admin'])) + .patch('/api/settings') + .send({ folderSize: { excludedPaths: ['Media/raw'] } }); + + expect(exclusions.effectivePaths()).toContain('Media/raw'); + } finally { + await envContext.cleanup(); + } + }); +}); diff --git a/backend/tests/routes/settings-partial-updates.test.js b/backend/tests/routes/settings-partial-updates.test.js new file mode 100644 index 000000000..e21cd3a97 --- /dev/null +++ b/backend/tests/routes/settings-partial-updates.test.js @@ -0,0 +1,587 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * A settings write that sends part of a section, or sends a field in the wrong + * shape. + * + * The settings page sends what changed, not the whole document, so the route + * merges each section over what is stored. And it drops a field that is not in + * the shape the field takes before anything is stored — which matters more than + * it looks, because the service underneath repairs a bad value by putting the + * *default* in its place. Without the route's check, a trash retention of + * ninety days sent back as "forever" becomes thirty, and an access rule list + * sent as anything other than a list becomes no rules at all: every folder an + * administrator had hidden, visible again. + * + * So every case here first stores a value that differs from the default, then + * sends the bad one, then reads back — a test that started from the default + * could not tell the route's refusal from the service's repair. + * + * Who may write which section is pinned in `settings-write-boundary.test.js`. + */ + +const MiB = 1024 * 1024; + +let currentEnv; + +afterEach(async () => { + if (currentEnv) { + await currentEnv.cleanup(); + currentEnv = null; + } +}); + +const seed = async () => { + currentEnv = await setupTestEnv({ tag: 'settings-partial-' }); + const db = await currentEnv.requireFresh('src/services/db').getDb(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES ('u1','u@example.com',1,'u','U','["user"]', ?, ?)` + ).run(now, now); + return db; +}; + +const buildApp = (roles) => { + const routes = currentEnv.requireFresh('src/routes/settings'); + const { errorHandler } = currentEnv.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { id: 'u1', email: 'u@example.com', roles }; + next(); + }); + app.use('/api', routes); + app.use(errorHandler); + return app; +}; + +const patch = (roles, payload) => request(buildApp(roles)).patch('/api/settings').send(payload); +const readAsAdmin = async () => (await request(buildApp(['admin'])).get('/api/settings')).body; + +describe('a field sent in a shape it does not take', () => { + it.each([ + [ + 'thumbnails.quality', + { thumbnails: { quality: 55 } }, + { thumbnails: { quality: 'best' } }, + (s) => s.thumbnails.quality, + 55, + ], + [ + 'uploads.chunkSizeBytes', + { uploads: { chunkSizeBytes: 16 * MiB } }, + { uploads: { chunkSizeBytes: 'huge' } }, + (s) => s.uploads.chunkSizeBytes, + 16 * MiB, + ], + [ + 'uploads.chunkedEnabled', + { uploads: { chunkedEnabled: true } }, + { uploads: { chunkedEnabled: 'yes' } }, + (s) => s.uploads.chunkedEnabled, + true, + ], + [ + 'trash.retentionDays', + { trash: { retentionDays: 90 } }, + { trash: { retentionDays: 'forever' } }, + (s) => s.trash.retentionDays, + 90, + ], + [ + 'trash.maxBytes', + { trash: { maxBytes: 5_000_000_000 } }, + { trash: { maxBytes: 'lots' } }, + (s) => s.trash.maxBytes, + 5_000_000_000, + ], + [ + 'versions.maxPerFile', + { versions: { maxPerFile: 7 } }, + { versions: { maxPerFile: 'many' } }, + (s) => s.versions.maxPerFile, + 7, + ], + [ + 'branding.appName', + { branding: { appName: 'Files' } }, + { branding: { appName: 42 } }, + (s) => s.branding.appName, + 'Files', + ], + ])( + 'leaves %s as it was, not reset to its default', + async (_field, stored, sent, readBack, kept) => { + await seed(); + await patch(['admin'], stored); + expect(readBack(await readAsAdmin())).toEqual(kept); + + const response = await patch(['admin'], sent); + + expect(response.status).toBe(200); + expect(readBack(await readAsAdmin())).toEqual(kept); + } + ); + + it('leaves thumbnails as they were when "enabled" is not a boolean', async () => { + await seed(); + await patch(['admin'], { thumbnails: { enabled: false } }); + + // Anything present used to count, and the service reads what is not a + // boolean as on: "false" switched thumbnails on for everybody. + const response = await patch(['admin'], { thumbnails: { enabled: 'false' } }); + + expect(response.status).toBe(200); + expect((await readAsAdmin()).thumbnails.enabled).toBe(false); + }); + + /** The one field where "nothing" is a value: no cap on the trash. */ + it('takes null for the trash size cap, which removes the cap', async () => { + await seed(); + await patch(['admin'], { trash: { maxBytes: 5_000_000_000 } }); + + await patch(['admin'], { trash: { maxBytes: null } }); + + expect((await readAsAdmin()).trash.maxBytes).toBeNull(); + }); +}); + +/** + * A number, but not one anybody chose: what an emptied or mistyped field sends. + * + * The shape is right, so the check above let these through, and the service + * brought each up to its lowest bound — a chunk size of 0 stored as 1 MiB, a + * thumbnail size of 0 as 64 pixels — in place of what the administrator had. + * A positive value beyond a bound is still brought within it. + */ +describe('a size or a count of nothing', () => { + it.each([ + ['uploads.chunkSizeBytes', 0, 'uploads', 'chunkSizeBytes', 16 * MiB], + ['uploads.chunkSizeBytes', -MiB, 'uploads', 'chunkSizeBytes', 16 * MiB], + ['thumbnails.size', 0, 'thumbnails', 'size', 320], + ['thumbnails.quality', -5, 'thumbnails', 'quality', 55], + ['thumbnails.concurrency', 0, 'thumbnails', 'concurrency', 4], + // The trash and the file versions, which the settings page already refuses + // with these bounds — this is what an API client saw instead. + ['trash.retentionDays', 0, 'trash', 'retentionDays', 90], + ['trash.retentionDays', -5, 'trash', 'retentionDays', 90], + ['trash.maxPercent', 0, 'trash', 'maxPercent', 40], + ['trash.maxBytes', 0, 'trash', 'maxBytes', 5_000_000_000], + ['versions.keepAllHours', 0, 'versions', 'keepAllHours', 48], + ['versions.hourlyDays', -3, 'versions', 'hourlyDays', 14], + ['versions.dailyDays', 0, 'versions', 'dailyDays', 60], + ['versions.maxPerFile', 0, 'versions', 'maxPerFile', 7], + ['versions.sessionCheckpointMinutes', -1, 'versions', 'sessionCheckpointMinutes', 30], + ])('leaves %s as it was when sent %j', async (_label, sent, section, field, kept) => { + await seed(); + await patch(['admin'], { [section]: { [field]: kept } }); + + const response = await patch(['admin'], { [section]: { [field]: sent } }); + + expect(response.status).toBe(200); + expect((await readAsAdmin())[section][field]).toBe(kept); + }); + + it('still brings a positive value beyond its bounds within them', async () => { + await seed(); + + await patch(['admin'], { + thumbnails: { size: 5000 }, + uploads: { chunkSizeBytes: 1024 }, + trash: { retentionDays: 9000 }, + versions: { maxPerFile: 5000 }, + }); + + const settings = await readAsAdmin(); + expect(settings.thumbnails.size).toBe(1024); + expect(settings.uploads.chunkSizeBytes).toBe(MiB); + expect(settings.trash.retentionDays).toBe(3650); + expect(settings.versions.maxPerFile).toBe(1000); + }); +}); + +describe('the application name', () => { + it.each([[''], [' ']])('is left as it was when sent as %j', async (appName) => { + await seed(); + await patch(['admin'], { branding: { appName: 'Files' } }); + + const response = await patch(['admin'], { branding: { appName } }); + + expect(response.status).toBe(200); + expect(response.body.branding.appName).toBe('Files'); + expect((await readAsAdmin()).branding.appName).toBe('Files'); + }); + + it('reads as the default where an empty one was stored before', async () => { + const db = await seed(); + db.prepare( + `INSERT INTO system_settings (id, category, key, value, updated_at) + VALUES ('b1', 'branding', 'branding', ?, ?)` + ).run(JSON.stringify({ appName: ' ', appLogoUrl: '/logo.svg' }), new Date().toISOString()); + + const response = await request(buildApp([])).get('/api/branding'); + + expect(response.body.appName).toBe('Explorer'); + }); +}); + +describe('a section sent with only some of its fields', () => { + it('changes those fields and leaves the rest of the section as it was', async () => { + await seed(); + await patch(['admin'], { + trash: { retentionDays: 90, maxPercent: 40 }, + versions: { maxPerFile: 7, dailyDays: 60 }, + }); + + await patch(['admin'], { trash: { retentionDays: 7 }, versions: { maxPerFile: 9 } }); + + const { trash, versions } = await readAsAdmin(); + expect(trash).toMatchObject({ retentionDays: 7, maxPercent: 40 }); + expect(versions).toMatchObject({ maxPerFile: 9, dailyDays: 60 }); + }); +}); + +describe('a list sent as something that is not a list', () => { + const HIDDEN_RULE = { path: 'Private', permissions: 'hidden', recursive: true }; + const rulesOf = (settings) => settings.access.rules.map((r) => `${r.path}:${r.permissions}`); + + it.each([ + [ + 'the access rules', + { access: { rules: [HIDDEN_RULE] } }, + { access: { rules: 'none' } }, + rulesOf, + ['Private:hidden'], + ], + [ + 'the access rules, when the list is missing', + { access: { rules: [HIDDEN_RULE] } }, + { access: {} }, + rulesOf, + ['Private:hidden'], + ], + [ + 'the search index exclusions', + { searchIndex: { excludedPaths: ['Private'] } }, + { searchIndex: { excludedPaths: 'Elsewhere' } }, + (s) => s.searchIndex.excludedPaths, + ['Private'], + ], + [ + 'the folder size exclusions', + { folderSize: { excludedPaths: ['Private'] } }, + { folderSize: { excludedPaths: null } }, + (s) => s.folderSize.excludedPaths, + ['Private'], + ], + ])('leaves %s in place', async (_label, stored, sent, readBack, kept) => { + await seed(); + await patch(['admin'], stored); + expect(readBack(await readAsAdmin())).toEqual(kept); + + const response = await patch(['admin'], sent); + + expect(response.status).toBe(200); + expect(readBack(await readAsAdmin())).toEqual(kept); + }); +}); + +/** + * A rule the server cannot store as it was written. + * + * Every one of these used to be sanitised away with a 200: the row for + * `../Secret` vanished from the page the moment it was saved, and an + * administrator was left believing a folder was hidden that never was. Worse, + * permissions that were not one of the three became `rw`, so a mistyped + * `readonly` opened a folder for writing instead of refusing the word. + * + * Each one stores a good rule first, so a refusal can be told from a list that + * was replaced by nothing. + */ +describe('an access rule the server cannot store', () => { + const STORED = { id: 'kept', path: 'Private', permissions: 'hidden', recursive: true }; + + it.each([ + [ + 'a path that climbs out of the volume', + { path: '../Secret', permissions: 'hidden' }, + /Traversal outside the volume root/, + ], + ['no path at all', { path: '', permissions: 'ro' }, /a rule needs the path of a folder/], + [ + 'permissions that are not one of the three', + { path: 'Legal', permissions: 'readonly' }, + /is not one of the permissions/, + ], + [ + 'a recursive flag that is not one', + { path: 'Legal', permissions: 'ro', recursive: 'yes' }, + /does not say whether the rule covers what is inside/, + ], + ['something that is not a rule', 'Legal', /this is not a rule/], + ])('is refused, with the reason, and changes nothing: %s', async (_label, rule, reason) => { + await seed(); + await patch(['admin'], { access: { rules: [STORED] } }); + + const response = await patch(['admin'], { access: { rules: [STORED, rule] } }); + + expect(response.status).toBe(400); + expect(response.body.error.message).toMatch(reason); + // Numbered as the page numbers the rows, so the reason names the one to fix. + expect(response.body.error.message).toMatch(/^Access rule 2: /); + expect((await readAsAdmin()).access.rules).toEqual([expect.objectContaining(STORED)]); + }); + + /** + * Read back, the same rule is still dropped rather than refused. A value an + * older version stored, or one edited into app.db by hand, must not make the + * settings unreadable — unreadable settings are every hidden folder visible. + */ + it('is dropped, not refused, when it is already in the database', async () => { + const db = await seed(); + db.prepare( + `INSERT INTO system_settings (id, category, key, value, updated_at) + VALUES ('a1', 'system', 'access', ?, ?)` + ).run( + JSON.stringify({ rules: [STORED, { path: '../Secret', permissions: 'hidden' }] }), + new Date().toISOString() + ); + + const settings = await readAsAdmin(); + + expect(settings.access.rules).toEqual([expect.objectContaining(STORED)]); + }); +}); + +describe('what a regular account may not change', () => { + /** + * `settings-write-boundary.test.js` covers one field of five sections. These + * are the other three, and the access rules are the ones that decide which + * folders anybody may see. + */ + it.each([ + ['the access rules', { access: { rules: [] } }, (s) => s.access.rules.length, 1], + ['the trash', { trash: { retentionDays: 1 } }, (s) => s.trash.retentionDays, 90], + ['the file versions', { versions: { maxPerFile: 1 } }, (s) => s.versions.maxPerFile, 7], + ])('is refused, and unchanged: %s', async (_label, sent, readBack, kept) => { + await seed(); + await patch(['admin'], { + access: { rules: [{ path: 'Private', permissions: 'hidden', recursive: true }] }, + trash: { retentionDays: 90 }, + versions: { maxPerFile: 7 }, + }); + + const response = await patch(['user'], sent); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Admin access required for system settings.'); + expect(readBack(await readAsAdmin())).toBe(kept); + }); +}); + +/** + * A save the route refuses halfway. + * + * One payload carries a section per group, and the sections used to be applied + * one after another: a valid one before a refused one was stored, and the + * answer was still 400. The person saw their save refused, the page kept the + * values it had sent, and the server had taken some of them — the two + * disagreed until the next reload, which is the worst state of the three. + * + * The access rules are the only section that refuses what it was sent, so they + * are what makes this reachable. The sections are checked in the order they + * are declared, and thumbnails come first: it is written before access is + * reached, or it is not written at all. + */ +describe('a payload with a valid section and a refused one', () => { + const REFUSED = { access: { rules: [{ path: 'Private', permissions: 'sideways' }] } }; + + it('stores none of it, and says which rule it refused', async () => { + await seed(); + await patch(['admin'], { thumbnails: { size: 321 } }); + + const response = await patch(['admin'], { ...REFUSED, thumbnails: { size: 654 } }); + + expect(response.status).toBe(400); + expect(response.body.error.message).toMatch(/Access rule 1/); + expect((await readAsAdmin()).thumbnails.size).toBe(321); + }); + + /** + * The other direction, so this cannot pass on the order of the sections + * alone: branding is written after access, and must be no more stored than + * thumbnails was. + */ + it('stores nothing that comes after the refusal either', async () => { + await seed(); + await patch(['admin'], { branding: { appName: 'Before' } }); + + const response = await patch(['admin'], { ...REFUSED, branding: { appName: 'After' } }); + + expect(response.status).toBe(400); + expect((await readAsAdmin()).branding.appName).toBe('Before'); + }); + + /** A preference of one's own is not stored by a save the server refuses. */ + it('leaves the sender’s own preferences alone', async () => { + await seed(); + await patch(['admin'], { user: { showHiddenFiles: true } }); + + const response = await patch(['admin'], { ...REFUSED, user: { showHiddenFiles: false } }); + + expect(response.status).toBe(400); + expect((await readAsAdmin()).user.showHiddenFiles).toBe(true); + }); + + /** And a save with nothing wrong in it still writes every section it carries. */ + it('still writes every section when none of them is refused', async () => { + await seed(); + + const response = await patch(['admin'], { + thumbnails: { size: 654 }, + branding: { appName: 'After' }, + access: { rules: [{ path: 'Private', permissions: 'hidden', recursive: true }] }, + }); + + expect(response.status).toBe(200); + const settings = await readAsAdmin(); + expect(settings.thumbnails.size).toBe(654); + expect(settings.branding.appName).toBe('After'); + expect(settings.access.rules.map((rule) => rule.path)).toEqual(['Private']); + }); +}); + +/** + * A rule that names no folder, and an exclusion list stored as it came. + * + * Both are the same kind of defect: something an administrator saved, that the + * page then showed back to them, doing nothing. A path of nothing but spaces + * normalises to itself, so it was stored and matched no folder. The search + * index had no sanitiser on its way into storage, so its list kept whatever + * spacing and repetition it arrived with — the worker was handed a clean copy + * and behaved, which is exactly why nobody noticed the stored one. + */ +describe('what a rule and an exclusion list are held to', () => { + it.each([[' '], ['\t'], [''], [' \n ']])( + 'refuses an access rule whose path is %j', + async (blank) => { + await seed(); + + const response = await patch(['admin'], { + access: { rules: [{ path: blank, permissions: 'hidden' }] }, + }); + + expect(response.status).toBe(400); + expect(response.body.error.message).toMatch(/Access rule 1.*folder/); + expect((await readAsAdmin()).access.rules).toEqual([]); + } + ); + + it('keeps a folder whose name has spaces in it', async () => { + await seed(); + + const response = await patch(['admin'], { + access: { rules: [{ path: 'My Documents/Q1 2026', permissions: 'ro' }] }, + }); + + expect(response.status).toBe(200); + expect((await readAsAdmin()).access.rules.map((rule) => rule.path)).toEqual([ + 'My Documents/Q1 2026', + ]); + }); + + it('stores the search index exclusions as the worker is given them', async () => { + const db = await seed(); + + const response = await patch(['admin'], { + searchIndex: { excludedPaths: [' Private ', 'Private', '', '/Cache/'] }, + }); + + expect(response.status).toBe(200); + const stored = JSON.parse( + db + .prepare( + "SELECT value FROM system_settings WHERE category = 'system' AND key = 'searchIndex'" + ) + .get().value + ); + + // Trimmed, emptied of nothing, and each folder once — the list the worker + // is handed, rather than what the request happened to carry. + expect(stored.excludedPaths).not.toContain(' Private '); + expect(stored.excludedPaths).not.toContain(''); + expect(stored.excludedPaths.filter((entry) => entry === 'Private')).toHaveLength(1); + expect(stored.excludedPaths).toEqual((await readAsAdmin()).searchIndex.excludedPaths); + }); +}); + +/** + * The access section is saved from two controls: the list of rules, and the one + * switch above them that holds administrators to every rule. + * + * The route used to forward the rules alone. Saving them switched the setting + * back off — silently widening what administrators could reach — and a request + * that carried only the switch stored nothing at all, so turning it on did + * nothing whatever the page showed. The service was right either way, which is + * why only a test that goes through the route catches it. + */ +describe('the access section, saved half at a time', () => { + const RULE = { path: 'Team', recursive: true, permissions: 'ro', appliesToAdmins: true }; + + const storeBoth = () => + patch(['admin'], { access: { rules: [RULE], applyToAdmins: true } }).expect(200); + + it('keeps the switch when only the rules are sent', async () => { + await seed(); + await storeBoth(); + + await patch(['admin'], { + access: { rules: [{ ...RULE, path: 'Finance' }] }, + }).expect(200); + + const { access } = await readAsAdmin(); + expect(access.applyToAdmins).toBe(true); + expect(access.rules.map((rule) => rule.path)).toEqual(['Finance']); + }); + + it('keeps the rules when only the switch is sent', async () => { + await seed(); + await storeBoth(); + + await patch(['admin'], { access: { applyToAdmins: false } }).expect(200); + + const { access } = await readAsAdmin(); + expect(access.applyToAdmins).toBe(false); + expect(access.rules.map((rule) => rule.path)).toEqual(['Team']); + }); + + it('stores what each rule says about administrators', async () => { + await seed(); + + await patch(['admin'], { + access: { + rules: [ + { path: 'Team', recursive: true, permissions: 'ro', appliesToAdmins: true }, + { path: 'Vault', recursive: true, permissions: 'hidden', appliesToAdmins: false }, + ], + }, + }).expect(200); + + const { access } = await readAsAdmin(); + expect(access.rules.map((rule) => rule.appliesToAdmins)).toEqual([true, false]); + }); + + it('refuses a switch that is not a yes or a no, and stores nothing', async () => { + await seed(); + await storeBoth(); + + await patch(['admin'], { access: { applyToAdmins: 'yes' } }).expect(400); + + const { access } = await readAsAdmin(); + expect(access.applyToAdmins).toBe(true); + }); +}); diff --git a/backend/tests/routes/settings-preferences.test.js b/backend/tests/routes/settings-preferences.test.js index 26c503722..ac95cee92 100644 --- a/backend/tests/routes/settings-preferences.test.js +++ b/backend/tests/routes/settings-preferences.test.js @@ -66,26 +66,34 @@ describe('a preference the screen offers', () => { /** * Every key the service knows how to sanitise is a key this route accepts: * one list, so neither can gain a preference the other drops. + * + * A value each preference actually takes, and the value is what is asserted + * rather than the key being present: the answer carries the settings as they + * now stand, so a key stored by an earlier turn of this loop would still be + * there after the one that dropped it. */ + const A_VALUE_IT_TAKES = { + defaultShareExpiration: null, + skipHome: null, + locale: 'fr', + defaultView: 'list', + }; + it('accepts exactly what the settings service calls a preference', async () => { - const { USER_SETTING_KEYS } = load('src/services/settingsService'); - - for (const key of USER_SETTING_KEYS) { - const value = - key === 'defaultShareExpiration' || key === 'skipHome' - ? null - : key === 'locale' - ? 'fr' - : true; + const { WRITABLE_USER_SETTINGS } = load('src/services/settingsService'); + + for (const key of WRITABLE_USER_SETTINGS) { + const value = key in A_VALUE_IT_TAKES ? A_VALUE_IT_TAKES[key] : true; const response = await save({ [key]: value }); - expect(response.body.user, `${key} was dropped`).toHaveProperty(key); + expect(response.body.user?.[key], `${key} was dropped`).toEqual(value); + expect((await stored())[key], `${key} was not stored`).toEqual(value); } }); it('ignores a key that is not a preference', async () => { const response = await save({ isAdmin: true }); - expect(response.body.user ?? {}).toEqual({}); + expect(response.body.user).not.toHaveProperty('isAdmin'); expect((await stored()).isAdmin).toBeUndefined(); }); }); diff --git a/backend/tests/routes/settings-user-preferences.test.js b/backend/tests/routes/settings-user-preferences.test.js new file mode 100644 index 000000000..2c196b9d3 --- /dev/null +++ b/backend/tests/routes/settings-user-preferences.test.js @@ -0,0 +1,323 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +const MODULES = [ + 'src/services/settingsService', + 'src/services/db', + 'src/routes/settings', + 'src/middleware/errorHandler', +]; + +/** + * The route, not the service underneath it. + * + * A preference used to have to be listed in two places — sanitised in the + * service and allowed in the route — and a key present in one but not the other + * was accepted by the API, silently dropped, and answered with its previous + * value. The client applied that answer, so the switch flicked itself back off. + * Testing setUserSetting directly could not see it: the route was the half that + * was missing. + */ +const buildContext = async () => { + const envContext = await setupTestEnv({ tag: 'settings-route-test-', modules: MODULES }); + const settingsService = envContext.requireFresh('src/services/settingsService'); + const settingsRoutes = envContext.requireFresh('src/routes/settings'); + const { errorHandler } = envContext.requireFresh('src/middleware/errorHandler'); + + const dbService = envContext.requireFresh('src/services/db'); + const db = await dbService.getDb(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run('user-1', 'user-1@example.com', 1, 'user-1', 'User 1', '["user"]', now, now); + + const express = require('express'); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { id: 'user-1', email: 'user-1@example.com', roles: ['user'] }; + next(); + }); + app.use('/api', settingsRoutes); + app.use(errorHandler); + + return { envContext, app, settingsService }; +}; + +// A value that is different from every default, so "it came back" cannot be +// confused with "it was already like that". +const NON_DEFAULT = { + showHiddenFiles: true, + showThumbnails: false, + showSidebarFavorites: false, + showSidebarShares: false, + showSidebarTools: false, + markdownOpensInEditor: true, + documentsOpenInNewTab: true, + // On by default, so off is the value that has to survive a round trip. + showVersionMarks: false, + defaultShareExpiration: { value: 3, unit: 'days' }, + skipHome: true, + defaultView: 'list', + // Null by default, which means "follow the browser". + locale: 'nl', +}; + +describe('PATCH /api/settings — user preferences', () => { + it('saves the markdown preference and reads it back', async () => { + const { envContext, app } = await buildContext(); + try { + const saved = await request(app) + .patch('/api/settings') + .send({ user: { markdownOpensInEditor: true } }) + .expect(200); + + // The response is what the client applies to its own state, so the value + // has to be in it — not merely stored somewhere. + expect(saved.body.user?.markdownOpensInEditor).toBe(true); + + const reread = await request(app).get('/api/settings').expect(200); + expect(reread.body.user.markdownOpensInEditor).toBe(true); + } finally { + await envContext.cleanup(); + } + }); + + // Every writable preference, so the next one added is covered without anyone + // having to remember to write a test for it. + it('saves and reads back every writable preference', async () => { + const { envContext, app, settingsService } = await buildContext(); + try { + const writable = [...settingsService.WRITABLE_USER_SETTINGS]; + + // Guard against the list and this test drifting apart. + for (const key of writable) { + expect(NON_DEFAULT, `add ${key} to NON_DEFAULT`).toHaveProperty(key); + } + + const payload = Object.fromEntries(writable.map((key) => [key, NON_DEFAULT[key]])); + const saved = await request(app).patch('/api/settings').send({ user: payload }).expect(200); + + for (const key of writable) { + expect(saved.body.user?.[key], `${key} missing from the response`).toEqual( + NON_DEFAULT[key] + ); + } + + const reread = await request(app).get('/api/settings').expect(200); + for (const key of writable) { + expect(reread.body.user[key], `${key} was not persisted`).toEqual(NON_DEFAULT[key]); + } + } finally { + await envContext.cleanup(); + } + }); + + /** + * A default expiry that is not one used to be stored as no default: minus + * three weeks sent from the page removed the default the person had, and the + * page then showed an empty field. + */ + it.each([ + [{ value: -3, unit: 'weeks' }], + [{ value: 0, unit: 'days' }], + [{ value: 3, unit: 'years' }], + [5], + ['soon'], + ])('leaves the default share expiry as it was when sent %j', async (sent) => { + const { envContext, app } = await buildContext(); + try { + await request(app) + .patch('/api/settings') + .send({ user: { defaultShareExpiration: { value: 3, unit: 'days' } } }) + .expect(200); + + const saved = await request(app) + .patch('/api/settings') + .send({ user: { defaultShareExpiration: sent } }) + .expect(200); + + expect(saved.body.user.defaultShareExpiration).toEqual({ value: 3, unit: 'days' }); + const reread = await request(app).get('/api/settings').expect(200); + expect(reread.body.user.defaultShareExpiration).toEqual({ value: 3, unit: 'days' }); + } finally { + await envContext.cleanup(); + } + }); + + it('removes the default share expiry when sent null', async () => { + const { envContext, app } = await buildContext(); + try { + await request(app) + .patch('/api/settings') + .send({ user: { defaultShareExpiration: { value: 3, unit: 'days' } } }) + .expect(200); + + await request(app) + .patch('/api/settings') + .send({ user: { defaultShareExpiration: null } }) + .expect(200); + + const reread = await request(app).get('/api/settings').expect(200); + expect(reread.body.user.defaultShareExpiration).toBeNull(); + } finally { + await envContext.cleanup(); + } + }); + + /** + * A switch used to be `Boolean(whatever came)`, which has an opinion about + * everything: `'false'` — what a form field, a query string or a shell + * client sends — was true, and `0` was false. Either way the preference was + * set to something nobody had chosen, and answered as though they had. + * + * Each case stores the opposite of what the coercion would have made of the + * value, so "it stayed" cannot be confused with "it was already like that". + */ + it.each([ + ['showHiddenFiles', 'false', false], + ['showThumbnails', 0, true], + ['showSidebarFavorites', 'no', false], + ['markdownOpensInEditor', '', true], + ['skipHome', 0, true], + ])('leaves %s as it was when sent %j', async (key, sent, stored) => { + const { envContext, app } = await buildContext(); + try { + await request(app) + .patch('/api/settings') + .send({ user: { [key]: stored } }) + .expect(200); + + const saved = await request(app) + .patch('/api/settings') + .send({ user: { [key]: sent } }) + .expect(200); + + expect(saved.body.user[key]).toBe(stored); + const reread = await request(app).get('/api/settings').expect(200); + expect(reread.body.user[key]).toBe(stored); + } finally { + await envContext.cleanup(); + } + }); + + /** + * A view mode we do not have used to become null, and null is a value here: + * the built-in default. One unknown word therefore put every folder back to + * the built-in view rather than being refused. + */ + it('leaves the default view as it was when sent a mode there is no such thing as', async () => { + const { envContext, app } = await buildContext(); + try { + await request(app) + .patch('/api/settings') + .send({ user: { defaultView: 'list' } }) + .expect(200); + + const saved = await request(app) + .patch('/api/settings') + .send({ user: { defaultView: 'mosaic' } }) + .expect(200); + + expect(saved.body.user.defaultView).toBe('list'); + const reread = await request(app).get('/api/settings').expect(200); + expect(reread.body.user.defaultView).toBe('list'); + } finally { + await envContext.cleanup(); + } + }); + + it('still takes null for the default view, which is the built-in one', async () => { + const { envContext, app } = await buildContext(); + try { + await request(app) + .patch('/api/settings') + .send({ user: { defaultView: 'list' } }) + .expect(200); + + await request(app) + .patch('/api/settings') + .send({ user: { defaultView: null } }) + .expect(200); + + const reread = await request(app).get('/api/settings').expect(200); + expect(reread.body.user.defaultView).toBeNull(); + } finally { + await envContext.cleanup(); + } + }); + + /** + * The language an account reads in, which is the account's and not the + * browser's: the only other way to choose one is the picker on the sign-in + * page, which writes into the browser and is never seen again once somebody + * is signed in (nxzai/NextExplorer discussion #408). + */ + describe('the language', () => { + it('follows the browser until an account says otherwise', async () => { + const { envContext, app } = await buildContext(); + try { + const fresh = await request(app).get('/api/settings').expect(200); + expect(fresh.body.user.locale ?? null).toBeNull(); + + const saved = await request(app) + .patch('/api/settings') + .send({ user: { locale: 'pt-BR' } }) + .expect(200); + expect(saved.body.user.locale).toBe('pt-BR'); + + const back = await request(app) + .patch('/api/settings') + .send({ user: { locale: null } }) + .expect(200); + expect(back.body.user.locale).toBeNull(); + } finally { + await envContext.cleanup(); + } + }); + + /** + * Refused rather than read as "follow the browser": a value that is not a + * language tag is a mistake, and turning it into the default would put the + * choice back where it was with nothing to show for it. + */ + it.each([['not a language'], ['en_US!'], [42], [{ code: 'fr' }], [['fr']]])( + 'leaves the language as it was when sent %j', + async (sent) => { + const { envContext, app } = await buildContext(); + try { + await request(app) + .patch('/api/settings') + .send({ user: { locale: 'nl' } }) + .expect(200); + + const saved = await request(app) + .patch('/api/settings') + .send({ user: { locale: sent } }) + .expect(200); + + expect(saved.body.user.locale).toBe('nl'); + } finally { + await envContext.cleanup(); + } + } + ); + }); + + it('ignores a key that is not a user preference', async () => { + const { envContext, app } = await buildContext(); + try { + await request(app) + .patch('/api/settings') + .send({ user: { notASetting: 'x' } }) + .expect(200); + + const reread = await request(app).get('/api/settings').expect(200); + expect(reread.body.user.notASetting).toBeUndefined(); + } finally { + await envContext.cleanup(); + } + }); +}); diff --git a/backend/tests/routes/settings-write-boundary.test.js b/backend/tests/routes/settings-write-boundary.test.js new file mode 100644 index 000000000..7fb754c41 --- /dev/null +++ b/backend/tests/routes/settings-write-boundary.test.js @@ -0,0 +1,189 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Who may change what, on the one endpoint that writes every setting. + * + * `PATCH /api/settings` takes a single payload with a section per group and + * decides section by section: anyone signed in may change their own + * preferences, only an administrator may change the ones that affect everybody. + * Fifty-five paths through one function, and only the read side of that + * boundary had a test — a regular account being refused the *write* did not. + * + * The response is the whole settings document rather than a list of changes, + * so what is asserted is the value before and after, not the shape of a reply. + */ + +let currentEnv; + +afterEach(async () => { + if (currentEnv) { + await currentEnv.cleanup(); + currentEnv = null; + } +}); + +const seed = async (env = {}) => { + currentEnv = await setupTestEnv({ tag: 'settings-write-', env }); + const dbService = currentEnv.requireFresh('src/services/db'); + const db = await dbService.getDb(); + // A preference is stored against an account that has to exist; without the + // row the write fails on a foreign key and the route answers 500. + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES ('u1','u@example.com',1,'u','U','["user"]', ?, ?)` + ).run(now, now); +}; + +const buildApp = (roles) => { + const routes = currentEnv.requireFresh('src/routes/settings'); + const { errorHandler } = currentEnv.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { id: 'u1', email: 'u@example.com', roles }; + next(); + }); + app.use('/api', routes); + app.use(errorHandler); + return app; +}; + +const patch = (roles, payload) => request(buildApp(roles)).patch('/api/settings').send(payload); +const read = (roles) => request(buildApp(roles)).get('/api/settings'); + +/** + * One writable field per section that only an administrator may touch, with a + * value that differs from the default, so a change is visible either way. + */ +const SYSTEM_CHANGES = [ + ['thumbnails', { size: 321 }, (settings) => settings.thumbnails?.size], + ['uploads', { chunkedEnabled: true }, (settings) => settings.uploads?.chunkedEnabled], + ['branding', { appName: 'Renamed' }, (settings) => settings.branding?.appName], + [ + 'folderSize', + { excludedPaths: ['Sneaked/in'] }, + (settings) => settings.folderSize?.excludedPaths?.join(), + ], + [ + 'searchIndex', + { excludedPaths: ['Sneaked/in'] }, + (settings) => settings.searchIndex?.excludedPaths?.join(), + ], +]; + +describe('what only an administrator may change', () => { + /** + * Refused outright rather than quietly dropped. Answering 200 to a change + * that was not made is worse than saying no: the page that asked has no way + * to tell, and shows the value the person typed. + */ + it.each(SYSTEM_CHANGES)( + 'is refused, and unchanged, when a regular account asks: %s', + async (section, value, readBack) => { + await seed(); + const before = readBack((await read(['admin'])).body); + + const response = await patch(['user'], { [section]: value }); + + expect(response.status).toBe(403); + expect(readBack((await read(['admin'])).body)).toEqual(before); + } + ); + + it.each(SYSTEM_CHANGES)( + 'is applied when an administrator asks: %s', + async (section, value, readBack) => { + await seed(); + const before = readBack((await read(['admin'])).body); + + await patch(['admin'], { [section]: value }); + + const after = readBack((await read(['admin'])).body); + expect(after).not.toEqual(before); + } + ); + + /** + * A payload that mixes the two is refused whole, and the preference in it is + * not kept either. + * + * It used to be: the user section was applied first and the refusal raised + * afterwards, so this answered 403 with the preference already saved. A + * request reported as refused that changed something is the one answer a + * caller cannot act on. + */ + it('refuses a payload that mixes its own preference with a system one', async () => { + await seed(); + + const response = await patch(['user'], { + branding: { appName: 'Taken over' }, + user: { markdownOpensInEditor: true }, + }); + + expect(response.status).toBe(403); + expect((await read(['user'])).body.user?.markdownOpensInEditor).not.toBe(true); + expect((await read(['admin'])).body.branding?.appName).not.toBe('Taken over'); + }); + + it('takes a preference on its own from a regular account', async () => { + await seed(); + + const response = await patch(['user'], { user: { markdownOpensInEditor: true } }); + + expect(response.status).toBe(200); + expect(response.body.user?.markdownOpensInEditor).toBe(true); + }); +}); + +describe('what a value has to look like to be stored', () => { + it('takes a boolean from anything truthy, since a checkbox may send either', async () => { + await seed(); + + const response = await patch(['admin'], { thumbnails: { enabled: 'yes' } }); + + expect(response.body.thumbnails.enabled).toBe(true); + }); + + /** + * A size that is not a number is a size nobody chose. Storing it would put + * something that is not a pixel count where one belongs, and every thumbnail + * generated afterwards would carry it. + * + * Refused twice — once by the route and once by the service that stores it — + * so neither mutation alone fails this. Removing both does. Said out loud + * because a single surviving mutation reads like a gap and is not one. + */ + it.each([['not-a-number'], [null], [Infinity]])( + 'keeps the size it had when given %s', + async (size) => { + await seed(); + const before = (await read(['admin'])).body.thumbnails.size; + + await patch(['admin'], { thumbnails: { size } }); + + expect((await read(['admin'])).body.thumbnails.size).toBe(before); + } + ); + + it('takes a size that is a number', async () => { + await seed(); + + await patch(['admin'], { thumbnails: { size: 256 } }); + + expect((await read(['admin'])).body.thumbnails.size).toBe(256); + }); + + it('ignores a section that is not an object', async () => { + await seed(); + const before = (await read(['admin'])).body.thumbnails; + + const response = await patch(['admin'], { thumbnails: 'enabled please' }); + + expect(response.status).toBe(200); + expect((await read(['admin'])).body.thumbnails).toEqual(before); + }); +}); diff --git a/backend/tests/services/folder-preferences-as-rows.test.js b/backend/tests/services/folder-preferences-as-rows.test.js new file mode 100644 index 000000000..b447d1ae1 --- /dev/null +++ b/backend/tests/services/folder-preferences-as-rows.test.js @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * What a user chose for one folder, held as a row of its own. + * + * It used to be two JSON values per account under `user_settings` — one map of + * sorts, one of views — read and rewritten whole on every change. Three things + * followed from that, and all three are asserted here: + * + * - Two tabs open on different folders overwrote each other. Each sent the + * whole map, so whichever saved last won and the other folder's choice was + * gone. + * - The map had to be capped, because it shipped entire on every load and was + * rewritten entire on every change. The hundred-and-first folder silently + * forgot the oldest. + * - Nothing could clean it up: a deleted folder's preferences stayed behind on + * every account that had ever opened it. + * + * The carry-over is asserted too. An installation that has the old values keeps + * them: they are moved into rows, and the values they came from are removed so + * a later version cannot read them back. + */ + +const MODULES = ['src/services/db', 'src/services/settingsService']; + +let envContext; +let settingsService; +let dbService; + +beforeEach(async () => { + envContext = await setupTestEnv({ tag: 'folder-preferences-', modules: MODULES }); + dbService = envContext.requireFresh('src/services/db'); + settingsService = envContext.requireFresh('src/services/settingsService'); + + const db = await dbService.getDb(); + const now = new Date().toISOString(); + for (const id of ['u-1', 'u-2']) { + db.prepare('INSERT INTO users (id, email, created_at, updated_at) VALUES (?, ?, ?, ?)').run( + id, + `${id}@example.com`, + now, + now + ); + } +}); + +afterEach(async () => { + await envContext.cleanup(); +}); + +const stored = (userId = 'u-1') => settingsService.getUserSettings(userId); + +describe('a folder’s remembered sort and view', () => { + it('is saved one folder at a time, so another folder’s choice survives it', async () => { + await settingsService.setUserFolderSort('u-1', 'Projects', { by: 'name', order: 'desc' }); + await settingsService.setUserFolderSort('u-1', 'Music', { by: 'size', order: 'asc' }); + + expect((await stored()).folderSorts).toMatchObject({ + Projects: { by: 'name', order: 'desc' }, + Music: { by: 'size', order: 'asc' }, + }); + }); + + it('keeps the sort when the view of the same folder is set, and the other way round', async () => { + await settingsService.setUserFolderSort('u-1', 'Projects', { by: 'name', order: 'desc' }); + await settingsService.setUserFolderView('u-1', 'Projects', { mode: 'photos' }); + + const settings = await stored(); + expect(settings.folderSorts.Projects).toMatchObject({ by: 'name', order: 'desc' }); + expect(settings.folderViews.Projects).toMatchObject({ mode: 'photos' }); + }); + + it('is one account’s alone', async () => { + await settingsService.setUserFolderView('u-1', 'Projects', { mode: 'list' }); + await settingsService.setUserFolderView('u-2', 'Projects', { mode: 'grid' }); + + expect((await stored('u-1')).folderViews.Projects).toMatchObject({ mode: 'list' }); + expect((await stored('u-2')).folderViews.Projects).toMatchObject({ mode: 'grid' }); + }); + + /** + * The hundred-and-first folder. As one value per account this was a ceiling, + * and the oldest entry was dropped to stay under it; as rows there is nothing + * to stay under. + */ + it('is remembered past the hundred the single value could hold', async () => { + for (let n = 0; n < 120; n += 1) { + await settingsService.setUserFolderSort('u-1', `Folder-${n}`, { by: 'name', order: 'asc' }); + } + + const { folderSorts } = await stored(); + expect(Object.keys(folderSorts)).toHaveLength(120); + expect(folderSorts['Folder-0']).toMatchObject({ by: 'name', order: 'asc' }); + }); + + it('refuses a view mode there is no such thing as, rather than storing it', async () => { + await settingsService.setUserFolderView('u-1', 'Projects', { mode: 'grid' }); + await settingsService.setUserFolderView('u-1', 'Projects', { mode: 'sideways' }); + + expect((await stored()).folderViews.Projects).toMatchObject({ mode: 'grid' }); + }); +}); + +/** + * The installation that already had them. + * + * Built by putting the database back the way schema 19 left it — the two values + * under `user_settings`, no table of rows, the version stamped back — and then + * opening it again, which is the migration this batch adds. + */ +describe('preferences carried over from the single value per account', () => { + const T = 1756300000000; + + const asSchema19 = async () => { + const db = await dbService.getDb(); + const setting = (id, userId, key, value) => + db + .prepare( + 'INSERT INTO user_settings (id, user_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?)' + ) + .run(id, userId, key, value, new Date(T).toISOString()); + + setting( + 'us-1', + 'u-1', + 'folderSorts', + JSON.stringify({ + Projects: { by: 'name', order: 'desc', updatedAt: T }, + Docs: { by: 'size', order: 'asc', updatedAt: T + 100 }, + }) + ); + setting( + 'us-2', + 'u-1', + 'folderViews', + JSON.stringify({ Projects: { mode: 'grid', updatedAt: T + 200 } }) + ); + setting('us-3', 'u-2', 'folderSorts', '{ not json'); + setting('us-4', 'u-1', 'theme', '"dark"'); + + db.exec('DROP TABLE folder_preferences'); + db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run('schema_version', '19'); + await dbService.closeDb(); + + envContext.requireFresh('src/services/db'); + dbService = envContext.requireFresh('src/services/db'); + settingsService = envContext.requireFresh('src/services/settingsService'); + return dbService.getDb(); + }; + + it('makes one row per folder, merging the sort and the view under the later time', async () => { + const db = await asSchema19(); + + expect( + db + .prepare( + `SELECT user_id, path, sort_by, sort_order, view_mode + FROM folder_preferences ORDER BY user_id, path` + ) + .all() + ).toEqual([ + { user_id: 'u-1', path: 'Docs', sort_by: 'size', sort_order: 'asc', view_mode: null }, + { user_id: 'u-1', path: 'Projects', sort_by: 'name', sort_order: 'desc', view_mode: 'grid' }, + ]); + }); + + it('removes the values it read, including one it could not, and keeps the rest', async () => { + const db = await asSchema19(); + + expect(db.prepare('SELECT user_id, key FROM user_settings ORDER BY key').all()).toEqual([ + { user_id: 'u-1', key: 'theme' }, + ]); + }); + + it('serves them through what the application reads', async () => { + await asSchema19(); + + expect(await settingsService.getUserSettings('u-1')).toMatchObject({ + folderSorts: { + Docs: { by: 'size', order: 'asc' }, + Projects: { by: 'name', order: 'desc' }, + }, + folderViews: { Projects: { mode: 'grid' } }, + theme: 'dark', + }); + }); +}); diff --git a/backend/tests/services/settings-without-json.test.js b/backend/tests/services/settings-without-json.test.js new file mode 100644 index 000000000..d9416513d --- /dev/null +++ b/backend/tests/services/settings-without-json.test.js @@ -0,0 +1,130 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import Database from 'better-sqlite3'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Settings live in app.db, and only there. + * + * They used to be mirrored into app-config.json by one save path and read back + * from it whenever app.db could not be read. The screens save through another + * path, so the file stopped following the settings — and a read that failed ran + * with whatever the file held, usually no access rules at all: reproduced, a + * folder hidden by a rule answered `rw` for as long as the read kept failing. + * The file is still read once by the migrations that carry very old settings + * into app.db; nothing at runtime reads or writes it. + */ + +let envContext; + +afterEach(async () => { + vi.restoreAllMocks(); + if (envContext) await envContext.cleanup(); + envContext = null; +}); + +const setup = async () => { + envContext = await setupTestEnv({ tag: 'settings-without-json-' }); + const db = await envContext.requireFresh('src/services/db').getDb(); + const accessControl = envContext.requireFresh('src/services/accessControlService'); + const settings = envContext.requireFresh('src/services/settingsService'); + return { db, accessControl, settings, file: path.join(envContext.configDir, 'app-config.json') }; +}; + +/** + * Make the read of the system settings fail, the way a damaged database does. + * + * Only that read: the branding is read from the same table, and failing both + * would let a fallback in the system settings hide behind the branding's own + * failure. + */ +const breakSettingsReads = (db) => { + const proto = Object.getPrototypeOf(db.prepare('SELECT 1')); + for (const method of ['get', 'all']) { + const original = proto[method]; + vi.spyOn(proto, method).mockImplementation(function (...args) { + if (/FROM system_settings WHERE category = \?\s*$/.test(this.source)) { + throw Object.assign(new Error('database disk image is malformed'), { + code: 'SQLITE_CORRUPT', + }); + } + return original.apply(this, args); + }); + } +}; + +describe('access rules when the settings cannot be read', () => { + it('refuse to answer rather than let a hidden folder open', async () => { + const { db, accessControl } = await setup(); + await accessControl.setRules([{ path: 'Secret', recursive: true, permissions: 'hidden' }]); + expect(await accessControl.getPermissionForPath('Secret/plan.pdf')).toBe('hidden'); + + breakSettingsReads(db); + + await expect(accessControl.getPermissionForPath('Secret/plan.pdf')).rejects.toThrow( + /malformed/ + ); + }); + + it('do not fall back to an app-config.json left on disk, whatever it says', async () => { + const { db, accessControl, file } = await setup(); + await accessControl.setRules([{ path: 'Secret', recursive: true, permissions: 'hidden' }]); + fs.writeFileSync( + file, + JSON.stringify({ version: 4, settings: { access: { rules: [] } }, favorites: [] }) + ); + + breakSettingsReads(db); + + await expect(accessControl.getPermissionForPath('Secret/plan.pdf')).rejects.toThrow(); + }); +}); + +describe('app-config.json at runtime', () => { + it('is not created by reading the settings of a new installation', async () => { + const { settings, accessControl, file } = await setup(); + + await settings.getPublicSettings(); + await settings.getSettings(); + await accessControl.getRules(); + + expect(fs.existsSync(file)).toBe(false); + }); + + it('is not written by saving settings, and one already there is left as it was', async () => { + const { accessControl, settings, file } = await setup(); + const before = JSON.stringify({ version: 4, settings: {}, favorites: [] }); + fs.writeFileSync(file, before); + + await accessControl.setRules([{ path: 'Secret', recursive: true, permissions: 'ro' }]); + await settings.setSettings({ thumbnails: { quality: 60 } }); + + expect(fs.readFileSync(file, 'utf8')).toBe(before); + expect(await accessControl.getPermissionForPath('Secret/a.txt')).toBe('ro'); + }); + + it('gives the sign-in page the default branding when none was saved', async () => { + const { settings } = await setup(); + + const { branding } = await settings.getPublicSettings(); + + expect(branding).toEqual(expect.objectContaining({ appName: expect.any(String) })); + }); + + it('keeps the default branding when the saved one cannot be parsed', async () => { + const { db, settings } = await setup(); + db.prepare( + "INSERT INTO system_settings (id, category, key, value, updated_at) VALUES ('b', 'branding', 'branding', '{not json', ?)" + ).run(new Date().toISOString()); + + const { branding } = await settings.getPublicSettings(); + + expect(branding).toEqual(expect.objectContaining({ appName: expect.any(String) })); + }); +}); + +// Database is imported for its prototype only through the connection above. +void Database; diff --git a/backend/tests/services/settings.test.js b/backend/tests/services/settings.test.js index cce8f1696..ebbf26e37 100644 --- a/backend/tests/services/settings.test.js +++ b/backend/tests/services/settings.test.js @@ -1,11 +1,7 @@ import { describe, it, expect } from 'vitest'; import { setupTestEnv } from '../helpers/env-test-utils.js'; -const SETTINGS_MODULES = [ - 'src/services/storage/jsonStorage', - 'src/services/settingsService', - 'src/services/db', -]; +const SETTINGS_MODULES = ['src/services/settingsService', 'src/services/db']; const createSettingsContext = async () => { const envContext = await setupTestEnv({ @@ -29,6 +25,8 @@ describe('Settings Service', () => { expect(settings.thumbnails.size).toBe(200); expect(settings.thumbnails.quality).toBe(70); expect(settings.thumbnails.concurrency).toBe(10); + expect(settings.uploads.chunkedEnabled).toBe(false); + expect(settings.uploads.chunkSizeBytes).toBe(8 * 1024 * 1024); } finally { await envContext.cleanup(); } @@ -36,18 +34,15 @@ describe('Settings Service', () => { }); describe('setSettings', () => { - it('should sanitize thumbnails and filter access rules', async () => { + it('should sanitize thumbnails and uploads, and tidy a rule path', async () => { const { envContext, settingsService } = await createSettingsContext(); try { const payload = { thumbnails: { size: 5000, quality: 150, concurrency: -2 }, access: { - rules: [ - { path: '/Projects', permissions: 'ro', recursive: true }, - { path: 'uploads', permissions: 'invalid', recursive: false }, - { path: '../bad', permissions: 'hidden' }, - ], + rules: [{ path: '/Projects', permissions: 'ro', recursive: true }], }, + uploads: { chunkedEnabled: true, chunkSizeBytes: 512 }, }; const updated = await settingsService.setSettings(payload); @@ -56,9 +51,42 @@ describe('Settings Service', () => { expect(updated.thumbnails.quality).toBe(100); expect(updated.thumbnails.concurrency).toBe(1); expect(updated.thumbnails.enabled).toBe(true); - expect(updated.access.rules.length).toBe(2); + expect(updated.access.rules.length).toBe(1); expect(updated.access.rules[0].path).toBe('Projects'); - expect(updated.access.rules[1].permissions).toBe('rw'); + expect(updated.uploads.chunkedEnabled).toBe(true); + expect(updated.uploads.chunkSizeBytes).toBe(1024 * 1024); + } finally { + await envContext.cleanup(); + } + }); + + /** + * A number out of its bounds is brought within them, because every value in + * the range means the same kind of thing. A rule is not like that: there is + * no nearest valid folder for `../bad`, and the nearest valid permissions + * for a misspelt `readonly` used to be `rw` — the opposite of what was + * meant. Both are answered instead, and nothing is stored. + */ + it('should refuse an access rule it cannot store rather than repair it', async () => { + const { envContext, settingsService } = await createSettingsContext(); + try { + await settingsService.setSettings({ + access: { rules: [{ path: 'Projects', permissions: 'ro', recursive: true }] }, + }); + + await expect( + settingsService.setSettings({ + access: { rules: [{ path: 'uploads', permissions: 'invalid', recursive: false }] }, + }) + ).rejects.toThrow(/is not one of the permissions/); + await expect( + settingsService.setSettings({ + access: { rules: [{ path: '../bad', permissions: 'hidden' }] }, + }) + ).rejects.toThrow(/Traversal outside the volume root/); + + const { access } = await settingsService.getSystemSettings(); + expect(access.rules).toEqual([expect.objectContaining({ path: 'Projects' })]); } finally { await envContext.cleanup(); } @@ -79,17 +107,143 @@ describe('Settings Service', () => { ).run('user-1', 'user-1@example.com', 1, 'user-1', 'User 1', '["user"]', now, now); await settingsService.setUserSetting('user-1', 'showSidebarFavorites', false); - await settingsService.setUserSetting('user-1', 'showSidebarShares', 0); - await settingsService.setUserSetting('user-1', 'showSidebarTools', 'yes'); + await settingsService.setUserSetting('user-1', 'showSidebarShares', true); + // Whether a .md file opens in the editor rather than the preview (#347) + // is a per-user choice, and a boolean like the others. + await settingsService.setUserSetting('user-1', 'markdownOpensInEditor', true); + + // Anything that is not a boolean is not an answer, and is not stored: + // `Boolean('yes')` used to store true and `Boolean(0)` false, in place + // of what the person had chosen. + expect( + await settingsService.setUserSetting('user-1', 'showSidebarShares', 0) + ).toBeUndefined(); + expect( + await settingsService.setUserSetting('user-1', 'showSidebarTools', 'yes') + ).toBeUndefined(); const settings = await settingsService.getUserSettings('user-1'); expect(settings.showSidebarFavorites).toBe(false); - expect(settings.showSidebarShares).toBe(false); - expect(settings.showSidebarTools).toBe(true); + expect(settings.showSidebarShares).toBe(true); + // Never stored, so the client's own default is what applies. + expect(settings.showSidebarTools).toBeUndefined(); + expect(settings.markdownOpensInEditor).toBe(true); + } finally { + await envContext.cleanup(); + } + }); + }); + + describe('folder sorts', () => { + it('keeps every folder a user has set a preference on', async () => { + // The cap existed because these lived in one JSON blob, rewritten whole + // on every change: past a hundred folders the oldest was silently + // forgotten. As rows there is nothing to cap, and nothing to forget. + const { envContext, settingsService, dbService } = await createSettingsContext(); + try { + const db = await dbService.getDb(); + const now = new Date().toISOString(); + db.prepare( + ` + INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run('user-1', 'user-1@example.com', 1, 'user-1', 'User 1', '["user"]', now, now); + + for (let index = 0; index < 150; index += 1) { + await settingsService.setUserFolderSort('user-1', `Projects/folder-${index}`, { + by: 'customColumn', + order: 'desc', + }); + } + + const settings = await settingsService.getUserSettings('user-1'); + + expect(Object.keys(settings.folderSorts)).toHaveLength(150); + expect(settings.folderSorts['Projects/folder-0']).toMatchObject({ + by: 'customColumn', + order: 'desc', + }); } finally { await envContext.cleanup(); } }); + + it('keeps a folder sort and its view side by side', async () => { + // One row carries both, so setting one must not wipe the other. + const { envContext, settingsService, dbService } = await createSettingsContext(); + try { + const db = await dbService.getDb(); + const now = new Date().toISOString(); + db.prepare( + ` + INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run('user-1', 'user-1@example.com', 1, 'user-1', 'User 1', '["user"]', now, now); + + await settingsService.setUserFolderSort('user-1', 'Photos', { by: 'name', order: 'asc' }); + await settingsService.setUserFolderView('user-1', 'Photos', { mode: 'photos' }); + + const settings = await settingsService.getUserSettings('user-1'); + + expect(settings.folderSorts.Photos).toMatchObject({ by: 'name', order: 'asc' }); + expect(settings.folderViews.Photos).toMatchObject({ mode: 'photos' }); + } finally { + await envContext.cleanup(); + } + }); + }); +}); + +/** + * A path written into the compose file has to reach the page that shows it. + * + * It did not: the server sent it and the browser dropped it, because the + * settings store copies system settings field by field and nobody added the + * new one. Both halves are covered now — this end, and the store's own test. + */ +describe('exclusions that come from the environment', () => { + it('reports the search index exclusions the environment set', async () => { + const envContext = await setupTestEnv({ + tag: 'settings-search-index-', + modules: [...SETTINGS_MODULES, 'src/services/searchIndexExclusions'], + env: { SEARCH_INDEX: 'true', SEARCH_INDEX_EXCLUDE: 'Stacks/docker, Sauvegardes/2024' }, + }); + try { + const settingsService = envContext.requireFresh('src/services/settingsService'); + const settings = await settingsService.getSettings(); + + expect(settings.searchIndex.environmentExcludedPaths).toEqual([ + 'Sauvegardes/2024', + 'Stacks/docker', + ]); + // The environment's list is not the administrator's, and neither is + // shown in place of the other. + expect(settings.searchIndex.excludedPaths).toEqual([]); + } finally { + await envContext.cleanup(); + } + }); + + it('keeps the two lists apart when an administrator adds one', async () => { + const envContext = await setupTestEnv({ + tag: 'settings-search-index-', + modules: [...SETTINGS_MODULES, 'src/services/searchIndexExclusions'], + env: { SEARCH_INDEX: 'true', SEARCH_INDEX_EXCLUDE: 'Stacks/docker' }, + }); + try { + const settingsService = envContext.requireFresh('src/services/settingsService'); + await settingsService.setSystemSetting('system', 'searchIndex', { + excludedPaths: ['Photos/RAW'], + }); + + const settings = await settingsService.getSettings(); + expect(settings.searchIndex.excludedPaths).toEqual(['Photos/RAW']); + expect(settings.searchIndex.environmentExcludedPaths).toEqual(['Stacks/docker']); + } finally { + await envContext.cleanup(); + } }); }); diff --git a/backend/tests/services/trash-settings.test.js b/backend/tests/services/trash-settings.test.js index 0e08adbe9..20210a38a 100644 --- a/backend/tests/services/trash-settings.test.js +++ b/backend/tests/services/trash-settings.test.js @@ -126,16 +126,14 @@ describe('changing them', () => { expect(response.body.trash).toMatchObject({ retentionDays: 60, maxPercent: 20 }); }); - it('is not changed by anyone but an administrator', async () => { - // The settings route ignores every system section a non-admin sends, trash - // included: the request is accepted but nothing system-wide is written. + it('is refused to everyone else, with nothing written', async () => { const app = await buildApp({ id: 'user', roles: ['user'] }); const response = await request(app) .patch('/api/settings') .send({ trash: { enabled: false } }); - expect(response.body.trash).toBeUndefined(); + expect(response.status).toBe(403); const settingsService = envContext.requireFresh('src/services/settingsService'); expect((await settingsService.getSystemSettings()).trash.enabled).toBe(true); }); diff --git a/frontend/src/api/settings.api.js b/frontend/src/api/settings.api.js index 86bf80d2f..a209def88 100644 --- a/frontend/src/api/settings.api.js +++ b/frontend/src/api/settings.api.js @@ -17,6 +17,17 @@ export async function patchSettings(partial) { }); } +/** + * Make an image the logo, with the rest of the branding in the same request: + * the server stores both, or neither. Answers the settings, as a patch does. + */ +export async function uploadLogo(file, branding) { + const form = new FormData(); + if (branding) form.append('branding', JSON.stringify(branding)); + form.append('logo', file); + return requestJson('/api/settings/upload-logo', { method: 'POST', body: form }); +} + /** * What each path of an access rule names on the disk, so the rule editor can * warn about one that names nothing and offer the folder probably meant. diff --git a/frontend/src/composables/navigation.js b/frontend/src/composables/navigation.js index 3274d6ba2..ea01069a9 100644 --- a/frontend/src/composables/navigation.js +++ b/frontend/src/composables/navigation.js @@ -5,6 +5,10 @@ import { usePreviewManager } from '@/plugins/preview/manager'; import { useAppSettings } from '@/stores/appSettings'; import { documentRoute } from '@/utils/documentRoute'; +// Kept beside the markdown preview plugin's own list, which matches the same +// two extensions. +const MARKDOWN_EXTENSIONS = ['md', 'markdown']; + export function useNavigation() { const router = useRouter(); const route = useRoute(); @@ -46,6 +50,16 @@ export function useNavigation() { const extensionFromName = name.includes('.') ? name.split('.').pop().toLowerCase() : ''; const editable = isEditableExtension(extensionFromKind) || isEditableExtension(extensionFromName); + + // Markdown is the one kind of file that has both a preview and an editor, + // so it is the only one where opening it is a choice. Whoever mostly writes + // markdown was going through the preview and clicking Edit every time; this + // sends them straight where they were heading. Everything else keeps + // preview-first: an image or a video has no editor to go to. + const opensInEditor = + appSettings.userSettings?.markdownOpensInEditor && + (MARKDOWN_EXTENSIONS.includes(extensionFromKind) || + MARKDOWN_EXTENSIONS.includes(extensionFromName)); const basePath = item.path ? `${item.path}/${name}` : name; const fullPath = basePath.replace(/^\/+/, ''); const encodedPath = fullPath.split('/').map(encodeURIComponent).join('/'); @@ -60,10 +74,10 @@ export function useNavigation() { // being handed one of them instead of this page filling itself. if (appSettings.userSettings?.documentsOpenInNewTab) { // Asked once: matching a plugin builds a context and walks the list. - const previewable = Boolean(previewManager.findPlugin(item)); + const previewable = !opensInEditor && Boolean(previewManager.findPlugin(item)); const target = previewable ? documentRoute(fullPath) - : editable + : opensInEditor || editable ? { path: `/editor/${encodedPath}` } : null; @@ -76,7 +90,7 @@ export function useNavigation() { } // Files: try preview first (no view transition – avoids double animations) - if (previewManager.open(item)) { + if (!opensInEditor && previewManager.open(item)) { return; } diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index c12addea8..bf2369925 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Zeigt Dateien und Ordner an, die den konfigurierten versteckten Mustern ({patterns}) entsprechen.", "showThumbnails": "Vorschaubilder anzeigen", "showThumbnailsHelp": "Zeigt Vorschaubilder für Bilder und Videos im Explorer an.", + "markdownOpensInEditor": "Markdown im Editor öffnen", + "markdownOpensInEditorHelp": "Ein Doppelklick auf eine .md-Datei öffnet direkt den Editor statt der Vorschau.", "showSidebarFavorites": "Favoritenbereich anzeigen", "showSidebarFavoritesHelp": "Zeigt Favoriten in der linken Seitenleiste an.", "showSidebarShares": "Freigabenbereich anzeigen", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Zeigt Werkzeuge in der linken Seitenleiste an.", "defaultShareExpiration": "Standard-Ablaufdatum für Freigaben", "defaultShareExpirationHelp": "Legen Sie einen Standardzeitraum für neue Freigaben fest. Das Ablaufdatum wird beim Erstellen automatisch entsprechend vorbelegt.", + "defaultShareExpirationInvalid": "Geben Sie eine ganze Zahl ab 1 ein, oder leeren Sie das Feld, um keinen Standard festzulegen.", "expirationValue": "Wert", "days": "Tage", "weeks": "Wochen", @@ -518,10 +521,16 @@ "skipHome": "Startseite überspringen", "skipHomeHelp": "Leitet beim Besuch der Startseite automatisch zur ersten Volume weiter. Wenn nicht gesetzt, wird die Serverkonfiguration verwendet.", "useEnvSetting": "Servereinstellung verwenden", - "showVersionMarks": "Dateien mit Versionen kennzeichnen", - "showVersionMarksHelp": "Ein kleines Zeichen in der Liste bei Dateien mit früheren Versionen, mit deren Anzahl. Ein Klick öffnet den Verlauf.", + "defaultView": "Standardansicht", + "defaultViewHelp": "Die Ansicht für Ordner ohne eigene gespeicherte Ansicht.", + "viewGrid": "Raster", + "viewList": "Liste", + "viewColumns": "Spalten", + "viewPhotos": "Fotos", "documentsOpenInNewTab": "Dokumente in einem neuen Tab öffnen", "documentsOpenInNewTabHelp": "Eine Datei bekommt beim Öffnen einen eigenen Browser-Tab, sodass mehrere offen bleiben, während Sie weiter stöbern. Aus öffnet sie sich wie bisher über dem Ordner.", + "showVersionMarks": "Dateien mit Versionen kennzeichnen", + "showVersionMarksHelp": "Ein kleines Zeichen in der Liste bei Dateien mit früheren Versionen, mit deren Anzahl. Ein Klick öffnet den Verlauf.", "languageHelp": "Wird bei Ihrem Konto gespeichert, in jedem Browser, in dem Sie sich anmelden." }, "thumbs": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 52268b689..7ce64d66e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Display files and folders matching the configured hidden patterns ({patterns}) in the file browser.", "showThumbnails": "Show thumbnails", "showThumbnailsHelp": "Display thumbnail previews for images and videos in the file browser.", + "markdownOpensInEditor": "Open Markdown in the editor", + "markdownOpensInEditorHelp": "Double-clicking a .md file goes straight to the editor instead of the preview.", "showSidebarFavorites": "Show Favorites section", "showSidebarFavoritesHelp": "Display Favorites in the left sidebar.", "showSidebarShares": "Show Shares section", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Display Tools in the left sidebar.", "defaultShareExpiration": "Default share expiration", "defaultShareExpirationHelp": "Set a default expiration period for new shares. When creating a share, the expiration date will be pre-filled based on this setting.", + "defaultShareExpirationInvalid": "Enter a whole number of at least 1, or clear the field for no default.", "expirationValue": "Value", "days": "Days", "weeks": "Weeks", @@ -518,10 +521,16 @@ "skipHome": "Skip home page", "skipHomeHelp": "Automatically redirect to the first volume when visiting the home page. If not set, follows the server configuration.", "useEnvSetting": "Use server setting", - "showVersionMarks": "Mark files that have versions", - "showVersionMarksHelp": "A small mark in the listing on any file with earlier versions, with how many. Click it to open the history.", + "defaultView": "Default view", + "defaultViewHelp": "The view a folder opens in when it has no remembered view of its own.", + "viewGrid": "Grid", + "viewList": "List", + "viewColumns": "Columns", + "viewPhotos": "Photos", "documentsOpenInNewTab": "Open documents in a new tab", "documentsOpenInNewTabHelp": "Opening a file gives it a browser tab of its own, so several stay open while you keep browsing. Off, it opens over the folder as it does today.", + "showVersionMarks": "Mark files that have versions", + "showVersionMarksHelp": "A small mark in the listing on any file with earlier versions, with how many. Click it to open the history.", "languageHelp": "Kept with your account, on every browser you sign in from." }, "thumbs": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index b5e3c40db..052a72abd 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Muestra archivos y carpetas que coinciden con los patrones ocultos configurados ({patterns}) en el explorador.", "showThumbnails": "Mostrar miniaturas", "showThumbnailsHelp": "Muestra previsualizaciones en miniatura para imágenes y vídeos.", + "markdownOpensInEditor": "Abrir Markdown en el editor", + "markdownOpensInEditorHelp": "Al hacer doble clic en un archivo .md se abre directamente el editor en lugar de la vista previa.", "showSidebarFavorites": "Mostrar sección Favoritos", "showSidebarFavoritesHelp": "Muestra Favoritos en la barra lateral izquierda.", "showSidebarShares": "Mostrar sección Compartidos", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Muestra Herramientas en la barra lateral izquierda.", "defaultShareExpiration": "Caducidad predeterminada para compartir", "defaultShareExpirationHelp": "Establece un período por defecto para nuevas comparticiones. La fecha de caducidad se rellena automáticamente basándose en este valor.", + "defaultShareExpirationInvalid": "Introduce un número entero de al menos 1, o vacía el campo para no tener caducidad predeterminada.", "expirationValue": "Valor", "days": "Días", "weeks": "Semanas", @@ -518,10 +521,16 @@ "skipHome": "Saltar inicio", "skipHomeHelp": "Redirige automáticamente a la primera unidad al visitar la pantalla de inicio. Si no se establece, usa la configuración del servidor.", "useEnvSetting": "Usar configuración del servidor", - "showVersionMarks": "Marcar los archivos con versiones", - "showVersionMarksHelp": "Una pequeña marca en la lista sobre los archivos con versiones anteriores, con su número. Haga clic para abrir el historial.", + "defaultView": "Vista predeterminada", + "defaultViewHelp": "La vista con la que se abre una carpeta que no tiene vista guardada.", + "viewGrid": "Cuadrícula", + "viewList": "Lista", + "viewColumns": "Columnas", + "viewPhotos": "Fotos", "documentsOpenInNewTab": "Abrir los documentos en una pestaña nueva", "documentsOpenInNewTabHelp": "Al abrir un archivo se le da su propia pestaña del navegador, así varios siguen abiertos mientras usted sigue navegando. Desactivado, se abre sobre la carpeta como hasta ahora.", + "showVersionMarks": "Marcar los archivos con versiones", + "showVersionMarksHelp": "Una pequeña marca en la lista sobre los archivos con versiones anteriores, con su número. Haga clic para abrir el historial.", "languageHelp": "Se guarda con su cuenta, en cualquier navegador desde el que inicie sesión." }, "thumbs": { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index d0c22af94..79d5e2b37 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Affiche les fichiers et dossiers correspondant aux patterns cachés configurés ({patterns}) dans l’explorateur.", "showThumbnails": "Afficher les miniatures", "showThumbnailsHelp": "Affiche des aperçus miniatures pour les images et vidéos.", + "markdownOpensInEditor": "Ouvrir le Markdown dans l'éditeur", + "markdownOpensInEditorHelp": "Un double-clic sur un fichier .md ouvre directement l'éditeur au lieu de l'aperçu.", "showSidebarFavorites": "Afficher la section Favoris", "showSidebarFavoritesHelp": "Affiche les Favoris dans la barre latérale gauche.", "showSidebarShares": "Afficher la section Partages", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Affiche les Outils dans la barre latérale gauche.", "defaultShareExpiration": "Expiration par défaut des partages", "defaultShareExpirationHelp": "Définissez un délai par défaut pour les nouveaux partages. La date d’expiration est pré-remplie selon ce délai.", + "defaultShareExpirationInvalid": "Indiquez un nombre entier d’au moins 1, ou videz le champ pour ne pas fixer d’expiration par défaut.", "expirationValue": "Valeur", "days": "Jours", "weeks": "Semaines", @@ -518,10 +521,16 @@ "skipHome": "Passer l’accueil", "skipHomeHelp": "Redirige automatiquement vers le premier volume depuis l’accueil. Si non défini, utilise la configuration serveur.", "useEnvSetting": "Utiliser la configuration serveur", - "showVersionMarks": "Signaler les fichiers qui ont des versions", - "showVersionMarksHelp": "Une petite marque dans la liste sur les fichiers qui ont des versions antérieures, avec leur nombre. Cliquez dessus pour ouvrir l’historique.", + "defaultView": "Vue par défaut", + "defaultViewHelp": "La vue utilisée à l'ouverture d'un dossier qui n'a pas de vue mémorisée.", + "viewGrid": "Grille", + "viewList": "Liste", + "viewColumns": "Colonnes", + "viewPhotos": "Photos", "documentsOpenInNewTab": "Ouvrir les documents dans un nouvel onglet", "documentsOpenInNewTabHelp": "Ouvrir un fichier lui donne son propre onglet de navigateur : plusieurs restent ouverts pendant que vous continuez à naviguer. Désactivé, il s’ouvre par-dessus le dossier comme aujourd’hui.", + "showVersionMarks": "Signaler les fichiers qui ont des versions", + "showVersionMarksHelp": "Une petite marque dans la liste sur les fichiers qui ont des versions antérieures, avec leur nombre. Cliquez dessus pour ouvrir l’historique.", "languageHelp": "Conservée avec votre compte, sur chaque navigateur où vous vous connectez." }, "thumbs": { diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 529d4a561..ce45df6da 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "एक्सप्लोरर में कॉन्फ़िगर किए गए छिपे पैटर्न ({patterns}) से मेल खाने वाली फ़ाइलें और फ़ोल्डर दिखाएँ।", "showThumbnails": "थंबनेल दिखाएँ", "showThumbnailsHelp": "इमेज और वीडियो के लिए थंबनेल पूर्वावलोकन दिखाएँ।", + "markdownOpensInEditor": "Markdown को संपादक में खोलें", + "markdownOpensInEditorHelp": "किसी .md फ़ाइल पर डबल-क्लिक करने पर पूर्वावलोकन के बजाय सीधे संपादक खुलता है।", "showSidebarFavorites": "पसंदीदा सेक्शन दिखाएँ", "showSidebarFavoritesHelp": "बाएँ साइडबार में पसंदीदा दिखाएँ।", "showSidebarShares": "शेयर सेक्शन दिखाएँ", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "बाएँ साइडबार में टूल्स दिखाएँ।", "defaultShareExpiration": "शेयर के लिए डिफ़ॉल्ट समाप्ति", "defaultShareExpirationHelp": "नई शेयरों के लिए एक डिफ़ॉल्ट अवधि सेट करें। यह सेटिंग शेयर बनाने पर समाप्ति तिथि को पूर्व-भरा रखेगी।", + "defaultShareExpirationInvalid": "कम से कम 1 की पूर्ण संख्या दर्ज करें, या कोई डिफ़ॉल्ट न रखने के लिए फ़ील्ड खाली करें।", "expirationValue": "मान", "days": "दिन", "weeks": "सप्ताह", @@ -518,10 +521,16 @@ "skipHome": "होम पेज छोड़ें", "skipHomeHelp": "होम पेज पर आने पर पहले वॉल्यूम पर स्वचालित रूप से रीडायरेक्ट करें। यदि सेट नहीं है, तो सर्वर कॉन्फ़िगरेशन का पालन करें।", "useEnvSetting": "सर्वर सेटिंग का उपयोग करें", - "showVersionMarks": "संस्करण वाली फ़ाइलों पर निशान लगाएँ", - "showVersionMarksHelp": "सूची में उन फ़ाइलों पर एक छोटा निशान जिनके पुराने संस्करण हैं, उनकी संख्या के साथ। इतिहास खोलने के लिए उस पर क्लिक करें।", + "defaultView": "डिफ़ॉल्ट दृश्य", + "defaultViewHelp": "ऐसे फ़ोल्डर के लिए दृश्य जिसका अपना सहेजा गया दृश्य नहीं है।", + "viewGrid": "ग्रिड", + "viewList": "सूची", + "viewColumns": "कॉलम", + "viewPhotos": "तस्वीरें", "documentsOpenInNewTab": "दस्तावेज़ नए टैब में खोलें", "documentsOpenInNewTabHelp": "कोई फ़ाइल खोलने पर उसे अपना ब्राउज़र टैब मिलता है, इसलिए ब्राउज़ करते हुए कई खुले रह सकते हैं। बंद होने पर वह आज की तरह फ़ोल्डर के ऊपर खुलती है।", + "showVersionMarks": "संस्करण वाली फ़ाइलों पर निशान लगाएँ", + "showVersionMarksHelp": "सूची में उन फ़ाइलों पर एक छोटा निशान जिनके पुराने संस्करण हैं, उनकी संख्या के साथ। इतिहास खोलने के लिए उस पर क्लिक करें।", "languageHelp": "आपके खाते के साथ सहेजा जाता है, हर उस ब्राउज़र में जहाँ आप साइन इन करते हैं।" }, "thumbs": { diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 51fc1e1d6..b8a928ac4 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Mostra file e cartelle che corrispondono ai pattern nascosti configurati ({patterns}) nell'esploratore.", "showThumbnails": "Mostra miniature", "showThumbnailsHelp": "Mostra anteprime miniatura per immagini e video.", + "markdownOpensInEditor": "Apri Markdown nell'editor", + "markdownOpensInEditorHelp": "Un doppio clic su un file .md apre direttamente l'editor invece dell'anteprima.", "showSidebarFavorites": "Mostra sezione Preferiti", "showSidebarFavoritesHelp": "Mostra i Preferiti nella barra laterale sinistra.", "showSidebarShares": "Mostra sezione Condivisioni", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Mostra gli Strumenti nella barra laterale sinistra.", "defaultShareExpiration": "Scadenza predefinita per la condivisione", "defaultShareExpirationHelp": "Imposta un intervallo predefinito per le nuove condivisioni. La data di scadenza viene precompilata secondo questo valore.", + "defaultShareExpirationInvalid": "Inserisci un numero intero di almeno 1, oppure svuota il campo per non avere una scadenza predefinita.", "expirationValue": "Valore", "days": "Giorni", "weeks": "Settimane", @@ -518,10 +521,16 @@ "skipHome": "Salta la home", "skipHomeHelp": "Reindirizza automaticamente al primo volume quando si visita la home. Se non impostato, usa la configurazione del server.", "useEnvSetting": "Usa impostazione del server", - "showVersionMarks": "Segnalare i file con versioni", - "showVersionMarksHelp": "Un piccolo segno nell’elenco sui file con versioni precedenti, con il loro numero. Un clic apre la cronologia.", + "defaultView": "Vista predefinita", + "defaultViewHelp": "La vista con cui si apre una cartella senza vista memorizzata.", + "viewGrid": "Griglia", + "viewList": "Elenco", + "viewColumns": "Colonne", + "viewPhotos": "Foto", "documentsOpenInNewTab": "Aprire i documenti in una nuova scheda", "documentsOpenInNewTabHelp": "Aprire un file gli dà una scheda del browser tutta sua, così più documenti restano aperti mentre continuate a navigare. Spento, si apre sopra la cartella come oggi.", + "showVersionMarks": "Segnalare i file con versioni", + "showVersionMarksHelp": "Un piccolo segno nell’elenco sui file con versioni precedenti, con il loro numero. Un clic apre la cronologia.", "languageHelp": "Salvata con il tuo account, su ogni browser da cui accedi." }, "thumbs": { diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 47ed5f923..f877a56f4 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "파일 탐색기에서 구성된 숨김 패턴({patterns})과 일치하는 파일과 폴더를 표시합니다.", "showThumbnails": "썸네일 표시", "showThumbnailsHelp": "파일 탐색기에서 사진과 영상 파일의 미리보기 썸네일을 표시합니다.", + "markdownOpensInEditor": "Markdown을 편집기에서 열기", + "markdownOpensInEditorHelp": ".md 파일을 두 번 클릭하면 미리 보기 대신 편집기가 바로 열립니다.", "showSidebarFavorites": "즐겨찾기 섹션 표시", "showSidebarFavoritesHelp": "왼쪽 사이드바에 즐겨찾기를 표시합니다.", "showSidebarShares": "공유 섹션 표시", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "왼쪽 사이드바에 도구를 표시합니다.", "defaultShareExpiration": "기본 공유 기한", "defaultShareExpirationHelp": "새 공유를 만들 때 사용할 기본 공유 기한을 지정하세요. 공유를 생성할 때 이 설정 값을 바탕으로 기본값이 채워집니다.", + "defaultShareExpirationInvalid": "1 이상의 정수를 입력하거나, 기본 기한을 두지 않으려면 입력란을 비우세요.", "expirationValue": "기간", "days": "일", "weeks": "주", @@ -518,10 +521,16 @@ "skipHome": "홈페이지 건너뛰기", "skipHomeHelp": "접속했을 때, 자동으로 첫 번째 볼륨으로 이동합니다. 설정하지 않을 경우 서버 설정을 따릅니다.", "useEnvSetting": "서버 설정 사용", - "showVersionMarks": "버전이 있는 파일 표시", - "showVersionMarksHelp": "이전 버전이 있는 파일에 개수와 함께 목록에서 작은 표시를 붙입니다. 누르면 기록이 열립니다.", + "defaultView": "기본 보기", + "defaultViewHelp": "저장된 보기가 없는 폴더에 사용할 보기입니다.", + "viewGrid": "그리드", + "viewList": "목록", + "viewColumns": "열", + "viewPhotos": "사진", "documentsOpenInNewTab": "문서를 새 탭에서 열기", "documentsOpenInNewTabHelp": "파일을 열면 브라우저 탭이 따로 생기므로, 계속 탐색하는 동안 여러 개를 열어 둘 수 있습니다. 끄면 지금처럼 폴더 위에서 열립니다.", + "showVersionMarks": "버전이 있는 파일 표시", + "showVersionMarksHelp": "이전 버전이 있는 파일에 개수와 함께 목록에서 작은 표시를 붙입니다. 누르면 기록이 열립니다.", "languageHelp": "계정에 저장되어 로그인하는 모든 브라우저에 적용됩니다." }, "thumbs": { diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 939b4d139..99cfb6b09 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Toon bestanden en mappen die overeenkomen met de geconfigureerde verborgen patronen ({patterns}) in de bestandsbrowser.", "showThumbnails": "Miniaturen weergeven", "showThumbnailsHelp": "Miniatuurvoorbeelden van afbeeldingen en video's weergeven in de bestandsbrowser.", + "markdownOpensInEditor": "Markdown openen in de editor", + "markdownOpensInEditorHelp": "Dubbelklikken op een .md-bestand opent direct de editor in plaats van het voorbeeld.", "showSidebarFavorites": "Sectie Favorieten weergeven", "showSidebarFavoritesHelp": "Toon Favorieten in de linker zijbalk.", "showSidebarShares": "Sectie Shares weergeven", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Toon Hulpmiddelen in de linker zijbalk.", "defaultShareExpiration": "Standaard verlooptijd bij Delen", "defaultShareExpirationHelp": "Stel een standaard vervaldatum in voor nieuwe shares. Bij het aanmaken van een share wordt de vervaldatum automatisch ingevuld op basis van deze instelling.", + "defaultShareExpirationInvalid": "Vul een geheel getal van minstens 1 in, of maak het veld leeg voor geen standaard verlooptijd.", "expirationValue": "Waarde", "days": "Dagen", "weeks": "Weken", @@ -518,10 +521,16 @@ "skipHome": "Startpagina overslaan", "skipHomeHelp": "Automatisch doorsturen naar het eerste volume bij het bezoeken van de startpagina. Als dit niet is ingesteld, wordt de serverconfiguratie gevolgd.", "useEnvSetting": "Serverconfiguratie gebruiken", - "showVersionMarks": "Bestanden met versies markeren", - "showVersionMarksHelp": "Een klein teken in de lijst bij bestanden met eerdere versies, met het aantal. Klik erop om de geschiedenis te openen.", + "defaultView": "Standaardweergave", + "defaultViewHelp": "De weergave waarin een map opent als er voor die map geen weergave is onthouden.", + "viewGrid": "Raster", + "viewList": "Lijst", + "viewColumns": "Kolommen", + "viewPhotos": "Foto's", "documentsOpenInNewTab": "Documenten in een nieuw tabblad openen", "documentsOpenInNewTabHelp": "Een bestand openen geeft het een eigen browsertabblad, zodat er meerdere open blijven terwijl u verder bladert. Uit opent het over de map heen, zoals nu.", + "showVersionMarks": "Bestanden met versies markeren", + "showVersionMarksHelp": "Een klein teken in de lijst bij bestanden met eerdere versies, met het aantal. Klik erop om de geschiedenis te openen.", "languageHelp": "Wordt bij uw account bewaard, in elke browser waarmee u zich aanmeldt." }, "thumbs": { diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 9e78b67c6..15fc115ce 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Pokazuje pliki i foldery pasujące do skonfigurowanych ukrytych wzorców ({patterns}) w eksploratorze.", "showThumbnails": "Pokaż miniatury", "showThumbnailsHelp": "Wyświetla miniatury obrazów i filmów w przeglądarce.", + "markdownOpensInEditor": "Otwieraj Markdown w edytorze", + "markdownOpensInEditorHelp": "Dwukrotne kliknięcie pliku .md otwiera od razu edytor zamiast podglądu.", "showSidebarFavorites": "Pokaż sekcję Ulubione", "showSidebarFavoritesHelp": "Wyświetla Ulubione na lewym pasku bocznym.", "showSidebarShares": "Pokaż sekcję Udostępnienia", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Wyświetla Narzędzia na lewym pasku bocznym.", "defaultShareExpiration": "Domyślny czas wygaśnięcia udostępnień", "defaultShareExpirationHelp": "Ustaw domyślny okres dla nowych udostępnień. Data wygaśnięcia zostanie wstępnie ustawiona zgodnie z tym okresem.", + "defaultShareExpirationInvalid": "Podaj liczbę całkowitą nie mniejszą niż 1 albo wyczyść pole, aby nie ustawiać domyślnego czasu.", "expirationValue": "Wartość", "days": "Dni", "weeks": "Tygodnie", @@ -518,10 +521,16 @@ "skipHome": "Pomiń stronę główną", "skipHomeHelp": "Automatycznie przekierowuje do pierwszego wolumenu przy otwieraniu strony głównej. Jeśli nie ustawiono, stosuje konfigurację serwera.", "useEnvSetting": "Użyj ustawień serwera", - "showVersionMarks": "Oznaczaj pliki, które mają wersje", - "showVersionMarksHelp": "Mały znak na liście przy plikach z wcześniejszymi wersjami, wraz z ich liczbą. Kliknięcie otwiera historię.", + "defaultView": "Widok domyślny", + "defaultViewHelp": "Widok folderu, który nie ma własnego zapamiętanego widoku.", + "viewGrid": "Siatka", + "viewList": "Lista", + "viewColumns": "Kolumny", + "viewPhotos": "Zdjęcia", "documentsOpenInNewTab": "Otwieraj dokumenty w nowej karcie", "documentsOpenInNewTabHelp": "Otwarcie pliku daje mu własną kartę przeglądarki, więc kilka pozostaje otwartych, gdy przeglądasz dalej. Wyłączone — otwiera się nad folderem, tak jak dziś.", + "showVersionMarks": "Oznaczaj pliki, które mają wersje", + "showVersionMarksHelp": "Mały znak na liście przy plikach z wcześniejszymi wersjami, wraz z ich liczbą. Kliknięcie otwiera historię.", "languageHelp": "Zapisywany przy koncie, w każdej przeglądarce, w której się logujesz." }, "thumbs": { diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index fcaaba56f..2b7e694fe 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Exibir arquivos e pastas que correspondem aos padrões ocultos configurados ({patterns}) no navegador de arquivos.", "showThumbnails": "Mostrar miniaturas", "showThumbnailsHelp": "Exibir pré-visualizações em miniatura de imagens e vídeos no navegador de arquivos.", + "markdownOpensInEditor": "Abrir Markdown no editor", + "markdownOpensInEditorHelp": "Um duplo clique em um arquivo .md abre direto o editor, em vez da visualização.", "showSidebarFavorites": "Mostrar seção Favoritos", "showSidebarFavoritesHelp": "Exibir Favoritos na barra lateral esquerda.", "showSidebarShares": "Mostrar seção Compartilhamentos", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Exibir Ferramentas na barra lateral esquerda.", "defaultShareExpiration": "Expiração padrão de compartilhamentos", "defaultShareExpirationHelp": "Defina um período de expiração padrão para novos compartilhamentos. Ao criar um compartilhamento, a data de expiração será pré-preenchida com base nesta configuração.", + "defaultShareExpirationInvalid": "Informe um número inteiro de pelo menos 1, ou limpe o campo para não ter expiração padrão.", "expirationValue": "Valor", "days": "Dias", "weeks": "Semanas", @@ -518,10 +521,16 @@ "skipHome": "Pular página inicial", "skipHomeHelp": "Redirecionar automaticamente para o primeiro volume ao visitar a página inicial. Se não definido, segue a configuração do servidor.", "useEnvSetting": "Usar configuração do servidor", - "showVersionMarks": "Marcar arquivos que têm versões", - "showVersionMarksHelp": "Uma pequena marca na lista nos arquivos com versões anteriores, com a quantidade. Clique nela para abrir o histórico.", + "defaultView": "Visualização padrão", + "defaultViewHelp": "A visualização usada ao abrir uma pasta que ainda não tem uma memorizada.", + "viewGrid": "Grade", + "viewList": "Lista", + "viewColumns": "Colunas", + "viewPhotos": "Fotos", "documentsOpenInNewTab": "Abrir documentos em uma nova aba", "documentsOpenInNewTabHelp": "Abrir um arquivo lhe dá uma aba do navegador só dele, então vários continuam abertos enquanto você navega. Desligado, ele abre sobre a pasta como hoje.", + "showVersionMarks": "Marcar arquivos que têm versões", + "showVersionMarksHelp": "Uma pequena marca na lista nos arquivos com versões anteriores, com a quantidade. Clique nela para abrir o histórico.", "languageHelp": "Salvo na sua conta, em todos os navegadores onde você entrar." }, "thumbs": { diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 3e55e03cd..1df831067 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Afișează fișierele și folderele care corespund modelelor ascunse configurate ({patterns}) în navigator.", "showThumbnails": "Afișează miniaturi", "showThumbnailsHelp": "Afișează previzualizări în miniatură pentru imagini și videoclipuri.", + "markdownOpensInEditor": "Deschide Markdown în editor", + "markdownOpensInEditorHelp": "Dublu clic pe un fișier .md deschide direct editorul în loc de previzualizare.", "showSidebarFavorites": "Afișează secțiunea Favorite", "showSidebarFavoritesHelp": "Afișează Favorite în bara laterală stângă.", "showSidebarShares": "Afișează secțiunea Partajări", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Afișează Instrumente în bara laterală stângă.", "defaultShareExpiration": "Expirare implicită pentru partajare", "defaultShareExpirationHelp": "Setează o perioadă implicită pentru noile partajări. Data de expirare este completată în prealabil pe baza acestei valori.", + "defaultShareExpirationInvalid": "Introduceți un număr întreg de cel puțin 1 sau goliți câmpul pentru a nu avea o expirare implicită.", "expirationValue": "Valoare", "days": "Zile", "weeks": "Săptămâni", @@ -518,10 +521,16 @@ "skipHome": "Sari peste pagina principală", "skipHomeHelp": "Redirectează automat către primul volum la deschiderea paginii principale. Dacă nu este setat, se folosește configurația serverului.", "useEnvSetting": "Folosește setarea serverului", - "showVersionMarks": "Marchează fișierele care au versiuni", - "showVersionMarksHelp": "Un semn discret în listă pe fișierele cu versiuni anterioare, cu numărul lor. Un clic deschide istoricul.", + "defaultView": "Vizualizare implicită", + "defaultViewHelp": "Vizualizarea folosită pentru un folder fără vizualizare memorată.", + "viewGrid": "Grilă", + "viewList": "Listă", + "viewColumns": "Coloane", + "viewPhotos": "Fotografii", "documentsOpenInNewTab": "Deschide documentele într-o filă nouă", "documentsOpenInNewTabHelp": "Deschiderea unui fișier îi dă o filă proprie de browser, așa că mai multe rămân deschise cât timp navigați. Oprit, se deschide peste dosar ca până acum.", + "showVersionMarks": "Marchează fișierele care au versiuni", + "showVersionMarksHelp": "Un semn discret în listă pe fișierele cu versiuni anterioare, cu numărul lor. Un clic deschide istoricul.", "languageHelp": "Se păstrează în contul dumneavoastră, în orice browser din care vă conectați." }, "thumbs": { diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 7d300f90d..9acf712b6 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Отображает файлы и папки, соответствующие настроенным скрытым шаблонам ({patterns}), в проводнике.", "showThumbnails": "Показывать миниатюры", "showThumbnailsHelp": "Показывает миниатюры для изображений и видео.", + "markdownOpensInEditor": "Открывать Markdown в редакторе", + "markdownOpensInEditorHelp": "Двойной щелчок по файлу .md сразу открывает редактор вместо предпросмотра.", "showSidebarFavorites": "Показывать раздел избранного", "showSidebarFavoritesHelp": "Показывает избранное в левой боковой панели.", "showSidebarShares": "Показывать раздел общего доступа", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Показывает инструменты в левой боковой панели.", "defaultShareExpiration": "Стандартный срок действия ссылки", "defaultShareExpirationHelp": "Установите интервал по умолчанию для новых ссылок. Дата истечения будет автоматически заполнена согласно этому значению.", + "defaultShareExpirationInvalid": "Укажите целое число не меньше 1 или очистите поле, чтобы не задавать срок по умолчанию.", "expirationValue": "Значение", "days": "Дней", "weeks": "Недель", @@ -518,10 +521,16 @@ "skipHome": "Пропустить главную", "skipHomeHelp": "Автоматически перенаправляет к первому тому при заходе на главную. Если не указано, используется настройка сервера.", "useEnvSetting": "Использовать серверную настройку", - "showVersionMarks": "Отмечать файлы, у которых есть версии", - "showVersionMarksHelp": "Небольшая отметка в списке у файлов с предыдущими версиями и их числом. Нажмите на неё, чтобы открыть историю.", + "defaultView": "Вид по умолчанию", + "defaultViewHelp": "Вид для папки, у которой нет сохранённого вида.", + "viewGrid": "Сетка", + "viewList": "Список", + "viewColumns": "Столбцы", + "viewPhotos": "Фотографии", "documentsOpenInNewTab": "Открывать документы в новой вкладке", "documentsOpenInNewTabHelp": "Открытый файл получает собственную вкладку браузера, так что несколько остаются открытыми, пока вы продолжаете просмотр. Выключено — открывается поверх папки, как сейчас.", + "showVersionMarks": "Отмечать файлы, у которых есть версии", + "showVersionMarksHelp": "Небольшая отметка в списке у файлов с предыдущими версиями и их числом. Нажмите на неё, чтобы открыть историю.", "languageHelp": "Сохраняется в вашей учётной записи, в любом браузере, где вы входите." }, "thumbs": { diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 3ef628f70..aeeb70938 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "Visar filer och mappar som matchar de konfigurerade dolda mönstren ({patterns}) i Utforskaren.", "showThumbnails": "Visa miniatyrbilder", "showThumbnailsHelp": "Visar förhandsgranskningar av bilder och videor i miniatyrformat.", + "markdownOpensInEditor": "Öppna Markdown i redigeraren", + "markdownOpensInEditorHelp": "Dubbelklick på en .md-fil öppnar redigeraren direkt i stället för förhandsvisningen.", "showSidebarFavorites": "Visa Favoriter", "showSidebarFavoritesHelp": "Visar Favoriter i vänster sidofält.", "showSidebarShares": "Visa Delningar", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "Visar Verktyg i vänster sidofält.", "defaultShareExpiration": "Standardutgång för delningar", "defaultShareExpirationHelp": "Ställ in en standardperiod för nya delningar. Utlösningsdatumet fylls i automatiskt baserat på detta val.", + "defaultShareExpirationInvalid": "Ange ett heltal på minst 1, eller töm fältet för att inte ha någon standardutgång.", "expirationValue": "Värde", "days": "Dagar", "weeks": "Veckor", @@ -518,10 +521,16 @@ "skipHome": "Hoppa över startsidan", "skipHomeHelp": "Omdirigerar automatiskt till den första volymen när startsidan öppnas. Om inget anges används serverinställningen.", "useEnvSetting": "Använd serverinställning", - "showVersionMarks": "Märk filer som har versioner", - "showVersionMarksHelp": "Ett litet märke i listan på filer med tidigare versioner, med antalet. Klicka på det för att öppna historiken.", + "defaultView": "Standardvy", + "defaultViewHelp": "Vyn för en mapp som saknar egen sparad vy.", + "viewGrid": "Rutnät", + "viewList": "Lista", + "viewColumns": "Kolumner", + "viewPhotos": "Foton", "documentsOpenInNewTab": "Öppna dokument i en ny flik", "documentsOpenInNewTabHelp": "En fil som öppnas får en egen webbläsarflik, så att flera kan stå öppna medan du bläddrar vidare. Av öppnas den ovanpå mappen som i dag.", + "showVersionMarks": "Märk filer som har versioner", + "showVersionMarksHelp": "Ett litet märke i listan på filer med tidigare versioner, med antalet. Klicka på det för att öppna historiken.", "languageHelp": "Sparas med ditt konto, i varje webbläsare du loggar in från." }, "thumbs": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index e8b5b7588..fa2d75e74 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "在文件浏览器中显示匹配已配置隐藏模式({patterns})的文件和文件夹。", "showThumbnails": "显示缩略图", "showThumbnailsHelp": "在浏览器中显示图像和视频的缩略预览。", + "markdownOpensInEditor": "在编辑器中打开 Markdown", + "markdownOpensInEditorHelp": "双击 .md 文件将直接打开编辑器,而不是预览。", "showSidebarFavorites": "显示收藏夹区域", "showSidebarFavoritesHelp": "在左侧边栏显示收藏夹。", "showSidebarShares": "显示共享区域", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "在左侧边栏显示工具。", "defaultShareExpiration": "分享默认过期时间", "defaultShareExpirationHelp": "为新的分享设置默认期限。创建分享时将自动填充到期日期。", + "defaultShareExpirationInvalid": "请输入不小于 1 的整数,或清空此字段以不设默认过期时间。", "expirationValue": "值", "days": "天", "weeks": "周", @@ -518,10 +521,16 @@ "skipHome": "跳过首页", "skipHomeHelp": "访问首页时自动重定向到第一个卷。未设置时遵循服务器配置。", "useEnvSetting": "使用服务器设置", - "showVersionMarks": "标记有版本的文件", - "showVersionMarksHelp": "在列表中为有历史版本的文件加一个小标记,并显示数量。点击即可打开历史记录。", + "defaultView": "默认视图", + "defaultViewHelp": "没有单独记住视图的文件夹所使用的视图。", + "viewGrid": "网格", + "viewList": "列表", + "viewColumns": "分栏", + "viewPhotos": "照片", "documentsOpenInNewTab": "在新标签页中打开文档", "documentsOpenInNewTabHelp": "打开文件时给它一个独立的浏览器标签页,这样你继续浏览时可以同时开着好几个。关闭后,它像现在一样覆盖在文件夹上打开。", + "showVersionMarks": "标记有版本的文件", + "showVersionMarksHelp": "在列表中为有历史版本的文件加一个小标记,并显示数量。点击即可打开历史记录。", "languageHelp": "保存在您的账户中,在您登录的每个浏览器上生效。" }, "thumbs": { diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 7f6cb7860..f93678f00 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -503,6 +503,8 @@ "showHiddenFilesHelp": "在檔案瀏覽器中顯示符合已設定隱藏模式({patterns})的檔案和資料夾。", "showThumbnails": "顯示縮圖", "showThumbnailsHelp": "在瀏覽器中顯示圖片和影片的縮圖預覽。", + "markdownOpensInEditor": "在編輯器中開啟 Markdown", + "markdownOpensInEditorHelp": "按兩下 .md 檔案會直接開啟編輯器,而不是預覽。", "showSidebarFavorites": "顯示我的最愛區段", "showSidebarFavoritesHelp": "在左側邊欄顯示我的最愛。", "showSidebarShares": "顯示分享區段", @@ -511,6 +513,7 @@ "showSidebarToolsHelp": "在左側邊欄顯示工具。", "defaultShareExpiration": "分享預設過期時間", "defaultShareExpirationHelp": "為新的分享設置預設期限。建立分享時將自動填入到期日期。", + "defaultShareExpirationInvalid": "請輸入不小於 1 的整數,或清空此欄位以不設預設過期時間。", "expirationValue": "值", "days": "天", "weeks": "週", @@ -518,10 +521,16 @@ "skipHome": "跳過首頁", "skipHomeHelp": "進入首頁時自動導向到第一個儲存卷。未設定時會使用伺服器預設。", "useEnvSetting": "使用伺服器預設", - "showVersionMarks": "標示有版本的檔案", - "showVersionMarksHelp": "在清單中為有舊版本的檔案加上一個小標記,並顯示數量。點一下即可開啟歷程記錄。", + "defaultView": "預設檢視", + "defaultViewHelp": "沒有單獨記住檢視的資料夾所使用的檢視。", + "viewGrid": "格狀", + "viewList": "清單", + "viewColumns": "分欄", + "viewPhotos": "相片", "documentsOpenInNewTab": "在新分頁中開啟文件", "documentsOpenInNewTabHelp": "開啟檔案時給它一個獨立的瀏覽器分頁,這樣你繼續瀏覽時可以同時開著好幾個。關閉後,它像現在一樣覆蓋在資料夾上開啟。", + "showVersionMarks": "標示有版本的檔案", + "showVersionMarksHelp": "在清單中為有舊版本的檔案加上一個小標記,並顯示數量。點一下即可開啟歷程記錄。", "languageHelp": "儲存在您的帳戶中,在您登入的每個瀏覽器上生效。" }, "thumbs": { diff --git a/frontend/src/stores/appSettings.js b/frontend/src/stores/appSettings.js index a4e859465..b832921b3 100644 --- a/frontend/src/stores/appSettings.js +++ b/frontend/src/stores/appSettings.js @@ -1,54 +1,111 @@ import { defineStore } from 'pinia'; -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; import { getBranding as getBrandingApi, getSettings as getSettingsApi, patchSettings as patchSettingsApi, + uploadLogo as uploadLogoApi, } from '@/api'; import { useAuthStore } from '@/stores/auth'; export const useAppSettings = defineStore('appSettings', () => { const loaded = ref(false); const loading = ref(false); + const loadedForUserId = ref(null); const lastError = ref(null); const authStore = useAuthStore(); - // Three-tier settings structure - const publicSettings = ref({ - branding: { appName: 'Explorer', appLogoUrl: '/logo.svg', showPoweredBy: false }, - }); - - const userSettings = ref({ + const createDefaultUserSettings = () => ({ showHiddenFiles: false, showThumbnails: true, showSidebarFavorites: true, showSidebarShares: true, showSidebarTools: true, - defaultShareExpiration: null, // { value: number, unit: 'days' | 'weeks' | 'months' } - skipHome: null, // null = use env var, true/false = override + defaultShareExpiration: null, + skipHome: null, + folderSorts: {}, + folderViews: {}, + defaultView: null, + // Markdown is the one kind of file with both a preview and an editor, so + // it is the only one where opening it is a choice (#347). + markdownOpensInEditor: false, + // Opening a document in a browser tab of its own, rather than over the + // folder it is in. Off, so nothing changes for anybody who does not ask + // for it. + documentsOpenInNewTab: false, + // The small mark on a row whose file has earlier versions. On, unlike the + // two above: it says something true about the file that nothing else in + // the listing says, and a history nobody knows about is a history nobody + // uses. It is read as `!== false` on the server, so this default and that + // one cannot drift apart. + showVersionMarks: true, + // The language this account is read in. null follows the browser, which is + // what everybody got before there was anywhere to say otherwise. + locale: null, + }); + + const createDefaultTrashSettings = () => ({ + enabled: true, + retentionDays: 30, + maxPercent: 10, + maxBytes: null, + }); + + const createDefaultVersionSettings = () => ({ + enabled: true, + keepAllHours: 24, + hourlyDays: 7, + dailyDays: 30, + maxPerFile: 50, + sessionCheckpointMinutes: 10, }); - const systemSettings = ref({ - thumbnails: { enabled: true, size: 200, quality: 70 }, - access: { rules: [], applyToAdmins: false }, - // What the server answers for the trash and the versions. Held as it comes: - // the screen that shows them sends back what it was given, and the server - // is the one that decides what a value may be. - trash: null, - versions: null, - uploads: null, - activity: null, + const createDefaultSystemSettings = () => ({ + thumbnails: { enabled: true, size: 200, quality: 70, concurrency: 10 }, + access: { rules: [] }, + uploads: { chunkedEnabled: false, chunkSizeBytes: 8 * 1024 * 1024 }, + folderSize: { excludedPaths: [], environmentExcludedPaths: [] }, + searchIndex: { excludedPaths: [], environmentExcludedPaths: [] }, + trash: createDefaultTrashSettings(), + versions: createDefaultVersionSettings(), + // Off until an administrator asks for it, which is what the server says + // too: a page that starts by showing the switch on would be a lie. + activity: { enabled: false, retentionDays: 90 }, }); + // Three-tier settings structure + const publicSettings = ref({ + branding: { appName: 'Explorer', appLogoUrl: '/logo.svg', showPoweredBy: false }, + }); + + const userSettings = ref(createDefaultUserSettings()); + const systemSettings = ref(createDefaultSystemSettings()); + + // Signing in as someone else must not leave the previous account's + // preferences on screen, so the store empties itself the moment the user + // changes rather than waiting for the next load to overwrite it. + watch( + () => authStore.currentUser?.id ?? null, + (userId, previousUserId) => { + if (userId === previousUserId) return; + + loaded.value = false; + loadedForUserId.value = null; + userSettings.value = createDefaultUserSettings(); + systemSettings.value = createDefaultSystemSettings(); + }, + { flush: 'sync' } + ); + // Computed state that combines all settings (for backward compatibility) const state = computed(() => ({ branding: publicSettings.value.branding, user: userSettings.value, thumbnails: systemSettings.value.thumbnails, access: systemSettings.value.access, - trash: systemSettings.value.trash, - versions: systemSettings.value.versions, uploads: systemSettings.value.uploads, + folderSize: systemSettings.value.folderSize, + searchIndex: systemSettings.value.searchIndex, })); // Whether thumbnails should be shown/requested for the current session. @@ -91,6 +148,7 @@ export const useAppSettings = defineStore('appSettings', () => { // - Authenticated user: branding + user settings // - Admin: branding + user settings + system settings const load = async () => { + const userId = authStore.currentUser?.id ?? null; loading.value = true; lastError.value = null; try { @@ -107,21 +165,15 @@ export const useAppSettings = defineStore('appSettings', () => { } // Update user settings if present (authenticated users) - if (s?.user && typeof s.user === 'object') { + if (userId === authStore.currentUser?.id && s?.user && typeof s.user === 'object') { userSettings.value = { - showHiddenFiles: false, - showThumbnails: true, - showSidebarFavorites: true, - showSidebarShares: true, - showSidebarTools: true, - defaultShareExpiration: null, - skipHome: null, + ...createDefaultUserSettings(), ...s.user, }; } // Update system settings if present (admin only) - if (s?.thumbnails) { + if (userId === authStore.currentUser?.id && s?.thumbnails) { systemSettings.value.thumbnails = { enabled: true, size: 200, @@ -129,18 +181,46 @@ export const useAppSettings = defineStore('appSettings', () => { ...s.thumbnails, }; } - if (s?.trash) systemSettings.value.trash = { ...s.trash }; - if (s?.versions) systemSettings.value.versions = { ...s.versions }; - if (s?.uploads) systemSettings.value.uploads = { ...s.uploads }; - if (s?.activity) systemSettings.value.activity = { ...s.activity }; - if (s?.access) { + if (userId === authStore.currentUser?.id && s?.access) { systemSettings.value.access = { rules: Array.isArray(s.access.rules) ? s.access.rules : [], - applyToAdmins: s.access.applyToAdmins === true, }; } + if (s?.uploads) { + systemSettings.value.uploads = { + chunkedEnabled: false, + chunkSizeBytes: 8 * 1024 * 1024, + ...s.uploads, + }; + } + if (s?.folderSize) { + systemSettings.value.folderSize = { + excludedPaths: [], + environmentExcludedPaths: [], + ...s.folderSize, + }; + } + if (s?.searchIndex) { + systemSettings.value.searchIndex = { + excludedPaths: [], + environmentExcludedPaths: [], + ...s.searchIndex, + }; + } + if (s?.trash) { + systemSettings.value.trash = { ...createDefaultTrashSettings(), ...s.trash }; + } + if (s?.versions) { + systemSettings.value.versions = { ...createDefaultVersionSettings(), ...s.versions }; + } + if (s?.activity) { + systemSettings.value.activity = { enabled: false, retentionDays: 90, ...s.activity }; + } - loaded.value = true; + if (userId === authStore.currentUser?.id) { + loadedForUserId.value = userId; + loaded.value = true; + } } catch (e) { // For non-admin users, 403 errors are expected for system settings // But we should still have branding loaded @@ -153,7 +233,10 @@ export const useAppSettings = defineStore('appSettings', () => { if (!isAdmin && e?.status === 403) { // Non-admin user - this is expected, just ensure branding is loaded await loadBranding(); - loaded.value = true; + if (userId === authStore.currentUser?.id) { + loadedForUserId.value = userId; + loaded.value = true; + } } else { lastError.value = e?.message || 'Failed to load settings'; } @@ -163,19 +246,29 @@ export const useAppSettings = defineStore('appSettings', () => { }; const ensureLoaded = async () => { - if (loaded.value || loading.value) { + const userId = authStore.currentUser?.id ?? null; + if (loaded.value && loadedForUserId.value === userId) { return state.value; } await load(); return state.value; }; - const save = async (partial) => { + /** + * Send a change and keep what the server answered, for as long as the + * person who sent it is still the one signed in. + */ + const saveWith = async (send) => { + const userId = authStore.currentUser?.id ?? null; lastError.value = null; try { - const updated = await patchSettingsApi(partial); + const updated = await send(); // Update local state based on what was returned + if (userId !== authStore.currentUser?.id) { + return state.value; + } + if (updated?.branding) { publicSettings.value.branding = { appName: 'Explorer', @@ -192,10 +285,6 @@ export const useAppSettings = defineStore('appSettings', () => { }; } - if (updated?.trash) systemSettings.value.trash = { ...updated.trash }; - if (updated?.versions) systemSettings.value.versions = { ...updated.versions }; - if (updated?.uploads) systemSettings.value.uploads = { ...updated.uploads }; - if (updated?.activity) systemSettings.value.activity = { ...updated.activity }; if (updated?.thumbnails) { systemSettings.value.thumbnails = { enabled: true, @@ -208,11 +297,48 @@ export const useAppSettings = defineStore('appSettings', () => { if (updated?.access) { systemSettings.value.access = { rules: Array.isArray(updated.access.rules) ? updated.access.rules : [], - applyToAdmins: updated.access.applyToAdmins === true, + }; + } + if (updated?.folderSize) { + systemSettings.value.folderSize = { + excludedPaths: [], + environmentExcludedPaths: [], + ...updated.folderSize, + }; + } + // Copied here as `load` copies it. It was not, so a saved exclusion list + // left the store holding the old one and the page still "unsaved". + if (updated?.searchIndex) { + systemSettings.value.searchIndex = { + excludedPaths: [], + environmentExcludedPaths: [], + ...updated.searchIndex, + }; + } + + if (updated?.uploads) { + systemSettings.value.uploads = { + chunkedEnabled: false, + chunkSizeBytes: 8 * 1024 * 1024, + ...updated.uploads, }; } + if (updated?.trash) { + systemSettings.value.trash = { ...createDefaultTrashSettings(), ...updated.trash }; + } + if (updated?.versions) { + systemSettings.value.versions = { + ...createDefaultVersionSettings(), + ...updated.versions, + }; + } + if (updated?.activity) { + systemSettings.value.activity = { enabled: false, retentionDays: 90, ...updated.activity }; + } + loaded.value = true; + loadedForUserId.value = userId; return state.value; } catch (e) { lastError.value = e?.message || 'Failed to save settings'; @@ -220,6 +346,12 @@ export const useAppSettings = defineStore('appSettings', () => { } }; + const save = (partial) => saveWith(() => patchSettingsApi(partial)); + + // The logo and the rest of the branding go in one request, so that a logo is + // never stored without the name saved alongside it, or the other way round. + const saveLogo = (file, branding) => saveWith(() => uploadLogoApi(file, branding)); + return { state, publicSettings, @@ -233,5 +365,6 @@ export const useAppSettings = defineStore('appSettings', () => { ensureLoaded, loadBranding, save, + saveLogo, }; }); diff --git a/frontend/src/stores/fileStore.js b/frontend/src/stores/fileStore.js index 5a81411dd..c0d29bdfa 100644 --- a/frontend/src/stores/fileStore.js +++ b/frontend/src/stores/fileStore.js @@ -467,6 +467,10 @@ export const useFileStore = defineStore('fileStore', () => { const previousItems = Array.isArray(currentPathItems.value) ? currentPathItems.value : []; const normalizedPath = normalizePath(typeof path === 'string' ? path : currentPath.value); + // Before the path changes, so the sort and the view this folder was left in + // are the ones the first render uses rather than a frame of the previous + // folder's. + useSettingsStore().restoreFolderPreferences(normalizedPath); currentPath.value = normalizedPath; clearSelection(); // When changing folders, exit selection mode (mobile UX). diff --git a/frontend/src/stores/folderPreference.js b/frontend/src/stores/folderPreference.js new file mode 100644 index 000000000..71540e45e --- /dev/null +++ b/frontend/src/stores/folderPreference.js @@ -0,0 +1,107 @@ +import { ref, watch } from 'vue'; + +/** + * A preference remembered per folder, per user. + * + * Sorting and view mode both work this way, and they were about to be written + * twice: the same reconciliation between what the server holds and what this + * tab has changed, the same guard against a stale response landing after the + * user has switched, the same cap on how much is kept. One copy, used twice. + * + * Saving sends a single folder rather than the whole map — two tabs open on + * different folders would otherwise overwrite each other with whichever copy + * was written last. + */ + +const MAX_ENTRIES = 100; +const MAX_PATH_LENGTH = 1024; + +export function createFolderPreference({ + /** Key under `userSettings` holding the stored map. */ + key, + /** Key the save endpoint expects for a single folder. */ + saveKey, + /** Field the endpoint expects the entry under, beside `path`. */ + entryKey, + /** Turn a stored entry into a valid one, or null. Without `updatedAt`. */ + sanitizeEntry, + appSettings, + authStore, +}) { + const entries = ref({}); + let hasLocalChanges = false; + let saveChain = Promise.resolve(); + + const normalize = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + + return Object.fromEntries( + Object.entries(value) + .map(([path, entry]) => { + const sanitized = sanitizeEntry(entry); + if (!sanitized || typeof path !== 'string' || !path || path.length > MAX_PATH_LENGTH) { + return null; + } + return [ + path, + { + ...sanitized, + updatedAt: Number.isFinite(entry?.updatedAt) ? Math.floor(entry.updatedAt) : 0, + }, + ]; + }) + .filter(Boolean) + .sort(([, a], [, b]) => b.updatedAt - a.updatedAt) + .slice(0, MAX_ENTRIES) + ); + }; + + // What the server holds, reconciled with anything changed here since. A + // straight overwrite would lose a preference set while the settings were + // being fetched. + watch( + () => appSettings.userSettings?.[key], + (value) => { + const saved = normalize(value); + entries.value = hasLocalChanges ? normalize({ ...saved, ...entries.value }) : saved; + }, + { immediate: true } + ); + + /** Signing in as someone else must not carry the previous account's choices. */ + const reset = () => { + hasLocalChanges = false; + entries.value = normalize(appSettings.userSettings?.[key]); + }; + + watch(() => authStore.currentUser?.id ?? null, reset, { flush: 'sync' }); + + const get = (folderPath) => entries.value?.[folderPath] ?? null; + + const set = (folderPath, entry) => { + const userId = authStore.currentUser?.id ?? null; + const sanitized = sanitizeEntry(entry); + if (!folderPath || !sanitized || !appSettings.loaded || !userId) return undefined; + + entries.value = normalize({ + ...entries.value, + [folderPath]: { ...sanitized, updatedAt: Date.now() }, + }); + hasLocalChanges = true; + + // Chained rather than concurrent: two quick changes would otherwise race, + // and the server would keep whichever write happened to land second. + const save = () => { + if (authStore.currentUser?.id !== userId) return undefined; + return appSettings.save({ + user: { [saveKey]: { path: folderPath, [entryKey]: sanitized } }, + }); + }; + + const result = saveChain.then(save, save); + saveChain = result.catch(() => undefined); + return result; + }; + + return { entries, get, set, reset, normalize }; +} diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index f4e58b46d..53b870553 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -1,22 +1,54 @@ -import { ref, computed, reactive } from 'vue'; +import { ref, computed, reactive, watch } from 'vue'; import { defineStore } from 'pinia'; import { useColorMode, useStorage } from '@vueuse/core'; +import { useAuthStore } from '@/stores/auth'; +import { useAppSettings } from '@/stores/appSettings'; +import { createFolderPreference } from '@/stores/folderPreference'; + +const VIEW_MODES = ['grid', 'list', 'tab', 'photos']; + +const DEFAULT_SORT_OPTIONS = [ + { key: 1, name: 'Name A to Z', by: 'name', order: 'asc' }, + { key: 2, name: 'Name Z to A', by: 'name', order: 'desc' }, + { key: 3, name: 'Small to large', by: 'size', order: 'asc' }, + { key: 4, name: 'Large to small', by: 'size', order: 'desc' }, + { key: 7, name: 'Kind A to Z', by: 'kind', order: 'asc' }, + { key: 8, name: 'Kind Z to A', by: 'kind', order: 'desc' }, + { key: 5, name: 'Old to new', by: 'dateModified', order: 'asc' }, + { key: 6, name: 'New to old', by: 'dateModified', order: 'desc' }, +]; export const useSettingsStore = defineStore('settings', () => { - const view = useStorage('settings:view', 'grid'); - const gridView = () => { - view.value = 'grid'; - }; - const listView = () => { - view.value = 'list'; - }; - const tabView = () => { - view.value = 'tab'; - }; - const photosView = () => { - view.value = 'photos'; + const appSettings = useAppSettings(); + const authStore = useAuthStore(); + + /** + * The view a folder gets when it has no remembered one of its own. + * + * Requested in #360. It lives with the user rather than in the browser, so it + * follows them between machines and does not leak to whoever signs in next on + * a shared one. + */ + const defaultView = computed(() => { + const preferred = appSettings.userSettings?.defaultView; + return VIEW_MODES.includes(preferred) ? preferred : 'grid'; + }); + + const view = ref('grid'); + + const setView = (mode) => { + if (!VIEW_MODES.includes(mode)) return undefined; + view.value = mode; + // Remembered against the folder being looked at, so coming back to it looks + // the way it was left. + return folderViewPreference.set(activeFolderPath.value, { mode }); }; + const gridView = () => setView('grid'); + const listView = () => setView('list'); + const tabView = () => setView('tab'); + const photosView = () => setView('photos'); + // Photos mode item size (in px) const photoSize = useStorage('settings:photos:size', 160); @@ -42,42 +74,121 @@ export const useSettingsStore = defineStore('settings', () => { themeMode.value === 'auto' ? 'light' : themeMode.value === 'light' ? 'dark' : 'auto'; }; - const sortOptions = reactive([ - { key: 1, name: 'Name A to Z', by: 'name', order: 'asc' }, - { key: 2, name: 'Name Z to A', by: 'name', order: 'desc' }, - { key: 3, name: 'Small to large', by: 'size', order: 'asc' }, - { key: 4, name: 'Large to small', by: 'size', order: 'desc' }, - { key: 7, name: 'Kind A to Z', by: 'kind', order: 'asc' }, - { key: 8, name: 'Kind Z to A', by: 'kind', order: 'desc' }, - { key: 5, name: 'Old to new', by: 'dateModified', order: 'asc' }, - { key: 6, name: 'New to old', by: 'dateModified', order: 'desc' }, - ]); - + const sortOptions = reactive(DEFAULT_SORT_OPTIONS.map((option) => ({ ...option }))); const sortBy = ref(sortOptions[0]); + const activeFolderPath = ref(''); - const setSortBy = (key) => { - sortBy.value = sortOptions.find((o) => o.key === key); - }; + const MAX_SORT_FIELD_LENGTH = 128; - const setSort = (by, order) => { - if (!by || !order) return; - const existing = sortOptions.find((o) => o.by === by && o.order === order); - if (existing) { - sortBy.value = existing; - return; - } + const getSortOption = (by, order) => sortOptions.find((o) => o.by === by && o.order === order); + + const isValidSort = (sort) => + sort && + typeof sort === 'object' && + typeof sort.by === 'string' && + sort.by.trim().length > 0 && + sort.by.length <= MAX_SORT_FIELD_LENGTH && + (sort.order === 'asc' || sort.order === 'desc'); + + const folderSortPreference = createFolderPreference({ + key: 'folderSorts', + saveKey: 'folderSort', + entryKey: 'sort', + sanitizeEntry: (entry) => + isValidSort(entry) ? { by: entry.by.trim(), order: entry.order } : null, + appSettings, + authStore, + }); + + const folderViewPreference = createFolderPreference({ + key: 'folderViews', + saveKey: 'folderView', + entryKey: 'view', + sanitizeEntry: (entry) => { + const mode = typeof entry === 'string' ? entry : entry?.mode; + return VIEW_MODES.includes(mode) ? { mode } : null; + }, + appSettings, + authStore, + }); + + const folderSorts = folderSortPreference.entries; + const folderViews = folderViewPreference.entries; + + // A sort on a column outside the built-in list is still a sort worth + // restoring, so the option is rebuilt rather than falling back to the + // default. + const getOrCreateSortOption = (by, order) => { + const existing = getSortOption(by, order); + if (existing) return existing; + if (!isValidSort({ by, order })) return null; const nextKey = Math.max(0, ...sortOptions.map((o) => Number(o.key) || 0)) + 1; const created = { key: nextKey, name: `${by} ${order}`, by, order }; sortOptions.push(created); - sortBy.value = created; + return created; + }; + + watch( + () => authStore.currentUser?.id ?? null, + () => { + activeFolderPath.value = ''; + sortOptions.splice( + 0, + sortOptions.length, + ...DEFAULT_SORT_OPTIONS.map((option) => ({ ...option })) + ); + sortBy.value = sortOptions[0]; + view.value = defaultView.value; + }, + { flush: 'sync' } + ); + + const saveSortForActiveFolder = (sort) => + folderSortPreference.set(activeFolderPath.value, { by: sort.by, order: sort.order }); + + const applySort = (sort) => { + if (!sort) return; + sortBy.value = sort; + return saveSortForActiveFolder(sort); + }; + + const setSortBy = (key) => { + applySort(sortOptions.find((o) => o.key === key)); + }; + + const setSort = (by, order) => { + const sort = getOrCreateSortOption(by, order); + if (!sort) return; + return applySort(sort); + }; + + /** + * Put a folder back the way it was left: its sort, and its view. + * + * A folder with no remembered view falls back to the default rather than + * keeping whatever the previous folder was showing — a photo folder set to + * the photo grid should not turn a folder of documents into one. + */ + const restoreFolderPreferences = (path) => { + activeFolderPath.value = typeof path === 'string' ? path : ''; + const savedSort = folderSorts.value?.[activeFolderPath.value]; + sortBy.value = getOrCreateSortOption(savedSort?.by, savedSort?.order) || sortOptions[0]; + + const savedView = folderViews.value?.[activeFolderPath.value]; + view.value = VIEW_MODES.includes(savedView?.mode) ? savedView.mode : defaultView.value; }; - const DEFAULT_LIST_VIEW_COLUMN_WIDTHS = [30, 420, 120, 160, 220]; - const LIST_VIEW_MIN_WIDTHS = [30, 200, 100, 120, 160]; + // Widths are sized to their content (icon, name, size, kind, modified date) so + // the grid doesn't reserve empty space that would trigger a horizontal + // scrollbar over nothing. Non-last columns keep a small surplus over their + // content as an inter-column margin; the trailing date column hugs its value. + // Key is versioned (:v2) so the tighter defaults replace any stored widths. + const DEFAULT_LIST_VIEW_COLUMN_WIDTHS = [30, 340, 96, 136, 150]; + const LIST_VIEW_MIN_WIDTHS = [30, 160, 70, 96, 140]; const listViewColumnWidths = useStorage( - 'settings:listView:columns', + 'settings:listView:columns:v2', DEFAULT_LIST_VIEW_COLUMN_WIDTHS ); @@ -124,6 +235,7 @@ export const useSettingsStore = defineStore('settings', () => { return { view, + setView, gridView, listView, tabView, @@ -136,6 +248,9 @@ export const useSettingsStore = defineStore('settings', () => { sortBy, setSortBy, setSort, + restoreFolderPreferences, + folderViews, + defaultView, sortOptions, terminalHeight, listViewColumnWidths, diff --git a/frontend/src/views/settings/SettingsUserPreferences.vue b/frontend/src/views/settings/SettingsUserPreferences.vue index 12ce62dad..857192cc1 100644 --- a/frontend/src/views/settings/SettingsUserPreferences.vue +++ b/frontend/src/views/settings/SettingsUserPreferences.vue @@ -3,15 +3,8 @@ import { computed, onMounted, reactive, watch } from 'vue'; import { useAppSettings } from '@/stores/appSettings'; import { useFeaturesStore } from '@/stores/features'; import { useI18n } from 'vue-i18n'; -import ToggleSwitch from '@/components/ToggleSwitch.vue'; import { languageLabel, supportedLocaleOptions } from '@/i18n'; - -// Each language named in itself — Deutsch, Français — since whoever is looking -// for theirs may not read the one the page is in. -const languages = supportedLocaleOptions.map(({ code }) => ({ - code, - label: languageLabel(code), -})); +import ToggleSwitch from '@/components/ToggleSwitch.vue'; const appSettings = useAppSettings(); const features = useFeaturesStore(); @@ -20,15 +13,17 @@ const { t } = useI18n(); const local = reactive({ showHiddenFiles: false, showThumbnails: true, - showVersionMarks: true, - documentsOpenInNewTab: false, - locale: null, showSidebarFavorites: true, showSidebarShares: true, showSidebarTools: true, defaultShareExpirationValue: null, defaultShareExpirationUnit: 'weeks', skipHome: null, // null = use env, true/false = override + defaultView: null, // null = the built-in default, otherwise a view mode + markdownOpensInEditor: false, + documentsOpenInNewTab: false, + showVersionMarks: true, + locale: null, }); const original = computed(() => appSettings.userSettings); @@ -42,17 +37,27 @@ const dirty = computed(() => { return ( local.showHiddenFiles !== orig.showHiddenFiles || local.showThumbnails !== orig.showThumbnails || - local.showVersionMarks !== (orig.showVersionMarks ?? true) || - local.documentsOpenInNewTab !== (orig.documentsOpenInNewTab ?? false) || - local.locale !== (orig.locale ?? null) || local.showSidebarFavorites !== (orig.showSidebarFavorites ?? true) || local.showSidebarShares !== (orig.showSidebarShares ?? true) || local.showSidebarTools !== (orig.showSidebarTools ?? true) || JSON.stringify(localExpiration) !== JSON.stringify(origExpiration) || - local.skipHome !== orig.skipHome + local.skipHome !== orig.skipHome || + local.defaultView !== orig.defaultView || + local.markdownOpensInEditor !== (orig.markdownOpensInEditor ?? false) || + local.documentsOpenInNewTab !== (orig.documentsOpenInNewTab ?? false) || + local.showVersionMarks !== (orig.showVersionMarks ?? true) || + local.locale !== (orig.locale ?? null) ); }); +// Empty means no default. Anything else has to be a whole number of at least +// one, as the server takes it: minus three weeks used to be sent as it was. +const expirationInvalid = computed(() => { + const value = local.defaultShareExpirationValue; + if (value === null || value === '') return false; + return !(Number.isInteger(value) && value >= 1); +}); + const hiddenFilePatternsLabel = computed(() => { const patterns = Array.isArray(features.hiddenFilePatterns) ? features.hiddenFilePatterns : []; return patterns.length ? patterns.join(', ') : t('common.disabled'); @@ -62,6 +67,13 @@ onMounted(() => { features.ensureLoaded(); }); +// Named in their own language, so somebody looking for theirs finds it even +// when the page is in one they do not read. +const languages = supportedLocaleOptions.map(({ code }) => ({ + code, + label: languageLabel(code), +})); + const sidebarPreferenceRows = [ { key: 'showSidebarFavorites', @@ -85,11 +97,6 @@ watch( (userSettings) => { local.showHiddenFiles = userSettings.showHiddenFiles ?? false; local.showThumbnails = userSettings.showThumbnails ?? true; - local.showVersionMarks = userSettings.showVersionMarks ?? true; - local.documentsOpenInNewTab = userSettings.documentsOpenInNewTab ?? false; - local.locale = userSettings.locale ?? null; - local.locale = userSettings.locale ?? null; - local.documentsOpenInNewTab = userSettings.documentsOpenInNewTab ?? false; local.showSidebarFavorites = userSettings.showSidebarFavorites ?? true; local.showSidebarShares = userSettings.showSidebarShares ?? true; local.showSidebarTools = userSettings.showSidebarTools ?? true; @@ -104,6 +111,11 @@ watch( } local.skipHome = userSettings.skipHome ?? null; + local.defaultView = userSettings.defaultView ?? null; + local.markdownOpensInEditor = userSettings.markdownOpensInEditor ?? false; + local.documentsOpenInNewTab = userSettings.documentsOpenInNewTab ?? false; + local.showVersionMarks = userSettings.showVersionMarks ?? true; + local.locale = userSettings.locale ?? null; }, { immediate: true } ); @@ -112,7 +124,6 @@ const reset = () => { const userSettings = appSettings.userSettings; local.showHiddenFiles = userSettings.showHiddenFiles ?? false; local.showThumbnails = userSettings.showThumbnails ?? true; - local.showVersionMarks = userSettings.showVersionMarks ?? true; local.showSidebarFavorites = userSettings.showSidebarFavorites ?? true; local.showSidebarShares = userSettings.showSidebarShares ?? true; local.showSidebarTools = userSettings.showSidebarTools ?? true; @@ -127,9 +138,15 @@ const reset = () => { } local.skipHome = userSettings.skipHome ?? null; + local.defaultView = userSettings.defaultView ?? null; + local.markdownOpensInEditor = userSettings.markdownOpensInEditor ?? false; + local.documentsOpenInNewTab = userSettings.documentsOpenInNewTab ?? false; + local.showVersionMarks = userSettings.showVersionMarks ?? true; + local.locale = userSettings.locale ?? null; }; const save = async () => { + if (expirationInvalid.value) return; const defaultShareExpiration = local.defaultShareExpirationValue ? { value: local.defaultShareExpirationValue, unit: local.defaultShareExpirationUnit } : null; @@ -138,14 +155,16 @@ const save = async () => { user: { showHiddenFiles: local.showHiddenFiles, showThumbnails: local.showThumbnails, - showVersionMarks: local.showVersionMarks, - documentsOpenInNewTab: local.documentsOpenInNewTab, - locale: local.locale, showSidebarFavorites: local.showSidebarFavorites, showSidebarShares: local.showSidebarShares, showSidebarTools: local.showSidebarTools, defaultShareExpiration, skipHome: local.skipHome, + defaultView: local.defaultView, + markdownOpensInEditor: local.markdownOpensInEditor, + documentsOpenInNewTab: local.documentsOpenInNewTab, + showVersionMarks: local.showVersionMarks, + locale: local.locale, }, }); }; @@ -160,7 +179,10 @@ const save = async () => {
{{ t('common.unsavedChanges') }}