diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 988d9bf83..a615c474d 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -72,11 +72,35 @@ module.exports = { // --- Archive extraction --- MAX_EXTRACTED_ARCHIVE_SIZE: process.env.MAX_EXTRACTED_ARCHIVE_SIZE?.trim() || null, MAX_ARCHIVE_ENTRIES: Number(process.env.MAX_ARCHIVE_ENTRIES) || 100000, + MAX_BROWSABLE_ARCHIVE_SIZE: process.env.MAX_BROWSABLE_ARCHIVE_SIZE?.trim() || null, + ARCHIVE_CACHE_MAX_SIZE: process.env.ARCHIVE_CACHE_MAX_SIZE?.trim() || null, ARCHIVE_EXTENSIONS: process.env.ARCHIVE_EXTENSIONS || '', // --- Folder size index --- FOLDER_SIZE_MODE: process.env.FOLDER_SIZE_MODE?.trim().toLowerCase() || 'off', FOLDER_SIZE_MODE_SET: typeof process.env.FOLDER_SIZE_MODE === 'string' && process.env.FOLDER_SIZE_MODE.trim() !== '', + // Lightweight process and cgroup diagnostics, off by default. When enabled the + // sampler logs only anomalous intervals unless explicitly told otherwise. + PERFORMANCE_DIAGNOSTICS_ENABLED: + normalizeBoolean(process.env.PERFORMANCE_DIAGNOSTICS_ENABLED) ?? false, + PERFORMANCE_DIAGNOSTICS_INTERVAL_MS: + process.env.PERFORMANCE_DIAGNOSTICS_INTERVAL_MS != null + ? Number(process.env.PERFORMANCE_DIAGNOSTICS_INTERVAL_MS) + : 15000, + PERFORMANCE_DIAGNOSTICS_LOG_EVERY_INTERVAL: + normalizeBoolean(process.env.PERFORMANCE_DIAGNOSTICS_LOG_EVERY_INTERVAL) ?? false, + PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD: + process.env.PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD != null + ? Number(process.env.PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD) + : 75, + PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB: + process.env.PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB != null + ? Number(process.env.PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB) + : 768, + PERFORMANCE_DIAGNOSTICS_EVENT_LOOP_DELAY_MS: + process.env.PERFORMANCE_DIAGNOSTICS_EVENT_LOOP_DELAY_MS != null + ? Number(process.env.PERFORMANCE_DIAGNOSTICS_EVENT_LOOP_DELAY_MS) + : 250, FOLDER_SIZE_EXCLUDE_PATHS: process.env.FOLDER_SIZE_EXCLUDE_PATHS || '', FOLDER_SIZE_CONCURRENCY: Number(process.env.FOLDER_SIZE_CONCURRENCY) || 6, FOLDER_SIZE_NETWORK_CONCURRENCY: Number(process.env.FOLDER_SIZE_NETWORK_CONCURRENCY) || 2, @@ -96,6 +120,7 @@ module.exports = { SEARCH_INDEX_CPU_PERCENT: Number(process.env.SEARCH_INDEX_CPU_PERCENT) || null, SEARCH_INDEX_MEMORY_MB: Number(process.env.SEARCH_INDEX_MEMORY_MB) || null, SEARCH_INDEX_EXCLUDE: process.env.SEARCH_INDEX_EXCLUDE?.trim() || null, + PREVIEW_MAX_RENDER_SIZE: process.env.PREVIEW_MAX_RENDER_SIZE?.trim() || null, SEARCH_INDEX_REBUILD: normalizeBoolean(process.env.SEARCH_INDEX_REBUILD) ?? false, SEARCH_INDEX_RECONCILE_MS: Number(process.env.SEARCH_INDEX_RECONCILE_MS) || null, SEARCH_TIMEOUT_MS: Number(process.env.SEARCH_TIMEOUT_MS) || null, diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 4bc9da340..3dbf58a73 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -562,6 +562,23 @@ const archives = (() => { return Number.isFinite(parsed) && parsed > 0 ? parsed : 32 * 1024 * 1024 * 1024; })(), maxEntries: env.MAX_ARCHIVE_ENTRIES, + // A compound archive — a .tar.gz and its family — is two archives, and the + // inner one has to be decompressed before anything inside it can be named. + // Above this it is not: browsing a backup by unpacking it first would + // betray the whole point, and extracting it is the operation that exists + // for that. The number is the inner archive's own declared size, so the + // refusal comes before anything is written. + browseMaxBytes: (() => { + const parsed = parseByteSize(env.MAX_BROWSABLE_ARCHIVE_SIZE); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 1024 * 1024 * 1024; + })(), + // What those decompressed copies may take up altogether. They are a + // convenience and are made again whenever they are missing, so the least + // recently opened goes first when this is passed. + cacheMaxBytes: (() => { + const parsed = parseByteSize(env.ARCHIVE_CACHE_MAX_SIZE); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 8 * 1024 * 1024 * 1024; + })(), }; if (!raw) return { extensions: DEFAULT_ARCHIVE_EXTENSIONS, ...limits }; // 'zip,iso' replaces the default list; '+udf,squashfs' extends it. @@ -661,7 +678,49 @@ const folderSize = { rebuild: env.FOLDER_SIZE_REBUILD, }; +// --- Runtime diagnostics --- +// --- Runtime diagnostics --- +const atLeast = (value, minimum, fallback) => + Number.isFinite(value) && value >= minimum ? value : fallback; + +const performanceDiagnostics = { + enabled: env.PERFORMANCE_DIAGNOSTICS_ENABLED, + intervalMs: atLeast(env.PERFORMANCE_DIAGNOSTICS_INTERVAL_MS, 5000, 15000), + logEveryInterval: env.PERFORMANCE_DIAGNOSTICS_LOG_EVERY_INTERVAL, + cpuThreshold: atLeast(env.PERFORMANCE_DIAGNOSTICS_CPU_THRESHOLD, 1, 75), + rssThresholdMb: atLeast(env.PERFORMANCE_DIAGNOSTICS_RSS_THRESHOLD_MB, 1, 768), + eventLoopDelayThresholdMs: atLeast(env.PERFORMANCE_DIAGNOSTICS_EVENT_LOOP_DELAY_MS, 1, 250), +}; + +/** + * How much of a document the preview will render. + * + * Not the same question as what the editor will open, and the difference is + * why this is a setting of its own. The editor streams text into a code view; + * the preview parses the document, sanitises the HTML it produces and then + * hands the browser every node to lay out — all on the one thread the + * interface has. A six-megabyte markdown file opens in the editor and freezes + * the tab in the preview, on the same machine, from the same file. + * + * It was hard-coded before this, which meant someone who raised + * EDITOR_MAX_FILESIZE in good faith was refused at a number that appeared in + * no setting and no document. + * + * Generous by default because freezing is no longer the failure mode: the + * preview renders in slices of a frame and hands the browser back between + * them. What is left is the weight of the document in the tab, which is a + * reader's problem rather than an application's. And the preview reads through + * the editor's endpoint, so EDITOR_MAX_FILESIZE already caps what can reach + * it — this only bites when it is set lower than that. + */ +const previewMaxRenderBytes = (() => { + const parsed = parseByteSize(env.PREVIEW_MAX_RENDER_SIZE); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 16 * 1024 * 1024; +})(); + module.exports = { + performanceDiagnostics, + preview: { maxRenderBytes: previewMaxRenderBytes }, folderSize, webauthn, activity, diff --git a/backend/src/openapi/paths/files.js b/backend/src/openapi/paths/files.js index 33ba89711..07854e1c1 100644 --- a/backend/src/openapi/paths/files.js +++ b/backend/src/openapi/paths/files.js @@ -132,6 +132,16 @@ module.exports = { }, }), }, + '/api/files/recent-destinations': { + get: op({ + id: 'listRecentDestinations', + summary: 'Folders this account recently copied or moved into', + description: 'Only those it can still reach.', + tag: TAG, + access: 'account', + responses: { 200: json(obj({ items: arrayOf(str()) }, ['items'])), ...errors(401) }, + }), + }, '/api/files/delete-impact': { post: op({ id: 'describeDeletion', diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 1df37e6ca..03af9b840 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -14,6 +14,7 @@ const logger = require('../utils/logger'); const { countUsers, createLocalUser, + getById, attemptLocalLogin, changeLocalPassword, addLocalPassword, @@ -28,15 +29,13 @@ const { twoFactorStatus, verifySecondFactor, } = require('../services/users'); -const { incrementFailedAttempts, clearLock, isLocked } = require('../services/users/lockout'); -const { issueCode, redeemCode, isValidChallenge } = require('../services/oidcMobileBridge'); -const rateLimit = require('express-rate-limit'); -const asyncHandler = require('../utils/asyncHandler'); -const { startAuthenticatedSession } = require('../utils/authenticatedSession'); const passkeys = require('../services/users/passkeys'); -const activityLog = require('../services/activityLog'); const apiTokens = require('../services/apiTokens'); +const activityLog = require('../services/activityLog'); const { WebAuthnError } = require('../utils/webauthn'); +const { issueCode, redeemCode, isValidChallenge } = require('../services/oidcMobileBridge'); +const rateLimit = require('express-rate-limit'); +const asyncHandler = require('../utils/asyncHandler'); const { ValidationError, UnauthorizedError, @@ -46,116 +45,9 @@ const { ServiceUnavailableError, } = require('../errors/AppError'); const { ErrorCodes } = require('../errors/errorCodes'); - -/** - * The relying party: who is asking for a passkey, and where from. - * - * A key is bound to a domain, and the browser refuses to use it anywhere else. - * The domain is taken from the public address when one is configured and from - * the request otherwise, because a deployment reached by several names would - * otherwise bind every key to whichever one was written down. - */ -const relyingParty = (req) => { - const known = uniqueOrigins(publicConfig.origins || []); - const origins = known.length - ? known - : uniqueOrigins([`${req.protocol}://${req.get('host') || ''}`]); - - let rpId = webauthnConfig.rpId; - if (!rpId) { - try { - rpId = new URL(publicConfig.url || origins[0] || '').hostname; - } catch (_) { - rpId = null; - } - } - if (!rpId) rpId = req.hostname; - return { rpId, rpName: webauthnConfig.rpName, origins }; -}; - -/** - * Keep the question until the answer arrives, and spend it then. - * - * In the session, not in a table: it belongs to one browser and one moment. - * Spending it means taking it away — an answer is worth one sign-in, and a - * challenge still lying about is one somebody else can answer with a recording - * of the first. - */ -const rememberChallenge = (req, purpose, challenge) => - new Promise((resolve, reject) => { - if (!req.session) { - reject(new Error('A passkey needs a session to ask its question in.')); - return; - } - req.session.webauthn = { purpose, challenge, at: Date.now() }; - req.session.save((error) => (error ? reject(error) : resolve())); - }); - -const spendChallenge = (req, purpose) => { - const held = req.session?.webauthn; - if (req.session) delete req.session.webauthn; - if (!held || held.purpose !== purpose) return null; - if (Date.now() - (Number(held.at) || 0) > passkeys.CEREMONY_TIMEOUT_MS) return null; - return held.challenge; -}; - -/** Every refusal reads the same from outside, and says what happened in the log. */ -const refusePasskey = (error) => { - if (!(error instanceof WebAuthnError)) throw error; - if (error.status === 409) throw new ValidationError(error.message); - logger.warn({ reason: error.message }, 'A passkey was refused'); - throw new UnauthorizedError('That passkey was not accepted.', ErrorCodes.AUTH_PASSKEY_REJECTED); -}; - -/** - * How long the second step stays open. - * - * Long enough to find a phone, pick the app and read the digits; short enough - * that a machine walked away from is not a sign-in waiting to be finished by - * whoever sits down next. - */ -const SECOND_STEP_MS = 5 * 60 * 1000; - -/** - * The password was right, and the account wants a code as well. - * - * Deliberately not a signed-in session with a flag on it: nothing but - * `localUserId` signs anybody in, and this state does not set it. The session - * is regenerated here for the same reason it is regenerated at the end — an id - * somebody planted in the browser must not be the one that finishes the - * sign-in. - */ -const startSecondStep = (req, userId) => - new Promise((resolve, reject) => { - if (!req.session) { - reject(new Error('No session to hold the second step in.')); - return; - } - req.session.regenerate((error) => { - if (error) { - reject(error); - return; - } - req.session.pendingTotpUserId = userId; - req.session.pendingTotpSince = Date.now(); - req.session.save((saveError) => (saveError ? reject(saveError) : resolve())); - }); - }); - -/** The account halfway through signing in here, or null. */ -const secondStepUserId = (req) => { - const userId = req.session?.pendingTotpUserId; - if (!userId) return null; - const since = Number(req.session.pendingTotpSince) || 0; - if (Date.now() - since > SECOND_STEP_MS) return null; - return userId; -}; - -const forgetSecondStep = (req) => { - if (!req.session) return; - delete req.session.pendingTotpUserId; - delete req.session.pendingTotpSince; -}; +const { startAuthenticatedSession } = require('../utils/authenticatedSession'); +const { incrementFailedAttempts, clearLock, isLocked } = require('../services/users/lockout'); +const { clientAddress } = require('../utils/clientAddress'); const rateLimitHandler = (req, res, next, options) => { const retryAfterSeconds = Math.ceil(options.windowMs / 1000); @@ -218,6 +110,56 @@ const oneSetupAtATime = (task) => { return run; }; +/** + * How long the second step stays open. + * + * Long enough to find a phone, pick the app and read the digits; short enough + * that a machine walked away from is not a sign-in waiting to be finished by + * whoever sits down next. + */ +const SECOND_STEP_MS = 5 * 60 * 1000; + +/** + * The password was right, and the account wants a code as well. + * + * Deliberately not a signed-in session with a flag on it: nothing but + * `localUserId` signs anybody in, and this state does not set it. The session + * is regenerated here for the same reason it is regenerated at the end — an id + * somebody planted in the browser must not be the one that finishes the + * sign-in. + */ +const startSecondStep = (req, userId) => + new Promise((resolve, reject) => { + if (!req.session) { + reject(new Error('A second factor needs a session to wait in.')); + return; + } + req.session.regenerate((error) => { + if (error) { + reject(error); + return; + } + req.session.pendingTotpUserId = userId; + req.session.pendingTotpSince = Date.now(); + req.session.save((saveError) => (saveError ? reject(saveError) : resolve())); + }); + }); + +/** The account halfway through signing in here, or null. */ +const secondStepUserId = (req) => { + const userId = req.session?.pendingTotpUserId; + if (!userId) return null; + const since = Number(req.session.pendingTotpSince) || 0; + if (Date.now() - since > SECOND_STEP_MS) return null; + return userId; +}; + +const forgetSecondStep = (req) => { + if (!req.session) return; + delete req.session.pendingTotpUserId; + delete req.session.pendingTotpSince; +}; + const respondWithUser = async (req, res) => { const user = await getRequestUser(req); res.json({ user }); @@ -251,10 +193,10 @@ router.get('/status', async (req, res) => { res.json({ requiresSetup, + strategies, // 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. totpPending: Boolean(secondStepUserId(req)), - strategies, authEnabled: auth.enabled, authMode, authenticated: auth.enabled ? Boolean(isEoc || hasLocal) : true, @@ -290,9 +232,8 @@ router.post( await startAuthenticatedSession(req, user.id); // Clear guest session cookie when user sets up account - // Both paths: the cookie has been set on `/api` and on `/`, and a guest - // session left behind on the other one outlives the sign-in that should - // have ended it. + // Clear both scopes: the cookie used to be set on /api, and browsers + // still holding that one would otherwise keep it. res.clearCookie('guestSession', { path: '/' }); res.clearCookie('guestSession', { path: '/api' }); @@ -300,22 +241,33 @@ router.post( }) ); -// Local login with email + password +// Local login with an email address or a username, and a password router.post( '/login', 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 || {}; + // `email` and `username` are the older field names; both carried whatever + // was typed into the one box on the sign-in screen. + const typed = identifier || email || username; - let user = null; + let user; 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); + // Seconds in `retryAfter`, which is what the interface reads, with the + // deadline itself beside it, under a code of its own so the message + // translates. The ISO date used to sit in `retryAfter` under a generic + // code, and the sign-in screen could say neither how long nor in what + // language. + const lockedUntil = e.until || null; + const msLeft = lockedUntil ? Date.parse(lockedUntil) - Date.now() : NaN; + const retryAfter = Number.isFinite(msLeft) ? Math.max(1, Math.ceil(msLeft / 1000)) : null; + const locked = new RateLimitError(e.message, retryAfter, ErrorCodes.AUTH_ACCOUNT_LOCKED); + if (lockedUntil) locked.details = { ...locked.details, lockedUntil }; + throw locked; } throw e; } @@ -324,7 +276,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, }); @@ -332,9 +284,9 @@ router.post( } // The password was right and the account asks for a code as well. Nothing - // about who they are is answered here: that an account has a second factor - // is not something to tell whoever guessed a password correctly, so the - // answer carries the question and nothing else. + // about who they are is answered here: an account that has a second factor + // is not something to tell anybody who guessed a password correctly, so + // the answer carries the question and nothing else. if (await twoFactorRequired(user.id)) { await startSecondStep(req, user.id); res.json({ totpRequired: true }); @@ -345,9 +297,8 @@ router.post( await activityLog.record({ action: 'sign-in', user, detail: { method: 'password' }, req }); // Clear guest session cookie when user logs in - // Both paths: the cookie has been set on `/api` and on `/`, and a guest - // session left behind on the other one outlives the sign-in that should - // have ended it. + // Clear both scopes: the cookie used to be set on /api, and browsers + // still holding that one would otherwise keep it. res.clearCookie('guestSession', { path: '/' }); res.clearCookie('guestSession', { path: '/api' }); @@ -355,56 +306,305 @@ router.post( }) ); -/** The passkeys on this account, so they can be named and taken away. */ +/** + * The second step: the code from the phone, or one off the paper. + * + * Wrong codes count against the same lockout a wrong password does, so the + * second factor is not a place to guess a million times at six digits while + * the first one is bounded. + */ +router.post( + '/login/totp', + loginLimiter, + asyncHandler(async (req, res) => { + refuseWithoutPasswordSignIn(); + const userId = secondStepUserId(req); + if (!userId) { + forgetSecondStep(req); + throw new UnauthorizedError( + 'That sign-in is no longer waiting for a code. Sign in again.', + ErrorCodes.AUTH_INVALID_CREDENTIALS + ); + } + + if (await isLocked(userId)) { + throw new RateLimitError( + 'Account is temporarily locked due to failed login attempts.', + null, + ErrorCodes.AUTH_ACCOUNT_LOCKED + ); + } + + const { code } = req.body || {}; + const outcome = await verifySecondFactor({ userId, code }); + if (!outcome.ok) { + await incrementFailedAttempts(userId); + await activityLog.record({ + action: 'sign-in', + outcome: 'refused', + userId, + actor: (await getById(userId))?.username || userId, + detail: { method: 'code' }, + req, + }); + throw new UnauthorizedError('That code is not right.', ErrorCodes.AUTH_INVALID_TOTP_CODE); + } + + await clearLock(userId); + forgetSecondStep(req); + await startAuthenticatedSession(req, userId); + + res.clearCookie('guestSession', { path: '/' }); + res.clearCookie('guestSession', { path: '/api' }); + + const user = await getRequestUser(req); + await activityLog.record({ + action: 'sign-in', + user, + detail: { method: outcome.usedRecoveryCode ? 'recovery code' : 'code' }, + req, + }); + res.json({ + user, + usedRecoveryCode: Boolean(outcome.usedRecoveryCode), + recoveryCodesLeft: outcome.recoveryCodesLeft ?? null, + }); + }) +); + +/** Whether this account asks for a code, and how many recovery codes are left. */ router.get( - '/passkeys', + '/totp', asyncHandler(async (req, res) => { const me = await getRequestUser(req); if (!me) throw new UnauthorizedError('Authentication required.'); - res.json({ passkeys: await passkeys.listPasskeys(me.id) }); + res.json(await twoFactorStatus(me.id)); }) ); /** - * Start making one. + * Draw a secret and show it, which turns nothing on. * - * Signed in here with this account, like the second factor: a session the - * identity provider opened is not one that adds a local way in. + * What comes back is shown once and never again: the phone keeps it, and the + * copy here is unreadable the moment it is written. */ router.post( - '/passkeys/register/start', + '/totp/start', passwordLimiter, asyncHandler(async (req, res) => { refuseWithoutPasswordSignIn(); const me = await getRequestUser(req); if (!me) throw new UnauthorizedError('Authentication required.'); if (!req.session || req.session.localUserId !== me.id) { - throw new ForbiddenError('Sign in with your password to add a passkey.'); + throw new ForbiddenError('Sign in with your password to set up a second factor.'); } - const { rpId, rpName, origins } = relyingParty(req); - const options = await passkeys.beginRegistration({ + const enrolment = await beginTwoFactorEnrolment({ userId: me.id, account: me.email || me.username || me.id, - displayName: me.displayName || me.username || me.email || me.id, - rpId, - rpName, }); - await rememberChallenge(req, 'register', options.challenge); - res.json({ options, origins }); + res.json(enrolment); }) ); -/** Keep it, if it answers the question this browser was just asked. */ +/** Turn it on, once a code proves the phone holds the same secret. */ router.post( - '/passkeys/register/finish', + '/totp/confirm', passwordLimiter, asyncHandler(async (req, res) => { - refuseWithoutPasswordSignIn(); const me = await getRequestUser(req); if (!me) throw new UnauthorizedError('Authentication required.'); - if (!req.session || req.session.localUserId !== me.id) { - throw new ForbiddenError('Sign in with your password to add a passkey.'); + + const confirmed = await confirmTwoFactorEnrolment({ userId: me.id, code: req.body?.code }); + if (!confirmed) { + throw new UnauthorizedError('That code is not right.', ErrorCodes.AUTH_INVALID_TOTP_CODE); + } + logger.info({ userId: me.id }, 'Two-factor authentication turned on'); + await activityLog.record({ action: 'account.two-factor', user: me, detail: { on: true }, req }); + res.json(confirmed); + }) +); + +/** + * New recovery codes, and the password to prove it is still the same person. + * + * A browser left unlocked is the case this is about: drawing new codes throws + * the old ones away, and somebody who sat down at a signed-in screen should + * not be able to leave with the only working set. + */ +router.post( + '/totp/recovery-codes', + passwordLimiter, + asyncHandler(async (req, res) => { + const me = await getRequestUser(req); + if (!me) throw new UnauthorizedError('Authentication required.'); + if (!(await verifyLocalPassword({ userId: me.id, password: req.body?.password }))) { + throw new UnauthorizedError( + 'That password is not right.', + ErrorCodes.AUTH_PASSWORD_INCORRECT + ); + } + + res.json({ recoveryCodes: await replaceRecoveryCodes(me.id) }); + }) +); + +/** Off, with the password for the same reason. */ +router.delete( + '/totp', + passwordLimiter, + asyncHandler(async (req, res) => { + const me = await getRequestUser(req); + if (!me) throw new UnauthorizedError('Authentication required.'); + if (!(await verifyLocalPassword({ userId: me.id, password: req.body?.password }))) { + throw new UnauthorizedError( + 'That password is not right.', + ErrorCodes.AUTH_PASSWORD_INCORRECT + ); + } + + await disableTwoFactor(me.id); + logger.info({ userId: me.id }, 'Two-factor authentication turned off'); + await activityLog.record({ + action: 'account.two-factor', + user: me, + detail: { on: false }, + req, + }); + res.status(204).end(); + }) +); + +/** + * The site a passkey is bound to, and the pages allowed to use one. + * + * A passkey is made for a name and signs only for that name — which is the + * phishing resistance, and also the reason an installation reached through two + * hostnames has to pick one. PUBLIC_URL answers it where it is set; where it + * is not, the name this request arrived on is the answer, which is right for + * the single-hostname installation that never configured anything. An operator + * who needs to settle it sets WEBAUTHN_RP_ID. + * + * Browsers refuse a passkey on an address that is not a name, and on a page + * that is not secure: a LAN IP or plain http offers nothing to bind to. That + * refusal happens in the browser, before this is reached. + */ +const relyingParty = (req) => { + const known = uniqueOrigins(publicConfig.origins || []); + const origins = known.length + ? known + : uniqueOrigins([`${req.protocol}://${req.get('host') || ''}`]); + + let rpId = webauthnConfig.rpId; + if (!rpId) { + try { + rpId = new URL(publicConfig.url || origins[0] || '').hostname; + } catch (_) { + rpId = null; + } + } + if (!rpId) rpId = req.hostname; + return { rpId, rpName: webauthnConfig.rpName, origins }; +}; + +/** + * Keep the question until the answer arrives, and spend it then. + * + * In the session, not in a table: it belongs to one browser and one moment. + * Spending it means taking it away — an answer is worth one sign-in, and a + * challenge still lying about is one somebody else can answer with a recording + * of the first. + */ +const rememberChallenge = (req, purpose, challenge) => + new Promise((resolve, reject) => { + if (!req.session) { + reject(new Error('A passkey needs a session to ask its question in.')); + return; + } + req.session.webauthn = { purpose, challenge, at: Date.now() }; + req.session.save((error) => (error ? reject(error) : resolve())); + }); + +const spendChallenge = (req, purpose) => { + const held = req.session?.webauthn; + if (req.session) delete req.session.webauthn; + if (!held || held.purpose !== purpose) return null; + if (Date.now() - (Number(held.at) || 0) > passkeys.CEREMONY_TIMEOUT_MS) return null; + return held.challenge; +}; + +/** Every refusal reads the same from outside, and says what happened in the log. */ +const refusePasskey = (error, req) => { + if (!(error instanceof WebAuthnError)) throw error; + if (error.status === 409) { + throw new ValidationError(error.message); + } + logger.warn({ reason: error.message, ip: clientAddress(req) }, 'A passkey was refused'); + // Not awaited: this is the throwing path, and a log line is not worth + // holding a refusal for. `record` never rejects. + activityLog.record({ + action: 'sign-in', + outcome: 'refused', + actor: 'unknown', + detail: { method: 'passkey' }, + req, + }); + throw new UnauthorizedError( + 'That passkey did not open anything here.', + ErrorCodes.AUTH_PASSKEY_REJECTED + ); +}; + +/** The passkeys on this account. */ +router.get( + '/passkeys', + asyncHandler(async (req, res) => { + const me = await getRequestUser(req); + if (!me) throw new UnauthorizedError('Authentication required.'); + res.json({ passkeys: await passkeys.listPasskeys(me.id) }); + }) +); + +/** + * Start making one. + * + * Signed in here with this account, like the second factor: a session the + * identity provider opened is not one that adds a local way in. + */ +router.post( + '/passkeys/register/start', + passwordLimiter, + asyncHandler(async (req, res) => { + refuseWithoutPasswordSignIn(); + const me = await getRequestUser(req); + if (!me) throw new UnauthorizedError('Authentication required.'); + if (!req.session || req.session.localUserId !== me.id) { + throw new ForbiddenError('Sign in with your password to add a passkey.'); + } + + const { rpId, rpName, origins } = relyingParty(req); + const options = await passkeys.beginRegistration({ + userId: me.id, + account: me.email || me.username || me.id, + displayName: me.displayName || me.username || me.email || me.id, + rpId, + rpName, + }); + await rememberChallenge(req, 'register', options.challenge); + res.json({ options, origins }); + }) +); + +/** Keep it, if it answers the question this browser was just asked. */ +router.post( + '/passkeys/register/finish', + passwordLimiter, + asyncHandler(async (req, res) => { + refuseWithoutPasswordSignIn(); + const me = await getRequestUser(req); + if (!me) throw new UnauthorizedError('Authentication required.'); + if (!req.session || req.session.localUserId !== me.id) { + throw new ForbiddenError('Sign in with your password to add a passkey.'); } const challenge = spendChallenge(req, 'register'); @@ -429,7 +629,7 @@ router.post( }); res.status(201).json({ passkey }); } catch (error) { - refusePasskey(error); + refusePasskey(error, req); } }) ); @@ -483,6 +683,12 @@ router.delete( 'This is the only way into this account. Add a password, or another passkey, before removing it.' ); } + await activityLog.record({ + action: 'account.passkey', + user: me, + detail: { added: false }, + req, + }); res.status(204).end(); }) ); @@ -511,8 +717,8 @@ router.post( * * A passkey that was unlocked — a fingerprint, a face, a PIN — is already two * things: the device, and whoever can open it. That is why it satisfies an - * account that asks for a second factor, and why one that was not unlocked does - * not: that proves only that the device was there. + * account that asks for a second factor, and why one that was not unlocked + * does not: it proves only that the device was there. */ router.post( '/login/passkey/finish', @@ -535,7 +741,7 @@ router.post( expected: { challenge, origins, rpId }, }); } catch (error) { - refusePasskey(error); + refusePasskey(error, req); } if (await isLocked(outcome.userId)) { @@ -554,9 +760,6 @@ router.post( } await startAuthenticatedSession(req, outcome.userId); - // Both paths: the cookie has been set on `/api` and on `/`, and a guest - // session left behind on the other one outlives the sign-in that should - // have ended it. res.clearCookie('guestSession', { path: '/' }); res.clearCookie('guestSession', { path: '/api' }); @@ -564,185 +767,14 @@ router.post( { userId: outcome.userId, passkeyId: outcome.passkeyId }, 'Signed in with a passkey' ); - const signedIn = await getRequestUser(req); + const user = await getRequestUser(req); await activityLog.record({ action: 'sign-in', - user: signedIn, + user, detail: { method: 'passkey', passkey: outcome.name }, req, }); - res.json({ user: signedIn }); - }) -); - -/** - * The second step: the code from the phone, or one off the paper. - * - * Wrong codes count against the same lockout a wrong password does, so the - * second factor is not a place to guess a million times at six digits while - * the first one is bounded. - */ -router.post( - '/login/totp', - loginLimiter, - asyncHandler(async (req, res) => { - refuseWithoutPasswordSignIn(); - const userId = secondStepUserId(req); - if (!userId) { - forgetSecondStep(req); - throw new UnauthorizedError( - 'That sign-in is no longer waiting for a code. Sign in again.', - ErrorCodes.AUTH_INVALID_CREDENTIALS - ); - } - - if (await isLocked(userId)) { - throw new RateLimitError( - 'Account is temporarily locked due to failed login attempts.', - null, - ErrorCodes.AUTH_ACCOUNT_LOCKED - ); - } - - const { code } = req.body || {}; - const outcome = await verifySecondFactor({ userId, code }); - if (!outcome.ok) { - await incrementFailedAttempts(userId); - await activityLog.record({ - action: 'sign-in', - outcome: 'refused', - userId, - detail: { method: 'code' }, - req, - }); - throw new UnauthorizedError('That code is not right.', ErrorCodes.AUTH_INVALID_TOTP_CODE); - } - - await clearLock(userId); - forgetSecondStep(req); - await startAuthenticatedSession(req, userId); - // Both paths: the cookie has been set on `/api` and on `/`, and a guest - // session left behind on the other one outlives the sign-in that should - // have ended it. - res.clearCookie('guestSession', { path: '/' }); - res.clearCookie('guestSession', { path: '/api' }); - - const signedIn = await getRequestUser(req); - await activityLog.record({ - action: 'sign-in', - user: signedIn, - detail: { method: outcome.usedRecoveryCode ? 'recovery code' : 'code' }, - req, - }); - res.json({ - user: signedIn, - usedRecoveryCode: Boolean(outcome.usedRecoveryCode), - recoveryCodesLeft: outcome.recoveryCodesLeft ?? null, - }); - }) -); - -/** Whether this account asks for a code, and how many recovery codes are left. */ -router.get( - '/totp', - asyncHandler(async (req, res) => { - const me = await getRequestUser(req); - if (!me) throw new UnauthorizedError('Authentication required.'); - res.json(await twoFactorStatus(me.id)); - }) -); - -/** - * Draw a secret and show it, which turns nothing on. - * - * What comes back is shown once and never again: the phone keeps it, and the - * copy here is unreadable the moment it is written. - */ -router.post( - '/totp/start', - passwordLimiter, - asyncHandler(async (req, res) => { - refuseWithoutPasswordSignIn(); - const me = await getRequestUser(req); - if (!me) throw new UnauthorizedError('Authentication required.'); - if (!req.session || req.session.localUserId !== me.id) { - throw new ForbiddenError('Sign in with your password to set up a second factor.'); - } - - res.json( - await beginTwoFactorEnrolment({ - userId: me.id, - account: me.email || me.username || me.id, - }) - ); - }) -); - -/** Turn it on, once a code proves the phone holds the same secret. */ -router.post( - '/totp/confirm', - passwordLimiter, - asyncHandler(async (req, res) => { - const me = await getRequestUser(req); - if (!me) throw new UnauthorizedError('Authentication required.'); - - const confirmed = await confirmTwoFactorEnrolment({ userId: me.id, code: req.body?.code }); - if (!confirmed) { - throw new UnauthorizedError('That code is not right.', ErrorCodes.AUTH_INVALID_TOTP_CODE); - } - logger.info({ userId: me.id }, 'Two-factor authentication turned on'); - await activityLog.record({ action: 'account.two-factor', user: me, detail: { on: true }, req }); - res.json(confirmed); - }) -); - -/** - * New recovery codes, and the password to prove it is still the same person. - * - * A browser left unlocked is the case this is about: drawing new codes throws - * the old ones away, and somebody who sat down at a signed-in screen should not - * be able to leave with the only working set. - */ -router.post( - '/totp/recovery-codes', - passwordLimiter, - asyncHandler(async (req, res) => { - const me = await getRequestUser(req); - if (!me) throw new UnauthorizedError('Authentication required.'); - if (!(await verifyLocalPassword({ userId: me.id, password: req.body?.password }))) { - throw new UnauthorizedError( - 'That password is not right.', - ErrorCodes.AUTH_PASSWORD_INCORRECT - ); - } - - res.json({ recoveryCodes: await replaceRecoveryCodes(me.id) }); - }) -); - -/** Off, with the password for the same reason. */ -router.delete( - '/totp', - passwordLimiter, - asyncHandler(async (req, res) => { - const me = await getRequestUser(req); - if (!me) throw new UnauthorizedError('Authentication required.'); - if (!(await verifyLocalPassword({ userId: me.id, password: req.body?.password }))) { - throw new UnauthorizedError( - 'That password is not right.', - ErrorCodes.AUTH_PASSWORD_INCORRECT - ); - } - - await disableTwoFactor(me.id); - logger.info({ userId: me.id }, 'Two-factor authentication turned off'); - await activityLog.record({ - action: 'account.two-factor', - user: me, - detail: { on: false }, - req, - }); - res.status(204).end(); + res.json({ user }); }) ); @@ -767,6 +799,7 @@ router.post( newPassword, keepSessionId: signedInHere ? req.sessionID : null, }); + if (signedInHere) await startAuthenticatedSession(req, me.id); await activityLog.record({ action: 'account.password', user: me, req }); res.status(204).end(); }) @@ -1174,9 +1207,8 @@ router.post( throw new UnauthorizedError('User no longer exists.', ErrorCodes.AUTH_INVALID_CREDENTIALS); } - // Both paths: the cookie has been set on `/api` and on `/`, and a guest - // session left behind on the other one outlives the sign-in that should - // have ended it. + // Clear both scopes: the cookie used to be set on /api, and browsers still + // holding that one would otherwise keep it. res.clearCookie('guestSession', { path: '/' }); res.clearCookie('guestSession', { path: '/api' }); res.json({ user }); 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/src/routes/files/transfer.js b/backend/src/routes/files/transfer.js index 3da76385c..b57a77949 100644 --- a/backend/src/routes/files/transfer.js +++ b/backend/src/routes/files/transfer.js @@ -1,8 +1,41 @@ const { transferItems } = require('../../services/fileTransferService'); +const recentDestinations = require('../../services/recentDestinationsService'); +const { ACTIONS, authorizeAndResolve } = require('../../services/authorizationService'); +const fs = require('node:fs/promises'); const asyncHandler = require('../../utils/asyncHandler'); const router = require('express').Router(); +/** + * Where this user has recently moved or copied things. + * + * Filtered against what they can reach right now: a folder can be deleted or + * have its access revoked long after it was last used, and offering it as a + * destination would only produce a failure at the end of the flow. Anything + * gone is forgotten on the way out, so the list heals itself. + */ +router.get( + '/files/recent-destinations', + asyncHandler(async (req, res) => { + const paths = await recentDestinations.list(req.user?.id); + const context = { user: req.user, guestSession: req.guestSession }; + + const reachable = []; + for (const relativePath of paths) { + const { allowed, resolved } = await authorizeAndResolve(context, relativePath, ACTIONS.write); + const stats = resolved ? await fs.stat(resolved.absolutePath).catch(() => null) : null; + + if (allowed && stats?.isDirectory()) { + reachable.push(relativePath); + } else { + await recentDestinations.forget(req.user?.id, relativePath); + } + } + + res.json({ items: reachable }); + }) +); + router.post( '/files/copy', asyncHandler(async (req, res) => { @@ -11,6 +44,11 @@ router.post( user: req.user, guestSession: req.guestSession, }); + // Recorded from the transfer itself rather than asked of the client, so every + // route into a folder counts — the picker, a drag onto a favorite, a paste — + // and the list reflects where things really go. + await recentDestinations.record(req.user?.id, result.destination); + res.json({ success: true, ...result }); }) ); @@ -23,6 +61,11 @@ router.post( user: req.user, guestSession: req.guestSession, }); + // Recorded from the transfer itself rather than asked of the client, so every + // route into a folder counts — the picker, a drag onto a favorite, a paste — + // and the list reflects where things really go. + await recentDestinations.record(req.user?.id, result.destination); + res.json({ success: true, ...result }); }) ); diff --git a/backend/src/routes/permissions.js b/backend/src/routes/permissions.js index 0d005f38a..7d358795a 100644 --- a/backend/src/routes/permissions.js +++ b/backend/src/routes/permissions.js @@ -1,12 +1,12 @@ 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'); const { ACTIONS, authorizeAndResolve } = require('../services/authorizationService'); -const logger = require('../utils/logger'); const { ensureAdmin } = require('../middleware/ensureAdmin'); +const logger = require('../utils/logger'); const asyncHandler = require('../utils/asyncHandler'); const { ValidationError, @@ -16,7 +16,21 @@ const { } = require('../errors/AppError'); const router = express.Router(); -const execAsync = promisify(exec); +// execFile never spawns a shell: user-supplied owner/group names and file paths +// stay plain arguments instead of being interpolated into a command string. +const execFileAsync = promisify(execFile); + +// POSIX-portable account name, or a numeric id. Rejecting anything else keeps +// `chown` from receiving a value it would read as an option. +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(`Invalid ${label} name.`); + } + return value; +}; /** * Get file permissions, owner, and group information @@ -53,7 +67,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 execFileAsync('id', ['-nu', String(stats.uid)]); owner = ownerOut.trim(); } catch (e) { logger.debug({ err: e }, 'Failed to get owner name'); @@ -61,7 +75,7 @@ router.get( try { // Get group name from gid - const { stdout: groupOut } = await execAsync(`id -gn ${stats.gid}`); + const { stdout: groupOut } = await execFileAsync('id', ['-gn', String(stats.gid)]); group = groupOut.trim(); } catch (e) { logger.debug({ err: e }, 'Failed to get group name'); @@ -91,10 +105,8 @@ router.get( */ router.post( '/permissions/chmod', - // Changing modes and ownership on a shared volume is an administration - // task: a plain write permission on a path is not consent to re-permission - // its tree. `ensureAdmin` says as much in its own comment, and these are the - // two routes it was written for. + // Changing modes on a shared volume is an administration task: a plain + // write permission on a path is not consent to re-permission its tree. ensureAdmin, asyncHandler(async (req, res) => { const { path: rawPath, mode, recursive } = req.body; @@ -107,13 +119,16 @@ router.post( throw new ValidationError('Mode must be a 3-digit octal string (e.g., "755").'); } + // Guests never reach this point: they have no req.user. Carrying a guest + // session on top of a real account does not make the account a guest. if (!req.user || !req.user.id) { throw new UnauthorizedError('Authentication required'); } - // Guests never reach this point: they have no req.user. Carrying a guest - // session on top of a real account does not make the account a guest. const relativePath = normalizeRelativePath(rawPath); + if (relativePath.startsWith('share/')) { + throw new ForbiddenError('Permissions cannot be changed through a share.'); + } const context = { user: req.user, guestSession: req.guestSession }; const { allowed, accessInfo, resolved } = await authorizeAndResolve( context, @@ -125,11 +140,14 @@ router.post( } try { - // Check if path exists - await fs.stat(resolved.absolutePath); - - // Use chmod via Node.js built-in - const modeInt = parseInt(mode, 8); + const before = await fs.stat(resolved.absolutePath); + + // The three digits set read, write and execute. The setuid, setgid and + // sticky bits are not among them, and `chmod` writes the whole mode it is + // given: unticking one box on a shared setgid folder, or on /tmp-like + // sticky one, would silently take those bits away. They are kept as the + // item already had them. + const modeInt = parseInt(mode, 8) | (before.mode & 0o7000); await fs.chmod(resolved.absolutePath, modeInt); // If recursive and directory, apply to all children @@ -139,10 +157,12 @@ router.post( // Use chmod -R for recursive on Unix systems if (process.platform !== 'win32') { try { - await execAsync(`chmod -R ${mode} "${resolved.absolutePath}"`); + // No `--` separator here: BSD chmod (macOS) does not accept it. + // The path is always absolute, so it can never look like a flag. + await execFileAsync('chmod', ['-R', mode, resolved.absolutePath]); } catch (e) { logger.error({ err: e }, 'Failed to apply recursive chmod'); - throw new Error('Failed to apply permissions recursively.'); + throw new Error('Failed to apply permissions recursively.', { cause: e }); } } else { // On Windows, we'd need to recursively walk the directory @@ -176,10 +196,6 @@ router.post( */ router.post( '/permissions/chown', - // Changing modes and ownership on a shared volume is an administration - // task: a plain write permission on a path is not consent to re-permission - // its tree. `ensureAdmin` says as much in its own comment, and these are the - // two routes it was written for. ensureAdmin, asyncHandler(async (req, res) => { const { path: rawPath, owner, group } = req.body; @@ -192,12 +208,18 @@ router.post( throw new ValidationError('Either owner or group must be specified.'); } + const safeOwner = ensureValidAccountName(owner, 'owner'); + const safeGroup = ensureValidAccountName(group, 'group'); + + // Same as above: only a real account gets here. if (!req.user || !req.user.id) { throw new UnauthorizedError('Authentication required'); } - // As above: a guest session beside an account is not a guest. const relativePath = normalizeRelativePath(rawPath); + if (relativePath.startsWith('share/')) { + throw new ForbiddenError('Ownership cannot be changed through a share.'); + } const context = { user: req.user, guestSession: req.guestSession }; const { allowed, accessInfo, resolved } = await authorizeAndResolve( context, @@ -212,21 +234,27 @@ router.post( // Check if path exists await fs.stat(resolved.absolutePath); - // chown requires shell execution as Node.js doesn't have built-in owner/group change - // This requires elevated privileges on most systems + // Node has no built-in owner/group change by name, so the system tools do + // it. Arguments are passed as an array, never through a shell, and the + // account names were validated above so they cannot look like flags + // (the path is absolute, so it cannot either). if (process.platform !== 'win32') { - let chownCmd = ''; - - if (owner && group) { - chownCmd = `chown "${owner}:${group}" "${resolved.absolutePath}"`; - } else if (owner) { - chownCmd = `chown "${owner}" "${resolved.absolutePath}"`; - } else if (group) { - chownCmd = `chgrp "${group}" "${resolved.absolutePath}"`; + let command = ''; + let args = []; + + if (safeOwner && safeGroup) { + command = 'chown'; + args = [`${safeOwner}:${safeGroup}`, resolved.absolutePath]; + } else if (safeOwner) { + command = 'chown'; + args = [safeOwner, resolved.absolutePath]; + } else { + command = 'chgrp'; + args = [safeGroup, resolved.absolutePath]; } try { - await execAsync(chownCmd); + await execFileAsync(command, args); logger.info({ path: relativePath, owner, group }, 'Ownership changed'); } catch (e) { logger.error({ err: e }, 'Failed to change ownership'); @@ -236,7 +264,7 @@ router.post( 'Permission denied. Changing ownership typically requires root/admin privileges.' ); } - throw new Error('Failed to change ownership: ' + e.message); + throw new Error('Failed to change ownership: ' + e.message, { cause: e }); } } else { throw new ValidationError('Changing ownership is not supported on Windows.'); 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/routes/shares.js b/backend/src/routes/shares.js index 69a44b5b7..f2ca4d5f1 100644 --- a/backend/src/routes/shares.js +++ b/backend/src/routes/shares.js @@ -1,11 +1,11 @@ const express = require('express'); -const { parseByteRange } = require('../utils/httpRange'); const fs = require('fs/promises'); const fss = require('fs'); const path = require('path'); const archiver = require('archiver'); const rateLimit = require('express-rate-limit'); const asyncHandler = require('../utils/asyncHandler'); +const { sendTextFile } = require('../utils/textFileResponse'); const { ValidationError, UnauthorizedError, @@ -32,23 +32,25 @@ const { const { createGuestSession } = require('../services/guestSessionService'); const { normalizeRelativePath, parsePathSpace } = require('../utils/pathUtils'); const { pathExists } = require('../utils/fsUtils'); +const { parseByteRange } = require('../utils/httpRange'); const { resolvePathWithAccess, sharePasswordApplies } = require('../services/accessManager'); const { extensions, mimeTypes } = require('../config/index'); +const env = require('../config/env'); const { getSettings, getUserSettings } = require('../services/settingsService'); const { listDirectoryItems } = require('../services/directoryListingService'); const { encodeContentDisposition } = require('./files/utils'); const { collectArchiveEntries, appendEntries } = require('../services/archiveTree'); const logger = require('../utils/logger'); - -const activityLog = require('../services/activityLog'); -const versions = require('../services/versions/operations'); -const { sendTextFile } = require('../utils/textFileResponse'); -const { clientAddress } = require('../utils/clientAddress'); const { readTextFileHead, encodeText, MAX_EDITOR_FILE_SIZE, } = require('../services/textEditorService'); +const versions = require('../services/versions/operations'); +const activityLog = require('../services/activityLog'); +const versionsService = require('../services/versions'); +const { rightsFrom: versionRights } = versionsService; +const { clientAddress } = require('../utils/clientAddress'); const router = express.Router(); @@ -82,9 +84,27 @@ const guestSessionCookieOptions = (req) => ({ maxAge: 24 * 60 * 60 * 1000, // 24 hours sameSite: 'lax', secure: req.secure === true, - path: '/api', // Ensure cookie is sent for all /api/* requests + // Root path, not /api: thumbnails are served from /static, and an + // cannot carry the X-Guest-Session header the API client uses. Scoping the + // cookie to /api left share visitors with broken thumbnails. + path: '/', }); +/** + * Set the guest session cookie, clearing the /api-scoped one first. + * + * An earlier build scoped this cookie to /api. Browsers keep both when the + * path differs, and RFC 6265 sends the longer path first, so on every /api + * request cookie-parser would read the stale value and shadow the session we + * just created — a dead end the visitor could not fix by retyping the + * password. Deleting it here reaches exactly the people affected, since every + * share visitor goes through one of these three endpoints. + */ +const setGuestSessionCookie = (req, res, sessionId) => { + res.clearCookie('guestSession', { path: '/api' }); + res.cookie('guestSession', sessionId, guestSessionCookieOptions(req)); +}; + const buildPublicBaseUrl = (req) => { const { public: publicConfig } = require('../config/index'); return publicConfig.origin || `${req.protocol}://${req.get('host')}`; @@ -212,7 +232,7 @@ const buildDirectFilePath = (shareToken, innerPath = '', mode = 'auto') => { const query = normalizedMode === 'auto' ? '' : `?mode=${encodeURIComponent(normalizedMode)}`; const pathPart = encodedInnerPath ? `/api/share/${encodedToken}/file/${encodedInnerPath}` - : `/api/share/${encodedToken}/file`; + : `/api/share/${encodedToken}`; return `${pathPart}${query}`; }; @@ -310,8 +330,8 @@ const streamResolvedDirectoryZip = async ({ }); archive.pipe(res); - // What the share lets its visitor see, not everything below its folder: a - // personal root and the paths an access rule hides stay out. + // What the share lets its visitor see, not everything below its folder: the + // trash zone, a personal root and the paths an access rule hides stay out. const stats = await fs.stat(absolutePath); const { entries } = await collectArchiveEntries(context, [ { @@ -338,6 +358,13 @@ router.post( const { sourcePath, accessMode = 'readonly', + allowDelete = true, + allowCreateFolder = true, + allowCreateFile = true, + allowUpload = true, + allowDownload = true, + versionsVisible, + versionsDownload, sharingType = 'anyone', password, userIds, @@ -409,6 +436,13 @@ router.post( sourcePath: sourcePathForDb, isDirectory, accessMode, + allowDelete, + allowCreateFolder, + allowCreateFile, + allowUpload, + allowDownload, + versionsVisible, + versionsDownload, sharingType, password, userIds: sharingType === 'users' ? userIds : [], @@ -416,6 +450,11 @@ router.post( label, }); + // Generate share URL using PUBLIC_URL if configured, otherwise use request host + const baseUrl = buildPublicBaseUrl(req); + const shareUrl = `${baseUrl}/share/${share.shareToken}`; + const directFileUrl = `${baseUrl}${buildDirectFilePath(share.shareToken)}`; + await activityLog.record({ action: 'share.create', user: req.user, @@ -424,11 +463,6 @@ router.post( req, }); - // Generate share URL using PUBLIC_URL if configured, otherwise use request host - const baseUrl = buildPublicBaseUrl(req); - const shareUrl = `${baseUrl}/share/${share.shareToken}`; - const directFileUrl = `${baseUrl}${buildDirectFilePath(share.shareToken)}`; - res.status(201).json({ ...share, shareUrl, @@ -475,6 +509,16 @@ router.get( router.get( '/:id', asyncHandler(async (req, res) => { + // This router is mounted under both /api/shares (management) and + // /api/share (public links). The exact public token URL must be handled + // here before the management endpoint can interpret the token as a share + // ID. Named public routes have an additional path segment and do not match + // this route. + if (req.baseUrl === '/api/share') { + req.params.token = req.params.id; + return handleDirectFileRequest(req, res); + } + if (!req.user || !req.user.id) { throw new UnauthorizedError('Authentication required'); } @@ -527,6 +571,18 @@ router.put( updates.accessMode = req.body.accessMode; } + for (const key of [ + 'allowDelete', + 'allowCreateFolder', + 'allowCreateFile', + 'allowUpload', + 'allowDownload', + 'versionsVisible', + 'versionsDownload', + ]) { + if (key in req.body) updates[key] = req.body[key]; + } + if ('sharingType' in req.body) { updates.sharingType = req.body.sharingType; } @@ -607,16 +663,14 @@ router.get( throw new NotFoundError('Share not found'); } - // Return limited public info + // Return limited public info. requiresPassword mirrors the backend rule + // rather than hasPassword alone, so the router does not send the owner to + // a password prompt the API would have let them skip. res.json({ shareToken: share.shareToken, label: share.label, isDirectory: share.isDirectory, hasPassword: share.hasPassword, - // Whether this caller has to type it: its owner does not, and neither - // does anybody when authentication is off. The interface asks for the - // password on this, rather than on hasPassword, so the owner is not sent - // to a prompt the server would have let them skip. requiresPassword: sharePasswordApplies(share, req.user), sharingType: share.sharingType, expiresAt: share.expiresAt, @@ -650,22 +704,23 @@ router.post( if (share.sharingType === 'anyone') { const session = await createGuestSession({ shareId: share.id, - ipAddress: req.ip, + ipAddress: clientAddress(req), userAgent: req.get('user-agent'), }); + await trackShareAccess(share.id, { ipAddress: clientAddress(req) }); // Set guest session cookie - res.cookie('guestSession', session.id, guestSessionCookieOptions(req)); + setGuestSessionCookie(req, res, session.id); res.json({ success: true, guestSessionId: session.id, }); return; - } else { - // User-specific share without password still requires auth - throw new UnauthorizedError('Authentication required'); } + + // User-specific share without password still requires auth + throw new UnauthorizedError('Authentication required'); } // Verify password @@ -679,12 +734,13 @@ router.post( if (share.sharingType === 'anyone') { const session = await createGuestSession({ shareId: share.id, - ipAddress: req.ip, + ipAddress: clientAddress(req), userAgent: req.get('user-agent'), }); + await trackShareAccess(share.id, { ipAddress: clientAddress(req) }); // Set guest session cookie - res.cookie('guestSession', session.id, guestSessionCookieOptions(req)); + setGuestSessionCookie(req, res, session.id); res.json({ success: true, @@ -716,9 +772,6 @@ router.get( throw new ForbiddenError('Share has expired'); } - // Track access - await trackShareAccess(share.id, { ipAddress: req.ip }); - // Check if user has permission if (share.sharingType === 'users') { if (!req.user || !req.user.id) { @@ -751,12 +804,15 @@ router.get( // looking made the branch below ask for the password a second time, // for a share it had just been given. if (req.guestSession && req.guestSession.shareId === share.id) { + await trackShareAccess(share.id, { ipAddress: clientAddress(req) }); + return res.json({ share: { shareToken: share.shareToken, label: share.label, sourcePath: `share/${share.shareToken}`, accessMode: share.accessMode, + allowDownload: share.allowDownload !== false, isDirectory: share.isDirectory, }, guestSessionId: req.guestSession.id, @@ -767,12 +823,13 @@ router.get( if (!share.hasPassword) { const session = await createGuestSession({ shareId: share.id, - ipAddress: req.ip, + ipAddress: clientAddress(req), userAgent: req.get('user-agent'), }); + await trackShareAccess(share.id, { ipAddress: clientAddress(req) }); // Set guest session cookie (overwrites any existing session) - res.cookie('guestSession', session.id, guestSessionCookieOptions(req)); + setGuestSessionCookie(req, res, session.id); return res.json({ share: { @@ -780,14 +837,17 @@ router.get( label: share.label, sourcePath: `share/${share.shareToken}`, accessMode: share.accessMode, + allowDownload: share.allowDownload !== false, isDirectory: share.isDirectory, }, guestSessionId: session.id, }); - } else { - throw new UnauthorizedError('Password verification required'); } + + throw new UnauthorizedError('Password verification required'); } + + await trackShareAccess(share.id, { ipAddress: clientAddress(req) }); } // Return share access info @@ -797,6 +857,7 @@ router.get( label: share.label, sourcePath: `share/${share.shareToken}`, accessMode: share.accessMode, + allowDownload: share.allowDownload !== false, isDirectory: share.isDirectory, expiresAt: share.expiresAt, }, @@ -805,36 +866,6 @@ router.get( }) ); -/** - * A file that left through a link. - * - * The share's own counters answer "how many"; this answers "which file, when, - * and from where" — the question somebody actually asks the day a link turns - * out to have been handed around. The person on the other end has no account, - * so the actor is the link itself. - */ -const recordShareDownload = ({ share, resolved, req }) => - activityLog.record({ - action: 'share.download', - user: req.user, - actor: req.user?.username || share.label || `link ${share.shareToken?.slice(0, 8)}`, - target: resolved.relativePath || share.sourcePath, - detail: { share: share.label || null, token: share.shareToken?.slice(0, 8) || null }, - req, - }); - -/** - * The file a share link points at, and what this caller may do with it. - * - * Everything a request through a link has to get past before the file is - * touched: the link itself, who is following it, and what the location - * underneath still allows. Written once because three routes ask it — the - * direct file, and the two the editor uses — and each asks for something - * slightly different, which is what the options are. - * - * @returns the target, or null when the caller was redirected to the share's - * own door and there is nothing more for the route to do. - */ const resolveSharedFileTarget = async ( req, res, @@ -918,15 +949,32 @@ const resolveSharedFileTarget = async ( return { share, innerPath, accessInfo, resolved, stats, context }; }; +/** + * A file that left through a link. + * + * The share's own counters answer "how many"; this answers "which file, when, + * and from where" — the question somebody actually asks the day a link turns + * out to have been handed around. The person on the other end has no account, + * so the actor is the link itself. + */ +const recordShareDownload = ({ share, resolved, req }) => + activityLog.record({ + action: 'share.download', + user: req.user, + actor: req.user?.username || share.label || `link ${share.shareToken?.slice(0, 8)}`, + target: resolved.relativePath || share.sourcePath, + detail: { share: share.label || null, token: share.shareToken?.slice(0, 8) || null }, + req, + }); + const handleDirectFileRequest = async (req, res) => { - const mode = normalizeDirectFileMode(req.query?.mode); const target = await resolveSharedFileTarget(req, res, { requireDownload: true }); if (!target) return; const { share, resolved, stats, context } = target; if (stats.isDirectory()) { - // A folder leaving as a zip is a download like any other. - await trackShareDownload(share.id, { ipAddress: req.ip }); + // Directories are always delivered as a ZIP attachment. + await trackShareDownload(share.id, { ipAddress: clientAddress(req) }); await recordShareDownload({ share, resolved, req }); await streamResolvedDirectoryZip({ absolutePath: resolved.absolutePath, @@ -942,27 +990,37 @@ const handleDirectFileRequest = async (req, res) => { return; } - await trackShareDownload(share.id, { ipAddress: req.ip }); - await recordShareDownload({ share, resolved, req }); - await streamResolvedFile({ absolutePath: resolved.absolutePath, stats, mode, req, res }); + // Count the hit the way the client receives the file: inline previews are + // accesses, attachment deliveries (explicit download mode or formats the + // browser cannot display) are downloads — same split as POST /api/download. + const mode = normalizeDirectFileMode(req.query?.mode); + const { disposition } = getDirectFilePresentation(path.basename(resolved.absolutePath), mode); + if (disposition === 'attachment') { + await trackShareDownload(share.id, { ipAddress: clientAddress(req) }); + await recordShareDownload({ share, resolved, req }); + } else { + await trackShareAccess(share.id, { ipAddress: clientAddress(req) }); + } + await streamResolvedFile({ + absolutePath: resolved.absolutePath, + stats, + mode, + req, + res, + }); }; /** * GET /api/share/:token/file/* - Open a shared file directly. * - * This keeps the same share rules as the Web UI but streams the target file - * itself, letting the browser preview supported formats or download others. + * The exact /api/share/:token alias is handled by the mounted router's + * management route above. It intentionally calls this same handler so token, + * expiry, password, guest-session and permission checks cannot drift apart. + * Keep /file routes for existing external integrations. */ router.get('/:token/file', asyncHandler(handleDirectFileRequest)); router.get('/:token/file/{*splat}', asyncHandler(handleDirectFileRequest)); -/** - * GET /api/share/:token/editor/* — read a shared text file, to edit it. - * - * The same rules as the direct file, and the same answer the editor gets - * inside the application: the text, what it is called, and what this visitor - * may do with it. - */ const handleSharedEditorRequest = async (req, res) => { const target = await resolveSharedFileTarget(req, res, { allowSharedFileName: true }); if (!target) return; @@ -985,12 +1043,12 @@ const handleSharedEditorRequest = async (req, res) => { }); }; +/** + * GET /api/share/:token/editor/* - Read a shared text file. + */ router.get('/:token/editor', asyncHandler(handleSharedEditorRequest)); router.get('/:token/editor/{*splat}', asyncHandler(handleSharedEditorRequest)); -/** - * PUT /api/share/:token/editor/* — save it back, when the link allows writing. - */ const handleSharedEditorSaveRequest = async (req, res) => { const target = await resolveSharedFileTarget(req, res, { requireWrite: true, @@ -1003,9 +1061,10 @@ const handleSharedEditorSaveRequest = async (req, res) => { if (typeof content !== 'string') { throw new ValidationError('Text editor content must be a string.'); } - // The editor's own text validation before anything is written, so a writable - // share cannot be used to modify a directory, a binary or an oversized file. - // It also says what the file is written in, so the save keeps that. + // Reuse the editor's text validation before writing so a writable share + // cannot be used to modify directories, binaries, or oversized files. It also + // says what the file is written in, so the save keeps that. From the head of + // the file: this asked for the whole of it, decoded, to read three bytes. const { encoding } = await readTextFileHead(resolved.absolutePath); const payload = encodeText(content, encoding); if (payload.length > MAX_EDITOR_FILE_SIZE) { @@ -1072,7 +1131,8 @@ router.get( // Determine thumbnail settings const settings = await getSettings(); const userSettings = req.user?.id ? await getUserSettings(req.user.id) : {}; - const thumbsEnabled = settings?.thumbnails?.enabled !== false; + const thumbsEnabled = + env.THUMBNAILS_ENABLED !== false && settings?.thumbnails?.enabled !== false; const includeHiddenFiles = userSettings?.showHiddenFiles === true; // Directory share or navigating inside a directory share @@ -1083,24 +1143,51 @@ router.get( shareCache.set(resolved.shareInfo.shareToken, resolved.shareInfo); } const userVolumeCache = new Map(); + const marks = + userSettings?.showVersionMarks === false + ? null + : await versionsService.marksForFolder(resolved.absolutePath).catch((error) => { + logger.warn( + { err: error, path: resolved.absolutePath }, + 'File versions were not counted for a shared listing' + ); + return null; + }); + const items = await listDirectoryItems({ absoluteDir: resolved.absolutePath, parentLogicalPath: resolved.relativePath, context, thumbsEnabled, - excludeDownloadArtifacts: false, includeHiddenFiles, access: settings?.access || null, shareCache, userVolumeCache, - itemExtras: () => ({ + itemExtras: ({ name, stats, access }) => ({ access: { canRead: true, canWrite: accessInfo.canWrite, canDelete: accessInfo.canDelete, + canCreateFolder: accessInfo.canCreateFolder, + canCreateFile: accessInfo.canCreateFile, canShare: false, - canDownload: true, + // Follows the share rather than being hard true: otherwise every + // row in a share with downloads withheld still shows the button, + // and clicking it is the only way to find out. + canDownload: accessInfo.canDownload, }, + // The same mark the browser shows, under the same rule: a share + // says nothing about a file's history unless its owner turned + // histories on for it. + ...(marks && stats?.isFile() && marks.get(name) && versionRights(access).see + ? { + versions: { + count: marks.get(name).versions, + bytes: marks.get(name).bytes, + newest: marks.get(name).newest, + }, + } + : null), }), }); @@ -1111,8 +1198,11 @@ router.get( canWrite: accessInfo.canWrite, canUpload: accessInfo.canUpload, canDelete: accessInfo.canDelete, + canCreateFolder: accessInfo.canCreateFolder, + canCreateFile: accessInfo.canCreateFile, canShare: false, canDownload: accessInfo.canDownload, + canSeeVersions: versionRights(accessInfo).see, }, current: { isDirectory: true, @@ -1169,8 +1259,11 @@ router.get( canWrite: accessInfo.canWrite, canUpload: false, canDelete: accessInfo.canDelete, + canCreateFolder: false, + canCreateFile: false, canShare: false, canDownload: accessInfo.canDownload, + canSeeVersions: versionRights(accessInfo).see, }, current: { isDirectory: false, diff --git a/backend/src/routes/usage.js b/backend/src/routes/usage.js index f7c5060e8..067320eaa 100644 --- a/backend/src/routes/usage.js +++ b/backend/src/routes/usage.js @@ -1,28 +1,41 @@ const express = require('express'); -const { promisify } = require('util'); -const { exec } = require('child_process'); +const fs = require('fs/promises'); const { normalizeRelativePath } = require('../utils/pathUtils'); const { resolvePathWithAccess } = require('../services/accessManager'); const logger = require('../utils/logger'); const asyncHandler = require('../utils/asyncHandler'); -const execp = promisify(exec); const router = express.Router(); -// Fast directory size using du command -const dirSize = async (root) => { +const toSafeNumber = (value) => { + const numeric = typeof value === 'bigint' ? Number(value) : Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : 0; +}; + +const getFilesystemUsage = 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}"`, { - maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large outputs - }); + const stats = await fs.statfs(root); + const blockSize = toSafeNumber(stats.bsize); + const total = toSafeNumber(stats.blocks) * blockSize; + const free = toSafeNumber(stats.bavail) * blockSize; + const used = Math.max(0, total - free); + const percentUsed = total > 0 ? Math.min(100, Math.max(0, (used / total) * 100)) : 0; - // Output format: "12345\t/path/to/dir" - const size = parseInt(stdout.split('\t')[0], 10); - return size || 0; + return { + size: used, + used, + free, + total, + percentUsed, + }; } catch (err) { - logger.debug(err); - return 0; + logger.debug({ err, root }, 'Failed to read filesystem usage'); + return { + size: 0, + used: 0, + free: 0, + total: 0, + percentUsed: 0, + }; } }; @@ -36,31 +49,18 @@ router.get( const { accessInfo, resolved } = await resolvePathWithAccess(context, inputRel); if (!accessInfo || !accessInfo.canAccess || !accessInfo.canRead) { - // Treat denied access the same as du failing; zero usage - return res.json({ path: inputRel, size: 0, free: 0, total: 0 }); + return res.json({ + path: inputRel, + size: 0, + used: 0, + free: 0, + total: 0, + percentUsed: 0, + }); } const { absolutePath: abs, relativePath: rel } = resolved; - - // Run both commands in parallel for maximum speed - const [size, dfResult] = await Promise.all([ - dirSize(abs), - execp(`df -Pk "${abs}"`).catch(() => ({ stdout: '' })), - ]); - - let total = 0, - free = 0; - - if (dfResult.stdout) { - const line = dfResult.stdout.trim().split('\n').pop(); - const parts = line.trim().split(/\s+/); - const totalKb = parseInt(parts[1], 10) || 0; - const availKb = parseInt(parts[3], 10) || 0; - total = totalKb * 1024; - free = availKb * 1024; - } - - res.json({ path: rel, size, free, total }); + res.json({ path: rel, ...(await getFilesystemUsage(abs)) }); }) ); diff --git a/backend/src/routes/userVolumes.js b/backend/src/routes/userVolumes.js index 6437f33fe..d8890a943 100644 --- a/backend/src/routes/userVolumes.js +++ b/backend/src/routes/userVolumes.js @@ -144,7 +144,7 @@ router.get( let targetPath; try { targetPath = path.resolve(requestedPath); - } catch (err) { + } catch (_) { throw new ValidationError('Invalid path'); } @@ -152,7 +152,7 @@ router.get( let stats; try { stats = await fs.stat(targetPath); - } catch (err) { + } catch (_) { throw new NotFoundError('Path not found'); } @@ -164,7 +164,7 @@ router.get( let entries; try { entries = await fs.readdir(targetPath, { withFileTypes: true }); - } catch (err) { + } catch (_) { throw new ForbiddenError('Cannot read directory'); } diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 9413843e7..52381f0c3 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -8,17 +8,18 @@ const { setLocalPasswordAdmin, deleteUser, getById, - countAdmins, disableTwoFactor, + twoFactorStatus, } = require('../services/users'); -const activityLog = require('../services/activityLog'); -const { ensureAdmin } = require('../middleware/ensureAdmin'); -const { clearLock } = require('../services/users/lockout'); -const { deleteAllPasskeys } = require('../services/users/passkeys'); const asyncHandler = require('../utils/asyncHandler'); -const logger = require('../utils/logger'); const { searchLocalUsers } = require('../services/userSearchService'); const { NotFoundError, ValidationError, UnauthorizedError } = require('../errors/AppError'); +const { ensureAdmin } = require('../middleware/ensureAdmin'); +const { clearLock, listActiveLocks } = require('../services/users/lockout'); +const { deleteAllPasskeys } = require('../services/users/passkeys'); +const activityLog = require('../services/activityLog'); +const logger = require('../utils/logger'); +const { startAuthenticatedSession } = require('../utils/authenticatedSession'); const router = express.Router(); @@ -52,8 +53,19 @@ router.get( '/users', ensureAdmin, asyncHandler(async (req, res) => { - const users = await listUsers(); - res.json({ users }); + const [users, locks] = await Promise.all([listUsers(), listActiveLocks()]); + // Locks are keyed on the account (see localAuth), so the list can say which + // account is locked and until when — the first question an administrator + // has when somebody cannot sign in. Whether a second factor is on is the + // second one, and the answer to "I have lost my phone". + const withFactors = await Promise.all( + users.map(async (user) => ({ + ...user, + lockedUntil: locks.get(user.id) || null, + twoFactorEnabled: (await twoFactorStatus(user.id)).enabled, + })) + ); + res.json({ users: withFactors }); }) ); @@ -139,39 +151,16 @@ router.post( asyncHandler(async (req, res) => { const { id } = req.params || {}; const { newPassword } = req.body || {}; - await setLocalPasswordAdmin({ userId: id, newPassword }); - await activityLog.record({ - action: 'admin.user', - user: req.user, - target: id, - detail: { passwordReset: true }, - req, - }); - res.status(204).end(); - }) -); - -// DELETE /api/users/:id/lock - release an account locked by failed sign-ins (admin only) -router.delete( - '/users/:id/lock', - ensureAdmin, - asyncHandler(async (req, res) => { - const { id } = req.params || {}; - const existing = await getById(id); - if (!existing) { - throw new NotFoundError('User not found.'); - } - // The count as well as the deadline. Left on the books, the failures would - // let the next typo lock the account straight back. - await clearLock(id); - logger.info({ adminId: req.user?.id, userId: id }, 'Sign-in lock released by an administrator'); - await activityLog.record({ - action: 'admin.user', - user: req.user, - target: existing.username || existing.email || id, - detail: { lockReleased: true }, - req, + // Every session of the account ends. An administrator resetting their own + // password here is changing it, and keeps the session they did it from — on + // a new id, as a change from their own settings does. + const ownSession = Boolean(req.session) && req.session.localUserId === id; + await setLocalPasswordAdmin({ + userId: id, + newPassword, + keepSessionId: ownSession ? req.sessionID : null, }); + if (ownSession) await startAuthenticatedSession(req, id); res.status(204).end(); }) ); @@ -190,20 +179,13 @@ router.delete( asyncHandler(async (req, res) => { const { id } = req.params || {}; const user = await getById(id); - if (!user) throw new NotFoundError('User not found.'); + if (!user) throw new NotFoundError('User not found'); const removed = await disableTwoFactor(id); logger.warn( { userId: id, by: req.user?.id || null, removed }, 'An administrator turned two-factor authentication off for an account' ); - await activityLog.record({ - action: 'admin.user', - user: req.user, - target: user.username || user.email || id, - detail: { twoFactorRemoved: true }, - req, - }); res.status(204).end(); }) ); @@ -223,7 +205,7 @@ router.delete( asyncHandler(async (req, res) => { const { id } = req.params || {}; const user = await getById(id); - if (!user) throw new NotFoundError('User not found.'); + if (!user) throw new NotFoundError('User not found'); const removed = await deleteAllPasskeys(id); logger.warn( @@ -241,6 +223,24 @@ router.delete( }) ); +// DELETE /api/users/:id/lock - release an account locked by failed sign-ins (admin only) +router.delete( + '/users/:id/lock', + ensureAdmin, + asyncHandler(async (req, res) => { + const { id } = req.params || {}; + const existing = await getById(id); + if (!existing) { + throw new NotFoundError('User not found.'); + } + // The count as well as the deadline. Left on the books, the failures would + // let the next typo lock the account straight back. + await clearLock(id); + logger.info({ adminId: req.user?.id, userId: id }, 'Sign-in lock released by an administrator'); + res.status(204).end(); + }) +); + // DELETE /api/users/:id - remove a user (admin only) router.delete( '/users/:id', @@ -251,18 +251,16 @@ router.delete( if (req.user?.id === id) { throw new ValidationError('You cannot delete your own account.'); } - // Prevent removing last admin explicitly const existing = await getById(id); if (!existing) { throw new NotFoundError('User not found.'); } - if (Array.isArray(existing.roles) && existing.roles.includes('admin')) { - const admins = await countAdmins(); - if (admins <= 1) { - throw new ValidationError('Cannot remove the last admin.'); - } - } + // The last administrator is protected by the service, so every caller of + // deleteUser gets the rule and not only this route. A second copy here + // read as the enforcement and was not: removing it changed nothing. await deleteUser({ userId: id }); + // Written after the deletion, and it outlives it: the row names the + // account as text, and nothing cascades this log away. await activityLog.record({ action: 'admin.user', user: req.user, diff --git a/backend/src/routes/zip.js b/backend/src/routes/zip.js index 7e09a4bfc..81ef7b9b7 100644 --- a/backend/src/routes/zip.js +++ b/backend/src/routes/zip.js @@ -29,6 +29,7 @@ const { archiveBaseName, normalizeArchivePassword, } = require('../services/archiveService'); +const folderSizeHooks = require('../services/folderSizeHooks'); const { ensureArchiveWithinLimits, buildItemMetadata, @@ -208,6 +209,7 @@ router.post( ); // The archive has produced an entire new tree. Queue its index refresh, // but never hold the archive operation open on background filesystem I/O. + folderSizeHooks.onDirectoryTreeCreated(placed.path); const item = await buildItemMetadata(placed.path, parentRelativePath, placed.name); writeEvent({ type: 'done', success: true, item, items: [item] }); @@ -410,6 +412,8 @@ router.post( destinationAbsolutePath, requestedName ); + const zipStats = await fs.stat(placed.path); + await folderSizeHooks.onFileWritten(placed.path, zipStats.size); const item = await buildItemMetadata(placed.path, normalizedDestination, placed.name); writeEvent({ type: 'done', success: true, item }); 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/accessControlService.js b/backend/src/services/accessControlService.js index 12a51d96a..a2206f1aa 100644 --- a/backend/src/services/accessControlService.js +++ b/backend/src/services/accessControlService.js @@ -2,18 +2,22 @@ const fs = require('fs/promises'); const path = require('path'); const { directories } = require('../config/index'); -const { isInsidePersonalRoot, normalizeRelativePath } = require('../utils/pathUtils'); -const { getSettings, setSettings } = require('../services/settingsService'); +const { normalizeRelativePath, isInsidePersonalRoot } = require('../utils/pathUtils'); const { ruleAppliesToAdmins } = require('../utils/accessRules'); +const { getSettings, setSettings } = require('../services/settingsService'); /** - * Whether this rule binds the caller. + * Whether a rule is one of the rules this caller is held to. * - * An administrator used to sit outside read-only rules and inside hidden ones, + * An administrator used to be outside read-only rules and inside hidden ones, * which is neither and was written nowhere: a read-only rule left the Create - * button on a folder for them and they wrote into it, while a hidden rule took - * a folder away from the one account meant to manage it, with no way to say - * otherwise. It is one switch now, the same whatever the rule grants. + * button there for them (nxzai/NextExplorer#407), while a hidden rule took the + * folder away from the one account meant to manage it. Each rule now says it, + * with one switch whatever it grants, and the setting above it applies them all + * to administrators at once. + * + * A rule an administrator is not held to is not a rule that lets them through: + * it is skipped, so a later rule still has its say. */ const heldTo = (rule, { isAdmin, applyToAdmins }) => { if (!isAdmin) return true; @@ -22,12 +26,12 @@ const heldTo = (rule, { isAdmin, applyToAdmins }) => { }; /** - * The rules, as a question that can be asked many times without reading them - * again. + * Resolve a path against the access rules, for one caller. * - * @param {{rules?: Array, applyToAdmins?: boolean}} access the access - * section, rules and the setting above them together — not the rules alone, - * because whom a rule holds is decided by both. + * @param {{rules?: Array, applyToAdmins?: boolean}} access the rules in + * force and whether every one of them also holds administrators. An object + * rather than the bare list, so a caller cannot pass the rules and quietly + * lose the setting that decides who they bind. * @returns {(relativePath: string, who?: {isAdmin?: boolean}) => 'rw'|'ro'|'hidden'} */ const createPermissionResolver = (access = {}) => { @@ -42,19 +46,12 @@ const createPermissionResolver = (access = {}) => { for (const rule of normalizedRules) { const rulePath = normalizeRelativePath(rule.path || ''); if (!rulePath) continue; - // A rule that does not hold this caller does not stand in the way of a - // later one either: it is passed over, not matched and waived. if (!heldTo(rule, { isAdmin, applyToAdmins })) continue; - if (rule.recursive) { - if (rel === rulePath || rel.startsWith(rulePath + '/')) { - return rule.permissions || 'rw'; - } - } else { - if (rel === rulePath) { - return rule.permissions || 'rw'; - } - } + const matches = rule.recursive + ? rel === rulePath || rel.startsWith(`${rulePath}/`) + : rel === rulePath; + if (matches) return rule.permissions || 'rw'; } return 'rw'; @@ -62,7 +59,7 @@ const createPermissionResolver = (access = {}) => { }; // Determine permission for a given relative path: 'rw' | 'ro' | 'hidden' -const getPermissionForPath = async (relativePath, who) => { +const getPermissionForPath = async (relativePath, who = {}) => { const settings = await getSettings(); return createPermissionResolver(settings?.access)(relativePath, who); }; diff --git a/backend/src/services/accessManager.js b/backend/src/services/accessManager.js index ce8096df1..6389ef691 100644 --- a/backend/src/services/accessManager.js +++ b/backend/src/services/accessManager.js @@ -18,18 +18,19 @@ const { auth, features, directories } = require('../config/index'); * everyone is the same synthetic admin who already browses the whole * filesystem, so the prompt would only lock the share without protecting it. * - * Everyone else is subject to it, and that includes a signed-in account: being - * authenticated is not knowing the password. The check used to be "is there a - * user or a guest session", so any account on the instance opening a protected - * link walked straight past the prompt its owner set up. + * Everyone else is subject to it, and that includes a visitor with no account + * at all — the very people a public password is for. This used to require a + * user, so the predicate answered "no password here" for anonymous callers and + * each caller made up the difference on its own: one added `|| (hasPassword && + * !user)` to what it reported, another let the case fall through to a later + * branch that happened to refuse. The protection was real and lived in two + * places under a name that promised one. */ const sharePasswordApplies = (share, user) => Boolean(share.hasPassword) && auth.enabled !== false && !(user && String(user.id) === String(share.ownerId)); -const PERSONAL_SIDEWAYS = 'Personal folders are reached through the personal space'; - /** * Get comprehensive access information for a path * @param {Object} context - { user, guestSession, shareToken } @@ -91,7 +92,7 @@ const getVolumeAccess = async (context, relativePath, options = {}) => { // The same rule as below, from the folder this account was assigned. const innerPath = relativePath.split('/').filter(Boolean).slice(1).join('/'); if (reachesIntoPersonalRoot(userVolume.path, path.resolve(userVolume.path, innerPath))) { - return createDeniedAccess(PERSONAL_SIDEWAYS); + return createDeniedAccess('Personal folders are reached through the personal space'); } // Use the volume's access mode @@ -112,6 +113,7 @@ const getVolumeAccess = async (context, relativePath, options = {}) => { canDelete: !effectiveReadOnly, canUpload: !effectiveReadOnly, canCreateFolder: !effectiveReadOnly, + canCreateFile: !effectiveReadOnly, canShare: true, canDownload: true, isShared: false, @@ -127,23 +129,22 @@ const getVolumeAccess = async (context, relativePath, options = {}) => { // Somebody's personal folder is not part of the volume, even where it sits // inside it — `/_users` by default. Resolving such a path already // refuses; this is the same answer for a caller who asks about a path it - // never resolves. The search is one: ripgrep and the index read the whole - // volume and ask only this whether each path may be shown, so an ordinary - // account searching the volume was offered another account's private - // files, by name and by the line that matched. + // never resolves. The search is one: it takes paths from the index, which + // reads the whole volume, and asks only this whether each may be shown. An + // ordinary account searching the volume was offered another account's + // private files, by name and by what they said. if ( reachesIntoPersonalRoot( directories.volume, path.resolve(directories.volume, relativePath || '') ) ) { - return createDeniedAccess(PERSONAL_SIDEWAYS); + return createDeniedAccess('Personal folders are reached through the personal space'); } // Check access control rules. A rule that does not hold administrators was // already passed over while resolving, so what comes back is what binds this - // caller — and an administrator is no longer excused from it a second time - // here, which is how a read-only rule left them the Create button. + // caller: an administrator is no longer excused from it here. const permission = await getPerm(relativePath, { isAdmin }); if (permission === 'hidden') { return createDeniedAccess('Path is hidden'); @@ -158,6 +159,7 @@ const getVolumeAccess = async (context, relativePath, options = {}) => { canDelete: !isReadOnly, canUpload: !isReadOnly, canCreateFolder: !isReadOnly, + canCreateFile: !isReadOnly, canShare: true, canDownload: true, isShared: false, @@ -192,6 +194,7 @@ const getPersonalAccess = async (context, relativePath) => { canDelete: true, canUpload: true, canCreateFolder: true, + canCreateFile: true, canShare: true, canDownload: true, isShared: false, @@ -202,163 +205,146 @@ const getPersonalAccess = async (context, relativePath) => { }; /** - * Get access info for share paths + * Whether the share itself may be opened, before anyone is considered. + * + * @returns {object|null} a denial, or null when the share is usable */ -const getShareAccess = async (context, shareToken, innerPath, options = {}) => { - const { user, guestSession } = context; - const permissionResolver = - typeof options.permissionResolver === 'function' ? options.permissionResolver : null; - // What a share opens is bound by the rules whoever follows the link is: the - // link is the lens, not the account behind it, so no rule is waived here for - // an administrator. - const getPerm = async (p) => - permissionResolver - ? permissionResolver(p, { isAdmin: false }) - : await getPermissionForPath(p, { isAdmin: false }); - const shareCache = options && options.shareCache instanceof Map ? options.shareCache : null; - const userVolumeCache = - options && options.userVolumeCache instanceof Map ? options.userVolumeCache : null; - - if (!shareToken) { - return createDeniedAccess('Share token is required'); - } - - // Validate share exists - let share = shareCache ? shareCache.get(shareToken) : null; - if (!share) { - share = await getShareByToken(shareToken); - if (shareCache && share) shareCache.set(shareToken, share); - } - if (!share) { - return createDeniedAccess('Share not found'); - } - - // Check expiration - if (isShareExpired(share)) { - return createDeniedAccess('Share has expired'); - } +const shareIsUnusable = (share) => { + if (!share) return createDeniedAccess('Share not found'); + if (isShareExpired(share)) return createDeniedAccess('Share has expired'); + return null; +}; - // Check sharing type and permissions +/** + * Whether this caller may open it. + * + * The two sharing types ask different questions — one wants an account on the + * list, the other wants a session that came through the door — and anything + * else fails closed rather than falling through to the grant below. + * + * @returns {Promise} a denial, or null when the caller may open it + */ +const callerMayNotOpen = async (share, { user, guestSession }) => { if (share.sharingType === 'users') { - // User-specific share requires authentication - if (!user || !user.id) { - return createDeniedAccess('Authentication required'); - } - - // Check if user has permission + if (!user || !user.id) return createDeniedAccess('Authentication required'); const permitted = await hasUserPermission(share.id, user.id); - if (!permitted) { - return createDeniedAccess('Access denied'); - } - } else if (share.sharingType === 'anyone') { - // Anyone shares require either user auth OR guest session - if (!user && !guestSession) { - // Password verification happens during share access/login - // If neither user nor guest session exists, they need to go through verification - return createDeniedAccess('Share access required'); - } + return permitted ? null : createDeniedAccess('Access denied'); + } + + if (share.sharingType === 'anyone') { + // Password verification happens during share access; a caller with neither + // an account nor a guest session has been through neither. + if (!user && !guestSession) return createDeniedAccess('Share access required'); - // If guest session exists, verify it belongs to this share if (guestSession && !user && guestSession.shareId !== share.id) { return createDeniedAccess('Invalid guest session for this share'); } - // A guest session for this share is the proof the password was typed. + // Being signed in is not the same as knowing the password. Without this, + // any authenticated user opening a protected link skipped the prompt the + // owner set it up for. if (sharePasswordApplies(share, user)) { const verified = guestSession && guestSession.shareId === share.id; if (!verified) return createDeniedAccess('Password verification required'); } - } else { - // Neither of the two types this knows: fail closed rather than fall - // through to the grant below. - return createDeniedAccess('Unknown sharing type'); + return null; } - const isOwner = user && user.id === share.ownerId; - const shareReadWrite = share.accessMode === 'readwrite'; + // Fail closed: a sharing type we do not know about must not fall through to + // the permission grant. + return createDeniedAccess('Unknown sharing type'); +}; - // Cap share write permissions by the underlying source permission. - // This allows admin changes (hide/ro/user-volume readonly) to take effect immediately. +/** + * What the location underneath still allows, which caps what the share grants. + * + * An administrator hiding a folder, marking it read-only or reassigning a + * personal volume takes effect on every existing link immediately, because the + * answer is read here on every request rather than frozen when the link was + * made. + * + * @returns {Promise<{denial: object}|{readOnly: boolean}>} + */ +const readSourceLimits = async (share, innerPath, { getPerm, userVolumeCache }) => { const isDirShare = Boolean(share.isDirectory); const safeInnerPath = typeof innerPath === 'string' ? innerPath : ''; - let underlyingPermission = 'rw'; - let underlyingReadOnly = false; + const under = (base) => + isDirShare && safeInnerPath ? combineRelativePath(base, safeInnerPath) : base; + + // Somebody's personal folder is not handed out by a share of a folder that + // holds it, any more than by the volume itself. + const personalRootDenial = (root, inner) => + reachesIntoPersonalRoot(root, path.resolve(root, inner || '')) + ? { denial: createDeniedAccess('Personal folders are reached through the personal space') } + : null; if (share.sourceSpace === 'volume') { - const combined = - isDirShare && safeInnerPath - ? combineRelativePath(share.sourcePath, safeInnerPath) - : share.sourcePath; - // Somebody's personal folder is not handed out by a share of a folder that - // holds it, any more than by the volume itself. - if ( - reachesIntoPersonalRoot(directories.volume, path.resolve(directories.volume, combined || '')) - ) { - return createDeniedAccess(PERSONAL_SIDEWAYS); - } - underlyingPermission = await getPerm(combined); - if (underlyingPermission === 'hidden') { - return createDeniedAccess('Path is hidden'); - } - underlyingReadOnly = underlyingPermission === 'ro'; - } else if (share.sourceSpace === 'user_volume') { + const refused = personalRootDenial(directories.volume, under(share.sourcePath)); + if (refused) return refused; + // What a share opens is bound by the rules whoever follows the link is: + // the link is the lens, not the account behind it, so no rule is waived + // here for an administrator. + const permission = await getPerm(under(share.sourcePath), { isAdmin: false }); + if (permission === 'hidden') return { denial: createDeniedAccess('Path is hidden') }; + return { readOnly: permission === 'ro' }; + } + + if (share.sourceSpace === 'user_volume') { const [volumeId, ...rest] = String(share.sourcePath || '') .split('/') .filter(Boolean); - if (!volumeId) { - return createDeniedAccess('Share source volume is invalid'); - } + if (!volumeId) return { denial: createDeniedAccess('Share source volume is invalid') }; + let userVolume = userVolumeCache ? userVolumeCache.get(volumeId) : null; if (!userVolume) { userVolume = await getVolumeById(volumeId); if (userVolumeCache && userVolume) userVolumeCache.set(volumeId, userVolume); } - if (!userVolume) { - return createDeniedAccess('Share source volume not found'); - } + if (!userVolume) return { denial: createDeniedAccess('Share source volume not found') }; + + // A share may only hand out a volume its own owner holds: without this, an + // account that once had one assigned could go on sharing it afterwards. if (String(userVolume.userId) !== String(share.ownerId)) { - return createDeniedAccess('Share source volume mismatch'); + return { denial: createDeniedAccess('Share source volume mismatch') }; } - const baseWithinVolume = rest.join('/'); - const combinedWithinVolume = - isDirShare && safeInnerPath - ? combineRelativePath(baseWithinVolume, safeInnerPath) - : baseWithinVolume; - if ( - reachesIntoPersonalRoot( - userVolume.path, - path.resolve(userVolume.path, combinedWithinVolume || '') - ) - ) { - return createDeniedAccess(PERSONAL_SIDEWAYS); - } - const logicalForRules = `${userVolume.label}${combinedWithinVolume ? `/${combinedWithinVolume}` : ''}`; - underlyingPermission = await getPerm(logicalForRules); - if (underlyingPermission === 'hidden') { - return createDeniedAccess('Path is hidden'); - } - underlyingReadOnly = userVolume.accessMode === 'readonly' || underlyingPermission === 'ro'; + const refused = personalRootDenial(userVolume.path, under(rest.join('/'))); + if (refused) return refused; + + const logicalForRules = `${userVolume.label}${under(rest.join('/')) ? `/${under(rest.join('/'))}` : ''}`; + const permission = await getPerm(logicalForRules, { isAdmin: false }); + if (permission === 'hidden') return { denial: createDeniedAccess('Path is hidden') }; + return { readOnly: userVolume.accessMode === 'readonly' || permission === 'ro' }; } - const isReadWrite = shareReadWrite && !underlyingReadOnly; + return { readOnly: false }; +}; + +/** What the share hands out, once the location underneath has had its say. */ +const grantFor = (share, { user, readOnly }) => { + const isReadWrite = share.accessMode === 'readwrite' && !readOnly; return { canAccess: true, canRead: true, canWrite: isReadWrite, - canDelete: isReadWrite, - canUpload: isReadWrite, - canCreateFolder: isReadWrite, + canDelete: isReadWrite && share.allowDelete !== false, + canUpload: isReadWrite && share.allowUpload !== false, + canCreateFolder: isReadWrite && share.allowCreateFolder !== false, + canCreateFile: isReadWrite && share.allowCreateFile !== false, canShare: false, // Cannot create shares within shares - canDownload: true, + // Deliberately not gated on `isReadWrite` like the others above it: a + // read-only share is exactly where withholding downloads means something — + // "read this" rather than "take a copy of this". Defaults to allowed, so + // every share made before this existed behaves as it always did. + canDownload: share.allowDownload !== false, isShared: true, shareInfo: { shareId: share.id, shareToken: share.shareToken, accessMode: isReadWrite ? 'readwrite' : 'readonly', expiresAt: share.expiresAt, - isOwner, + isOwner: Boolean(user && user.id === share.ownerId), label: share.label, }, share, // Include full share object for path resolution (avoids duplicate DB query) @@ -367,6 +353,60 @@ const getShareAccess = async (context, shareToken, innerPath, options = {}) => { }; }; +/** + * What a caller may do with a path inside a share. + * + * Three questions in order, each answerable on its own: may this share be + * opened at all, may this caller open it, and what does the location underneath + * still allow. Only then is a grant composed. It was one function of fifty-three + * paths, which is fifty-three tests to know it — and the reason it is worth + * splitting is that it decides what a link hands out. + */ +/** + * The optional machinery a caller may hand in: a permission resolver, and two + * caches for a route that is asking about many paths at once. Normalised here + * so the decision below reads as the sequence of questions it is. + */ +const readOptions = (options = {}) => { + const permissionResolver = + typeof options.permissionResolver === 'function' ? options.permissionResolver : null; + + return { + getPerm: async (p, who) => + permissionResolver ? permissionResolver(p, who) : getPermissionForPath(p, who), + shareCache: options.shareCache instanceof Map ? options.shareCache : null, + userVolumeCache: options.userVolumeCache instanceof Map ? options.userVolumeCache : null, + }; +}; + +/** The share this token names, from the caller's cache when it has one. */ +const loadShare = async (shareToken, shareCache) => { + const cached = shareCache ? shareCache.get(shareToken) : null; + if (cached) return cached; + + const share = await getShareByToken(shareToken); + if (shareCache && share) shareCache.set(shareToken, share); + return share; +}; + +const getShareAccess = async (context, shareToken, innerPath, options = {}) => { + if (!shareToken) return createDeniedAccess('Share token is required'); + + const { getPerm, shareCache, userVolumeCache } = readOptions(options); + const share = await loadShare(shareToken, shareCache); + + const unusable = shareIsUnusable(share); + if (unusable) return unusable; + + const refused = await callerMayNotOpen(share, context); + if (refused) return refused; + + const limits = await readSourceLimits(share, innerPath, { getPerm, userVolumeCache }); + if (limits.denial) return limits.denial; + + return grantFor(share, { user: context.user, readOnly: limits.readOnly }); +}; + /** * Helper to create a denied access object */ @@ -378,6 +418,7 @@ const createDeniedAccess = (reason) => { canDelete: false, canUpload: false, canCreateFolder: false, + canCreateFile: false, canShare: false, canDownload: false, isShared: false, @@ -432,42 +473,12 @@ const resolvePathWithAccess = async (context, relativePath, options = {}) => { return { accessInfo, resolved }; }; -/** - * Check if user can create shares (only authenticated users, not guests) - */ -const canCreateShare = (context) => { - const { user, guestSession } = context; - - // Guests cannot create shares - if (guestSession) { - return false; - } - - // Must be authenticated - return Boolean(user && user.id); -}; - -/** - * Get context from request object - */ -const getContextFromRequest = (req) => { - return { - user: req.user || null, - guestSession: req.guestSession || null, - shareToken: req.shareToken || null, - }; -}; - module.exports = { getAccessInfo, - getVolumeAccess, getPersonalAccess, - sharePasswordApplies, getShareAccess, canAccess, canWrite, - canCreateShare, - getContextFromRequest, - createDeniedAccess, + sharePasswordApplies, resolvePathWithAccess, }; diff --git a/backend/src/services/archiveExtraction.js b/backend/src/services/archiveExtraction.js index d2bcbd3c9..0728003ec 100644 --- a/backend/src/services/archiveExtraction.js +++ b/backend/src/services/archiveExtraction.js @@ -6,6 +6,7 @@ const { placeWithoutOverwrite } = require('../utils/placeWithoutOverwrite'); const { takeInventory } = require('../utils/ownedTree'); const { ValidationError } = require('../errors/AppError'); const { archives } = require('../config/index'); +const folderSizeHooks = require('./folderSizeHooks'); /** * What is shared between extracting a whole archive and extracting part of one. @@ -79,6 +80,13 @@ const extractIntoCurrentFolder = async ({ ); movedPaths.push({ path: destinationPath, inventory }); + if (entry.isDirectory()) { + folderSizeHooks.onDirectoryTreeCreated(destinationPath); + } else { + const stats = await fs.stat(destinationPath); + folderSizeHooks.onFileWritten(destinationPath, stats.size); + } + items.push(await buildItemMetadata(destinationPath, relativeParentPath, destinationName)); } diff --git a/backend/src/services/archiveTree.js b/backend/src/services/archiveTree.js index d60071e30..07dd8db83 100644 --- a/backend/src/services/archiveTree.js +++ b/backend/src/services/archiveTree.js @@ -30,7 +30,7 @@ const { getSettings } = require('./settingsService'); */ const readAccess = async () => { const settings = await getSettings(); - return settings?.access || null; + return settings?.access && typeof settings.access === 'object' ? settings.access : { rules: [] }; }; /** @@ -42,8 +42,9 @@ const readAccess = async () => { */ const collectArchiveEntries = async (context, sources) => { const access = await readAccess(); + const rules = Array.isArray(access.rules) ? access.rules : []; const accessOptions = { - permissionResolver: access?.rules?.length ? createPermissionResolver(access) : undefined, + permissionResolver: rules.length ? createPermissionResolver(access) : undefined, shareCache: new Map(), userVolumeCache: new Map(), }; @@ -56,7 +57,7 @@ const collectArchiveEntries = async (context, sources) => { if (guardPersonalRoot && isInsidePersonalRoot(absolutePath)) return false; // With no rules, nothing below a readable folder is less readable than it: // every other decision was made once, for the source. - if (!access?.rules?.length) return true; + if (!rules.length) return true; const info = await getAccessInfo(context, logicalPath, accessOptions); return Boolean(info?.canAccess && info.canRead); }; diff --git a/backend/src/services/authorizationService.js b/backend/src/services/authorizationService.js index a940b9e1b..dd11ac436 100644 --- a/backend/src/services/authorizationService.js +++ b/backend/src/services/authorizationService.js @@ -26,10 +26,8 @@ const actionToFlag = (action) => { return 'canUpload'; case ACTIONS.createFolder: return 'canCreateFolder'; - // Upstream expresses "may put a file here" as canUpload; a trash restore of - // a file asks for exactly that. case ACTIONS.createFile: - return 'canUpload'; + return 'canCreateFile'; case ACTIONS.rename: return 'canWrite'; case ACTIONS.download: diff --git a/backend/src/services/db.js b/backend/src/services/db.js index 09b8189cd..228153f39 100644 --- a/backend/src/services/db.js +++ b/backend/src/services/db.js @@ -207,6 +207,9 @@ const ACTIVITY_DDL = ` CREATE INDEX IF NOT EXISTS idx_activity_user ON activity_events(user_id, at DESC); `; +const tableExists = (db, name) => + Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); + const getDbPath = () => { const configDir = directories.config; // Generic app database for auth, shares, and user settings. @@ -221,6 +224,176 @@ const generateId = () => : `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`; const DEFAULT_FAVORITE_ICON = favorites.defaultIcon; +/** + * Where a user has recently moved or copied things to. + * + * Kept as history rather than a preference: the point is that the folders you + * actually use rise to the top of the destination picker without anyone + * curating a list. One row per user and path — using a destination again moves + * it up rather than adding a duplicate. + */ +const RECENT_DESTINATIONS_DDL = ` + CREATE TABLE IF NOT EXISTS recent_destinations ( + user_id TEXT NOT NULL, + path TEXT NOT NULL, + used_at DATETIME NOT NULL, + PRIMARY KEY (user_id, path) + ); +`; + +/** + * 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 ensureShareOperationPermissionColumns = (db) => { + addColumnIfMissing(db, 'shares', 'allow_delete', 'allow_delete INTEGER NOT NULL DEFAULT 1'); + addColumnIfMissing( + db, + 'shares', + 'allow_create_folder', + 'allow_create_folder INTEGER NOT NULL DEFAULT 1' + ); + addColumnIfMissing( + db, + 'shares', + 'allow_create_file', + 'allow_create_file INTEGER NOT NULL DEFAULT 1' + ); + addColumnIfMissing(db, 'shares', 'allow_upload', 'allow_upload INTEGER NOT NULL DEFAULT 1'); + // Defaults to 1 so every share that already exists keeps working exactly as + // it did: withholding downloads is something an owner opts into, never + // something a migration decides for them. + addColumnIfMissing(db, 'shares', 'allow_download', 'allow_download INTEGER NOT NULL DEFAULT 1'); + + // What a share hands out of a file's history: the list of its versions, and + // the versions themselves. A link for anyone shows none until its owner says + // so; a share with named accounts shows what those accounts would see anyway, + // which is why the ones that exist already are switched on as they gain it. + const columns = new Set( + db + .prepare('PRAGMA table_info(shares)') + .all() + .map((column) => column.name) + ); + for (const column of ['versions_visible', 'versions_download']) { + if (columns.has(column)) continue; + db.exec(`ALTER TABLE shares ADD COLUMN ${column} INTEGER NOT NULL DEFAULT 0`); + db.exec(`UPDATE shares SET ${column} = 1 WHERE sharing_type = 'users'`); + } +}; + +const PERSONAL_FOLDER_RESERVATIONS_DDL = ` + CREATE TABLE IF NOT EXISTS personal_folder_reservations ( + name TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + reserved_at TEXT NOT NULL + ); +`; + const migrate = (db) => { // Simple schema versioning db.exec(` @@ -665,6 +838,53 @@ 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; + } + if (version < 21) { + logger.info('[DB Migration] Migrating to v21: the folders somebody files into...'); + db.exec(RECENT_DESTINATIONS_DDL); + db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run( + 'schema_version', + String(21) + ); + version = 21; + } + if (version < 22) { + logger.info('[DB Migration] Migrating to v22: what a share permits, and when it was used...'); + ensureShareOperationPermissionColumns(db); + addColumnIfMissing(db, 'shares', 'last_downloaded_at', 'last_downloaded_at DATETIME'); + addColumnIfMissing(db, 'shares', 'last_download_ip', 'last_download_ip TEXT'); + db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run( + 'schema_version', + String(22) + ); + version = 22; + } + if (version < 23) { + logger.info('[DB Migration] Migrating to v23: one personal folder per account...'); + db.exec(PERSONAL_FOLDER_RESERVATIONS_DDL); + // Rows an account's deletion used to leave behind. None of these tables + // points at users through a foreign key, so nothing ever removed them. + if (tableExists(db, 'folder_preferences')) { + db.exec('DELETE FROM folder_preferences WHERE user_id NOT IN (SELECT id FROM users)'); + } + if (tableExists(db, 'recent_destinations')) { + db.exec('DELETE FROM recent_destinations WHERE user_id NOT IN (SELECT id FROM users)'); + } + db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run( + 'schema_version', + String(23) + ); + version = 23; + } })(); }; @@ -866,9 +1086,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 +1156,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/src/services/directoryListingService.js b/backend/src/services/directoryListingService.js index 1e3c4e4c4..01e247fe9 100644 --- a/backend/src/services/directoryListingService.js +++ b/backend/src/services/directoryListingService.js @@ -2,7 +2,7 @@ const path = require('path'); const fs = require('fs/promises'); const { excludedFiles, extensions, hiddenFiles } = require('../config/index'); -const { combineRelativePath } = require('../utils/pathUtils'); +const { combineRelativePath, resolveLogicalPath } = require('../utils/pathUtils'); const { getAccessInfo } = require('./accessManager'); const { createPermissionResolver } = require('./accessControlService'); const logger = require('../utils/logger'); @@ -41,28 +41,54 @@ const mapWithConcurrency = async (items, concurrency, mapper) => { return results; }; +/** + * Whether a symbolic link leads out of the space it sits in. + * + * Asked of the same resolver every operation goes through, so the listing and + * the operations cannot disagree: a link the resolver refuses is one nothing + * can be done through. A link it cannot follow at all — to nothing — is left to + * the stat that comes next, which skips it as before. + */ +const linkLeavesTheSpace = async (context, logicalPath, access) => { + try { + await resolveLogicalPath(logicalPath, { + user: context?.user || null, + guestSession: context?.guestSession || null, + share: access?.share || null, + userVolume: access?.userVolume || null, + }); + return false; + } catch (error) { + return error?.statusCode === 403; + } +}; + /** * List a directory and filter out entries that the caller cannot access. * * - Uses accessManager for per-child visibility (covers shares + user volumes + hidden rules). * - Does not throw for child-level failures; unreadable / inaccessible children are skipped. + * - A symbolic link that leads out of the space is listed as what it is — a link, + * marked `link: 'outside'` — and never followed. It used to be described by + * what it points at: the size and type of a file outside the volume, on a row + * every action then refused with "Resolved path is outside the configured + * volume root", with nothing on screen to say why. */ const listDirectoryItems = async ({ absoluteDir, parentLogicalPath, context, thumbsEnabled, - excludeDownloadArtifacts = false, includeHiddenFiles = false, itemExtras = null, access = null, shareCache = null, userVolumeCache = null, }) => { - // The whole access section rather than the rules alone: whom a rule holds is - // decided by the rule and by the setting above it together, so a resolver - // built from half of it would answer for the wrong caller. - const permissionResolver = access?.rules?.length ? createPermissionResolver(access) : null; + // The whole section, never the bare list: a caller handing over the rules + // alone would silently drop the setting that says whom they hold. + const permissionResolver = + Array.isArray(access?.rules) && access.rules.length ? createPermissionResolver(access) : null; const accessOptions = { ...(permissionResolver ? { permissionResolver } : null), @@ -70,21 +96,40 @@ const listDirectoryItems = async ({ ...(userVolumeCache instanceof Map ? { userVolumeCache } : null), }; + /** + * Which entries carry the read-only mark: the ones a rule holds this caller + * to, and only where that starts. + * + * A rule was invisible until something was attempted in the folder it covers, + * and on an account no rule held it was never refused at all + * (nxzai/NextExplorer#407). The mark says it up front — but a recursive rule + * covers everything below it, and a lock on every row inside a folder that is + * already read-only says nothing the row above did not. So it is drawn where + * the restriction begins: on the entry whose folder is not itself read-only. + * + * Asked of the rules alone, not of the whole access decision: this mark is + * about a rule, and a volume read-only for another reason carries its own. + */ + const isAdmin = Boolean(context?.user?.roles?.includes?.('admin')); + const ruleSays = (logicalPath) => + permissionResolver ? permissionResolver(logicalPath || '', { isAdmin }) : 'rw'; + const insideReadOnly = ruleSays(parentLogicalPath) === 'ro'; + const entries = await fs.readdir(absoluteDir); const filtered = entries .filter((name) => !excludedFiles.includes(name)) - .filter((name) => includeHiddenFiles || !hiddenFiles.isHiddenName(name)) - .filter((name) => - excludeDownloadArtifacts ? path.extname(name).toLowerCase() !== '.download' : true - ); + .filter((name) => includeHiddenFiles || !hiddenFiles.isHiddenName(name)); const items = await mapWithConcurrency(filtered, LIST_DIRECTORY_CONCURRENCY, async (name) => { const filePath = path.join(absoluteDir, name); + const logicalChildPath = combineRelativePath(parentLogicalPath || '', name); let stats; + let entry; try { - stats = await fs.stat(filePath); + entry = await fs.lstat(filePath); + stats = entry.isSymbolicLink() ? null : entry; } catch (err) { if (['EPERM', 'EACCES', 'ENOENT', 'ELOOP'].includes(err?.code)) { logger.warn({ filePath, err }, 'Skipping unreadable entry'); @@ -93,12 +138,33 @@ const listDirectoryItems = async ({ throw err; } - const logicalChildPath = combineRelativePath(parentLogicalPath || '', name); const childAccess = await getAccessInfo(context, logicalChildPath, accessOptions); if (!childAccess?.canAccess) { return null; } + if (!stats) { + if (await linkLeavesTheSpace(context, logicalChildPath, childAccess)) { + return { + name, + path: parentLogicalPath, + dateModified: entry.mtime, + size: null, + kind: toKind(entry, name), + link: 'outside', + }; + } + try { + stats = await fs.stat(filePath); + } catch (err) { + if (['EPERM', 'EACCES', 'ENOENT', 'ELOOP'].includes(err?.code)) { + logger.warn({ filePath, err }, 'Skipping unreadable entry'); + return null; + } + throw err; + } + } + const kind = toKind(stats, name); const item = { name, @@ -108,8 +174,6 @@ const listDirectoryItems = async ({ kind, }; - // Advisory only, and never a lock: who has this document open in an - // editor, so the row can say so and a move can ask first. if (stats.isFile()) { const activity = onlyofficeActivity.get(filePath); if (activity?.active) item.onlyofficeActivity = activity; @@ -119,6 +183,9 @@ const listDirectoryItems = async ({ item.supportsThumbnail = true; } + // `access`, as a volume held to reading says it: the same lock, the same reason. + if (!insideReadOnly && ruleSays(logicalChildPath) === 'ro') item.readOnly = 'access'; + if (typeof itemExtras === 'function') { Object.assign(item, itemExtras({ name, stats, kind, access: childAccess }) || {}); } diff --git a/backend/src/services/guestSessionService.js b/backend/src/services/guestSessionService.js index 7d2978e1d..4d40fbd36 100644 --- a/backend/src/services/guestSessionService.js +++ b/backend/src/services/guestSessionService.js @@ -1,12 +1,5 @@ -const crypto = require('crypto'); const { getDb } = require('./db'); - -const nowIso = () => new Date().toISOString(); - -const generateId = () => - typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`; +const { generateId, nowIso } = require('../utils/ids'); // Default session duration: 24 hours const DEFAULT_SESSION_HOURS = 24; @@ -67,24 +60,6 @@ const getGuestSession = async (sessionId) => { return toClientSession(row); }; -/** - * Get all guest sessions for a share - */ -const getGuestSessionsByShareId = async (shareId) => { - const db = await getDb(); - const rows = db - .prepare( - ` - SELECT * FROM guest_sessions - WHERE share_id = ? - ORDER BY created_at DESC - ` - ) - .all(shareId); - - return rows.map(toClientSession); -}; - /** * Check if a guest session is valid (exists and not expired) */ @@ -119,24 +94,6 @@ const updateGuestSessionActivity = async (sessionId) => { return result.changes > 0; }; -/** - * Delete guest session - */ -const deleteGuestSession = async (sessionId) => { - const db = await getDb(); - const result = db.prepare('DELETE FROM guest_sessions WHERE id = ?').run(sessionId); - return result.changes > 0; -}; - -/** - * Delete all guest sessions for a share - */ -const deleteGuestSessionsByShareId = async (shareId) => { - const db = await getDb(); - const result = db.prepare('DELETE FROM guest_sessions WHERE share_id = ?').run(shareId); - return result.changes; -}; - /** * Clean up expired guest sessions */ @@ -153,74 +110,10 @@ const cleanupExpiredSessions = async () => { return result.changes; }; -/** - * Get guest session count for a share - */ -const getActiveSessionCount = async (shareId) => { - const db = await getDb(); - const row = db - .prepare( - ` - SELECT COUNT(*) as count - FROM guest_sessions - WHERE share_id = ? AND expires_at > ? - ` - ) - .get(shareId, nowIso()); - - return row?.count || 0; -}; - -/** - * Extend guest session expiration - */ -const extendGuestSession = async (sessionId, additionalHours = DEFAULT_SESSION_HOURS) => { - const session = await getGuestSession(sessionId); - - if (!session) { - const e = new Error('Guest session not found'); - e.status = 404; - throw e; - } - - const currentExpiry = new Date(session.expiresAt); - const newExpiry = new Date(currentExpiry.getTime() + additionalHours * 60 * 60 * 1000); - - const db = await getDb(); - db.prepare( - ` - UPDATE guest_sessions - SET expires_at = ?, last_activity_at = ? - WHERE id = ? - ` - ).run(newExpiry.toISOString(), nowIso(), sessionId); - - return getGuestSession(sessionId); -}; - -/** - * Verify guest session belongs to a specific share - */ -const verifyGuestSessionShare = async (sessionId, shareId) => { - const session = await getGuestSession(sessionId); - - if (!session) { - return false; - } - - return session.shareId === shareId; -}; - module.exports = { createGuestSession, getGuestSession, - getGuestSessionsByShareId, isGuestSessionValid, updateGuestSessionActivity, - deleteGuestSession, - deleteGuestSessionsByShareId, cleanupExpiredSessions, - getActiveSessionCount, - extendGuestSession, - verifyGuestSessionShare, }; diff --git a/backend/src/services/indexDb.js b/backend/src/services/indexDb.js index 1e078c2a4..fe1524d3e 100644 --- a/backend/src/services/indexDb.js +++ b/backend/src/services/indexDb.js @@ -78,14 +78,8 @@ const metaValue = (db, key) => { } }; -/** - * An index database of this application's own, under the cache, and the write- - * ahead files beside it. Nothing anybody put anywhere is ever in one, so it - * does not go through the trash. - */ const removeFile = (file) => { for (const suffix of ['', '-wal', '-shm', '-journal']) { - // eslint-disable-next-line no-restricted-properties fs.rmSync(`${file}${suffix}`, { force: true }); } }; 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/src/services/personalFolders.js b/backend/src/services/personalFolders.js index 702b4a45f..a6e96de61 100644 --- a/backend/src/services/personalFolders.js +++ b/backend/src/services/personalFolders.js @@ -1,3 +1,7 @@ +const fs = require('fs'); +const path = require('path'); + +const { directories } = require('../config/index'); const { getUserFolderNameCandidates } = require('../utils/pathUtils'); const logger = require('../utils/logger'); @@ -39,6 +43,32 @@ const takenNames = (db, userId) => { return new Set(rows.map((row) => row.name)); }; +/** + * Whether a name is held for an account that was deleted. + * + * Deleting an account leaves its folder on disk, with what it kept there, its + * trash and its versions. Handing the name to the next account that derives it + * handed over that folder too. The name stays reserved for as long as the + * folder is there; an administrator frees it by removing or renaming the folder + * on the server, and the reservation goes the first time the name is asked for + * after that. + */ +const isReserved = (db, name) => { + // Names are claimed by the v15 migration, long before v20 creates this table; + // until it exists, nothing can have been reserved. + const hasTable = db + .prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'personal_folder_reservations'" + ) + .get(); + if (!hasTable) return false; + const row = db.prepare('SELECT name FROM personal_folder_reservations WHERE name = ?').get(name); + if (!row) return false; + if (fs.existsSync(path.join(directories.userRoot, name))) return true; + db.prepare('DELETE FROM personal_folder_reservations WHERE name = ?').run(name); + return false; +}; + /** * Give this account a folder name of its own, and answer it. Idempotent: an * account that already holds one keeps it. @@ -53,7 +83,7 @@ const claimPersonalFolderName = (db, user) => { const candidates = getUserFolderNameCandidates(user); for (const candidate of candidates) { - if (taken.has(candidate)) continue; + if (taken.has(candidate) || isReserved(db, candidate)) continue; try { db.prepare('UPDATE users SET personal_folder_name = ? WHERE id = ?').run(candidate, user.id); @@ -61,7 +91,7 @@ const claimPersonalFolderName = (db, user) => { if (candidate !== candidates[0]) { logger.warn( { userId: user.id, preferred: candidates[0], assigned: candidate }, - 'Personal folder name was already taken by another account; assigned the next one' + 'Personal folder name was taken, or kept for a deleted account; assigned the next one' ); } return candidate; diff --git a/backend/src/services/recentDestinationsService.js b/backend/src/services/recentDestinationsService.js new file mode 100644 index 000000000..a363195b5 --- /dev/null +++ b/backend/src/services/recentDestinationsService.js @@ -0,0 +1,87 @@ +const { getDb } = require('./db'); +const logger = require('../utils/logger'); + +/** + * The folders a user actually moves things into. + * + * The destination picker opens on a list rather than at the root, because the + * folder someone wants is nearly always one they have used before. Nobody + * curates that list: it is written by the transfers themselves, so it stays + * true to how the person really files things. + * + * Kept per user. A shared favourite is a deliberate bookmark; this is a trace + * of one person's habits, and showing someone else's would be both wrong and + * a small leak of where they work. + */ + +const MAX_ENTRIES = 10; + +/** Note that a transfer landed here. Never throws: this is a convenience. */ +const record = async (userId, relativePath) => { + if (!userId || typeof relativePath !== 'string' || !relativePath.trim()) return; + + try { + const db = await getDb(); + const now = new Date().toISOString(); + + db.prepare( + `INSERT INTO recent_destinations (user_id, path, used_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id, path) DO UPDATE SET used_at = excluded.used_at` + ).run(userId, relativePath, now); + + // Trim to the most recent entries. Done on write so the table cannot grow + // for a user who never opens the picker. + db.prepare( + `DELETE FROM recent_destinations + WHERE user_id = ? + AND path NOT IN ( + SELECT path FROM recent_destinations + WHERE user_id = ? + ORDER BY used_at DESC + LIMIT ? + )` + ).run(userId, userId, MAX_ENTRIES); + } catch (error) { + // A destination that fails to be remembered must never fail the transfer + // that reached it. + logger.debug({ err: error, relativePath }, 'Could not record recent destination'); + } +}; + +/** Most recently used first. */ +const list = async (userId) => { + if (!userId) return []; + + const db = await getDb(); + return db + .prepare( + `SELECT path FROM recent_destinations + WHERE user_id = ? + ORDER BY used_at DESC + LIMIT ?` + ) + .all(userId, MAX_ENTRIES) + .map((row) => row.path); +}; + +/** Drop a destination that no longer exists or is no longer reachable. */ +const forget = async (userId, relativePath) => { + if (!userId || !relativePath) return; + + try { + const db = await getDb(); + db.prepare('DELETE FROM recent_destinations WHERE user_id = ? AND path = ?').run( + userId, + relativePath + ); + } catch (error) { + logger.debug({ err: error, relativePath }, 'Could not forget recent destination'); + } +}; + +module.exports = { + record, + list, + forget, +}; diff --git a/backend/src/services/searchCollector.js b/backend/src/services/searchCollector.js index 64382d314..6318227c1 100644 --- a/backend/src/services/searchCollector.js +++ b/backend/src/services/searchCollector.js @@ -40,7 +40,9 @@ const collectResults = async ({ const contentReserve = Math.max(1, limit - Math.floor(limit * NAME_SHARE)); for await (const item of results) { - (item.matchLine ? contents : names).push(item); + // A content match the index vouched for without its line being read in + // time is still a content match, and is counted as one. + (item.matchLine || item.inContents ? contents : names).push(item); if (names.length < nameCap) continue; if (contents.length >= contentReserve) break; diff --git a/backend/src/services/searchIndexer.js b/backend/src/services/searchIndexer.js index 1c863f43d..d946d0380 100644 --- a/backend/src/services/searchIndexer.js +++ b/backend/src/services/searchIndexer.js @@ -31,7 +31,19 @@ const { containerMemoryLimitBytes } = require('../utils/containerMemory'); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules', 'dist', 'build', '.cache']); +/** + * Folders the pass does not walk into. + * + * Dot-folders only, and for one reason each: the application keeps its trash + * and its file versions in one of them, and the rest are somebody's hidden + * things, which the search refuses to show unless they asked. `.git`, + * `node_modules`, `dist` and `build` used to be here too — an editor's habits + * in a file server, where those are ordinary folder names somebody may have a + * year of work in, and nothing that decides what is not worth finding belongs + * in the source (#11). The administrator's exclusion list is where that is + * said. + */ +const isSkippedDirectoryName = (name) => name.startsWith('.'); /** * How much of one document is worth indexing, and how much may be held at once. @@ -61,10 +73,39 @@ const NON_TEXT_EXTENSIONS = new Set([ ...extensions.rawImages, ...extensions.videos, ...extensions.audios, - 'zip', 'rar', '7z', 'gz', 'bz2', 'xz', 'zst', 'tar', 'tgz', 'iso', 'dmg', 'jar', - 'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a', 'class', 'pyc', 'wasm', - 'ttf', 'otf', 'woff', 'woff2', 'eot', - 'db', 'sqlite', 'sqlite3', 'mdb', 'pack', 'idx', + 'zip', + 'rar', + '7z', + 'gz', + 'bz2', + 'xz', + 'zst', + 'tar', + 'tgz', + 'iso', + 'dmg', + 'jar', + 'exe', + 'dll', + 'so', + 'dylib', + 'bin', + 'o', + 'a', + 'class', + 'pyc', + 'wasm', + 'ttf', + 'otf', + 'woff', + 'woff2', + 'eot', + 'db', + 'sqlite', + 'sqlite3', + 'mdb', + 'pack', + 'idx', ]); const extensionOf = (absolutePath) => path.extname(absolutePath).slice(1).toLowerCase(); @@ -182,6 +223,7 @@ const indexTree = async ({ let pendingBytes = 0; let indexed = 0; let skipped = 0; + let folders = 0; let batches = 0; let interrupted = false; @@ -351,7 +393,14 @@ const indexTree = async ({ const batch = pending.splice(0, pending.length); pendingBytes = 0; writeBatch(batch); - indexed += batch.length; + // Counted apart: `indexed` has always meant files this pass had to read, + // and a folder row is written without opening anything. Folding the two + // together would make a volume of empty folders look like a volume that + // changes constantly. + for (const document of batch) { + if (document.isDirectory) folders += 1; + else indexed += 1; + } batches += 1; if (typeof onProgress === 'function' && Date.now() - lastReport >= progressMs) { @@ -369,7 +418,9 @@ const indexTree = async ({ skipped, batches, reindexed: reindexedKnown, - ...(worstFolder ? { rereadTopFolder: worstFolder.value, rereadTopCount: worstFolder.count } : {}), + ...(worstFolder + ? { rereadTopFolder: worstFolder.value, rereadTopCount: worstFolder.count } + : {}), ...cost(), }); } @@ -392,7 +443,7 @@ const indexTree = async ({ for (const entry of entries) { throwIfAborted(); - if (entry.name.startsWith('.') || IGNORED_DIRECTORIES.has(entry.name)) continue; + if (isSkippedDirectoryName(entry.name)) continue; const absolutePath = path.join(dirAbs, entry.name); const relativePath = dirRel ? `${dirRel}/${entry.name}` : entry.name; @@ -400,16 +451,28 @@ const indexTree = async ({ if (isExcluded(relativePath)) continue; if (entry.isDirectory()) { - // eslint-disable-next-line no-await-in-loop + // A row of its own, so a folder nobody has put anything in yet can be + // found by its name. Its own timestamps say nothing useful — a folder's + // mtime moves when its children do — so the row is written once and + // left alone. + seenHere.add(relativePath); + if (!store.getIndexedDocument(db, relativePath)) { + pending.push({ + path: relativePath, + mtimeMs: 0, + size: 0, + text: null, + isDirectory: true, + }); + if (pending.length >= batchSize) flush(); + } await walk(absolutePath, relativePath); continue; } if (!entry.isFile()) continue; - // eslint-disable-next-line no-await-in-loop const stats = await fs.stat(absolutePath).catch(() => null); if (!stats) continue; - if (maxFileSizeBytes && stats.size > maxFileSizeBytes) continue; seenHere.add(relativePath); @@ -418,7 +481,6 @@ const indexTree = async ({ const known = store.getIndexedDocument(db, relativePath); if (store.isUpToDate(known, stats)) { skipped += 1; - // eslint-disable-next-line no-await-in-loop await payForTimeUsed(); continue; } @@ -453,24 +515,27 @@ const indexTree = async ({ } } - // eslint-disable-next-line no-await-in-loop - const text = await readIndexableText(absolutePath, stats.size, scratch); - if (text === null || !text.trim()) continue; + // The size bound is on reading a file, not on knowing it is there. A + // two-gigabyte recording has no words worth keeping and a name somebody + // will look for, and the row costs what the stat above already paid. + const tooLargeToRead = maxFileSizeBytes && stats.size > maxFileSizeBytes; + const text = tooLargeToRead + ? null + : await readIndexableText(absolutePath, stats.size, scratch); + const indexable = text === null || !text.trim() ? null : capText(text); - const indexable = capText(text); pending.push({ path: relativePath, mtimeMs: stats.mtimeMs, size: stats.size, text: indexable, }); - pendingBytes += indexable.length; + pendingBytes += indexable ? indexable.length : 0; // Whichever ceiling is reached first. The byte one is what keeps a // handful of large documents from being held together. if (pending.length >= batchSize || pendingBytes >= MAX_TEXT_PER_BATCH) flush(); - // eslint-disable-next-line no-await-in-loop await payForTimeUsed(); } @@ -516,6 +581,7 @@ const indexTree = async ({ return { indexed, skipped, + folders, removed, batches, pauses, @@ -538,29 +604,22 @@ const indexFile = async (db, relativePath, absolutePath) => { return { removed: true }; } - const maxBytes = searchConfig?.maxFileSizeBytes ?? 0; - if (maxBytes && stats.size > maxBytes) { - store.removeDocument(db, relativePath); - return { skipped: true }; - } - if (store.isUpToDate(store.getIndexedDocument(db, relativePath), stats)) { return { unchanged: true }; } - const text = await readIndexableText(absolutePath, stats.size); - if (text === null || !text.trim()) { - store.removeDocument(db, relativePath); - return { skipped: true }; - } + const maxBytes = searchConfig?.maxFileSizeBytes ?? 0; + const text = + maxBytes && stats.size > maxBytes ? null : await readIndexableText(absolutePath, stats.size); + const indexable = text === null || !text.trim() ? null : capText(text); store.upsertDocument(db, { path: relativePath, mtimeMs: stats.mtimeMs, size: stats.size, - text: capText(text), + text: indexable, }); - return { indexed: true }; + return indexable ? { indexed: true } : { catalogued: true }; }; module.exports = { indexTree, indexFile, readIndexableText }; 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/src/services/sharesService.js b/backend/src/services/sharesService.js index f9d785f39..9bcf28cf4 100644 --- a/backend/src/services/sharesService.js +++ b/backend/src/services/sharesService.js @@ -1,15 +1,9 @@ const crypto = require('crypto'); const bcrypt = require('bcryptjs'); -const { getDb } = require('./db'); +const { getDb, prepared } = require('./db'); +const { generateId, nowIso } = require('../utils/ids'); const logger = require('../utils/logger'); -const nowIso = () => new Date().toISOString(); - -const generateId = () => - typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`; - /** * Generate a URL-safe share token * Uses base62 encoding for readability (no special characters) @@ -37,6 +31,13 @@ const toClientShare = (row) => { sourcePath: row.source_path, isDirectory: Boolean(row.is_directory), accessMode: row.access_mode, + allowDelete: row.allow_delete !== 0, + allowCreateFolder: row.allow_create_folder !== 0, + allowCreateFile: row.allow_create_file !== 0, + allowUpload: row.allow_upload !== 0, + allowDownload: row.allow_download !== 0, + versionsVisible: row.versions_visible === 1, + versionsDownload: row.versions_download === 1, sharingType: row.sharing_type, hasPassword: Boolean(row.password_hash), expiresAt: row.expires_at || null, @@ -44,6 +45,9 @@ const toClientShare = (row) => { accessCount: row.access_count || 0, downloadCount: row.download_count || 0, lastAccessedAt: row.last_accessed_at || null, + lastAccessIp: row.last_access_ip || null, + lastDownloadedAt: row.last_downloaded_at || null, + lastDownloadIp: row.last_download_ip || null, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -58,6 +62,13 @@ const createShare = async ({ sourcePath, isDirectory = false, accessMode = 'readonly', + allowDelete = true, + allowCreateFolder = true, + allowCreateFile = true, + allowUpload = true, + allowDownload = true, + versionsVisible, + versionsDownload, sharingType = 'anyone', password = null, userIds = [], @@ -88,6 +99,20 @@ const createShare = async ({ throw e; } + const operationPermissions = { + allowDelete, + allowCreateFolder, + allowCreateFile, + allowUpload, + }; + for (const [key, value] of Object.entries(operationPermissions)) { + if (typeof value !== 'boolean') { + const e = new Error(`${key} must be a boolean`); + e.status = 400; + throw e; + } + } + if (!['anyone', 'users'].includes(sharingType)) { const e = new Error('Invalid sharing type'); e.status = 400; @@ -100,6 +125,21 @@ const createShare = async ({ throw e; } + // A share with named accounts shows the history they would see anyway; a link + // for anyone shows none until its owner decides otherwise. + const historyByDefault = sharingType === 'users'; + const history = { + versionsVisible: versionsVisible === undefined ? historyByDefault : versionsVisible, + versionsDownload: versionsDownload === undefined ? historyByDefault : versionsDownload, + }; + for (const [key, value] of Object.entries(history)) { + if (typeof value !== 'boolean') { + const e = new Error(`${key} must be a boolean`); + e.status = 400; + throw e; + } + } + const db = await getDb(); const shareId = generateId(); const shareToken = generateShareToken(10); @@ -107,13 +147,15 @@ const createShare = async ({ const passwordHash = password ? await bcrypt.hash(password, 10) : null; // Create share - db.prepare( + prepared( + db, ` INSERT INTO shares ( id, share_token, owner_id, source_space, source_path, is_directory, - access_mode, sharing_type, password_hash, expires_at, label, - download_count, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + access_mode, allow_delete, allow_create_folder, allow_create_file, allow_upload, + allow_download, sharing_type, password_hash, expires_at, label, download_count, + created_at, updated_at, versions_visible, versions_download + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?) ` ).run( shareId, @@ -123,20 +165,30 @@ const createShare = async ({ sourcePath, isDirectory ? 1 : 0, accessMode, + allowDelete ? 1 : 0, + allowCreateFolder ? 1 : 0, + allowCreateFile ? 1 : 0, + allowUpload ? 1 : 0, + allowDownload ? 1 : 0, sharingType, passwordHash, expiresAt, label, now, - now + now, + history.versionsVisible ? 1 : 0, + history.versionsDownload ? 1 : 0 ); // Add user permissions if user-specific share if (sharingType === 'users' && Array.isArray(userIds) && userIds.length > 0) { - const insertPerm = db.prepare(` + const insertPerm = prepared( + db, + ` INSERT INTO share_permissions (id, share_id, user_id, created_at) VALUES (?, ?, ?, ?) - `); + ` + ); for (const userId of userIds) { try { @@ -158,7 +210,7 @@ const createShare = async ({ */ const getShareById = async (shareId) => { const db = await getDb(); - const row = db.prepare('SELECT * FROM shares WHERE id = ?').get(shareId); + const row = prepared(db, 'SELECT * FROM shares WHERE id = ?').get(shareId); if (!row) return null; const share = toClientShare(row); @@ -183,7 +235,7 @@ const getShareById = async (shareId) => { */ const getShareByToken = async (token) => { const db = await getDb(); - const row = db.prepare('SELECT * FROM shares WHERE share_token = ?').get(token); + const row = prepared(db, 'SELECT * FROM shares WHERE share_token = ?').get(token); if (!row) return null; const share = toClientShare(row); @@ -274,13 +326,28 @@ const getSharesForUser = async (userId) => { */ const updateShare = async (shareId, updates = {}) => { const db = await getDb(); - const existing = db.prepare('SELECT * FROM shares WHERE id = ?').get(shareId); + const existing = prepared(db, 'SELECT * FROM shares WHERE id = ?').get(shareId); if (!existing) { const e = new Error('Share not found'); e.status = 404; throw e; } + const effectiveSharingType = updates.sharingType || existing.sharing_type; + const hasUserIdsUpdate = 'userIds' in updates; + if (effectiveSharingType === 'users' && hasUserIdsUpdate) { + if (!Array.isArray(updates.userIds) || updates.userIds.length === 0) { + const e = new Error('At least one user is required for user-specific shares'); + e.status = 400; + throw e; + } + } + if (effectiveSharingType === 'users' && existing.sharing_type !== 'users' && !hasUserIdsUpdate) { + const e = new Error('At least one user is required for user-specific shares'); + e.status = 400; + throw e; + } + const fields = []; const values = []; @@ -292,6 +359,26 @@ const updateShare = async (shareId, updates = {}) => { values.push(updates.accessMode); } + const operationPermissionFields = [ + ['allowDelete', 'allow_delete'], + ['allowCreateFolder', 'allow_create_folder'], + ['allowCreateFile', 'allow_create_file'], + ['allowUpload', 'allow_upload'], + ['allowDownload', 'allow_download'], + ['versionsVisible', 'versions_visible'], + ['versionsDownload', 'versions_download'], + ]; + for (const [key, column] of operationPermissionFields) { + if (!(key in updates)) continue; + if (typeof updates[key] !== 'boolean') { + const e = new Error(`${key} must be a boolean`); + e.status = 400; + throw e; + } + fields.push(`${column} = ?`); + values.push(updates[key] ? 1 : 0); + } + if ( typeof updates.sharingType === 'string' && ['anyone', 'users'].includes(updates.sharingType) @@ -326,29 +413,30 @@ const updateShare = async (shareId, updates = {}) => { values.push(updates.label); } - if (fields.length === 0) { - return getShareById(shareId); - } - - fields.push('updated_at = ?'); - values.push(nowIso()); - values.push(shareId); + const permissionsWillChange = + hasUserIdsUpdate || (effectiveSharingType === 'anyone' && existing.sharing_type === 'users'); + if (fields.length > 0 || permissionsWillChange) { + fields.push('updated_at = ?'); + values.push(nowIso()); + values.push(shareId); - db.prepare(`UPDATE shares SET ${fields.join(', ')} WHERE id = ?`).run(...values); - - // Update user permissions if provided and sharing type is 'users' - if ('userIds' in updates && Array.isArray(updates.userIds)) { - const sharingType = updates.sharingType || existing.sharing_type; + prepared(db, `UPDATE shares SET ${fields.join(', ')} WHERE id = ?`).run(...values); + } - if (sharingType === 'users') { - // Remove all existing permissions - db.prepare('DELETE FROM share_permissions WHERE share_id = ?').run(shareId); + // A permission list is meaningful only for user-specific shares. Always clear + // it when switching back to an anyone link so revoked recipients have no stale + // database entries left behind. + if (hasUserIdsUpdate || effectiveSharingType === 'anyone') { + prepared(db, 'DELETE FROM share_permissions WHERE share_id = ?').run(shareId); - // Add new permissions - const insertPerm = db.prepare(` + if (effectiveSharingType === 'users') { + const insertPerm = prepared( + db, + ` INSERT INTO share_permissions (id, share_id, user_id, created_at) VALUES (?, ?, ?, ?) - `); + ` + ); const now = nowIso(); for (const userId of updates.userIds) { @@ -381,7 +469,7 @@ const updateShare = async (shareId, updates = {}) => { */ const deleteShare = async (shareId) => { const db = await getDb(); - const result = db.prepare('DELETE FROM shares WHERE id = ?').run(shareId); + const result = prepared(db, 'DELETE FROM shares WHERE id = ?').run(shareId); return result.changes > 0; }; @@ -391,7 +479,63 @@ const normalizeShareSourcePath = (sourcePath = '') => .replace(/^\/+/, '') .replace(/\/+$/, ''); -const escapeLikePattern = (value = '') => String(value).replace(/[\\%_]/g, '\\$&'); +/** + * The shares inside a folder, found by bounds on the path: every path that + * begins with `prefix/` sorts at or after it and before `prefix0`, `0` being + * the character right after `/`. + * + * `LIKE 'prefix/%'` ignored case, as SQLite's LIKE always does for ASCII: + * deleting `Docs` counted, and then deleted, the share links of `docs/…` — + * another folder on a Linux volume, and somebody else's links as often as not. + */ +const CHILD_SHARES_SQL = + 'SELECT * FROM shares WHERE source_space = ? AND source_path >= ? AND source_path < ?'; +const childRange = (prefix) => [`${prefix}/`, `${prefix}0`]; + +/** + * Shares affected by each target, in one pass. + * + * A bulk delete asked this per file — three thousand round trips through the + * database to answer a question the whole selection could ask once. Returned + * as a map so each entry still knows which shares are its own. + */ +const getSharesBySourceTarget = async (targets = []) => { + const byTarget = new Map(); + const normalized = (Array.isArray(targets) ? targets : []) + .map((target) => ({ + key: `${target?.sourceSpace}:${normalizeShareSourcePath(target?.sourcePath)}`, + sourceSpace: target?.sourceSpace, + sourcePath: normalizeShareSourcePath(target?.sourcePath), + includeChildren: Boolean(target?.includeChildren), + })) + .filter((target) => target.sourceSpace && target.sourcePath); + + if (normalized.length === 0) return byTarget; + + const db = await getDb(); + const exactQuery = prepared( + db, + 'SELECT * FROM shares WHERE source_space = ? AND source_path = ?' + ); + const childQuery = prepared(db, CHILD_SHARES_SQL); + + for (const target of normalized) { + if (byTarget.has(target.key)) continue; + const rows = new Map(); + exactQuery.all(target.sourceSpace, target.sourcePath).forEach((row) => rows.set(row.id, row)); + if (target.includeChildren) { + childQuery + .all(target.sourceSpace, ...childRange(target.sourcePath)) + .forEach((row) => rows.set(row.id, row)); + } + byTarget.set(target.key, Array.from(rows.values()).map(toClientShare)); + } + + return byTarget; +}; + +const shareTargetKey = (target) => + `${target?.sourceSpace}:${normalizeShareSourcePath(target?.sourcePath)}`; const getSharesForSourceTargets = async (targets = []) => { const normalizedTargets = (Array.isArray(targets) ? targets : []) @@ -409,20 +553,18 @@ const getSharesForSourceTargets = async (targets = []) => { const db = await getDb(); const sharesById = new Map(); - const exactQuery = db.prepare('SELECT * FROM shares WHERE source_space = ? AND source_path = ?'); - const childQuery = db.prepare( - "SELECT * FROM shares WHERE source_space = ? AND source_path LIKE ? ESCAPE '\\'" + const exactQuery = prepared( + db, + 'SELECT * FROM shares WHERE source_space = ? AND source_path = ?' ); + const childQuery = prepared(db, CHILD_SHARES_SQL); for (const target of normalizedTargets) { const exactRows = exactQuery.all(target.sourceSpace, target.sourcePath); exactRows.forEach((row) => sharesById.set(row.id, row)); if (target.includeChildren) { - const childRows = childQuery.all( - target.sourceSpace, - `${escapeLikePattern(target.sourcePath)}/%` - ); + const childRows = childQuery.all(target.sourceSpace, ...childRange(target.sourcePath)); childRows.forEach((row) => sharesById.set(row.id, row)); } } @@ -437,7 +579,7 @@ const deleteSharesByIds = async (shareIds = []) => { } const db = await getDb(); - const deleteOne = db.prepare('DELETE FROM shares WHERE id = ?'); + const deleteOne = prepared(db, 'DELETE FROM shares WHERE id = ?'); const transaction = db.transaction((ids) => { let changes = 0; ids.forEach((id) => { @@ -454,7 +596,7 @@ const deleteSharesByIds = async (shareIds = []) => { */ const verifySharePassword = async (shareId, password) => { const db = await getDb(); - const row = db.prepare('SELECT password_hash FROM shares WHERE id = ?').get(shareId); + const row = prepared(db, 'SELECT password_hash FROM shares WHERE id = ?').get(shareId); if (!row) { return false; @@ -469,9 +611,6 @@ const verifySharePassword = async (shareId, password) => { return false; } - // The asynchronous form: this is reachable without an account, and the - // synchronous one stops the server doing anything else for the length of - // the hash. return bcrypt.compare(password, row.password_hash); }; @@ -480,7 +619,7 @@ const verifySharePassword = async (shareId, password) => { */ const hasUserPermission = async (shareId, userId) => { const db = await getDb(); - const share = db.prepare('SELECT sharing_type, owner_id FROM shares WHERE id = ?').get(shareId); + const share = prepared(db, 'SELECT sharing_type, owner_id FROM shares WHERE id = ?').get(shareId); if (!share) { return false; @@ -525,19 +664,15 @@ const isShareExpired = (share) => { }; /** - * Somebody opened the share. - * - * Opening it is not downloading from it: this used to raise the download - * counter, so the number an owner was shown counted page loads, reloads and - * every folder they browsed inside the share. A link opened twenty times and - * never downloaded from read as twenty downloads. + * Update share access tracking */ const trackShareAccess = async (shareId, { ipAddress = null } = {}) => { const db = await getDb(); - db.prepare( + prepared( + db, ` UPDATE shares - SET access_count = COALESCE(access_count, 0) + 1, + SET access_count = access_count + 1, last_accessed_at = ?, last_access_ip = ? WHERE id = ? @@ -545,15 +680,18 @@ const trackShareAccess = async (shareId, { ipAddress = null } = {}) => { ).run(nowIso(), ipAddress, shareId); }; -/** Something was actually sent: a file left through the link. */ +/** + * Update share download tracking + */ const trackShareDownload = async (shareId, { ipAddress = null } = {}) => { const db = await getDb(); - db.prepare( + prepared( + db, ` UPDATE shares - SET download_count = COALESCE(download_count, 0) + 1, - last_accessed_at = ?, - last_access_ip = ? + SET download_count = download_count + 1, + last_downloaded_at = ?, + last_download_ip = ? WHERE id = ? ` ).run(nowIso(), ipAddress, shareId); @@ -564,7 +702,7 @@ const trackShareDownload = async (shareId, { ipAddress = null } = {}) => { */ const getShareStats = async (shareId) => { const db = await getDb(); - const share = db.prepare('SELECT * FROM shares WHERE id = ?').get(shareId); + const share = prepared(db, 'SELECT * FROM shares WHERE id = ?').get(shareId); if (!share) return null; // Count guest sessions @@ -580,6 +718,9 @@ const getShareStats = async (shareId) => { accessCount: share.access_count || 0, downloadCount: share.download_count || 0, lastAccessedAt: share.last_accessed_at || null, + lastAccessIp: share.last_access_ip || null, + lastDownloadedAt: share.last_downloaded_at || null, + lastDownloadIp: share.last_download_ip || null, guestSessionCount: guestSessions?.count || 0, }; }; @@ -606,6 +747,8 @@ module.exports = { getShareByToken, getSharesByOwnerId, getSharesForSourceTargets, + getSharesBySourceTarget, + shareTargetKey, getSharesForUser, updateShare, deleteShare, diff --git a/backend/src/services/userSearchService.js b/backend/src/services/userSearchService.js index 7351d540a..158b80561 100644 --- a/backend/src/services/userSearchService.js +++ b/backend/src/services/userSearchService.js @@ -81,21 +81,25 @@ const searchUsersForMentions = async (query, limit = 10) => { }; /** - * Who can be mentioned in a comment. + * Everyone who can be mentioned, without a search term. * - * ONLYOFFICE asks for the whole list and filters it in the editor as the - * comment is typed, so this answers with names and addresses rather than to a - * query. + * ONLYOFFICE asks for the list once and filters it in the editor as the comment + * is typed, so there is nothing to search on here — which is why this cannot go + * through `searchLocalUsers`, whose pattern match is what makes it safe to run + * on user input in the first place. Bounded by `limit` for the same reason a + * search is: an unbounded list is a mistake waiting for a large deployment. */ const listUsersForMentions = async (limit = 100) => { try { const db = await getDb(); const rows = db .prepare( - `SELECT id, email, username, display_name - FROM users - ORDER BY display_name ASC, email ASC - LIMIT ?` + ` + SELECT id, email, username, display_name + FROM users + ORDER BY display_name ASC, email ASC + LIMIT ? + ` ) .all(limit); @@ -111,7 +115,7 @@ const listUsersForMentions = async (limit = 100) => { }; module.exports = { - listUsersForMentions, searchUsersForMentions, searchLocalUsers, + listUsersForMentions, }; diff --git a/backend/src/services/userVolumesService.js b/backend/src/services/userVolumesService.js index 82ab52ed5..b82fc6f84 100644 --- a/backend/src/services/userVolumesService.js +++ b/backend/src/services/userVolumesService.js @@ -4,8 +4,6 @@ const fs = require('fs/promises'); const { getDb } = require('./db'); const { generateId, nowIso } = require('../utils/ids'); - - const RESERVED_VOLUME_LABELS = new Set(['personal', 'share', 'volumes']); const assertValidVolumeLabel = (labelRaw) => { diff --git a/backend/src/services/users/index.js b/backend/src/services/users/index.js index 4e9a262da..e52fe267e 100644 --- a/backend/src/services/users/index.js +++ b/backend/src/services/users/index.js @@ -35,8 +35,8 @@ module.exports = { // OIDC authentication getOrCreateOidcUser: oidcAuth.getOrCreateOidcUser, - deriveRolesFromClaims: oidcAuth.deriveRolesFromClaims, rolesFromClaimsAreAuthoritative: oidcAuth.rolesFromClaimsAreAuthoritative, + deriveRolesFromClaims: oidcAuth.deriveRolesFromClaims, // Request user handling getRequestUser: requestUser.getRequestUser, diff --git a/backend/src/services/users/management.js b/backend/src/services/users/management.js index d6d0e6699..eea8cabe2 100644 --- a/backend/src/services/users/management.js +++ b/backend/src/services/users/management.js @@ -1,5 +1,5 @@ const { getDb } = require('../db'); -const { toClientUser, toShareableUser, normalizeEmail, nowIso } = require('./utils'); +const { toClientUser, toShareableUser, normalizeEmail, nowIso, usernameTaken } = require('./utils'); const { countAdmins } = require('./queries'); const listUsers = async () => { @@ -75,6 +75,12 @@ const updateUserProfile = async ({ userId, email, username, displayName }) => { if (typeof username === 'string') { const trimmed = username.trim(); + // A username is something to sign in with, so it has to name one account. + if (trimmed && usernameTaken(db, trimmed, userId)) { + const err = new Error('Username already in use.'); + err.status = 409; + throw err; + } updates.push('username = ?'); values.push(trimmed || null); } @@ -132,8 +138,24 @@ const deleteUser = async ({ userId }) => { /* ignore parse errors */ } - // Delete user (cascade will delete auth_methods) - db.prepare('DELETE FROM users WHERE id = ?').run(userId); + // One transaction. The account goes (its auth_methods cascade with it); its + // folder name stays reserved while its folder is on disk (personalFolders.js); + // and the rows that only ever described this account go too — none of these + // tables points at users through a foreign key, so nothing else removes them. + db.transaction(() => { + if (row.personal_folder_name) { + db.prepare( + 'INSERT OR REPLACE INTO personal_folder_reservations (name, user_id, reserved_at) VALUES (?, ?, ?)' + ).run(row.personal_folder_name, userId, new Date().toISOString()); + } + db.prepare('DELETE FROM folder_preferences WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM recent_destinations WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM auth_locks WHERE key = ?').run(userId); + // Its API tokens do cascade, and are removed here all the same: a way into + // an account is the one leftover worth writing twice. + db.prepare('DELETE FROM api_tokens WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + })(); return true; }; diff --git a/backend/src/services/users/requestUser.js b/backend/src/services/users/requestUser.js index 5693e9154..379486708 100644 --- a/backend/src/services/users/requestUser.js +++ b/backend/src/services/users/requestUser.js @@ -2,6 +2,22 @@ const { getDb } = require('../db'); const { auth: envAuthConfig } = require('../../config/index'); const { toClientUser, normalizeEmail } = require('./utils'); const { deriveRolesFromClaims } = require('./oidcAuth'); +const { claimPersonalFolderName } = require('../personalFolders'); + +/** + * An account that has no folder name yet gets one here. + * + * The migration gave every account that existed a name; this covers the ones + * created since, wherever they were created from, without every creation path + * having to remember. It writes once in an account's life and reads a column + * that was already loaded, so the cost after that is a null check. + */ +const withPersonalFolder = (db, row) => { + if (row && !row.personal_folder_name) { + row.personal_folder_name = claimPersonalFolderName(db, row); + } + return row; +}; const getRequestUser = async (req) => { // Synthetic or pre-populated user (e.g., AUTH_ENABLED=false) @@ -22,10 +38,10 @@ const getRequestUser = async (req) => { const db = await getDb(); const row = db.prepare('SELECT * FROM users WHERE id = ?').get(req.apiToken.userId); if (!row) return null; - const user = toClientUser(row); + const user = toClientUser(withPersonalFolder(db, row)); if (user) { // Which kind of account this is lives in `auth_methods` rather than on - // the row. + // the row — the column that used to say so was carried there years ago. const local = db .prepare( `SELECT 1 FROM auth_methods @@ -42,7 +58,7 @@ const getRequestUser = async (req) => { if (req?.session?.localUserId) { const db = await getDb(); const row = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.localUserId); - const user = toClientUser(row); + const user = toClientUser(withPersonalFolder(db, row)); if (user) { user.provider = 'local'; } @@ -73,7 +89,7 @@ const getRequestUser = async (req) => { if (authMethod) { const row = db.prepare('SELECT * FROM users WHERE id = ?').get(authMethod.user_id); - const user = toClientUser(row); + const user = toClientUser(withPersonalFolder(db, row)); if (user) { user.provider = 'oidc'; user.oidcIssuer = issuer; @@ -111,6 +127,21 @@ const getRequestUser = async (req) => { roles, createdAt: null, updatedAt: null, + // The subject, not the username. + // + // This account has no row yet, so there is no claimed folder name to + // carry and nothing to claim one against. Left null, the folder would be + // derived from `USER_FOLDER_NAME_ORDER` — and the order the reference + // recommends for reusing /home puts `username` first, which two + // identities from two providers can share. The claim mechanism exists to + // stop exactly that, and it cannot run here. + // + // The subject is unique to the provider that issued it, so it is a + // folder of this account's own. It is deliberately not the folder the + // account will get once its row exists: that one is claimed, recorded + // and permanent, and guessing at it here would be handing out a name + // nothing had reserved. + personalFolderName: `oidc-${claims.sub}`, }; } catch (_) { return null; diff --git a/backend/src/utils/env.js b/backend/src/utils/env.js index dca4b8b92..299ccea8b 100644 --- a/backend/src/utils/env.js +++ b/backend/src/utils/env.js @@ -22,7 +22,11 @@ const parseByteSize = (value) => { const s = value.trim(); if (!s) return null; - const m = s.match(/^([0-9]+)\s*([kKmMgGtT]?)b?$/); + // `5MB` is how everyone writes it, the README included, and it used to be + // rejected outright for the capital B — leaving the setting silently at its + // default. Spaces and either case are accepted; the unit is what carries the + // meaning, and `5 mb`, `5MB` and `5m` all mean the same thing to a reader. + const m = s.match(/^([0-9]+)\s*([kKmMgGtT]?)[bB]?$/); if (!m) return null; const num = Number(m[1]); if (!Number.isFinite(num)) return null; diff --git a/backend/src/utils/ownedTree.js b/backend/src/utils/ownedTree.js index d6bf5222c..eb824fd71 100644 --- a/backend/src/utils/ownedTree.js +++ b/backend/src/utils/ownedTree.js @@ -40,7 +40,6 @@ const takeInventory = async (root) => { owned.add(identityOf(stats)); if (!stats.isDirectory()) return; for (const name of await fs.readdir(entryPath)) { - // eslint-disable-next-line no-await-in-loop await walk(path.join(entryPath, name)); } }; @@ -73,7 +72,6 @@ const removeInventoried = async (root, inventory) => { } for (const name of await fs.readdir(entryPath)) { - // eslint-disable-next-line no-await-in-loop await visit(path.join(entryPath, name)); } if (!ours) { diff --git a/backend/tests/config/archive-bounds.test.js b/backend/tests/config/archive-bounds.test.js new file mode 100644 index 000000000..bbc37163a --- /dev/null +++ b/backend/tests/config/archive-bounds.test.js @@ -0,0 +1,133 @@ +import { describe, it, expect, afterEach } from 'vitest'; + +import { modulePath } from '../helpers/env-test-utils.js'; + +/** + * The two bounds on browsing inside an archive. + * + * `archiveCacheService` reads both of them, and nothing defined either. In + * JavaScript that is not an error, it is `undefined`, and every comparison + * against `undefined` is false — so the guards were the wrong way round in two + * different directions at once: + * + * - `innerSize > browseMaxBytes` was never true, so no archive was ever too + * large to look inside. A .tar.gz of any size was decompressed whole into + * the cache so its listing could be shown, which is the one thing that + * guard exists to prevent. + * - `total <= cacheMaxBytes` was never true either, so the sweep never + * returned early and never stopped: it removed every cached copy it found, + * on every pass, and each archive was decompressed again from scratch the + * next time somebody opened it. + * + * Asserted against the configuration rather than the service, because that is + * where the numbers were missing, and a service test would have passed just as + * happily against `undefined`. + */ + +const load = () => { + delete require.cache[require.resolve(modulePath('src/config/index.js'))]; + delete require.cache[require.resolve(modulePath('src/config/env.js'))]; + return require(modulePath('src/config/index.js')); +}; + +const withEnv = (values, run) => { + const previous = {}; + for (const [key, value] of Object.entries(values)) { + previous[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}; + +afterEach(() => load()); + +describe('the bounds on browsing inside an archive', () => { + it('gives both of them a number, so every comparison against them means something', () => { + const { archives } = withEnv( + { MAX_BROWSABLE_ARCHIVE_SIZE: undefined, ARCHIVE_CACHE_MAX_SIZE: undefined }, + load + ); + + expect(Number.isFinite(archives.browseMaxBytes)).toBe(true); + expect(Number.isFinite(archives.cacheMaxBytes)).toBe(true); + expect(archives.browseMaxBytes).toBeGreaterThan(0); + expect(archives.cacheMaxBytes).toBeGreaterThan(0); + }); + + /** + * The comparisons as `archiveCacheService` writes them. Undefined passes the + * first assertion above in no version of this — but it also has to be said + * the way the code says it, because that is where it went wrong. + */ + it('refuses an archive larger than the bound, and admits one under it', () => { + const { archives } = load(); + + expect(archives.browseMaxBytes + 1 > archives.browseMaxBytes).toBe(true); + expect(1 > archives.browseMaxBytes).toBe(false); + }); + + it('lets a cache under the bound alone, and sweeps one over it', () => { + const { archives } = load(); + + expect(1 <= archives.cacheMaxBytes).toBe(true); + expect(archives.cacheMaxBytes + 1 <= archives.cacheMaxBytes).toBe(false); + }); + + it('takes the size an administrator sets', () => { + const { archives } = withEnv( + { MAX_BROWSABLE_ARCHIVE_SIZE: '512MB', ARCHIVE_CACHE_MAX_SIZE: '1GB' }, + load + ); + + expect(archives.browseMaxBytes).toBe(512 * 1024 * 1024); + expect(archives.cacheMaxBytes).toBe(1024 * 1024 * 1024); + }); + + it('falls back rather than failing the start when the value is not a size', () => { + const { archives } = withEnv( + { MAX_BROWSABLE_ARCHIVE_SIZE: 'as much as it takes', ARCHIVE_CACHE_MAX_SIZE: '-1' }, + load + ); + + expect(archives.browseMaxBytes).toBe(2 * 1024 * 1024 * 1024); + expect(archives.cacheMaxBytes).toBe(8 * 1024 * 1024 * 1024); + }); +}); + +/** + * Every size an administrator can set, and the capital B. + * + * `5MB` is how the README writes it and how everyone writes it, and the parser + * rejected it for the capital B alone — returning null, which every setting + * reads as "not set" and answers with its default. Ten settings took their + * default from a value that had been given: the upload chunk size, the search + * and editor ceilings, the JSON body limit, the direct-upload limit, the + * storage reserve, both archive bounds, the trash quota and the preview ceiling. + */ +describe('a size, however it is written', () => { + const sizes = () => require(modulePath('src/utils/env.js')).parseByteSize; + + it.each([ + ['512M', 512 * 1024 * 1024], + ['512MB', 512 * 1024 * 1024], + ['512mb', 512 * 1024 * 1024], + ['512 MB', 512 * 1024 * 1024], + ['2GB', 2 * 1024 * 1024 * 1024], + ['1k', 1024], + ['4096', 4096], + ])('reads %s', (written, bytes) => { + expect(sizes()(written)).toBe(bytes); + }); + + it.each(['512Mo', 'as much as it takes', '', '5 5MB'])('refuses %s', (written) => { + expect(sizes()(written)).toBe(null); + }); +}); 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/openapi/tour.js b/backend/tests/openapi/tour.js index 4fc6a46bc..c63ab2b0c 100644 --- a/backend/tests/openapi/tour.js +++ b/backend/tests/openapi/tour.js @@ -353,6 +353,7 @@ const tour = async (app, { volume, requireFresh }) => { destination: 'Documents/Projets', }) ); + await call('GET /api/files/recent-destinations', admin.get('/api/files/recent-destinations')); await call( 'POST /api/files/delete-impact', admin 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/auth.test.js b/backend/tests/routes/auth.test.js index c6a69780b..8df7732c0 100644 --- a/backend/tests/routes/auth.test.js +++ b/backend/tests/routes/auth.test.js @@ -5,7 +5,7 @@ import express from 'express'; import session from 'express-session'; import bodyParser from 'body-parser'; import request from 'supertest'; -import { setupTestEnv, clearModuleCache } from '../helpers/env-test-utils.js'; +import { setupTestEnv, clearModuleCache, modulePath } from '../helpers/env-test-utils.js'; let envContext; @@ -20,12 +20,26 @@ afterAll(async () => { await envContext.cleanup(); }); -const buildApp = ({ authEnabled } = {}) => { +const buildApp = async ({ authEnabled } = {}) => { if (!envContext) { throw new Error('Test environment not initialized'); } // Ensure each test app starts with a clean database. + // + // Closed before it is removed, not only dropped from the module cache. SQLite + // keeps an unlinked file alive for whoever still holds it open, so the second + // app in a file went on reading the first one's accounts through the old + // handle: `/auth/setup` answered "already configured" and the test that + // needed a fresh install failed on its very first call, for a reason that had + // nothing to do with what it was testing. + try { + // The instance that has it open, not a fresh one: `requireFresh` would hand + // back a module that has never opened anything, and close nothing. + await require(modulePath('src/services/db')).closeDb(); + } catch (_) { + // Nothing had opened it yet. + } try { fs.rmSync(path.join(envContext.configDir, 'app.db'), { force: true }); } catch (_) { @@ -72,7 +86,7 @@ const buildApp = ({ authEnabled } = {}) => { describe('Auth Routes', () => { describe('Authentication Flow', () => { it('should complete setup -> login -> me -> password -> logout flow', async () => { - const app = buildApp({ authEnabled: true }); + const app = await buildApp({ authEnabled: true }); // status before setup const s1 = await request(app).get('/api/auth/status'); @@ -81,13 +95,11 @@ describe('Auth Routes', () => { expect(s1.body.authEnabled).toBe(true); // setup admin - const setup = await request(app) - .post('/api/auth/setup') - .send({ - email: 'admin@example.com', - username: 'admin', - password: 'secret123', - }); + const setup = await request(app).post('/api/auth/setup').send({ + email: 'admin@example.com', + username: 'admin', + password: 'secret123', + }); expect(setup.status).toBe(201); expect(setup.body.user).toBeDefined(); expect(setup.body.user.roles).toContain('admin'); @@ -116,16 +128,14 @@ describe('Auth Routes', () => { }); it('should return JSON 401 when current password is incorrect', async () => { - const app = buildApp({ authEnabled: true }); + const app = await buildApp({ authEnabled: true }); // setup admin - const setup = await request(app) - .post('/api/auth/setup') - .send({ - email: 'admin@example.com', - username: 'admin', - password: 'secret123', - }); + const setup = await request(app).post('/api/auth/setup').send({ + email: 'admin@example.com', + username: 'admin', + password: 'secret123', + }); expect(setup.status).toBe(201); // login @@ -149,7 +159,7 @@ describe('Auth Routes', () => { describe('Auth Status', () => { it('should reflect disabled auth via AUTH_ENABLED', async () => { - const app = buildApp({ authEnabled: false }); + const app = await buildApp({ authEnabled: false }); const status = await request(app).get('/api/auth/status'); expect(status.status).toBe(200); 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'); + }); +}); diff --git a/backend/tests/routes/browse-hidden-files.test.js b/backend/tests/routes/browse-hidden-files.test.js index 6ef8c7dee..d46d0df41 100644 --- a/backend/tests/routes/browse-hidden-files.test.js +++ b/backend/tests/routes/browse-hidden-files.test.js @@ -20,6 +20,17 @@ const createBrowseContext = async () => { ], }); + // The account the requests below are made as. A preference belongs to an + // account — `user_settings` says so with a foreign key — so writing one for + // an id nothing created fails on the constraint rather than on anything this + // test is about. + const db = await envContext.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 ('admin', 'admin@example.com', 1, 'admin', 'Admin', '["admin"]', ?, ?)` + ).run(now, now); + const browseRoutes = envContext.requireFresh('src/routes/browse'); const { errorHandler } = envContext.requireFresh('src/middleware/errorHandler'); const app = createTestApp({ 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); + }); +}); diff --git a/backend/tests/routes/permissions.test.js b/backend/tests/routes/permissions.test.js new file mode 100644 index 000000000..20454d5ae --- /dev/null +++ b/backend/tests/routes/permissions.test.js @@ -0,0 +1,265 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express from 'express'; +import request from 'supertest'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * The two routes that hand a path to `chmod` and `chown`. + * + * They are the pair that once shipped with no admin check at all, and they are + * the only place in the application where a value from a request reaches a + * system tool. Both facts are pinned here: that a regular account is refused, + * and that nothing shaped like an option can reach the argument list. + */ + +let currentEnv; + +afterEach(async () => { + if (currentEnv) { + await currentEnv.cleanup(); + currentEnv = null; + } +}); + +const REGULAR_USER = { id: 'user-1', username: 'regular', roles: ['user'] }; +const ADMIN_USER = { id: 'admin-1', username: 'admin', roles: ['admin'] }; + +const seed = async () => { + currentEnv = await setupTestEnv({ tag: 'permissions-' }); + const dbService = currentEnv.requireFresh('src/services/db'); + const db = await dbService.getDb(); + const now = new Date().toISOString(); + for (const user of [REGULAR_USER, ADMIN_USER]) { + db.prepare( + `INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES (?, ?, 1, ?, ?, ?, ?, ?)` + ).run( + user.id, + `${user.username}@example.com`, + user.username, + user.username, + JSON.stringify(user.roles), + now, + now + ); + } + const dir = path.join(currentEnv.volumeDir, 'Docs'); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'note.txt'), 'hello\n'); + return dir; +}; + +const buildApp = (user) => { + const routes = currentEnv.requireFresh('src/routes/permissions'); + const { errorHandler } = currentEnv.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + if (user) req.user = user; + next(); + }); + app.use('/api', routes); + app.use(errorHandler); + return app; +}; + +describe('who may change permissions', () => { + it('refuses a regular account on chmod', async () => { + await seed(); + + const response = await request(buildApp(REGULAR_USER)) + .post('/api/permissions/chmod') + .send({ path: 'Docs/note.txt', mode: '777' }); + + expect(response.status).toBe(403); + }); + + it('refuses a regular account on chown', async () => { + await seed(); + + const response = await request(buildApp(REGULAR_USER)) + .post('/api/permissions/chown') + .send({ path: 'Docs/note.txt', owner: 'root' }); + + expect(response.status).toBe(403); + }); + + /** + * A write permission on a path is not consent to re-permission its tree — + * the refusal has to come from the role, not from the path being unreachable. + */ + it('refuses before it has looked at the path at all', async () => { + await seed(); + + const response = await request(buildApp(REGULAR_USER)) + .post('/api/permissions/chmod') + .send({ path: 'Docs/does-not-exist.txt', mode: '777' }); + + expect(response.status).toBe(403); + }); +}); + +describe('what may reach chmod', () => { + it('changes the mode of a real file', async () => { + const dir = await seed(); + + const response = await request(buildApp(ADMIN_USER)) + .post('/api/permissions/chmod') + .send({ path: 'Docs/note.txt', mode: '640' }); + + expect(response.status).toBe(200); + const stats = await fs.stat(path.join(dir, 'note.txt')); + expect(stats.mode & 0o777).toBe(0o640); + }); + + // Three digits say nothing about the setuid, setgid and sticky bits, and + // chmod writes the whole mode: unticking one box on a sticky or setgid folder + // used to take those bits away with it. + it('keeps the special bits the folder already had', async () => { + const dir = await seed(); + const shared = path.join(dir, 'drop-box'); + await fs.mkdir(shared); + await fs.chmod(shared, 0o1777); + + const response = await request(buildApp(ADMIN_USER)) + .post('/api/permissions/chmod') + .send({ path: 'Docs/drop-box', mode: '775' }); + + expect(response.status).toBe(200); + const stats = await fs.stat(shared); + expect(stats.mode & 0o777).toBe(0o775); + expect(stats.mode & 0o7000).toBe(0o1000); + expect(response.body.mode & 0o7777).toBe(0o1775); + }); + + // The mode is interpolated into a `chmod -R` argument list on the recursive + // path. Only three octal digits can get that far. + it.each([['755 --reference=/etc/shadow'], ['7555'], ['75\n5'], ['a+x'], ['']])( + 'refuses the mode %j', + async (mode) => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)) + .post('/api/permissions/chmod') + .send({ path: 'Docs/note.txt', mode }); + + expect(response.status).toBe(400); + } + ); + + // The status alone proves nothing here: a share path is unreachable anyway, + // so it is refused either way. Only the reason says which check fired. + it('refuses a path reached through a share, and says so', async () => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)) + .post('/api/permissions/chmod') + .send({ path: 'share/abc/note.txt', mode: '640' }); + + expect(response.status).toBe(403); + expect(response.body.error.message).toBe('Permissions cannot be changed through a share.'); + }); + + it('requires a path', async () => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)) + .post('/api/permissions/chmod') + .send({ mode: '640' }); + + expect(response.status).toBe(400); + }); +}); + +describe('what may reach chown', () => { + /** + * `chown` takes its arguments as an array and never through a shell, so a + * semicolon is harmless — an argument that reads as an *option* is not. + * `--reference=FILE` makes chown copy another file's ownership, and a + * leading dash is what the pattern exists to refuse. + */ + it.each([['--reference=/etc/shadow'], ['-R'], ['root nobody'], ['root;id'], ['.hidden'], ['-']])( + 'refuses the owner %j', + async (owner) => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)) + .post('/api/permissions/chown') + .send({ path: 'Docs/note.txt', owner }); + + expect(response.status).toBe(400); + } + ); + + it('refuses a group of the same shape', async () => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)) + .post('/api/permissions/chown') + .send({ path: 'Docs/note.txt', group: '--reference=/etc/shadow' }); + + expect(response.status).toBe(400); + }); + + it('accepts an ordinary account name', async () => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)) + .post('/api/permissions/chown') + .send({ path: 'Docs/note.txt', owner: 'nobody' }); + + // Changing ownership needs root, which the test process is not; what + // matters is that the name passed validation and the call was attempted. + expect(response.status).not.toBe(400); + }); + + it('requires an owner or a group', async () => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)) + .post('/api/permissions/chown') + .send({ path: 'Docs/note.txt' }); + + expect(response.status).toBe(400); + }); + + it('refuses a path reached through a share, and says so', async () => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)) + .post('/api/permissions/chown') + .send({ path: 'share/abc/note.txt', owner: 'nobody' }); + + expect(response.status).toBe(403); + expect(response.body.error.message).toBe('Ownership cannot be changed through a share.'); + }); +}); + +describe('reading permissions', () => { + it('reports the mode, owner and group of a file', async () => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)).get('/api/permissions/Docs/note.txt'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ path: 'Docs/note.txt' }); + }); + + it('requires a path', async () => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)).get('/api/permissions/'); + + expect(response.status).toBe(400); + }); + + it('says not found rather than failing, for a path that is not there', async () => { + await seed(); + + const response = await request(buildApp(ADMIN_USER)).get('/api/permissions/Docs/absent.txt'); + + expect(response.status).toBe(404); + }); +}); diff --git a/backend/tests/routes/personal-folder-isolation.test.js b/backend/tests/routes/personal-folder-isolation.test.js index 97b6d238b..e58140c76 100644 --- a/backend/tests/routes/personal-folder-isolation.test.js +++ b/backend/tests/routes/personal-folder-isolation.test.js @@ -61,8 +61,7 @@ const setup = async ({ userRootEnv = {} } = {}) => { await fs.writeFile(path.join(aliceRoot, 'salary.txt'), 'alice private'); const bob = { id: 'bob', username: 'bob', roles: ['user'] }; - const asBob = (router) => - createTestApp({ router, mountPath: '/api', user: bob, errorHandler }); + const asBob = (router) => createTestApp({ router, mountPath: '/api', user: bob, errorHandler }); return { env, @@ -176,7 +175,7 @@ describe('a volume that does not hold the personal folders', () => { const browseRoutes = env.requireFresh('src/routes/browse'); const { errorHandler } = env.requireFresh('src/middleware/errorHandler'); - const db = await (env.requireFresh('src/services/db').getDb()); + const db = await env.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) diff --git a/backend/tests/routes/public-endpoints-hardening.test.js b/backend/tests/routes/public-endpoints-hardening.test.js index dcb6ed5b9..0539eed4b 100644 --- a/backend/tests/routes/public-endpoints-hardening.test.js +++ b/backend/tests/routes/public-endpoints-hardening.test.js @@ -88,8 +88,14 @@ describe('checking a share password', () => { .post(`/api/share/${token}/verify`) .send({ password: 'open-sesame' }); + // The one being handed out, not the one being cleared. The cookie moved + // from /api to the root — an asking /static for a thumbnail cannot + // carry a header, and a cookie scoped to /api never reaches it — so the + // old one is cleared in the same answer, and a browser keeps both headers. const cookieOf = (response) => - [].concat(response.headers['set-cookie'] || []).find((c) => c.startsWith('guestSession=')); + [] + .concat(response.headers['set-cookie'] || []) + .find((c) => /^guestSession=.+/.test(c) && !/Expires=Thu, 01 Jan 1970/.test(c)); expect(overHttps.status).toBe(200); expect(cookieOf(overHttps)).toMatch(/;\s*Secure/i); // The control: plain HTTP cannot carry a Secure cookie back at all. diff --git a/backend/tests/routes/recent-destinations.test.js b/backend/tests/routes/recent-destinations.test.js new file mode 100644 index 000000000..8e44c7c8c --- /dev/null +++ b/backend/tests/routes/recent-destinations.test.js @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import request from 'supertest'; +import { createTestApp, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * The folders a user actually files things into. + * + * The destination picker opens on this list rather than at the root, so what it + * contains has to be true without anyone maintaining it: written by the + * transfers themselves, ordered by use, and holding nothing the person cannot + * still reach. A destination offered and then refused at the end of the flow + * would be worse than no list at all. + */ + +describe('recent destinations', () => { + let env; + const asUser = (id) => ({ id, roles: ['admin'] }); + + const setup = async () => { + env = await setupTestEnv({ + tag: 'recent-destinations-', + modules: [ + 'src/services/db', + 'src/services/recentDestinationsService', + 'src/services/accessManager', + 'src/routes/files', + 'src/middleware/errorHandler', + ], + }); + + for (const folder of ['Archive', 'Invoices', 'Photos']) { + await fs.mkdir(path.join(env.volumeDir, folder), { recursive: true }); + } + await fs.writeFile(path.join(env.volumeDir, 'report.txt'), 'contents'); + }; + + const appFor = (user) => { + const routes = env.requireFresh('src/routes/files'); + const { errorHandler } = env.requireFresh('src/middleware/errorHandler'); + return createTestApp({ router: routes, mountPath: '/api', user, errorHandler }); + }; + + /** Move a file into a folder, the way the client does. */ + const moveInto = async (app, name, destination) => + request(app) + .post('/api/files/move') + .send({ + items: [{ name, path: '' }], + destination, + }); + + const listFor = async (app) => { + const response = await request(app).get('/api/files/recent-destinations'); + expect(response.status).toBe(200); + return response.body.items; + }; + + afterEach(async () => { + if (env) { + await env.cleanup(); + env = null; + } + }); + + it('remembers where a transfer landed, without being asked to', async () => { + // Nothing in the client reports this: recording it from the transfer means + // a drag onto a favorite and a paste count exactly like a pick. + await setup(); + const app = appFor(asUser('alice')); + + expect(await listFor(app)).toEqual([]); + + expect((await moveInto(app, 'report.txt', 'Archive')).status).toBe(200); + + expect(await listFor(app)).toEqual(['Archive']); + }); + + it('puts the destination used most recently first', async () => { + await setup(); + const app = appFor(asUser('alice')); + + await moveInto(app, 'report.txt', 'Archive'); + await fs.writeFile(path.join(env.volumeDir, 'report.txt'), 'again'); + await moveInto(app, 'report.txt', 'Invoices'); + + expect(await listFor(app)).toEqual(['Invoices', 'Archive']); + }); + + it('moves a destination up rather than listing it twice', async () => { + await setup(); + const app = appFor(asUser('alice')); + + await moveInto(app, 'report.txt', 'Archive'); + await fs.writeFile(path.join(env.volumeDir, 'report.txt'), 'again'); + await moveInto(app, 'report.txt', 'Invoices'); + await fs.writeFile(path.join(env.volumeDir, 'report.txt'), 'once more'); + await moveInto(app, 'report.txt', 'Archive'); + + expect(await listFor(app)).toEqual(['Archive', 'Invoices']); + }); + + it('keeps one user habits out of another list', async () => { + // Where someone files their work says something about it; a favorite is + // shared deliberately, this is not. + await setup(); + const alice = appFor(asUser('alice')); + const bob = appFor(asUser('bob')); + + await moveInto(alice, 'report.txt', 'Archive'); + + expect(await listFor(alice)).toEqual(['Archive']); + expect(await listFor(bob)).toEqual([]); + }); + + it('drops a destination that has since been deleted', async () => { + // Offering it would only produce a failure at the end of the flow. + await setup(); + const app = appFor(asUser('alice')); + + await moveInto(app, 'report.txt', 'Archive'); + await fs.rm(path.join(env.volumeDir, 'Archive'), { recursive: true, force: true }); + + expect(await listFor(app)).toEqual([]); + + // And forgotten for good, rather than re-tested on every open. + await fs.mkdir(path.join(env.volumeDir, 'Archive'), { recursive: true }); + expect(await listFor(app)).toEqual([]); + }); + + it('swallows a write it cannot perform, rather than failing the transfer', async () => { + // The list is a convenience; the file arriving is not. A /config directory + // shared with an older image is the real way this happens — the schema is + // behind and the table simply isn't there, which must not turn a successful + // move into a 500. + await setup(); + + const { getDb } = env.requireFresh('src/services/db'); + const service = env.requireFresh('src/services/recentDestinationsService'); + const db = await getDb(); + db.exec('DROP TABLE recent_destinations'); + + await expect(service.record('alice', 'Archive')).resolves.toBeUndefined(); + }); +}); diff --git a/backend/tests/routes/search-ordinary-folder-names.test.js b/backend/tests/routes/search-ordinary-folder-names.test.js new file mode 100644 index 000000000..f6ca2c2d0 --- /dev/null +++ b/backend/tests/routes/search-ordinary-folder-names.test.js @@ -0,0 +1,168 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express from 'express'; +import request from 'supertest'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Folder names an editor skips and a file server must not. + * + * Search carried `.git`, `node_modules`, `dist` and `build` as names to walk + * past — a habit that belongs in a code editor. Here they are folder names + * somebody may have put a year of work in, and a file under one of them could + * not be found by name or by content, with nothing in the answer to say why + * (#11). + * + * Both engines are exercised, because the names were hard-coded twice: once as + * ripgrep globs and once in the walker that runs when ripgrep is absent. + */ + +let envContext; + +const buildApp = () => { + const searchRoutes = envContext.requireFresh('src/routes/search'); + const { errorHandler } = envContext.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use((req, _res, next) => { + req.user = { id: 'u1', email: 'u@example.com', roles: ['admin'] }; + next(); + }); + app.use('/api', searchRoutes); + app.use(errorHandler); + return app; +}; + +const seed = async (env = {}) => { + envContext = await setupTestEnv({ + tag: 'search-folder-names-', + env: { SEARCH_RIPGREP: 'true', SEARCH_DEEP: 'true', ...env }, + }); + 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 ('u1', 'u@example.com', 1, 'u', 'U', '["admin"]', ?, ?)` + ).run(now, now); + return path.join(envContext.volumeDir, 'Docs'); +}; + +const search = async (term) => { + const response = await request(buildApp()).get('/api/search').query({ q: term }); + expect(response.status).toBe(200); + return (response.body.items || []).map((item) => `${item.path}/${item.name}`); +}; + +/** One file at `relDir/name`, and what searching for `term` returns. */ +const withFile = async ({ relDir, name, contents = 'nothing in particular', term, env }) => { + const docs = await seed(env); + const dir = path.join(docs, relDir); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, name), contents); + return search(term); +}; + +afterEach(async () => { + if (envContext) await envContext.cleanup(); + envContext = null; +}); + +describe('a folder named like a build directory', () => { + for (const folder of ['build', 'dist', 'node_modules']) { + it(`finds a file by name under ${folder}`, async () => { + const found = await withFile({ + relDir: `projects/${folder}/reports`, + name: 'quarterly.txt', + term: 'quarterly', + }); + expect(found).toContain(`Docs/projects/${folder}/reports/quarterly.txt`); + }); + } + + it('finds what such a folder holds, by its contents', async () => { + const found = await withFile({ + relDir: 'projects/build', + name: 'notes.txt', + contents: 'the measurement was taken at dawn', + term: 'dawn', + }); + expect(found).toContain('Docs/projects/build/notes.txt'); + }); + + it('finds the folder itself', async () => { + const found = await withFile({ + relDir: 'projects/build/reports', + name: 'quarterly.txt', + term: 'build', + }); + expect(found).toContain('Docs/projects/build'); + }); + + it('finds it with the walker too, when ripgrep is not there', async () => { + const found = await withFile({ + relDir: 'projects/build/reports', + name: 'quarterly.txt', + term: 'quarterly', + env: { SEARCH_RIPGREP: 'false' }, + }); + expect(found).toContain('Docs/projects/build/reports/quarterly.txt'); + }); +}); + +describe('what may still be left out', () => { + // The mechanism that decides is the administrator's, not a name in the + // source: removing the four must not remove this one. + it('honours the exclusion list', async () => { + const found = await withFile({ + relDir: 'private', + name: 'quarterly.txt', + term: 'quarterly', + env: { SEARCH_INDEX_EXCLUDE: 'Docs/private' }, + }); + expect(found).toEqual([]); + }); + + it('leaves hidden folders hidden while the reader has not asked', async () => { + const found = await withFile({ + relDir: '.private', + name: 'quarterly.txt', + term: 'quarterly', + }); + expect(found).toEqual([]); + }); +}); + +/** + * And the third place the same four names were written down. + * + * The index is a fourth engine, not a variant of the other two: it walks the + * volume itself, on its own schedule, and the search answers from what it + * catalogued. A name it never walked into is a name no amount of asking will + * return — and unlike the other two, being absent from the index shows up as + * an empty answer with nothing to explain it, however the search is run. + */ +describe('a folder named like a build directory, catalogued', () => { + const indexAndSearch = async (relDir, name, term) => { + const docs = await seed({ SEARCH_INDEX_ENABLED: 'true' }); + const dir = path.join(docs, relDir); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, name), 'le mot pangolin'); + + const indexDb = envContext.requireFresh('src/services/indexDb'); + const db = await indexDb.getIndexDb(); + const indexer = envContext.requireFresh('src/services/searchIndexer'); + await indexer.indexTree({ db, rootAbs: envContext.volumeDir, cpuPercent: 100 }); + + const store = envContext.requireFresh('src/services/searchIndexStore'); + return { catalogued: store.search(db, term) }; + }; + + for (const folder of ['build', 'dist', 'node_modules']) { + it(`catalogues what is under ${folder}`, async () => { + const { catalogued } = await indexAndSearch(`projets/${folder}`, 'rapport.txt', 'pangolin'); + + expect(catalogued).toContain(`Docs/projets/${folder}/rapport.txt`); + }); + } +}); 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/routes/share-counters.test.js b/backend/tests/routes/share-counters.test.js index ddfc9ac7b..78cc9e1fd 100644 --- a/backend/tests/routes/share-counters.test.js +++ b/backend/tests/routes/share-counters.test.js @@ -98,7 +98,15 @@ describe('a share link', () => { expect(await numbers(id)).toEqual({ opened: 2, downloaded: 0 }); }); - it('counts a file leaving as a download', async () => { + /** + * Counted the way the file is delivered, not by which route asked for it. + * + * Every fetch of `/file/...` used to be a download, so reading a text file in + * the browser — which never leaves the page — was written down as a copy + * taken away. An owner reading "downloaded 40 times" was reading the number + * of times somebody had looked at it. + */ + it('counts a file shown in the page as an opening', async () => { const { token, id } = await seedShare(); const app = buildApp(); @@ -106,6 +114,19 @@ describe('a share link', () => { const file = await request(app).get(`/api/share/${token}/file/file.txt`); expect(file.status).toBe(200); + expect(file.headers['content-disposition'] || '').not.toMatch(/attachment/); + expect(await numbers(id)).toEqual({ opened: 2, downloaded: 0 }); + }); + + it('counts a file handed over as a download', async () => { + const { token, id } = await seedShare(); + const app = buildApp(); + + await request(app).get(`/api/share/${token}/access`); + const file = await request(app).get(`/api/share/${token}/file/file.txt?mode=download`); + + expect(file.status).toBe(200); + expect(file.headers['content-disposition'] || '').toMatch(/attachment/); expect(await numbers(id)).toEqual({ opened: 1, downloaded: 1 }); }); diff --git a/backend/tests/routes/share-door.test.js b/backend/tests/routes/share-door.test.js new file mode 100644 index 000000000..ca1b6ea96 --- /dev/null +++ b/backend/tests/routes/share-door.test.js @@ -0,0 +1,615 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express from 'express'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; + +import { setupTestEnv, clearModuleCache } from '../helpers/env-test-utils.js'; + +/** + * The three routes a shared link is opened through. + * + * `/info` is answered to anyone holding a token, before any password has been + * typed — so what it does *not* say matters as much as what it does: where the + * file lives on the server, and who the share was named to, are not a visitor's + * to learn from a link they may not even be able to open. + * + * `/verify` and `/access` are the door itself. Between them they decide who + * gets a guest session, who is sent to sign in, and who is turned away — and + * the rules are not symmetrical: a password protects a link from everyone but + * its owner, being signed in is not the same as knowing it, and a share named + * to people is not opened by a password at all. + */ + +let envContext; + +beforeAll(async () => { + envContext = await setupTestEnv({ + tag: 'share-door-test-', + env: { USER_VOLUMES: 'true' }, + modules: [ + 'src/services/db', + 'src/services/users', + 'src/services/userVolumesService', + 'src/services/sharesService', + 'src/services/guestSessionService', + 'src/utils/pathUtils', + 'src/middleware/authMiddleware', + 'src/middleware/errorHandler', + 'src/routes/shares', + ], + }); +}); + +afterAll(async () => { + await envContext.cleanup(); +}); + +const buildApp = ({ user } = {}) => { + clearModuleCache('src/config/env'); + clearModuleCache('src/config/index'); + + const sharesRoutes = envContext.requireFresh('src/routes/shares'); + const { errorHandler } = envContext.requireFresh('src/middleware/errorHandler'); + const authMiddleware = envContext.requireFresh('src/middleware/authMiddleware'); + + const app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use((req, _res, next) => { + req.session = user ? { localUserId: user.id } : {}; + next(); + }); + app.use(authMiddleware); + app.use('/api/shares', sharesRoutes); + app.use('/api/share', sharesRoutes); + app.use(errorHandler); + return app; +}; + +let seq = 0; + +/** An owner with a volume of their own, and something in it. */ +const makeOwner = async (files = { 'hello.txt': 'bonjour' }) => { + seq += 1; + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + + const root = path.join(envContext.tmpRoot, `door-volume-${seq}`); + await fs.mkdir(root, { recursive: true }); + for (const [name, contents] of Object.entries(files)) { + await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true }); + await fs.writeFile(path.join(root, name), contents); + } + + const user = await usersService.createLocalUser({ + email: `door-${seq}@example.com`, + username: `door-${seq}`, + displayName: `Door ${seq}`, + password: 'secret123', + roles: ['user'], + }); + const label = `DoorVol${seq}`; + await userVolumesService.addVolumeToUser({ + userId: user.id, + label, + volumePath: root, + accessMode: 'readwrite', + }); + + return { user, label, root }; +}; + +const createShare = async (user, body) => { + const response = await request(buildApp({ user })).post('/api/shares').send(body); + expect(response.status).toBe(201); + return response.body; +}; + +const visitor = () => buildApp(); + +describe('what a link tells someone holding it', () => { + it('names the share and says what it is', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + label: 'Le mot de passe du wifi', + }); + + const info = await request(visitor()).get(`/api/share/${share.shareToken}/info`); + + expect(info.status).toBe(200); + expect(info.body).toMatchObject({ + shareToken: share.shareToken, + label: 'Le mot de passe du wifi', + isDirectory: false, + hasPassword: false, + sharingType: 'anyone', + isExpired: false, + }); + }); + + /** + * A token is not a permission. Anyone who has one — from a forwarded message, + * a browser history, a proxy log — can call this before typing a password, so + * it must not describe the server's filesystem or name the people the share + * was made for. + */ + it('says nothing about where the file lives or who it was made for', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + }); + + const info = await request(visitor()).get(`/api/share/${share.shareToken}/info`); + + expect(Object.keys(info.body).sort()).toEqual( + [ + 'expiresAt', + 'hasPassword', + 'isDirectory', + 'isExpired', + 'label', + 'requiresPassword', + 'sharingType', + 'shareToken', + ].sort() + ); + expect(JSON.stringify(info.body)).not.toContain(label); + }); + + it('says a protected link wants a password', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + password: 'ouvre-toi', + }); + + const info = await request(visitor()).get(`/api/share/${share.shareToken}/info`); + + expect(info.body).toMatchObject({ hasPassword: true, requiresPassword: true }); + }); + + /** + * The owner of a protected link is not sent to a prompt the API would let + * them skip — the router reads this field, so the two have to agree. + */ + it('does not ask its own owner for the password', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + password: 'ouvre-toi', + }); + + const info = await request(buildApp({ user })).get(`/api/share/${share.shareToken}/info`); + + expect(info.body).toMatchObject({ hasPassword: true, requiresPassword: false }); + }); + + it('says an expired link has expired', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + const sharesService = envContext.requireFresh('src/services/sharesService'); + const dbService = envContext.requireFresh('src/services/db'); + const db = await dbService.getDb(); + db.prepare('UPDATE shares SET expires_at = ? WHERE share_token = ?').run( + new Date(Date.now() - 60_000).toISOString(), + share.shareToken + ); + expect(sharesService).toBeTruthy(); + + const info = await request(visitor()).get(`/api/share/${share.shareToken}/info`); + + expect(info.body.isExpired).toBe(true); + }); + + it('answers a token that was never a share with a plain not-found', async () => { + const info = await request(visitor()).get('/api/share/pas-un-jeton/info'); + + expect(info.status).toBe(404); + }); +}); + +describe('typing the password on a link', () => { + const lockedShare = async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + password: 'ouvre-toi', + }); + return { user, share }; + }; + + it('opens it, and hands back a session for this share', async () => { + const { share } = await lockedShare(); + + const verified = await request(visitor()) + .post(`/api/share/${share.shareToken}/verify`) + .send({ password: 'ouvre-toi' }); + + expect(verified.status).toBe(200); + expect(verified.body.success).toBe(true); + expect(verified.body.guestSessionId).toBeTruthy(); + }); + + it('sets the session as a cookie, so a reload keeps it', async () => { + const { share } = await lockedShare(); + + const verified = await request(visitor()) + .post(`/api/share/${share.shareToken}/verify`) + .send({ password: 'ouvre-toi' }); + + expect(String(verified.headers['set-cookie'])).toContain('guest'); + }); + + it('refuses the wrong one, and hands back nothing', async () => { + const { share } = await lockedShare(); + + const refused = await request(visitor()) + .post(`/api/share/${share.shareToken}/verify`) + .send({ password: 'au-hasard' }); + + expect(refused.status).toBe(401); + expect(refused.body.guestSessionId).toBeUndefined(); + }); + + it('refuses an empty one', async () => { + const { share } = await lockedShare(); + + const refused = await request(visitor()).post(`/api/share/${share.shareToken}/verify`).send({}); + + expect(refused.status).toBe(401); + }); + + /** A link that has run out is not opened by the right password either. */ + it('refuses an expired link whatever is typed', async () => { + const { share } = await lockedShare(); + const db = await envContext.requireFresh('src/services/db').getDb(); + db.prepare('UPDATE shares SET expires_at = ? WHERE share_token = ?').run( + new Date(Date.now() - 60_000).toISOString(), + share.shareToken + ); + + const refused = await request(visitor()) + .post(`/api/share/${share.shareToken}/verify`) + .send({ password: 'ouvre-toi' }); + + expect(refused.status).toBe(403); + }); + + /** + * A share named to people is not opened by knowing something. The visitor is + * told to sign in rather than handed a session. + */ + it('sends a named share to sign in rather than opening it', async () => { + const { user, label } = await makeOwner(); + const other = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'users', + userIds: [other.user.id], + password: 'ouvre-toi', + }); + + const verified = await request(visitor()) + .post(`/api/share/${share.shareToken}/verify`) + .send({ password: 'ouvre-toi' }); + + expect(verified.status).toBe(200); + expect(verified.body).toEqual({ success: true, requiresAuth: true }); + expect(verified.body.guestSessionId).toBeUndefined(); + }); + + it('opens a link with no password at all, for anyone', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + }); + + const verified = await request(visitor()) + .post(`/api/share/${share.shareToken}/verify`) + .send({}); + + expect(verified.status).toBe(200); + expect(verified.body.guestSessionId).toBeTruthy(); + }); + + it('still requires signing in for a named share with no password', async () => { + const { user, label } = await makeOwner(); + const other = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'users', + userIds: [other.user.id], + }); + + const refused = await request(visitor()).post(`/api/share/${share.shareToken}/verify`).send({}); + + expect(refused.status).toBe(401); + }); + + it('answers a token that was never a share with a plain not-found', async () => { + const refused = await request(visitor()) + .post('/api/share/pas-un-jeton/verify') + .send({ password: 'x' }); + + expect(refused.status).toBe(404); + }); +}); + +describe('opening a link', () => { + it('gives a visitor a session and describes what they may do', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + }); + + const opened = await request(visitor()).get(`/api/share/${share.shareToken}/access`); + + expect(opened.status).toBe(200); + expect(opened.body.guestSessionId).toBeTruthy(); + expect(opened.body.share).toMatchObject({ + shareToken: share.shareToken, + sourcePath: `share/${share.shareToken}`, + accessMode: 'readonly', + allowDownload: true, + isDirectory: false, + }); + }); + + /** Being signed in is not knowing the password. */ + it('refuses a protected link to a visitor who has not typed the password', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + password: 'ouvre-toi', + }); + + const refused = await request(visitor()).get(`/api/share/${share.shareToken}/access`); + + expect(refused.status).toBe(401); + }); + + it('refuses it to a signed-in stranger too', async () => { + const { user, label } = await makeOwner(); + const stranger = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + password: 'ouvre-toi', + }); + + const refused = await request(buildApp({ user: stranger.user })).get( + `/api/share/${share.shareToken}/access` + ); + + expect(refused.status).toBe(401); + }); + + it('opens it for its owner without a password', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + password: 'ouvre-toi', + }); + + const opened = await request(buildApp({ user })).get(`/api/share/${share.shareToken}/access`); + + expect(opened.status).toBe(200); + }); + + /** + * Reloading the page calls here again. A visitor who has just typed the + * password holds a session that says so, and asking for it a second time — + * for a share they were just given — is how this went wrong before. + */ + it('accepts the session a visitor was just handed', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + password: 'ouvre-toi', + }); + const app = visitor(); + const agent = request.agent(app); + const verified = await agent + .post(`/api/share/${share.shareToken}/verify`) + .send({ password: 'ouvre-toi' }); + expect(verified.status).toBe(200); + + const opened = await agent.get(`/api/share/${share.shareToken}/access`); + + expect(opened.status).toBe(200); + expect(opened.body.guestSessionId).toBe(verified.body.guestSessionId); + }); + + it('refuses a named share to somebody not on it', async () => { + const { user, label } = await makeOwner(); + const invited = await makeOwner(); + const stranger = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'users', + userIds: [invited.user.id], + }); + + const refused = await request(buildApp({ user: stranger.user })).get( + `/api/share/${share.shareToken}/access` + ); + + expect(refused.status).toBe(403); + }); + + it('opens it for somebody who is on it', async () => { + const { user, label } = await makeOwner(); + const invited = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'users', + userIds: [invited.user.id], + }); + + const opened = await request(buildApp({ user: invited.user })).get( + `/api/share/${share.shareToken}/access` + ); + + expect(opened.status).toBe(200); + }); + + it('asks a signed-out visitor of a named share to sign in', async () => { + const { user, label } = await makeOwner(); + const invited = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'users', + userIds: [invited.user.id], + }); + + const refused = await request(visitor()).get(`/api/share/${share.shareToken}/access`); + + expect(refused.status).toBe(401); + }); + + it('refuses an expired link to everyone, its owner included', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + }); + const db = await envContext.requireFresh('src/services/db').getDb(); + db.prepare('UPDATE shares SET expires_at = ? WHERE share_token = ?').run( + new Date(Date.now() - 60_000).toISOString(), + share.shareToken + ); + + expect((await request(visitor()).get(`/api/share/${share.shareToken}/access`)).status).toBe( + 403 + ); + expect( + (await request(buildApp({ user })).get(`/api/share/${share.shareToken}/access`)).status + ).toBe(403); + }); + + it('answers a token that was never a share with a plain not-found', async () => { + const refused = await request(visitor()).get('/api/share/pas-un-jeton/access'); + + expect(refused.status).toBe(404); + }); +}); + +describe('browsing a share that is one file', () => { + const openFileShare = async (body = {}) => { + const { user, label } = await makeOwner({ 'photo.png': 'pas vraiment une image' }); + const share = await createShare(user, { + sourcePath: `${label}/photo.png`, + accessMode: 'readonly', + sharingType: 'anyone', + ...body, + }); + const agent = request.agent(visitor()); + await agent.get(`/api/share/${share.shareToken}/access`); + return { agent, share, user }; + }; + + it('answers with the one file, and says it is not a folder', async () => { + const { agent, share } = await openFileShare(); + + const listing = await agent.get(`/api/share/${share.shareToken}/browse/`); + + expect(listing.status).toBe(200); + expect(listing.body.items).toHaveLength(1); + expect(listing.body.items[0].name).toBe('photo.png'); + expect(listing.body.current.isDirectory).toBe(false); + }); + + /** + * A file share is a file, so the things a folder allows are refused whatever + * the share's own access mode says: there is nowhere to upload to, nothing to + * create, and a link is not a right to hand out more links. + */ + it('offers none of the things a folder would', async () => { + const { agent, share } = await openFileShare({ accessMode: 'readwrite' }); + + const listing = await agent.get(`/api/share/${share.shareToken}/browse/`); + + expect(listing.body.access).toMatchObject({ + canUpload: false, + canCreateFolder: false, + canCreateFile: false, + canShare: false, + }); + }); + + it('follows the share on whether the file may be downloaded', async () => { + const { agent, share } = await openFileShare({ allowDownload: false }); + + const listing = await agent.get(`/api/share/${share.shareToken}/browse/`); + + expect(listing.body.access.canDownload).toBe(false); + }); + + it('names the folder the file came from, for the breadcrumb', async () => { + const { user, label } = await makeOwner({ 'Rapports/mars.txt': 'bonjour' }); + const share = await createShare(user, { + sourcePath: `${label}/Rapports/mars.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + label: 'Le rapport de mars', + }); + const agent = request.agent(visitor()); + await agent.get(`/api/share/${share.shareToken}/access`); + + const listing = await agent.get(`/api/share/${share.shareToken}/browse/`); + + expect(listing.body.shareInfo).toEqual({ + label: 'Le rapport de mars', + sourceFolderName: 'mars.txt', + }); + }); + + it('is refused to a visitor who has not opened the link', async () => { + const { user, label } = await makeOwner(); + const share = await createShare(user, { + sourcePath: `${label}/hello.txt`, + accessMode: 'readonly', + sharingType: 'anyone', + password: 'ouvre-toi', + }); + + const refused = await request(visitor()).get(`/api/share/${share.shareToken}/browse/`); + + expect(refused.status).toBe(401); + }); +}); diff --git a/backend/tests/routes/share-download-permission.test.js b/backend/tests/routes/share-download-permission.test.js new file mode 100644 index 000000000..4530a1e9e --- /dev/null +++ b/backend/tests/routes/share-download-permission.test.js @@ -0,0 +1,309 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express from 'express'; +import request from 'supertest'; + +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * A share that may be read but not taken away. + * + * `canDownload` existed across four call sites and was set to `true` at every + * place that set it — the only `false` was in the denied-access object, where + * `canAccess` had already answered. Removing the check from the download route + * broke no test, because nothing could ever withhold it. It was a promise the + * code did not keep, and the frontend gated a button on it. + * + * `allowDownload` on a share is what makes it mean something: "read this" + * rather than "take a copy of this". It is deliberately not tied to read-write + * like the other granular permissions — a read-only share is exactly where + * withholding a download is the point. + * + * The default is allowed, everywhere, so every share made before this existed + * behaves as it always did. That is the property most worth pinning: a + * permission added to a live system must not silently take something away. + */ + +let ctx; + +const setup = async () => { + ctx = await setupTestEnv({ + tag: 'share-download-', + modules: [ + 'src/config/env', + 'src/config/index', + 'src/services/db', + 'src/services/sharesService', + 'src/services/accessManager', + 'src/routes/shares', + 'src/routes/files/download', + 'src/middleware/errorHandler', + ], + }); + + await fs.mkdir(path.join(ctx.volumeDir, 'Docs'), { recursive: true }); + await fs.writeFile(path.join(ctx.volumeDir, 'Docs', 'report.txt'), 'the contents'); + + const { getDb } = ctx.requireFresh('src/services/db'); + const db = await getDb(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES ('owner', 'owner@example.com', 1, 'owner', 'Owner', '["admin"]', ?, ?)` + ).run(now, now); + + const shares = ctx.requireFresh('src/services/sharesService'); + const routes = ctx.requireFresh('src/routes/shares'); + const { errorHandler } = ctx.requireFresh('src/middleware/errorHandler'); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { id: 'owner', roles: ['admin'] }; + next(); + }); + // Mounted at both paths, as the application mounts it: `/api/shares` is the + // owner's view of their shares, `/api/share` is what a link resolves to. + app.use('/api/shares', routes); + app.use('/api/share', routes); + app.use(errorHandler); + + // The ordinary download endpoint, which resolves a `share//...` path + // through the same access manager as everything else. + const downloadRoutes = ctx.requireFresh('src/routes/files/download'); + const downloadApp = express(); + downloadApp.use(express.json()); + downloadApp.use((req, _res, next) => { + req.user = { id: 'owner', roles: ['admin'] }; + next(); + }); + downloadApp.use('/api', downloadRoutes); + downloadApp.use(errorHandler); + + return { shares, app, db, downloadApp }; +}; + +afterEach(async () => { + if (ctx) { + await ctx.cleanup(); + ctx = null; + } +}); + +const makeShare = (shares, overrides = {}) => + shares.createShare({ + ownerId: 'owner', + sourceSpace: 'volume', + sourcePath: 'Docs/report.txt', + isDirectory: false, + accessMode: 'readonly', + sharingType: 'anyone', + ...overrides, + }); + +describe('a share that allows downloads', () => { + it('is the default, so nothing that already exists changes', async () => { + const { shares } = await setup(); + + const share = await makeShare(shares); + + expect(share.allowDownload).toBe(true); + }); + + it('serves the file', async () => { + const { shares, app } = await setup(); + const share = await makeShare(shares); + + const response = await request(app).get(`/api/share/${share.shareToken}/file`); + + expect(response.status).toBe(200); + }); + + /** + * A row written before the column existed has it filled in by the migration + * default. Anything else would take downloads away from live share links on + * an upgrade. + */ + it('is what a row from before the column reads as', async () => { + const { shares, db } = await setup(); + const share = await makeShare(shares); + db.prepare('UPDATE shares SET allow_download = 1 WHERE id = ?').run(share.id); + + const reloaded = await shares.getShareById(share.id); + + expect(reloaded.allowDownload).toBe(true); + }); +}); + +describe('a share that withholds them', () => { + it('records the choice', async () => { + const { shares } = await setup(); + + const share = await makeShare(shares, { allowDownload: false }); + + expect(share.allowDownload).toBe(false); + }); + + it('refuses the file', async () => { + const { shares, app } = await setup(); + const share = await makeShare(shares, { allowDownload: false }); + + const response = await request(app).get(`/api/share/${share.shareToken}/file`); + + expect(response.status).toBe(403); + }); + + /** + * The whole point: reading still works. A share nobody can open is not a + * read-only share, it is a broken one. + */ + it('still lets the share be opened', async () => { + const { shares, app } = await setup(); + const share = await makeShare(shares, { allowDownload: false }); + + const response = await request(app).get(`/api/share/${share.shareToken}/access`); + + expect(response.status).toBe(200); + }); + + /** + * Told to the client before it browses anything, so a share view can hide the + * button instead of offering a click whose only outcome is a 403. + */ + it('says so in the payload that opens the share', async () => { + const { shares, app } = await setup(); + const share = await makeShare(shares, { allowDownload: false }); + + const response = await request(app).get(`/api/share/${share.shareToken}/access`); + + expect(response.body?.share?.allowDownload).toBe(false); + }); + + it('says the opposite for one that allows them', async () => { + const { shares, app } = await setup(); + const share = await makeShare(shares); + + const response = await request(app).get(`/api/share/${share.shareToken}/access`); + + expect(response.body?.share?.allowDownload).toBe(true); + }); + + /** + * And per row in the listing, which used to be hard-coded true: every file in + * a share with downloads withheld still showed the button. + */ + it('says so for each file in the listing too', async () => { + const { shares, app } = await setup(); + const folderShare = await shares.createShare({ + ownerId: 'owner', + sourceSpace: 'volume', + sourcePath: 'Docs', + isDirectory: true, + accessMode: 'readonly', + sharingType: 'anyone', + allowDownload: false, + }); + + const response = await request(app).get(`/api/share/${folderShare.shareToken}/browse/`); + + expect(response.status).toBe(200); + const items = response.body?.items || []; + expect(items.length).toBeGreaterThan(0); + expect(items.every((item) => item.access?.canDownload === false)).toBe(true); + }); + + /** + * Not tied to read-write, unlike delete, upload and the create permissions. + * A read-write share where downloads are withheld is coherent — collaborate + * on it, do not take it home — and gating it the way the others are gated + * would make that impossible to express. + */ + it('withholds them on a read-write share too', async () => { + const { shares, app } = await setup(); + const share = await makeShare(shares, { accessMode: 'readwrite', allowDownload: false }); + + const response = await request(app).get(`/api/share/${share.shareToken}/file`); + + expect(response.status).toBe(403); + }); +}); + +describe('the ordinary download route, on a share path', () => { + /** + * A signed-in person can reach a share through the normal explorer, and the + * normal download endpoint resolves `share//...` through the same + * access manager. That endpoint's own `canDownload` check was the one nothing + * could reach — it is reachable now, and this is what reaches it. + */ + it('refuses a file inside a share that withholds downloads', async () => { + const { shares, app, downloadApp } = await setup(); + const share = await shares.createShare({ + ownerId: 'owner', + sourceSpace: 'volume', + sourcePath: 'Docs', + isDirectory: true, + accessMode: 'readonly', + sharingType: 'anyone', + allowDownload: false, + }); + expect(app).toBeTruthy(); + + const response = await request(downloadApp) + .post('/api/download') + .send({ paths: [`share/${share.shareToken}/report.txt`] }); + + expect(response.status).toBe(403); + }); + + it('serves it from a share that allows them', async () => { + const { shares, downloadApp } = await setup(); + const share = await shares.createShare({ + ownerId: 'owner', + sourceSpace: 'volume', + sourcePath: 'Docs', + isDirectory: true, + accessMode: 'readonly', + sharingType: 'anyone', + }); + + const response = await request(downloadApp) + .post('/api/download') + .send({ paths: [`share/${share.shareToken}/report.txt`] }); + + expect(response.status).toBe(200); + }); +}); + +describe('changing it afterwards', () => { + it('can be switched off on an existing share', async () => { + const { shares, app } = await setup(); + const share = await makeShare(shares); + + await request(app).put(`/api/shares/${share.id}`).send({ allowDownload: false }); + const response = await request(app).get(`/api/share/${share.shareToken}/file`); + + expect(response.status).toBe(403); + }); + + it('can be switched back on', async () => { + const { shares, app } = await setup(); + const share = await makeShare(shares, { allowDownload: false }); + + await request(app).put(`/api/shares/${share.id}`).send({ allowDownload: true }); + const response = await request(app).get(`/api/share/${share.shareToken}/file`); + + expect(response.status).toBe(200); + }); + + /** An update that says nothing about it must not reset it. */ + it('is left alone by an update that does not mention it', async () => { + const { shares, app } = await setup(); + const share = await makeShare(shares, { allowDownload: false }); + + await request(app).put(`/api/shares/${share.id}`).send({ label: 'Renamed' }); + const reloaded = await shares.getShareById(share.id); + + expect(reloaded.allowDownload).toBe(false); + }); +}); diff --git a/backend/tests/routes/share-editor-save.test.js b/backend/tests/routes/share-editor-save.test.js index bba4fd87b..f0fc15573 100644 --- a/backend/tests/routes/share-editor-save.test.js +++ b/backend/tests/routes/share-editor-save.test.js @@ -62,8 +62,8 @@ const buildApp = ({ user } = {}) => { return application; }; -/** An "anyone" share of `sourceName` inside the owner's assigned volume. */ -const shareOf = async (sourceName, { accessMode = 'readwrite' } = {}) => { +/** A writable "anyone" share of `sourceName` inside the owner's assigned volume. */ +const shareOf = async (sourceName) => { const owner = await load('src/services/users').createLocalUser({ email: `owner-${sourceName.replace(/\W/g, '-')}@example.com`, username: `owner-${sourceName.replace(/\W/g, '-')}`, @@ -81,7 +81,7 @@ const shareOf = async (sourceName, { accessMode = 'readwrite' } = {}) => { .post('/api/shares') .send({ sourcePath: `Assigned/${sourceName}`, - accessMode, + accessMode: 'readwrite', sharingType: 'anyone', }); expect(create.status).toBe(201); @@ -149,24 +149,4 @@ describe('saving a text file through a share link', () => { expect(response.status).toBe(400); expect((await fsp.stat(path.join(assignedRoot, 'minutes'))).isDirectory()).toBe(true); }); - - /** - * A link that only reads is the case this route exists to keep apart from - * the one that writes: the editor opens on both, and offers to save on one. - */ - it('refuses a link that was not made writable', async () => { - const target = path.join(assignedRoot, 'lecture.txt'); - await fsp.writeFile(target, 'à lire seulement'); - const token = await shareOf('lecture.txt', { accessMode: 'readonly' }); - - // It still opens: reading is what the link is for. - const opened = await request(buildApp()).get(`/api/share/${token}/editor`); - expect(opened.status).toBe(200); - expect(opened.body).toMatchObject({ canWrite: false }); - - const response = await save(token, 'réécrit quand même'); - - expect(response.status).toBe(403); - expect(await fsp.readFile(target, 'utf8')).toBe('à lire seulement'); - }); }); diff --git a/backend/tests/routes/shareLinksUsers.test.js b/backend/tests/routes/shareLinksUsers.test.js index 430edc4b3..a2c6c21d8 100644 --- a/backend/tests/routes/shareLinksUsers.test.js +++ b/backend/tests/routes/shareLinksUsers.test.js @@ -18,6 +18,7 @@ beforeAll(async () => { 'src/middleware/errorHandler', 'src/routes/auth', 'src/routes/shares', + 'src/services/textEditorService', ], }); }); @@ -66,7 +67,12 @@ const buildApp = () => { describe('Share Links for Specific Users', () => { describe('User-Specific Share Access', () => { - it('should allow /api/share/:token/access when logged in as recipient', async () => { + /** + * Slow for the same reason as the local-auth walk-through: bcryptjs is pure + * JavaScript, the share password is hashed, and v8 coverage instrumentation + * multiplies that cost past the five-second default. + */ + it('should restrict the shared editor to the intended recipient', async () => { const usersService = envContext.requireFresh('src/services/users'); const app = buildApp(); @@ -86,6 +92,13 @@ describe('Share Links for Specific Users', () => { password: 'secret123', roles: ['user'], }); + const outsider = await usersService.createLocalUser({ + email: 'outsider@example.com', + username: 'outsider', + displayName: 'Outsider', + password: 'secret123', + roles: ['user'], + }); // Create a folder to share under the volume root. const sharedFolder = path.join(envContext.volumeDir, 'docs'); @@ -122,6 +135,19 @@ describe('Share Links for Specific Users', () => { expect(access.status).toBe(200); expect(access.body?.share?.shareToken).toBe(token); expect(access.body?.share?.sourcePath).toBe(`share/${token}`); - }); + + const editor = await recipientAgent.get(`/api/share/${token}/editor/hello.txt`); + expect(editor.status).toBe(200); + expect(editor.body).toMatchObject({ name: 'hello.txt', content: 'hello' }); + + const outsiderAgent = request.agent(app); + const outsiderLogin = await outsiderAgent + .post('/api/auth/login') + .send({ email: outsider.email, password: 'secret123' }); + expect(outsiderLogin.status).toBe(200); + + const forbidden = await outsiderAgent.get(`/api/share/${token}/editor/hello.txt`); + expect(forbidden.status).toBe(403); + }, 30_000); }); }); diff --git a/backend/tests/routes/shares.test.js b/backend/tests/routes/shares.test.js index 027a81a55..8ca901cc3 100644 --- a/backend/tests/routes/shares.test.js +++ b/backend/tests/routes/shares.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'node:path'; import fs from 'node:fs/promises'; import express from 'express'; +import cookieParser from 'cookie-parser'; import request from 'supertest'; import { setupTestEnv, clearModuleCache } from '../helpers/env-test-utils.js'; @@ -20,9 +21,13 @@ beforeAll(async () => { 'src/services/sharesService', 'src/services/guestSessionService', 'src/utils/pathUtils', + 'src/middleware/authMiddleware', 'src/middleware/errorHandler', 'src/routes/shares', 'src/routes/files/delete', + 'src/routes/files/folder', + 'src/routes/files/file', + 'src/routes/permissions', 'src/services/fileTransferService', ], }); @@ -40,29 +45,253 @@ const buildApp = ({ user } = {}) => { const sharesRoutes = envContext.requireFresh('src/routes/shares'); const deleteRoutes = envContext.requireFresh('src/routes/files/delete'); + const folderRoutes = envContext.requireFresh('src/routes/files/folder'); + const fileRoutes = envContext.requireFresh('src/routes/files/file'); + const permissionsRoutes = envContext.requireFresh('src/routes/permissions'); const { errorHandler } = envContext.requireFresh('src/middleware/errorHandler'); const app = express(); app.use(express.json()); - - app.use(async (req, _res, next) => { - if (user) req.user = user; - const guestSessionId = req.headers['x-guest-session']; - if (guestSessionId) { - const { getGuestSession } = envContext.requireFresh('src/services/guestSessionService'); - req.guestSession = await getGuestSession(guestSessionId); - } + app.use(cookieParser()); + + // The real middleware, not a stand-in for it. An earlier version of this + // harness attached req.guestSession unconditionally, which the middleware + // does not do — and a bug that lived in exactly that gap shipped green. + const authMiddleware = envContext.requireFresh('src/middleware/authMiddleware'); + app.use((req, _res, next) => { + // express-session normally provides this; the middleware loads the user + // from the database, exactly as it does in production. + req.session = user ? { localUserId: user.id } : {}; next(); }); + app.use(authMiddleware); app.use('/api/shares', sharesRoutes); app.use('/api/share', sharesRoutes); app.use('/api', deleteRoutes); + app.use('/api', folderRoutes); + app.use('/api', fileRoutes); + app.use('/api', permissionsRoutes); app.use(errorHandler); return app; }; describe('Shares Routes', () => { + describe('Share updates', () => { + it('should replace recipient permissions and clear them when changing to an anyone link', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-share-updates'); + await fs.mkdir(assignedRoot, { recursive: true }); + await fs.writeFile(path.join(assignedRoot, 'shared.txt'), 'shared'); + + const owner = await usersService.createLocalUser({ + email: 'share-owner@example.com', + username: 'share-owner', + displayName: 'Share Owner', + password: 'secret123', + roles: ['user'], + }); + const recipient = await usersService.createLocalUser({ + email: 'share-recipient@example.com', + username: 'share-recipient', + displayName: 'Share Recipient', + password: 'secret123', + roles: ['user'], + }); + await userVolumesService.addVolumeToUser({ + userId: owner.id, + label: 'ShareUpdateVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const app = buildApp({ user: owner }); + const created = await request(app) + .post('/api/shares') + .send({ + sourcePath: 'ShareUpdateVol/shared.txt', + accessMode: 'readonly', + sharingType: 'users', + userIds: [recipient.id], + }); + expect(created.status).toBe(201); + expect(created.body.permittedUserIds).toEqual([recipient.id]); + + const updated = await request(app).put(`/api/shares/${created.body.id}`).send({ + accessMode: 'readwrite', + sharingType: 'anyone', + userIds: [], + allowDelete: false, + allowCreateFolder: false, + allowCreateFile: false, + allowUpload: false, + label: 'Updated share', + }); + expect(updated.status).toBe(200); + expect(updated.body).toMatchObject({ + accessMode: 'readwrite', + sharingType: 'anyone', + allowDelete: false, + allowCreateFolder: false, + allowCreateFile: false, + allowUpload: false, + label: 'Updated share', + }); + + const recipientApp = buildApp({ user: recipient }); + const received = await request(recipientApp).get('/api/shares/shared-with-me'); + expect(received.status).toBe(200); + expect(received.body.shares).toEqual([]); + }); + }); + + describe('Shared item permissions', () => { + it('should allow viewing permissions through a share but reject permission changes', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-share-permissions'); + await fs.mkdir(assignedRoot, { recursive: true }); + await fs.writeFile(path.join(assignedRoot, 'shared.txt'), 'shared'); + + const user = await usersService.createLocalUser({ + email: 'share-permissions@example.com', + username: 'share-permissions', + displayName: 'Share Permissions', + password: 'secret123', + roles: ['user'], + }); + await userVolumesService.addVolumeToUser({ + userId: user.id, + label: 'SharePermissionsVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const app = buildApp({ user }); + const created = await request(app).post('/api/shares').send({ + sourcePath: 'SharePermissionsVol/shared.txt', + accessMode: 'readwrite', + sharingType: 'anyone', + }); + expect(created.status).toBe(201); + + const sharedPath = `share/${created.body.shareToken}/shared.txt`; + const view = await request(app).get(`/api/permissions/${sharedPath}`); + expect(view.status).toBe(200); + + const chmod = await request(app) + .post('/api/permissions/chmod') + .send({ path: sharedPath, mode: '644' }); + expect(chmod.status).toBe(403); + + const chown = await request(app) + .post('/api/permissions/chown') + .send({ path: sharedPath, owner: 'root', group: 'root' }); + expect(chown.status).toBe(403); + }); + }); + + describe('Granular write permissions', () => { + it('should apply directory write permissions to share access and mutation routes', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-granular-permissions'); + const sharedFolder = path.join(assignedRoot, 'shared'); + await fs.mkdir(sharedFolder, { recursive: true }); + await fs.writeFile(path.join(sharedFolder, 'existing.txt'), 'existing'); + + const user = await usersService.createLocalUser({ + email: 'permissions@example.com', + username: 'permissions', + displayName: 'Permissions', + password: 'secret123', + roles: ['user'], + }); + + await userVolumesService.addVolumeToUser({ + userId: user.id, + label: 'PermissionsVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const ownerApp = buildApp({ user }); + const create = await request(ownerApp).post('/api/shares').send({ + sourcePath: 'PermissionsVol/shared', + accessMode: 'readwrite', + allowDelete: false, + allowCreateFolder: false, + allowCreateFile: false, + allowUpload: false, + sharingType: 'anyone', + }); + + expect(create.status).toBe(201); + expect(create.body.allowDelete).toBe(false); + expect(create.body.allowCreateFolder).toBe(false); + expect(create.body.allowCreateFile).toBe(false); + expect(create.body.allowUpload).toBe(false); + + const guestApp = buildApp(); + const access = await request(guestApp).get(`/api/share/${create.body.shareToken}/access`); + expect(access.status).toBe(200); + expect(access.body.guestSessionId).toBeTruthy(); + + const sessionHeader = { 'X-Guest-Session': access.body.guestSessionId }; + const browse = await request(guestApp) + .get(`/api/share/${create.body.shareToken}/browse/`) + .set(sessionHeader); + expect(browse.status).toBe(200); + expect(browse.body.access).toMatchObject({ + canWrite: true, + canDelete: false, + canUpload: false, + canCreateFolder: false, + canCreateFile: false, + }); + + const createFolder = await request(guestApp) + .post('/api/files/folder') + .set(sessionHeader) + .send({ path: `share/${create.body.shareToken}`, name: 'blocked-folder' }); + expect(createFolder.status).toBe(403); + + const createFile = await request(guestApp) + .post('/api/files/file') + .set(sessionHeader) + .send({ path: `share/${create.body.shareToken}`, name: 'blocked.txt' }); + expect(createFile.status).toBe(403); + }); + + it('should default granular permissions to the current full read-write behavior', async () => { + const sharesService = envContext.requireFresh('src/services/sharesService'); + const usersService = envContext.requireFresh('src/services/users'); + const owner = await usersService.createLocalUser({ + email: 'default-permissions@example.com', + username: 'default-permissions', + displayName: 'Default Permissions', + password: 'secret123', + roles: ['user'], + }); + const share = await sharesService.createShare({ + ownerId: owner.id, + sourceSpace: 'volume', + sourcePath: 'Volume/default-permissions', + isDirectory: true, + accessMode: 'readwrite', + }); + + expect(share).toMatchObject({ + allowDelete: true, + allowCreateFolder: true, + allowCreateFile: true, + allowUpload: true, + }); + }); + }); + describe('User Volumes', () => { it('should create and browse share from assigned volume path', async () => { const usersService = envContext.requireFresh('src/services/users'); @@ -339,10 +568,11 @@ describe('Shares Routes', () => { }); expect(create.status).toBe(201); - expect(create.body.directFileUrl).toContain(`/api/share/${create.body.shareToken}/file`); + expect(create.body.directFileUrl).toContain(`/api/share/${create.body.shareToken}`); + expect(create.body.directFileUrl).not.toContain('/file'); const publicApp = buildApp(); - const direct = await request(publicApp).get(`/api/share/${create.body.shareToken}/file`); + const direct = await request(publicApp).get(`/api/share/${create.body.shareToken}`); expect(direct.status).toBe(200); expect(direct.headers['content-disposition']).toContain('inline'); @@ -350,12 +580,164 @@ describe('Shares Routes', () => { expect(direct.text).toBe('hello direct link'); const download = await request(publicApp).get( - `/api/share/${create.body.shareToken}/file?mode=download` + `/api/share/${create.body.shareToken}?mode=download` ); expect(download.status).toBe(200); expect(download.headers['content-disposition']).toContain('attachment'); expect(download.headers['content-disposition']).toContain('hello.txt'); + + const raw = await request(publicApp).get(`/api/share/${create.body.shareToken}?mode=raw`); + expect(raw.status).toBe(200); + expect(raw.text).toBe('hello direct link'); + + const legacyDirect = await request(publicApp).get( + `/api/share/${create.body.shareToken}/file` + ); + expect(legacyDirect.status).toBe(200); + expect(legacyDirect.text).toBe('hello direct link'); + }); + + it('records the client IP when a shared file is accessed directly', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-direct-ip'); + await fs.mkdir(assignedRoot, { recursive: true }); + await fs.writeFile(path.join(assignedRoot, 'ip.txt'), 'track my ip'); + + const user = await usersService.createLocalUser({ + email: 'direct-ip@example.com', + username: 'direct-ip', + displayName: 'Direct Ip', + password: 'secret123', + roles: ['user'], + }); + + await userVolumesService.addVolumeToUser({ + userId: user.id, + label: 'DirectIpVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const ownerApp = buildApp({ user }); + const create = await request(ownerApp).post('/api/shares').send({ + sourcePath: 'DirectIpVol/ip.txt', + accessMode: 'readonly', + sharingType: 'anyone', + }); + expect(create.status).toBe(201); + + // Accessing the file directly is the common path for viewing a share; it + // must record the access IP (regression: it previously tracked with none). + const direct = await request(buildApp()).get(`/api/share/${create.body.shareToken}/file`); + expect(direct.status).toBe(200); + + const details = await request(ownerApp).get(`/api/shares/${create.body.id}`); + expect(details.status).toBe(200); + expect(details.body.stats.accessCount).toBeGreaterThan(0); + expect(typeof details.body.stats.lastAccessIp).toBe('string'); + expect(details.body.stats.lastAccessIp.length).toBeGreaterThan(0); + }); + + it('counts direct attachment deliveries as downloads, inline views as accesses', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-direct-download'); + await fs.mkdir(assignedRoot, { recursive: true }); + await fs.writeFile(path.join(assignedRoot, 'notes.txt'), 'count my download'); + + const user = await usersService.createLocalUser({ + email: 'direct-download@example.com', + username: 'direct-download', + displayName: 'Direct Download', + password: 'secret123', + roles: ['user'], + }); + + await userVolumesService.addVolumeToUser({ + userId: user.id, + label: 'DirectDownloadVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const ownerApp = buildApp({ user }); + const create = await request(ownerApp).post('/api/shares').send({ + sourcePath: 'DirectDownloadVol/notes.txt', + accessMode: 'readonly', + sharingType: 'anyone', + }); + expect(create.status).toBe(201); + + // Explicit download mode serves an attachment: it must increment the + // download counter (regression: it only ever counted an access). + const download = await request(buildApp()).get( + `/api/share/${create.body.shareToken}/file?mode=download` + ); + expect(download.status).toBe(200); + expect(download.headers['content-disposition']).toContain('attachment'); + + const afterDownload = await request(ownerApp).get(`/api/shares/${create.body.id}`); + expect(afterDownload.status).toBe(200); + expect(afterDownload.body.stats.downloadCount).toBe(1); + expect(afterDownload.body.stats.accessCount).toBe(0); + expect(afterDownload.body.stats.lastDownloadedAt).toBeTruthy(); + expect(typeof afterDownload.body.stats.lastDownloadIp).toBe('string'); + expect(afterDownload.body.stats.lastDownloadIp.length).toBeGreaterThan(0); + + // An inline view of the same link still counts as an access. + const view = await request(buildApp()).get(`/api/share/${create.body.shareToken}/file`); + expect(view.status).toBe(200); + expect(view.headers['content-disposition']).toContain('inline'); + + const afterView = await request(ownerApp).get(`/api/shares/${create.body.id}`); + expect(afterView.status).toBe(200); + expect(afterView.body.stats.accessCount).toBe(1); + expect(afterView.body.stats.downloadCount).toBe(1); + }); + + it('counts a direct directory ZIP delivery as a download', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-direct-zip-count'); + await fs.mkdir(path.join(assignedRoot, 'folder'), { recursive: true }); + await fs.writeFile(path.join(assignedRoot, 'folder', 'nested.txt'), 'nested file'); + + const user = await usersService.createLocalUser({ + email: 'direct-zip-count@example.com', + username: 'direct-zip-count', + displayName: 'Direct Zip Count', + password: 'secret123', + roles: ['user'], + }); + + await userVolumesService.addVolumeToUser({ + userId: user.id, + label: 'DirectZipCountVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const ownerApp = buildApp({ user }); + const create = await request(ownerApp).post('/api/shares').send({ + sourcePath: 'DirectZipCountVol/folder', + accessMode: 'readonly', + sharingType: 'anyone', + }); + expect(create.status).toBe(201); + + const direct = await request(buildApp()).get(`/api/share/${create.body.shareToken}/file`); + expect(direct.status).toBe(200); + expect(direct.headers['content-disposition']).toContain('attachment'); + + const details = await request(ownerApp).get(`/api/shares/${create.body.id}`); + expect(details.status).toBe(200); + expect(details.body.stats.downloadCount).toBe(1); + expect(details.body.stats.accessCount).toBe(0); }); it('should redirect a password-protected direct file until the password is verified', async () => { @@ -393,7 +775,7 @@ describe('Shares Routes', () => { const publicApp = buildApp(); const directBeforePassword = await request(publicApp).get( - `/api/share/${create.body.shareToken}/file` + `/api/share/${create.body.shareToken}` ); expect(directBeforePassword.status).toBe(302); expect(directBeforePassword.headers.location).toContain(`/share/${create.body.shareToken}`); @@ -407,7 +789,7 @@ describe('Shares Routes', () => { expect(verify.body.guestSessionId).toBeDefined(); const directAfterPassword = await request(publicApp) - .get(`/api/share/${create.body.shareToken}/file`) + .get(`/api/share/${create.body.shareToken}`) .set('X-Guest-Session', verify.body.guestSessionId); expect(directAfterPassword.status).toBe(200); @@ -445,10 +827,11 @@ describe('Shares Routes', () => { }); expect(create.status).toBe(201); - expect(create.body.directFileUrl).toContain(`/api/share/${create.body.shareToken}/file`); + expect(create.body.directFileUrl).toContain(`/api/share/${create.body.shareToken}`); + expect(create.body.directFileUrl).not.toContain('/file'); const publicApp = buildApp(); - const direct = await request(publicApp).get(`/api/share/${create.body.shareToken}/file`); + const direct = await request(publicApp).get(`/api/share/${create.body.shareToken}`); expect(direct.status).toBe(200); expect(direct.headers['content-type']).toContain('application/zip'); @@ -542,4 +925,271 @@ describe('Shares Routes', () => { expect(direct.status).toBe(403); }); }); + + describe('Shared Pastebin Editor', () => { + it('should keep a read-only public text share read-only', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-shared-editor'); + await fs.mkdir(assignedRoot, { recursive: true }); + await fs.writeFile( + path.join(assignedRoot, 'Analyze-FileServerData.ps1'), + 'Write-Output hello' + ); + + const user = await usersService.createLocalUser({ + email: 'shared-editor@example.com', + username: 'shared-editor', + displayName: 'Shared Editor', + password: 'secret123', + roles: ['user'], + }); + + await userVolumesService.addVolumeToUser({ + userId: user.id, + label: 'SharedEditorVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const ownerApp = buildApp({ user }); + const create = await request(ownerApp).post('/api/shares').send({ + sourcePath: 'SharedEditorVol/Analyze-FileServerData.ps1', + accessMode: 'readonly', + sharingType: 'anyone', + }); + expect(create.status).toBe(201); + + const publicApp = buildApp(); + const editor = await request(publicApp).get(`/api/share/${create.body.shareToken}/editor`); + expect(editor.status).toBe(200); + expect(editor.headers['cache-control']).toBe('private, no-cache'); + expect(editor.body).toMatchObject({ + name: 'Analyze-FileServerData.ps1', + content: 'Write-Output hello', + canDownload: true, + canWrite: false, + }); + + // Friendly links may include the source filename, but no arbitrary child path. + const friendly = await request(publicApp).get( + `/api/share/${create.body.shareToken}/editor/Analyze-FileServerData.ps1` + ); + expect(friendly.status).toBe(200); + expect(friendly.body.path).toBe(''); + + const write = await request(publicApp) + .put(`/api/share/${create.body.shareToken}/editor`) + .send({ content: 'should never be written' }); + expect(write.status).toBe(403); + expect( + await fs.readFile(path.join(assignedRoot, 'Analyze-FileServerData.ps1'), 'utf-8') + ).toBe('Write-Output hello'); + }); + + it('should send a large shared text file compressed', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-shared-editor-large'); + await fs.mkdir(assignedRoot, { recursive: true }); + const content = '# Journal\n\nUne ligne de texte, encore une.\n'.repeat(2000); + await fs.writeFile(path.join(assignedRoot, 'journal.md'), content); + + const user = await usersService.createLocalUser({ + email: 'shared-editor-large@example.com', + username: 'shared-editor-large', + displayName: 'Shared Editor Large', + password: 'secret123', + roles: ['user'], + }); + await userVolumesService.addVolumeToUser({ + userId: user.id, + label: 'SharedEditorLargeVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const create = await request(buildApp({ user })).post('/api/shares').send({ + sourcePath: 'SharedEditorLargeVol/journal.md', + accessMode: 'readonly', + sharingType: 'anyone', + }); + expect(create.status).toBe(201); + + const editor = await request(buildApp()) + .get(`/api/share/${create.body.shareToken}/editor`) + .set('Accept-Encoding', 'gzip, deflate'); + expect(editor.status).toBe(200); + expect(editor.headers['content-encoding']).toBe('gzip'); + expect(editor.headers.vary).toMatch(/accept-encoding/i); + expect(editor.body).toMatchObject({ name: 'journal.md', content, canWrite: false }); + }); + + /** + * The shared editor's answer says whether the visitor may save. Kept by the + * browser and revalidated, it must be read again when that changes, even + * though the file did not. + */ + it('should never hide a change of permission behind a 304', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-shared-editor-etag'); + await fs.mkdir(assignedRoot, { recursive: true }); + await fs.writeFile(path.join(assignedRoot, 'notes.txt'), 'Shared notes'); + + const user = await usersService.createLocalUser({ + email: 'shared-editor-etag@example.com', + username: 'shared-editor-etag', + displayName: 'Shared Editor Etag', + password: 'secret123', + roles: ['user'], + }); + await userVolumesService.addVolumeToUser({ + userId: user.id, + label: 'SharedEditorEtagVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const ownerApp = buildApp({ user }); + const create = await request(ownerApp).post('/api/shares').send({ + sourcePath: 'SharedEditorEtagVol/notes.txt', + accessMode: 'readonly', + sharingType: 'anyone', + }); + expect(create.status).toBe(201); + + const publicApp = buildApp(); + const editorUrl = `/api/share/${create.body.shareToken}/editor`; + const first = await request(publicApp).get(editorUrl); + expect(first.body).toMatchObject({ content: 'Shared notes', canWrite: false }); + expect(first.headers.etag).toMatch(/^W\/".+"$/); + + const unchanged = await request(publicApp) + .get(editorUrl) + .set('If-None-Match', first.headers.etag); + expect(unchanged.status).toBe(304); + + const updated = await request(ownerApp) + .put(`/api/shares/${create.body.id}`) + .send({ accessMode: 'readwrite' }); + expect(updated.status).toBe(200); + + const after = await request(publicApp) + .get(editorUrl) + .set('If-None-Match', first.headers.etag); + expect(after.status).toBe(200); + expect(after.body).toMatchObject({ content: 'Shared notes', canWrite: true }); + expect(after.headers.etag).not.toBe(first.headers.etag); + }); + + it('should save a text file only through a read-write share', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-shared-editor-write'); + await fs.mkdir(assignedRoot, { recursive: true }); + const filePath = path.join(assignedRoot, 'editable.txt'); + await fs.writeFile(filePath, 'initial content'); + + const user = await usersService.createLocalUser({ + email: 'shared-editor-write@example.com', + username: 'shared-editor-write', + displayName: 'Shared Editor Write', + password: 'secret123', + roles: ['user'], + }); + + await userVolumesService.addVolumeToUser({ + userId: user.id, + label: 'SharedEditorWriteVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const ownerApp = buildApp({ user }); + const create = await request(ownerApp).post('/api/shares').send({ + sourcePath: 'SharedEditorWriteVol/editable.txt', + accessMode: 'readwrite', + sharingType: 'anyone', + }); + expect(create.status).toBe(201); + + const publicApp = buildApp(); + const editor = await request(publicApp).get(`/api/share/${create.body.shareToken}/editor`); + expect(editor.status).toBe(200); + expect(editor.body.canWrite).toBe(true); + + const save = await request(publicApp) + .put(`/api/share/${create.body.shareToken}/editor`) + .send({ content: 'updated through the share' }); + expect(save.status).toBe(200); + expect(await fs.readFile(filePath, 'utf-8')).toBe('updated through the share'); + }); + + it('should require a verified guest session and reject binary shared files', async () => { + const usersService = envContext.requireFresh('src/services/users'); + const userVolumesService = envContext.requireFresh('src/services/userVolumesService'); + + const assignedRoot = path.join(envContext.tmpRoot, 'assigned-volume-shared-editor-protected'); + await fs.mkdir(assignedRoot, { recursive: true }); + await fs.writeFile(path.join(assignedRoot, 'protected.txt'), 'protected text'); + await fs.writeFile(path.join(assignedRoot, 'binary.dat'), Buffer.from([0, 1, 2, 3])); + + const user = await usersService.createLocalUser({ + email: 'shared-editor-protected@example.com', + username: 'shared-editor-protected', + displayName: 'Shared Editor Protected', + password: 'secret123', + roles: ['user'], + }); + + await userVolumesService.addVolumeToUser({ + userId: user.id, + label: 'SharedEditorProtectedVol', + volumePath: assignedRoot, + accessMode: 'readwrite', + }); + + const ownerApp = buildApp({ user }); + const protectedShare = await request(ownerApp).post('/api/shares').send({ + sourcePath: 'SharedEditorProtectedVol/protected.txt', + accessMode: 'readonly', + sharingType: 'anyone', + password: 'open-sesame', + }); + const binaryShare = await request(ownerApp).post('/api/shares').send({ + sourcePath: 'SharedEditorProtectedVol/binary.dat', + accessMode: 'readonly', + sharingType: 'anyone', + }); + expect(protectedShare.status).toBe(201); + expect(binaryShare.status).toBe(201); + + const publicApp = buildApp(); + const beforeVerification = await request(publicApp).get( + `/api/share/${protectedShare.body.shareToken}/editor` + ); + expect(beforeVerification.status).toBe(302); + + const verify = await request(publicApp) + .post(`/api/share/${protectedShare.body.shareToken}/verify`) + .send({ password: 'open-sesame' }); + expect(verify.status).toBe(200); + + const afterVerification = await request(publicApp) + .get(`/api/share/${protectedShare.body.shareToken}/editor`) + .set('X-Guest-Session', verify.body.guestSessionId); + expect(afterVerification.status).toBe(200); + expect(afterVerification.body.content).toBe('protected text'); + + const binary = await request(publicApp).get( + `/api/share/${binaryShare.body.shareToken}/editor` + ); + expect(binary.status).toBe(415); + }); + }); }); 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/backend/tests/routes/usage-no-shell.test.js b/backend/tests/routes/usage-no-shell.test.js new file mode 100644 index 000000000..fc2628289 --- /dev/null +++ b/backend/tests/routes/usage-no-shell.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import request from 'supertest'; + +import { createTestApp, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * How full a volume is, asked of the kernel rather than of a shell. + * + * It used to be two commands with the path pasted into them — `du -sb ""` + * and `df -Pk ""` — and a folder name is not a shell string. A folder + * called `x";;echo "` closed the quote and left whatever followed for + * the shell to run, as the server's user, the moment somebody opened it. Any + * account that can make a folder could do it, and with sign-in switched off + * that is anybody at all. + * + * `fs.statfs` answers the same question with no shell and no subprocess, which + * is also why it is instant: `du` walked the whole tree to report a number the + * filesystem already had. + * + * The test makes such a folder, asks for its usage, and checks that what the + * name said to run did not run. It is written against the route because the + * route is where the path arrived. + */ + +describe('the usage of a folder whose name is shell syntax', () => { + let env; + let app; + let witness; + + const NAME = 'wedge";touch $WITNESS;echo "'; + + beforeEach(async () => { + env = await setupTestEnv({ tag: 'usage-no-shell-', modules: ['src/routes/usage'] }); + witness = path.join(env.cacheDir, 'ran'); + process.env.WITNESS = witness; + await fs.mkdir(path.join(env.volumeDir, NAME), { recursive: true }); + app = createTestApp({ + router: env.requireFresh('src/routes/usage'), + mountPath: '/api', + user: { id: 'u-1', roles: ['admin'] }, + }); + }); + + afterEach(async () => { + delete process.env.WITNESS; + await env.cleanup(); + }); + + const exists = async (file) => + fs + .access(file) + .then(() => true) + .catch(() => false); + + it('does not run what the name says to run', async () => { + expect(await exists(witness)).toBe(false); + + const response = await request(app).get(`/api/usage/${encodeURIComponent(NAME)}`); + + expect(response.status).toBe(200); + expect(await exists(witness)).toBe(false); + }); + + it('answers with the numbers the filesystem holds', async () => { + const response = await request(app).get('/api/usage/'); + + expect(response.status).toBe(200); + expect(response.body.total).toBeGreaterThan(0); + expect(response.body.free).toBeGreaterThan(0); + expect(response.body.used).toBe(response.body.total - response.body.free); + expect(response.body.percentUsed).toBeGreaterThanOrEqual(0); + expect(response.body.percentUsed).toBeLessThanOrEqual(100); + }); + + it('answers zeroes rather than failing when the path cannot be read', async () => { + const response = await request(app).get('/api/usage/nothing-here'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ size: 0, free: 0, total: 0, percentUsed: 0 }); + }); +}); diff --git a/backend/tests/routes/user-volumes-ownership.test.js b/backend/tests/routes/user-volumes-ownership.test.js new file mode 100644 index 000000000..9a5e815e6 --- /dev/null +++ b/backend/tests/routes/user-volumes-ownership.test.js @@ -0,0 +1,123 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express from 'express'; +import request from 'supertest'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Which account a volume belongs to, on the routes that change one. + * + * A volume is addressed twice in these URLs: by the account in the path and by + * its own id. Only the id is needed to find it, so a route that looked it up by + * id alone would let `/users/alice/volumes/` rename, re-mode or + * remove Bob's folder while the screen said it was editing Alice. The routes + * refuse that pairing as "not found", and these pin it — with the same calls + * made through the right account as the control, so a 404 cannot come from a + * wrong id instead. + * + * The admin and feature gates in front of all of this are pinned in + * `user-volumes.test.js`. + */ + +let currentEnv; + +afterEach(async () => { + if (currentEnv) { + await currentEnv.cleanup(); + currentEnv = null; + } +}); + +const ADMIN = { id: 'admin-1', roles: ['admin'] }; + +const seed = async () => { + currentEnv = await setupTestEnv({ tag: 'user-volumes-owner-', env: { USER_VOLUMES: 'true' } }); + const db = await currentEnv.requireFresh('src/services/db').getDb(); + const now = new Date().toISOString(); + for (const id of ['alice', 'bob']) { + db.prepare( + `INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES (?, ?, 1, ?, ?, '["user"]', ?, ?)` + ).run(id, `${id}@example.com`, id, id, now, now); + } + const media = path.join(currentEnv.volumeDir, 'Media'); + await fs.mkdir(media, { recursive: true }); + + const routes = currentEnv.requireFresh('src/routes/userVolumes'); + const { errorHandler } = currentEnv.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = ADMIN; + next(); + }); + app.use('/api', routes); + app.use(errorHandler); + + const created = await request(app) + .post('/api/users/bob/volumes') + .send({ label: 'Media', path: media, accessMode: 'readonly' }); + expect(created.status).toBe(201); + + return { app, db, media, bobVolume: created.body.volume }; +}; + +const volumesOf = async (app, userId) => + (await request(app).get(`/api/users/${userId}/volumes`)).body.volumes; + +describe('a volume reached through an account it does not belong to', () => { + it('is not changed', async () => { + const { app, bobVolume } = await seed(); + + const response = await request(app) + .patch(`/api/users/alice/volumes/${bobVolume.id}`) + .send({ label: 'Taken', accessMode: 'readwrite' }); + + expect(response.status).toBe(404); + expect(response.body.error.message).toBe('Volume not found.'); + expect(await volumesOf(app, 'bob')).toMatchObject([{ label: 'Media', accessMode: 'readonly' }]); + }); + + it('is not removed', async () => { + const { app, bobVolume } = await seed(); + + const response = await request(app).delete(`/api/users/alice/volumes/${bobVolume.id}`); + + expect(response.status).toBe(404); + expect(response.body.error.message).toBe('Volume not found.'); + expect((await volumesOf(app, 'bob')).map((v) => v.id)).toEqual([bobVolume.id]); + }); +}); + +describe('a volume reached through the account it belongs to', () => { + it('is changed, and then removed', async () => { + const { app, bobVolume } = await seed(); + + const patched = await request(app) + .patch(`/api/users/bob/volumes/${bobVolume.id}`) + .send({ label: 'Films', accessMode: 'readwrite' }); + expect(patched.status).toBe(200); + expect(patched.body.volume).toMatchObject({ label: 'Films', accessMode: 'readwrite' }); + + const removed = await request(app).delete(`/api/users/bob/volumes/${bobVolume.id}`); + expect(removed.status).toBe(204); + expect(await volumesOf(app, 'bob')).toEqual([]); + }); +}); + +describe('assigning a volume to an account that does not exist', () => { + it('is refused, and stores nothing', async () => { + const { app, db, media } = await seed(); + + const response = await request(app) + .post('/api/users/nobody/volumes') + .send({ label: 'Elsewhere', path: media }); + + expect(response.status).toBe(404); + expect(response.body.error.message).toBe('User not found.'); + expect( + db.prepare("SELECT COUNT(*) AS n FROM user_volumes WHERE user_id = 'nobody'").get().n + ).toBe(0); + }); +}); diff --git a/backend/tests/routes/user-volumes.test.js b/backend/tests/routes/user-volumes.test.js new file mode 100644 index 000000000..ac198a272 --- /dev/null +++ b/backend/tests/routes/user-volumes.test.js @@ -0,0 +1,174 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express from 'express'; +import request from 'supertest'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Assigning volumes to accounts. Every endpoint sits behind two gates — an + * administrator, and the feature actually being switched on — and the order + * matters: a regular account must be told no before it learns whether the + * feature exists. + * + * `/admin/browse-directories` reads the container's filesystem outside the + * volume root on purpose, because a volume may point anywhere the container can + * see. That is exactly why both gates are pinned here. + */ + +let currentEnv; + +afterEach(async () => { + if (currentEnv) { + await currentEnv.cleanup(); + currentEnv = null; + } +}); + +const seed = async (env = { USER_VOLUMES: 'true' }) => { + currentEnv = await setupTestEnv({ tag: 'user-volumes-', env }); + const dbService = currentEnv.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 ('user-1','regular@example.com',1,'regular','Regular','["user"]', ?, ?)` + ).run(now, now); + await fs.mkdir(path.join(currentEnv.volumeDir, 'Media'), { recursive: true }); + return db; +}; + +const buildApp = (user) => { + const routes = currentEnv.requireFresh('src/routes/userVolumes'); + const { errorHandler } = currentEnv.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + if (user) req.user = user; + next(); + }); + app.use('/api', routes); + app.use(errorHandler); + return app; +}; + +const ADMIN = { id: 'admin-1', roles: ['admin'] }; +const REGULAR = { id: 'user-1', roles: ['user'] }; + +const ENDPOINTS = [ + ['get', '/api/users/user-1/volumes', undefined], + ['post', '/api/users/user-1/volumes', { label: 'Media', path: '/tmp' }], + ['patch', '/api/users/user-1/volumes/anything', { accessMode: 'readonly' }], + ['delete', '/api/users/user-1/volumes/anything', undefined], + ['get', '/api/admin/browse-directories', undefined], +]; + +const call = (app, method, url, body) => { + const pending = request(app)[method](url); + return body ? pending.send(body) : pending; +}; + +describe('the two gates on assigning volumes', () => { + it.each(ENDPOINTS)('refuses a regular account on %s %s', async (method, url, body) => { + await seed(); + + const response = await call(buildApp(REGULAR), method, url, body); + + expect(response.status).toBe(403); + expect(response.body.error.message).toBe('Admin access required.'); + }); + + it.each(ENDPOINTS)( + 'refuses even an administrator when the feature is off, on %s %s', + async (method, url, body) => { + await seed({ USER_VOLUMES: 'false' }); + + const response = await call(buildApp(ADMIN), method, url, body); + + expect(response.status).toBe(403); + expect(response.body.error.message).toBe('User volumes feature is not enabled.'); + } + ); + + /** + * The role is checked first, so someone who is not an administrator cannot + * learn from the answer whether the feature is configured. + */ + it('tells a regular account about the role, never about the feature', async () => { + await seed({ USER_VOLUMES: 'false' }); + + const response = await request(buildApp(REGULAR)).get('/api/users/user-1/volumes'); + + expect(response.body.error.message).toBe('Admin access required.'); + }); +}); + +describe('assigning a volume', () => { + it('says not found for an account that does not exist', async () => { + await seed(); + + const response = await request(buildApp(ADMIN)).get('/api/users/nobody/volumes'); + + expect(response.status).toBe(404); + }); + + it('lists nothing for an account with no assignment', async () => { + await seed(); + + const response = await request(buildApp(ADMIN)).get('/api/users/user-1/volumes'); + + expect(response.status).toBe(200); + expect(response.body.volumes).toEqual([]); + }); + + it('gives back what it assigned', async () => { + await seed(); + const target = path.join(currentEnv.volumeDir, 'Media'); + + const created = await request(buildApp(ADMIN)) + .post('/api/users/user-1/volumes') + .send({ label: 'Media', path: target, accessMode: 'readonly' }); + + expect([200, 201]).toContain(created.status); + + const listed = await request(buildApp(ADMIN)).get('/api/users/user-1/volumes'); + expect(listed.body.volumes.map((v) => v.label)).toEqual(['Media']); + }); +}); + +describe('browsing for a folder to assign', () => { + it('lists only the directories it finds', async () => { + await seed(); + await fs.mkdir(path.join(currentEnv.volumeDir, 'Media', 'inner'), { recursive: true }); + await fs.writeFile(path.join(currentEnv.volumeDir, 'Media', 'file.txt'), 'x'); + + const response = await request(buildApp(ADMIN)) + .get('/api/admin/browse-directories') + .query({ path: path.join(currentEnv.volumeDir, 'Media') }); + + expect(response.status).toBe(200); + expect(response.body.directories.map((d) => d.name)).toEqual(['inner']); + }); + + it('says not found for a path that is not there', async () => { + await seed(); + + const response = await request(buildApp(ADMIN)) + .get('/api/admin/browse-directories') + .query({ path: '/definitely/not/here' }); + + expect(response.status).toBe(404); + }); + + it('refuses a file', async () => { + await seed(); + const file = path.join(currentEnv.volumeDir, 'Media', 'file.txt'); + await fs.writeFile(file, 'x'); + + const response = await request(buildApp(ADMIN)) + .get('/api/admin/browse-directories') + .query({ path: file }); + + expect(response.status).toBe(400); + }); +}); diff --git a/backend/tests/routes/versions-browse-access.test.js b/backend/tests/routes/versions-browse-access.test.js new file mode 100644 index 000000000..bfea0f593 --- /dev/null +++ b/backend/tests/routes/versions-browse-access.test.js @@ -0,0 +1,91 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Whether a listing says its files show their history, which is what decides + * whether the Versions entry appears in the menu: always for a place someone + * may read, and through a share only when its owner turned it on. + */ + +let envContext; +let users; +let app; + +const load = (relative) => require(modulePath(relative)); + +beforeEach(async () => { + envContext = await setupTestEnv({ tag: 'versions-browse-', env: { SHARES_ENABLED: 'true' } }); + users = { + alice: await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }), + }; + await fs.mkdir(path.join(envContext.volumeDir, 'Projects'), { recursive: true }); + await fs.writeFile(path.join(envContext.volumeDir, 'Projects', 'notes.md'), '# notes\n'); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + const who = req.get('x-test-user'); + if (who?.startsWith('guest:')) { + req.guestSession = { id: 'guest-session', shareId: who.slice('guest:'.length) }; + } else if (who) { + req.user = users[who]; + } + next(); + }); + app.use('/api', load('src/routes/browse')); + app.use('/api/shares', load('src/routes/shares')); + app.use('/api/share', load('src/routes/shares')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + await envContext.cleanup(); +}); + +describe('what a listing says about file histories', () => { + it('shows them in a place someone may read', async () => { + const response = await request(app).get('/api/browse/Projects').set('x-test-user', 'alice'); + + expect(response.status).toBe(200); + expect(response.body.access.canSeeVersions).toBe(true); + }); + + it('shows them through a share only once its owner turns them on', async () => { + const folder = await request(app) + .post('/api/shares') + .set('x-test-user', 'alice') + .send({ sourcePath: 'Projects', sharingType: 'anyone' }); + const file = await request(app) + .post('/api/shares') + .set('x-test-user', 'alice') + .send({ sourcePath: 'Projects/notes.md', sharingType: 'anyone' }); + const browseAs = (share) => + request(app) + .get(`/api/share/${share.shareToken}/browse/`) + .set('x-test-user', `guest:${share.id}`); + + expect((await browseAs(folder.body)).body.access.canSeeVersions).toBe(false); + expect((await browseAs(file.body)).body.access.canSeeVersions).toBe(false); + + for (const share of [folder.body, file.body]) { + await request(app) + .put(`/api/shares/${share.id}`) + .set('x-test-user', 'alice') + .send({ versionsVisible: true }); + } + + expect((await browseAs(folder.body)).body.access.canSeeVersions).toBe(true); + expect((await browseAs(file.body)).body.access.canSeeVersions).toBe(true); + }); +}); diff --git a/backend/tests/routes/volumes.test.js b/backend/tests/routes/volumes.test.js new file mode 100644 index 000000000..fdd093286 --- /dev/null +++ b/backend/tests/routes/volumes.test.js @@ -0,0 +1,112 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express from 'express'; +import request from 'supertest'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Which volumes a caller is told about. Four different answers depending on who + * is asking and whether `USER_VOLUMES` is on — and one of them, the empty list + * for a share visitor, is the difference between a link to one folder and a map + * of the whole server. + */ + +let currentEnv; + +afterEach(async () => { + if (currentEnv) { + await currentEnv.cleanup(); + currentEnv = null; + } +}); + +const seed = async (env = {}) => { + currentEnv = await setupTestEnv({ tag: 'volumes-route-', env }); + const dbService = currentEnv.requireFresh('src/services/db'); + const db = await dbService.getDb(); + const now = new Date().toISOString(); + for (const [id, username, roles] of [ + ['admin-1', 'admin', '["admin"]'], + ['user-1', 'regular', '["user"]'], + ]) { + db.prepare( + `INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES (?, ?, 1, ?, ?, ?, ?, ?)` + ).run(id, `${username}@example.com`, username, username, roles, now, now); + } + await fs.mkdir(path.join(currentEnv.volumeDir, 'Media'), { recursive: true }); + await fs.mkdir(path.join(currentEnv.volumeDir, 'Documents'), { recursive: true }); + return db; +}; + +const buildApp = (user) => { + const routes = currentEnv.requireFresh('src/routes/volumes'); + const { errorHandler } = currentEnv.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use((req, _res, next) => { + if (user) req.user = user; + next(); + }); + app.use('/api', routes); + app.use(errorHandler); + return app; +}; + +const ADMIN = { id: 'admin-1', roles: ['admin'] }; +const REGULAR = { id: 'user-1', roles: ['user'] }; + +describe('who is told which volumes exist', () => { + /** + * A share visitor carries a guest session and no account. Listing volumes for + * them turns a link to one folder into a map of the server. + */ + it('tells a visitor with no account nothing', async () => { + await seed(); + + const response = await request(buildApp(null)).get('/api/volumes'); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + + it('shows every volume when the feature is off', async () => { + await seed(); + + const response = await request(buildApp(REGULAR)).get('/api/volumes'); + + expect(response.status).toBe(200); + expect(response.body.map((v) => v.name).sort()).toEqual(['Documents', 'Media']); + }); + + it('shows every volume to an administrator even when the feature is on', async () => { + await seed({ USER_VOLUMES: 'true' }); + + const response = await request(buildApp(ADMIN)).get('/api/volumes'); + + expect(response.body.map((v) => v.name).sort()).toEqual(['Documents', 'Media']); + }); + + it('shows a regular account only what it was assigned', async () => { + const db = await seed({ USER_VOLUMES: 'true' }); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO user_volumes (id, user_id, label, path, access_mode, created_at, updated_at) + VALUES ('uv1','user-1','Media', ?, 'readwrite', ?, ?)` + ).run(path.join(currentEnv.volumeDir, 'Media'), now, now); + + const response = await request(buildApp(REGULAR)).get('/api/volumes'); + + expect(response.status).toBe(200); + expect(response.body.map((v) => v.name)).toEqual(['Media']); + expect(response.body[0]).toMatchObject({ kind: 'volume', accessMode: 'readwrite' }); + }); + + it('gives a regular account with no assignment an empty list', async () => { + await seed({ USER_VOLUMES: 'true' }); + + const response = await request(buildApp(REGULAR)).get('/api/volumes'); + + expect(response.body).toEqual([]); + }); +}); diff --git a/backend/tests/services/access-control.test.js b/backend/tests/services/access-control.test.js index 1c5689d86..b751ef096 100644 --- a/backend/tests/services/access-control.test.js +++ b/backend/tests/services/access-control.test.js @@ -1,11 +1,7 @@ import { describe, it, expect } from 'vitest'; import { setupTestEnv } from '../helpers/env-test-utils.js'; -const ACCESS_MODULES = [ - 'src/services/storage/jsonStorage', - 'src/services/settingsService', - 'src/services/accessControlService', -]; +const ACCESS_MODULES = ['src/services/settingsService', 'src/services/accessControlService']; const createAccessContext = async () => { const envContext = await setupTestEnv({ diff --git a/backend/tests/services/access-manager.test.js b/backend/tests/services/access-manager.test.js index 3ff61b169..c8fc10f77 100644 --- a/backend/tests/services/access-manager.test.js +++ b/backend/tests/services/access-manager.test.js @@ -569,6 +569,7 @@ describe('accessManager — what a share grants', () => { canDelete: false, canUpload: false, canCreateFolder: false, + canCreateFile: false, effectivePermission: 'ro', }); }); @@ -585,10 +586,28 @@ describe('accessManager — what a share grants', () => { canDelete: true, canUpload: true, canCreateFolder: true, + canCreateFile: true, effectivePermission: 'rw', }); }); + it.each([ + ['allowDelete', 'canDelete'], + ['allowUpload', 'canUpload'], + ['allowCreateFolder', 'canCreateFolder'], + ['allowCreateFile', 'canCreateFile'], + ])('withholds %s on its own, leaving the rest of the write grant', async (flag, granted) => { + const { share, guestSession } = await openShare(`grant-without-${flag}`, { + accessMode: 'readwrite', + [flag]: false, + }); + + const access = await accessTo(share, { guestSession }); + + expect(access[granted]).toBe(false); + expect(access.canWrite).toBe(true); + }); + it('never lets a share be shared again', async () => { const { share, guestSession } = await openShare('grant-no-resharing', { accessMode: 'readwrite', 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); + }); +}); 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/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'); + }); +}); diff --git a/backend/tests/services/personal-folder-reservation.test.js b/backend/tests/services/personal-folder-reservation.test.js new file mode 100644 index 000000000..173f19304 --- /dev/null +++ b/backend/tests/services/personal-folder-reservation.test.js @@ -0,0 +1,153 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * A deleted account's personal folder is not handed to the next account. + * + * Deleting an account removed its row and nothing else: `_users/` stayed + * on disk with what it held, its trash and its versions, and the name was free + * again. With `USER_FOLDER_NAME_ORDER=username,id` — what the environment + * reference recommends — the next account called bob claimed `bob`, and with it + * the previous bob's files. Reproduced before this change. + * + * The name now stays reserved while the folder is on disk; removing or renaming + * that folder on the server frees it. The rows that only described the account + * go with it. + */ + +let envContext; + +afterEach(async () => { + if (envContext) await envContext.cleanup(); + envContext = null; +}); + +const build = async () => { + envContext = await setupTestEnv({ + tag: 'personal-folder-reservation-', + env: { USER_FOLDER_NAME_ORDER: 'username,id', USER_DIR_ENABLED: 'true' }, + }); + const dbModule = envContext.requireFresh('src/services/db'); + const db = await dbModule.getDb(); + const { claimPersonalFolderName } = envContext.requireFresh('src/services/personalFolders'); + const { deleteUser } = envContext.requireFresh('src/services/users/management'); + const userRoot = path.join(envContext.volumeDir, '_users'); + return { db, dbModule, claimPersonalFolderName, deleteUser, userRoot }; +}; + +const addUser = (db, { id, username, createdAt = new Date().toISOString() }) => { + db.prepare( + `INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES (?, ?, 1, ?, ?, '["user"]', ?, ?)` + ).run(id, `${id}@example.com`, username, username, createdAt, createdAt); + return db.prepare('SELECT * FROM users WHERE id = ?').get(id); +}; + +const count = (db, sql, ...args) => + db + .prepare(sql) + .pluck() + .get(...args); + +describe('the folder name of a deleted account', () => { + it('is not given to the next account that derives it while its folder is on disk', async () => { + const { db, claimPersonalFolderName, deleteUser, userRoot } = await build(); + expect(claimPersonalFolderName(db, addUser(db, { id: 'bob-1', username: 'bob' }))).toBe('bob'); + fs.mkdirSync(path.join(userRoot, 'bob'), { recursive: true }); + fs.writeFileSync(path.join(userRoot, 'bob', 'payslip.pdf'), 'private'); + + await deleteUser({ userId: 'bob-1' }); + const claimed = claimPersonalFolderName(db, addUser(db, { id: 'bob-2', username: 'bob' })); + + expect(claimed).not.toBe('bob'); + expect(claimed).toBe('bob-2'); + expect(fs.readFileSync(path.join(userRoot, 'bob', 'payslip.pdf'), 'utf8')).toBe('private'); + }); + + it('is given out again once its folder has been removed from the disk', async () => { + const { db, claimPersonalFolderName, deleteUser, userRoot } = await build(); + claimPersonalFolderName(db, addUser(db, { id: 'bob-1', username: 'bob' })); + fs.mkdirSync(path.join(userRoot, 'bob'), { recursive: true }); + await deleteUser({ userId: 'bob-1' }); + + fs.rmSync(path.join(userRoot, 'bob'), { recursive: true }); + const claimed = claimPersonalFolderName(db, addUser(db, { id: 'bob-2', username: 'bob' })); + + expect(claimed).toBe('bob'); + expect(count(db, 'SELECT COUNT(*) FROM personal_folder_reservations')).toBe(0); + }); + + it('reserves nothing for an account that never had a folder name', async () => { + const { db, deleteUser } = await build(); + addUser(db, { id: 'ann-1', username: 'ann' }); + + await deleteUser({ userId: 'ann-1' }); + + expect(count(db, 'SELECT COUNT(*) FROM personal_folder_reservations')).toBe(0); + }); +}); + +describe('deleting an account', () => { + const seedRows = (db, userId) => { + const now = new Date().toISOString(); + db.prepare( + "INSERT INTO folder_preferences (user_id, path, sort_by, sort_order, view_mode, updated_at) VALUES (?, 'Docs', 'name', 'asc', 'list', ?)" + ).run(userId, now); + db.prepare( + "INSERT INTO recent_destinations (user_id, path, used_at) VALUES (?, 'Docs', ?)" + ).run(userId, now); + db.prepare('INSERT INTO auth_locks (key, failed_count, locked_until) VALUES (?, 3, NULL)').run( + userId + ); + }; + + it('removes the rows that only described it, and leaves everyone else their own', async () => { + const { db, deleteUser } = await build(); + addUser(db, { id: 'gone', username: 'gone' }); + addUser(db, { id: 'stays', username: 'stays' }); + seedRows(db, 'gone'); + seedRows(db, 'stays'); + + await deleteUser({ userId: 'gone' }); + + for (const table of ['folder_preferences', 'recent_destinations']) { + expect(count(db, `SELECT COUNT(*) FROM ${table} WHERE user_id = 'gone'`), table).toBe(0); + expect(count(db, `SELECT COUNT(*) FROM ${table} WHERE user_id = 'stays'`), table).toBe(1); + } + expect(count(db, "SELECT COUNT(*) FROM auth_locks WHERE key = 'gone'")).toBe(0); + expect(count(db, "SELECT COUNT(*) FROM auth_locks WHERE key = 'stays'")).toBe(1); + }); + + it('removes, at the upgrade, the rows accounts deleted before it left behind', async () => { + const { db, dbModule, deleteUser } = await build(); + addUser(db, { id: 'stays', username: 'stays' }); + addUser(db, { id: 'deleted-long-ago', username: 'old' }); + seedRows(db, 'stays'); + seedRows(db, 'deleted-long-ago'); + // Deleted the way every release before this one did: the row alone. + db.prepare("DELETE FROM users WHERE id = 'deleted-long-ago'").run(); + // Back to before the reservations table existed here, which is 22: the + // numbering is this repository's, not the fork's. + db.prepare("UPDATE meta SET value = '22' WHERE key = 'schema_version'").run(); + dbModule.closeDb(); + void deleteUser; + + const reopened = await envContext.requireFresh('src/services/db').getDb(); + + expect(count(reopened, "SELECT value FROM meta WHERE key = 'schema_version'")).toBe('23'); + for (const table of ['folder_preferences', 'recent_destinations']) { + expect( + count(reopened, `SELECT COUNT(*) FROM ${table} WHERE user_id = 'deleted-long-ago'`) + ).toBe(0); + expect(count(reopened, `SELECT COUNT(*) FROM ${table} WHERE user_id = 'stays'`)).toBe(1); + } + // Sign-in locks are left alone: those older releases wrote are keyed by the name + // that was typed, not by an account id, so which account one belonged to + // cannot be told, and an upgrade must not lift a lock it cannot place. + expect(count(reopened, 'SELECT COUNT(*) FROM auth_locks')).toBe(2); + }); +}); diff --git a/backend/tests/services/search-index.test.js b/backend/tests/services/search-index.test.js index e85a7420d..2e7b1f26e 100644 --- a/backend/tests/services/search-index.test.js +++ b/backend/tests/services/search-index.test.js @@ -561,12 +561,14 @@ describe('forgetting what is gone', () => { }); // Nothing walks a folder that is not there, so nothing asks what it held. + // Three rows go, not two: a folder has a row of its own now, so that somebody + // can find it by its name without knowing what is inside it. it('forgets a folder that was removed outright', async () => { await fs.rm(volumePath('Docs', 'Notes'), { recursive: true }); const result = await indexAll(); - expect(result.removed).toBe(2); + expect(result.removed).toBe(3); expect(store.search(db, 'pangolin')).toEqual(['Docs/kept.txt']); }); 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/docs/admin/user-volumes.md b/docs/admin/user-volumes.md index 64f4d70b4..6238221b3 100644 --- a/docs/admin/user-volumes.md +++ b/docs/admin/user-volumes.md @@ -29,12 +29,14 @@ Each assignment creates a top-level entry in the user’s sidebar using the assi Make sure the directories you want to expose exist inside the container and are readable/writable by the container user as appropriate. Typical pattern: + - `VOLUME_ROOT=/mnt` - Mount team folders as subdirectories under `/mnt` (e.g., `/mnt/Projects`, `/mnt/Media`, `/mnt/Finance`) ### 2) Create or pick a user profile Go to **Settings → Admin → Users**, then: + - Select an existing user profile, or - Create a new local user profile (so you can pre-assign volumes before their first login) diff --git a/docs/public/openapi.json b/docs/public/openapi.json index e6aa903a2..fee406cea 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -3221,6 +3221,47 @@ } } }, + "/api/files/recent-destinations": { + "get": { + "operationId": "listRecentDestinations", + "summary": "Folders this account recently copied or moved into", + "description": "Only those it can still reach.", + "tags": ["Files"], + "security": [ + { + "session": [] + }, + { + "apiToken": [] + } + ], + "x-access": "account", + "responses": { + "200": { + "description": "Done.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["items"] + } + } + } + }, + "401": { + "$ref": "#/components/responses/E401" + } + } + } + }, "/api/files/delete-impact": { "post": { "operationId": "describeDeletion", 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/api/files.api.js b/frontend/src/api/files.api.js index 686b0d7f0..f5dc1412f 100644 --- a/frontend/src/api/files.api.js +++ b/frontend/src/api/files.api.js @@ -60,6 +60,16 @@ async function moveItems(items, destination) { }); } +/** + * Folders this user has recently moved or copied things into, most recent + * first. The server only returns the ones still reachable, so the picker can + * offer them without checking each in turn. + */ +async function fetchRecentDestinations() { + const payload = await requestJson('/api/files/recent-destinations'); + return Array.isArray(payload?.items) ? payload.items : []; +} + async function deleteItems(items) { const normalizedItems = Array.isArray(items) ? items : []; if (normalizedItems.length <= DELETE_BATCH_SIZE) { @@ -386,6 +396,7 @@ export { refreshFolderSize, copyItems, moveItems, + fetchRecentDestinations, deleteItems, getDeleteImpact, createFile, 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/api/shares.api.js b/frontend/src/api/shares.api.js index ce42cdaa0..deaeae049 100644 --- a/frontend/src/api/shares.api.js +++ b/frontend/src/api/shares.api.js @@ -6,11 +6,20 @@ import { requestJson, normalizePath, encodePath } from './http'; async function createShare({ sourcePath, accessMode = 'readonly', + allowDelete = true, + allowCreateFolder = true, + allowCreateFile = true, + allowUpload = true, + allowDownload = true, sharingType = 'anyone', password = null, userIds = [], expiresAt = null, label = null, + // Left out of the body when not given, so the server's default for the kind + // of share applies: shown for named people, hidden for a link for anyone. + versionsVisible, + versionsDownload, }) { const normalizedPath = normalizePath(sourcePath); @@ -19,11 +28,18 @@ async function createShare({ body: JSON.stringify({ sourcePath: normalizedPath, accessMode, + allowDelete, + allowCreateFolder, + allowCreateFile, + allowUpload, + allowDownload, sharingType, password, userIds, expiresAt, label, + versionsVisible, + versionsDownload, }), }); } @@ -42,13 +58,6 @@ async function getSharedWithMe() { return requestJson('/api/shares/shared-with-me', { method: 'GET' }); } -/** - * Get share details by ID - */ -async function getShareById(shareId) { - return requestJson(`/api/shares/${shareId}`, { method: 'GET' }); -} - /** * Update an existing share */ @@ -95,39 +104,33 @@ async function accessShare(shareToken) { /** * Browse share contents */ -async function browseShare(shareToken, innerPath = '') { +async function browseShare(shareToken, innerPath = '', options = {}) { const normalizedInnerPath = normalizePath(innerPath); const encodedPath = encodePath(normalizedInnerPath); const endpoint = encodedPath ? `/api/share/${shareToken}/browse/${encodedPath}` : `/api/share/${shareToken}/browse/`; - return requestJson(endpoint, { method: 'GET' }); + return requestJson(endpoint, { method: 'GET', signal: options.signal }); } /** * Store guest session ID in sessionStorage */ -function setGuestSession(sessionId) { +function setGuestSession(sessionId, shareToken = '') { if (sessionId) { sessionStorage.setItem('guestSessionId', sessionId); + if (shareToken) { + sessionStorage.setItem('guestSessionShareToken', shareToken); + } } else { sessionStorage.removeItem('guestSessionId'); + sessionStorage.removeItem('guestSessionShareToken'); } } -/** - * Get guest session ID from sessionStorage - */ -function getGuestSession() { - return sessionStorage.getItem('guestSessionId'); -} - -/** - * Clear guest session - */ -function clearGuestSession() { - sessionStorage.removeItem('guestSessionId'); +function getGuestSessionShareToken() { + return sessionStorage.getItem('guestSessionShareToken'); } /** @@ -142,6 +145,7 @@ const DIRECT_SHARE_FILE_MODES = [ { value: 'auto', labelKey: 'share.directLinkModes.auto', fallback: 'Auto' }, { value: 'inline', labelKey: 'share.directLinkModes.inline', fallback: 'View' }, { value: 'raw', labelKey: 'share.directLinkModes.raw', fallback: 'Raw' }, + { value: 'editor', labelKey: 'share.directLinkModes.editor', fallback: 'Editor' }, { value: 'download', labelKey: 'share.directLinkModes.download', fallback: 'Download' }, ]; @@ -160,11 +164,23 @@ function getDirectShareFileUrl(shareToken, innerPath = '', mode = 'auto') { const encodedInnerPath = encodePath(normalizedInnerPath); const url = encodedInnerPath ? `${baseUrl}/api/share/${encodedToken}/file/${encodedInnerPath}` - : `${baseUrl}/api/share/${encodedToken}/file`; + : `${baseUrl}/api/share/${encodedToken}`; const normalizedMode = normalizeDirectShareFileMode(mode); + if (normalizedMode === 'editor') { + return getDirectShareEditorUrl(shareToken, normalizedInnerPath); + } return normalizedMode === 'auto' ? url : `${url}?mode=${encodeURIComponent(normalizedMode)}`; } +function getDirectShareEditorUrl(shareToken, innerPath = '') { + const baseUrl = window.location.origin; + const encodedToken = encodeURIComponent(shareToken); + const encodedInnerPath = encodePath(normalizePath(innerPath)); + return encodedInnerPath + ? `${baseUrl}/editor/share/${encodedToken}/${encodedInnerPath}` + : `${baseUrl}/editor/share/${encodedToken}`; +} + const writeToClipboard = async (value) => { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(value); @@ -203,7 +219,6 @@ export { createShare, getMyShares, getSharedWithMe, - getShareById, updateShare, deleteShare, getShareInfo, @@ -211,9 +226,7 @@ export { accessShare, browseShare, setGuestSession, - getGuestSession, - clearGuestSession, - getShareUrl, + getGuestSessionShareToken, DIRECT_SHARE_FILE_MODES, getDirectShareFileUrl, copyShareUrl, diff --git a/frontend/src/api/users.api.js b/frontend/src/api/users.api.js index f98055e80..e83fede4d 100644 --- a/frontend/src/api/users.api.js +++ b/frontend/src/api/users.api.js @@ -44,6 +44,13 @@ export async function adminSetUserPassword(userId, newPassword) { }); } +/** Release an account locked by failed sign-ins, before its lock runs out. */ +export async function unlockUser(userId) { + return requestJson(`/api/users/${encodeURIComponent(userId)}/lock`, { + method: 'DELETE', + }); +} + export async function deleteUser(userId) { return requestJson(`/api/users/${encodeURIComponent(userId)}`, { method: 'DELETE', diff --git a/frontend/src/components/ConfigWarningNotice.vue b/frontend/src/components/ConfigWarningNotice.vue index 9e02e2e2e..8ff67f180 100644 --- a/frontend/src/components/ConfigWarningNotice.vue +++ b/frontend/src/components/ConfigWarningNotice.vue @@ -47,7 +47,12 @@ onKeyStroke('Escape', dismiss);