diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 636281e67..e01d921a7 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -49,7 +49,10 @@ module.exports = { 'backend/src/services/uploadRemnants.js', 'backend/src/services/uploadService.js', 'backend/src/routes/onlyoffice.js', - 'backend/src/routes/settings.js', + // Its own logos, under the config directory: named by the content they + // hold, removed only once nothing points at one, and made again by + // uploading it. The settings route no longer removes them itself. + 'backend/src/services/brandingLogo.js', 'backend/src/routes/zip.js', 'backend/src/scripts/**', ], diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 70a126307..4bc9da340 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -1,6 +1,7 @@ const path = require('path'); const crypto = require('crypto'); const env = require('./env'); +const { resolveSessionSecret } = require('./sessionSecret'); const constants = require('./constants'); const loggingConfig = require('./logging'); const { parseByteSize } = require('../utils/env'); @@ -251,7 +252,7 @@ const authMode = determineAuthMode(); const auth = { enabled: authMode === 'disabled' ? false : env.AUTH_ENABLED !== false, - sessionSecret: env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'), + sessionSecret: resolveSessionSecret({ configured: env.SESSION_SECRET, configDir }), sessionMaxAgeMs: env.SESSION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000, // Convert days to milliseconds mode: authMode, oidc: { diff --git a/backend/src/config/sessionSecret.js b/backend/src/config/sessionSecret.js new file mode 100644 index 000000000..4fabb83eb --- /dev/null +++ b/backend/src/config/sessionSecret.js @@ -0,0 +1,135 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const logger = require('../utils/logger'); + +/** + * The secret sessions are signed with, when nobody configured one. + * + * It was drawn at random at every start. Sessions themselves outlive a restart + * — they are kept in CACHE_DIR/sessions.db — but a cookie signed with the + * previous secret no longer verifies, so every restart, every upgrade and every + * crash signed everyone out. The secrets derived from it (ONLYOFFICE without + * ONLYOFFICE_SECRET, the thumbnail links) changed with it. + * + * So the first start draws one and keeps it in CONFIG_DIR, and later starts read + * it back. SESSION_SECRET, when set, always wins and nothing is written. + * + * Synchronous on purpose: the configuration is required before anything else + * runs, and every value derived from the secret is computed at that moment. + */ + +const SECRET_FILE_NAME = 'session-secret'; +const SECRET_PATTERN = /^[0-9a-f]{64}$/i; + +const draw = () => crypto.randomBytes(32).toString('hex'); + +/** Read the stored secret: the secret, `null` when there is none to use, or the error. */ +const readStored = (file) => { + let contents; + try { + contents = fs.readFileSync(file, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') return { secret: null }; + return { error }; + } + + const value = contents.trim(); + if (SECRET_PATTERN.test(value)) return { secret: value }; + + // Nothing of the file's contents is logged: a hand-written secret would + // otherwise land in the logs on its way to being replaced. + logger.warn( + { file, reason: value ? 'not a 64-character hexadecimal secret' : 'empty' }, + 'The stored session secret is unusable and is being replaced; sessions signed with it end ' + + 'here. To choose the secret yourself, set SESSION_SECRET instead.' + ); + return { secret: null }; +}; + +/** + * Write the secret beside its final name, then rename it into place, so a start + * interrupted half-way leaves either no file or a whole one — never a truncated + * secret that the next start would have to throw away. + * + * The staging name is fixed rather than unique: a write that keeps failing (a + * full disk) then leaves one stray file that the next attempt reuses, not a new + * one per start. Removing it is not this module's business. + */ +const store = (configDir, file, secret) => { + fs.mkdirSync(configDir, { recursive: true }); + + const staging = path.join(configDir, `.${SECRET_FILE_NAME}.tmp`); + const fd = fs.openSync(staging, 'w', 0o600); + try { + // The mode given to open only applies to a file it creates; a staging file + // left by an older attempt keeps its own. + fs.fchmodSync(fd, 0o600); + fs.writeFileSync(fd, `${secret}\n`); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(staging, file); + + // The rename only survives a power cut once the directory is on disk too. + // Not every platform lets a directory be opened for that, and the file is + // already in place, so a refusal here costs nothing but that guarantee. + try { + const dirFd = fs.openSync(configDir, 'r'); + try { + fs.fsyncSync(dirFd); + } finally { + fs.closeSync(dirFd); + } + } catch { + /* best effort */ + } +}; + +const warnEphemeral = (configDir, error, action) => { + logger.warn( + { directory: configDir, code: error.code || null, err: { message: error.message } }, + `Could not ${action} the session secret in CONFIG_DIR, so a new one is used for this run ` + + 'only: everyone will be signed out at the next restart. Make CONFIG_DIR writable by the ' + + 'user the server runs as, or set SESSION_SECRET.' + ); +}; + +/** + * @param {object} options + * @param {string|null|undefined} options.configured SESSION_SECRET, as read from the environment + * @param {string} options.configDir The resolved CONFIG_DIR + * @returns {string} + */ +const resolveSessionSecret = ({ configured, configDir }) => { + if (configured) return configured; + + const file = path.join(configDir, SECRET_FILE_NAME); + + const stored = readStored(file); + if (stored.error) { + // A file that exists and cannot be read belongs to someone else — another + // user, a mount gone wrong. Replacing it would throw away a secret that may + // still be good once the permissions are, so it is left alone. + warnEphemeral(configDir, stored.error, 'read'); + return draw(); + } + if (stored.secret) return stored.secret; + + const secret = draw(); + try { + store(configDir, file, secret); + } catch (error) { + warnEphemeral(configDir, error, 'store'); + return secret; + } + + logger.info( + { file }, + 'Generated a session secret and stored it in CONFIG_DIR; sessions now survive restarts' + ); + return secret; +}; + +module.exports = { resolveSessionSecret, SECRET_FILE_NAME }; diff --git a/backend/src/errors/AppError.js b/backend/src/errors/AppError.js index 52b4c9851..e34a41fd2 100644 --- a/backend/src/errors/AppError.js +++ b/backend/src/errors/AppError.js @@ -123,6 +123,20 @@ class UnsupportedMediaTypeError extends AppError { } /** 507: the storage cannot hold what is being sent. */ +/** + * 503 Service Unavailable - something this server depends on did not answer + * + * Not the caller's doing and not a misconfiguration: the request was right and + * can be made again. Saying 404 here is what sends an administrator to change + * settings that are already correct. + */ +class ServiceUnavailableError extends AppError { + constructor(message = 'Service unavailable', code = null) { + super(message, 503, code); + this.name = 'ServiceUnavailableError'; + } +} + class InsufficientStorageError extends AppError { constructor(message = 'Insufficient storage') { super(message, 507, 'INSUFFICIENT_STORAGE'); @@ -140,5 +154,6 @@ module.exports = { RateLimitError, InternalError, UnsupportedMediaTypeError, + ServiceUnavailableError, InsufficientStorageError, }; diff --git a/backend/src/errors/errorCodes.js b/backend/src/errors/errorCodes.js index ee7e6f1f1..33cef687b 100644 --- a/backend/src/errors/errorCodes.js +++ b/backend/src/errors/errorCodes.js @@ -10,6 +10,8 @@ const ErrorCodes = { AUTH_PASSWORD_INCORRECT: 'AUTH_PASSWORD_INCORRECT', AUTH_INVALID_TOTP_CODE: 'AUTH_INVALID_TOTP_CODE', AUTH_PASSKEY_REJECTED: 'AUTH_PASSKEY_REJECTED', + AUTH_OIDC_NOT_CONFIGURED: 'AUTH_OIDC_NOT_CONFIGURED', + AUTH_OIDC_PROVIDER_UNAVAILABLE: 'AUTH_OIDC_PROVIDER_UNAVAILABLE', // Validation (400) VALIDATION_EMAIL_REQUIRED: 'VALIDATION_EMAIL_REQUIRED', diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js index 5ca748c63..f57a7bee7 100644 --- a/backend/src/middleware/errorHandler.js +++ b/backend/src/middleware/errorHandler.js @@ -48,8 +48,15 @@ const MULTIPART_REFUSALS = { LIMIT_UNEXPECTED_FILE: [400, 'A file was sent in a field this request does not take.'], }; -const multipartRefusal = (err) => - err instanceof multer.MulterError ? MULTIPART_REFUSALS[err.code] || [400, err.message] : null; +const multipartRefusal = (err) => { + if (!(err instanceof multer.MulterError)) return null; + const [statusCode, sentence] = MULTIPART_REFUSALS[err.code] || [400, err.message]; + // A route that knows its own limit says so: `explainMultipartRefusals` puts that + // sentence on the error, and it was being built and then thrown away — "the file + // is larger than this server accepts" where "a logo can be at most 2 MB" was + // ready to be said. + return [statusCode, err.clientMessage || sentence]; +}; /** * A write the storage itself refused: the mount is read-only, or the folder @@ -73,9 +80,21 @@ const storageRefusal = (err) => { return sentence ? [403, sentence] : null; }; +/** + * The addresses a browser is sent to, rather than fetches, on the way through + * an identity provider: where a sign-in starts, and where it comes back. + * + * A failure at either is a page the person is looking at, so it has to land + * back on the sign-in screen. `/login` is the provider's own route, mounted + * only when there is a provider to mount; `/api/auth/oidc/login` answers + * whether or not there is, which is how the screen learns which of the two + * happened. + */ +const OIDC_DOCUMENT_PATHS = new Set(['/callback', '/login', '/api/auth/oidc/login']); + const isOidcDocumentRequest = (req) => { const path = req?.path || ''; - if (path !== '/callback') return false; + if (!OIDC_DOCUMENT_PATHS.has(path)) return false; const accept = typeof req.headers?.accept === 'string' ? req.headers.accept : ''; const secFetchDest = typeof req.headers?.['sec-fetch-dest'] === 'string' ? req.headers['sec-fetch-dest'] : ''; @@ -84,27 +103,18 @@ const isOidcDocumentRequest = (req) => { return accept.includes('text/html') || secFetchDest === 'document' || secFetchMode === 'navigate'; }; -const clearOidcSessionCookies = (res) => { - // express-openid-connect defaults to "appSession" - try { - res.clearCookie('appSession', { - path: '/', - sameSite: 'Lax', - secure: true, - httpOnly: true, - }); - } catch (_) { - /* ignore */ - } - try { - res.clearCookie('appSession', { - path: '/', - sameSite: 'Lax', - secure: false, - httpOnly: true, - }); - } catch (_) { - /* ignore */ +const clearOidcSessionCookies = (req, res) => { + const cookieNames = new Set([req.nextExplorerOidcSessionCookieName, 'appSession']); + for (const cookieName of cookieNames) { + if (!cookieName) continue; + try { + if (cookieName in req) req[cookieName] = undefined; + const cookieOptions = { path: '/', sameSite: 'Lax', httpOnly: true }; + res.clearCookie(cookieName, { ...cookieOptions, secure: true }); + res.clearCookie(cookieName, { ...cookieOptions, secure: false }); + } catch (_) { + /* ignore */ + } } }; @@ -129,10 +139,18 @@ const errorHandler = (err, req, res, next) => { // For OIDC callback navigations, redirect back into the SPA so the login screen can show the error. // Otherwise, the browser will render the JSON payload as a standalone error page. if (!res.headersSent && isOidcDocumentRequest(req)) { - clearOidcSessionCookies(res); - const nextUrl = `/auth/login?error=${encodeURIComponent(message)}`; + clearOidcSessionCookies(req, res); + // Same redaction as the JSON body: this one lands in the address bar, browser + // history and every proxy log along the way, so a raw server path here travels + // further than it would in a response body. + const query = new URLSearchParams({ error: sanitizeClientMessage(message) }); + // The code travels beside the sentence, never instead of it. The screen says + // what the codes it knows mean in the reader's own language — which is how "the + // provider could not be reached" stops reading as "OIDC is not configured" — + // and falls back to the sentence for the ones it does not. + if (err.code) query.set('error_code', String(err.code)); res.setHeader('Cache-Control', 'no-store'); - res.redirect(302, nextUrl); + res.redirect(302, `/auth/login?${query.toString()}`); return; } diff --git a/backend/src/middleware/oidc.js b/backend/src/middleware/oidc.js index 50b5115bb..a5adcca5b 100644 --- a/backend/src/middleware/oidc.js +++ b/backend/src/middleware/oidc.js @@ -9,46 +9,27 @@ const { } = require('../services/users'); const { fetchUserInfoClaims } = require('../services/oidcService'); const { oidcStore } = require('../utils/sessionStore'); -const { UnauthorizedError } = require('../errors/AppError'); +const { readIdTokenClaims } = require('../utils/idToken'); +const { + recordOidcNotConfigured, + recordOidcReady, + recordOidcUnavailable, +} = require('../utils/oidcAvailability'); +const { UnauthorizedError, ServiceUnavailableError } = require('../errors/AppError'); +const { ErrorCodes } = require('../errors/errorCodes'); +const { + uniqueOrigins, + sanitizeReturnTo, + getConfiguredRequestOrigin, + absoluteReturnTo, + callbackUrlForOrigin, + oidcCookieNamesForOrigin, + sanitizeOidcPrompt, + markProviderSignIn, + isProviderSignIn, +} = require('../utils/oidcRedirect'); const logger = require('../utils/logger'); -/** - * The address a sign-out ends on, from what the request asked for. - * - * Only a path on this site: the value comes from the query string, and it is - * where the browser is sent once the provider has signed the person out — or - * straight away, when building the provider's address fails. Anything that is - * not a plain same-site path becomes the sign-in page. - */ -const sameSiteReturnTo = (candidate, baseURL) => { - const fallback = '/auth/login'; - let pathOnSite = fallback; - if (typeof candidate === 'string') { - const value = candidate.trim(); - if (value.startsWith('/') && !value.startsWith('//') && !value.includes('\\')) { - pathOnSite = value; - } - } - return baseURL ? `${baseURL}${pathOnSite}` : pathOnSite; -}; - -/** - * The claims inside an id token, read without checking its signature — the - * library has already verified it, nonce included, before the after-callback - * handler is handed the session. Anything that is not a JWT reads as none. - */ -const claimsFromIdToken = (idToken) => { - if (typeof idToken !== 'string') return null; - const payload = idToken.split('.')[1]; - if (!payload) return null; - try { - const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); - return parsed && typeof parsed === 'object' ? parsed : null; - } catch { - return null; - } -}; - /** * Derives baseURL from callbackUrl or PUBLIC_URL */ @@ -70,7 +51,7 @@ const deriveBaseUrl = (oidc) => { }; /** - * Determines if OIDC cookies should be secure based on baseURL + * Determines if OIDC cookies should be secure based on an origin. */ const shouldOidcCookieBeSecure = (baseURL) => { try { @@ -101,11 +82,16 @@ const parseUrl = (urlString) => { * Creates a custom logout handler for IdP logout * @param {object} options - Configuration options * @param {string} options.logoutURL - The IdP logout URL - * @param {string} options.baseURL - The application base URL - * @param {boolean} options.cookieSecure - Whether cookies should be secure + * @param {Function} options.getReturnTo - Resolves the validated browser return URL * @returns {Function} Express route handler */ -const createLogoutHandler = ({ logoutURL, baseURL, cookieSecure }) => { +const clearOidcCookie = (res, name) => { + const cookieOptions = { path: '/', sameSite: 'Lax', httpOnly: true }; + res.clearCookie(name, { ...cookieOptions, secure: true }); + res.clearCookie(name, { ...cookieOptions, secure: false }); +}; + +const createLogoutHandler = ({ logoutURL, getReturnTo, getSessionCookieName }) => { // Pre-validate the logout URL at configuration time const parsedLogoutUrl = parseUrl(logoutURL); if (!parsedLogoutUrl) { @@ -114,8 +100,9 @@ const createLogoutHandler = ({ logoutURL, baseURL, cookieSecure }) => { } return async (req, res) => { - // Calculate returnTo early for use in both success and error paths - const returnTo = sameSiteReturnTo(req.query.returnTo, baseURL); + const returnTo = getReturnTo(req); + const idTokenHint = req.oidc?.idToken; + const sessionCookieName = getSessionCookieName(req); try { // Clear local session (promisified for proper sequencing) @@ -128,17 +115,29 @@ const createLogoutHandler = ({ logoutURL, baseURL, cookieSecure }) => { }); } - // Clear EOC session cookie (both secure variants for robustness) - const cookieOptions = { path: '/', sameSite: 'Lax', httpOnly: true }; - res.clearCookie('appSession', { ...cookieOptions, secure: cookieSecure }); - res.clearCookie('appSession', { ...cookieOptions, secure: false }); + // Clear the server-side EOC session while the browser still provides its + // session cookie. The client-side cookie is cleared below as well. + if (sessionCookieName in req) { + req[sessionCookieName] = undefined; + } + + // Clear both the active origin-scoped cookie and the legacy name from + // versions that used a shared cookie across origins. + clearOidcCookie(res, sessionCookieName); + if (sessionCookieName !== 'appSession') clearOidcCookie(res, 'appSession'); // Build logout URL with redirect parameter // Use post_logout_redirect_uri (OIDC standard) as primary, but also support returnTo for Auth0 const idpLogoutUrl = new URL(parsedLogoutUrl.toString()); idpLogoutUrl.searchParams.set('post_logout_redirect_uri', returnTo); + if (idTokenHint) { + idpLogoutUrl.searchParams.set('id_token_hint', idTokenHint); + } - logger.debug({ logoutUrl: idpLogoutUrl.toString() }, 'Redirecting to IdP logout URL'); + logger.debug( + { logoutOrigin: idpLogoutUrl.origin, hasIdTokenHint: Boolean(idTokenHint) }, + 'Redirecting to IdP logout URL' + ); res.redirect(idpLogoutUrl.toString()); } catch (e) { logger.warn({ err: e }, 'Error during custom logout'); @@ -184,9 +183,10 @@ const createAfterCallbackHandler = (oidc, envAuthConfig) => { // Who this sign-in is, as the provider's id token says — verified by the // library by the time this runs. `req.oidc.user` is not it: during the // callback it is still the user of the session the browser arrived with, - // if it had one. + // if it had one, which made a second person in the same browser a + // "mismatch" and let a brand-new sign-in skip the subject check entirely. const idTokenClaims = - claimsFromIdToken(session?.id_token) || session?.id_token_claims || session?.claims || null; + readIdTokenClaims(session?.id_token) || session?.id_token_claims || session?.claims || null; let claims = idTokenClaims || {}; // Fetch from userinfo endpoint if access token is available @@ -215,17 +215,13 @@ const createAfterCallbackHandler = (oidc, envAuthConfig) => { } } - const sub = claims && claims.sub ? claims.sub : null; + const sub = typeof claims?.sub === 'string' && claims.sub.trim() ? claims.sub : null; if (!sub) { - logger.debug('afterCallback: no usable claims found; skipping user sync'); - return session; + throw new UnauthorizedError('OIDC identity is missing a subject claim.'); } // Derive user information from claims const email = claims.email || null; - // Only a boolean true is a verified address. `"false"` is a non-empty - // string, and read as truthy it attached a sign-in to whichever account - // already held that address. const emailVerified = claims.email_verified === true; const preferredUsername = claims.preferred_username || claims.username || email || sub; const displayName = claims.name || preferredUsername || null; @@ -237,15 +233,8 @@ const createAfterCallbackHandler = (oidc, envAuthConfig) => { const rolesAreAuthoritative = rolesFromClaimsAreAuthoritative(claims, adminGroups); logger.debug( - { - sub, - preferredUsername, - displayName, - email, - emailVerified, - roles, - }, - 'afterCallback: derived user info' + { emailVerified, roleCount: roles.length }, + 'afterCallback: OIDC claims validated' ); // Persist user to database @@ -281,6 +270,10 @@ const createAfterCallbackHandler = (oidc, envAuthConfig) => { * Configures Express OpenID Connect (OIDC) authentication */ const configureOidc = async (app) => { + // Whether the settings for a sign-in were all there. Read again in the catch + // below, where what is worth saying depends on how far this got. + let settingsArePresent = false; + try { logger.debug('Configuring Express OpenID Connect'); @@ -290,27 +283,47 @@ const configureOidc = async (app) => { const scopeParam = resolveOidcScopes(oidc); const baseURL = deriveBaseUrl(oidc); const sessionSecret = - (envAuthConfig && envAuthConfig.sessionSecret) || - process.env.SESSION_SECRET || - crypto.randomBytes(32).toString('hex'); - - // Check if OIDC should be enabled + (envAuthConfig && envAuthConfig.sessionSecret) || crypto.randomBytes(32).toString('hex'); + + // Check if OIDC should be enabled. + // + // The client secret counts: the hand-off below asks for the authorization + // code flow, which has no other way to prove which application is asking. + // Left out, the library threw while being configured and the instance + // reported a provider that could not be started — sending an administrator + // to look at a provider that was perfectly well, while the one setting + // they had missed was named in the log and nowhere else. const eocEnabled = Boolean( - oidc.enabled && oidc.issuer && oidc.clientId && sessionSecret && baseURL + oidc.enabled && oidc.issuer && oidc.clientId && oidc.clientSecret && sessionSecret && baseURL ); + settingsArePresent = eocEnabled; logger.debug( { enabled: eocEnabled, issuer: !!oidc.issuer, clientId: !!oidc.clientId, + clientSecret: !!oidc.clientSecret, baseURL: !!baseURL, }, 'EOC enablement check' ); if (!eocEnabled) { + // Named one by one, and recorded: a sign-in refused later says it is the + // configuration that is missing, and this is where an administrator finds + // which part of it. + const missing = [ + !oidc.enabled && 'OIDC_ENABLED', + !oidc.issuer && 'OIDC_ISSUER', + !oidc.clientId && 'OIDC_CLIENT_ID', + !oidc.clientSecret && 'OIDC_CLIENT_SECRET', + !sessionSecret && 'SESSION_SECRET', + !baseURL && 'PUBLIC_URL or OIDC_CALLBACK_URL', + ].filter(Boolean); + recordOidcNotConfigured(missing.join(', ') || null); logger.info( + { missing }, 'Express OpenID Connect not configured (missing issuer/client/baseURL/secret or disabled)' ); logger.debug( @@ -326,63 +339,165 @@ const configureOidc = async (app) => { return; } - // Determine cookie security - const eocCookieSecure = shouldOidcCookieBeSecure(baseURL); - logger.debug({ eocCookieSecure }, 'OIDC session cookie security'); - logger.debug('Using shared SQLite session store for OIDC'); + // PUBLIC_URL remains canonical for links and integrations. OIDC is the + // exception: every explicitly configured INTERNAL_URL needs its own + // callback URL so a login can return to the origin where it began. + const oidcOrigins = uniqueOrigins([baseURL, ...(publicConfig?.origins || [])]); + const oidcMiddlewares = new Map(); + const oidcCookieNames = new Map(); + + for (const origin of oidcOrigins) { + const cookieSecure = shouldOidcCookieBeSecure(origin); + const cookieNames = oidcCookieNamesForOrigin(origin); + oidcCookieNames.set(origin, cookieNames); + oidcMiddlewares.set( + origin, + eocAuth({ + authRequired: false, + auth0Logout: false, + idpLogout: false, + issuerBaseURL: oidc.issuer, + baseURL: origin, + clientID: oidc.clientId, + clientSecret: oidc.clientSecret || undefined, + secret: sessionSecret, + authorizationParams: { + response_type: 'code', + scope: scopeParam, + }, + session: { + store: oidcStore, + name: cookieNames.session, + rolling: true, + // Convert milliseconds to seconds for absoluteDuration + absoluteDuration: Math.floor( + ((envAuthConfig && envAuthConfig.sessionMaxAgeMs) || 30 * 24 * 60 * 60 * 1000) / 1000 + ), // Default: 30 days in seconds + cookie: { + sameSite: 'Lax', + secure: cookieSecure, + httpOnly: true, + }, + }, + transactionCookie: { + name: cookieNames.transaction, + sameSite: 'Lax', + }, + afterCallback: createAfterCallbackHandler(oidc, envAuthConfig), + // The native routes always use one baseURL. Register them ourselves + // after dispatching the request to its matching origin middleware. + routes: { + login: false, + callback: false, + logout: false, + }, + }) + ); + } + + const resolveOrigin = (req) => getConfiguredRequestOrigin(req, oidcOrigins) || baseURL; + + // Attach an EOC request/response context selected by the actual, approved + // browser origin. Unknown hosts deliberately fall back to PUBLIC_URL. + app.use((req, res, next) => { + const origin = resolveOrigin(req); + req.nextExplorerOidcSessionCookieName = oidcCookieNames.get(origin).session; + oidcMiddlewares.get(origin)(req, res, next); + }); + + const returnToForRequest = (req) => + absoluteReturnTo(resolveOrigin(req), req.query?.returnTo || '/auth/login'); + + app.get('/login', (req, res, next) => { + if (!res.oidc || typeof res.oidc.login !== 'function') { + next(new Error('OIDC is not configured.')); + return; + } + const prompt = sanitizeOidcPrompt(req.query?.prompt); + markProviderSignIn(req); + res.oidc.login({ + returnTo: sanitizeReturnTo(req.query?.returnTo), + authorizationParams: { + redirect_uri: callbackUrlForOrigin(resolveOrigin(req)), + ...(prompt ? { prompt } : {}), + }, + }); + }); + + const callbackHandler = (req, res, next) => { + if (!res.oidc || typeof res.oidc.callback !== 'function') { + next(new Error('OIDC is not configured.')); + return; + } + res.oidc.callback({ redirectUri: callbackUrlForOrigin(resolveOrigin(req)) }); + }; + app.get('/callback', callbackHandler); + app.post('/callback', callbackHandler); - // Add custom logout handler if OIDC_LOGOUT_URL is configured - // This must be added before EOC middleware to intercept /logout requests if (oidc.logoutURL) { const logoutHandler = createLogoutHandler({ logoutURL: oidc.logoutURL, - baseURL, - cookieSecure: eocCookieSecure, + getReturnTo: returnToForRequest, + getSessionCookieName: (req) => req.nextExplorerOidcSessionCookieName, }); - if (logoutHandler) { app.get('/logout', logoutHandler); - logger.debug({ logoutURL: oidc.logoutURL }, 'Custom logout handler configured'); + logger.debug('Custom OIDC logout handler configured'); } + } else { + app.get('/logout', (req, res, next) => { + if (!res.oidc || typeof res.oidc.logout !== 'function') { + next(new Error('OIDC is not configured.')); + return; + } + res.oidc.logout({ returnTo: returnToForRequest(req) }); + }); } - // Configure OIDC middleware - app.use( - eocAuth({ - authRequired: false, - auth0Logout: false, - idpLogout: false, - issuerBaseURL: oidc.issuer, - baseURL, - clientID: oidc.clientId, - clientSecret: oidc.clientSecret || undefined, - secret: sessionSecret, - authorizationParams: { - response_type: 'code', - scope: scopeParam, - }, - session: { - store: oidcStore, - rolling: true, - // Convert milliseconds to seconds for absoluteDuration - absoluteDuration: Math.floor( - ((envAuthConfig && envAuthConfig.sessionMaxAgeMs) || 30 * 24 * 60 * 60 * 1000) / 1000 - ), // Default: 30 days in seconds - cookie: { - sameSite: 'Lax', - secure: eocCookieSecure, - httpOnly: true, - }, - }, - afterCallback: createAfterCallbackHandler(oidc, envAuthConfig), - }) - ); + // A hand-off that fails reports it to the `next` express-openid-connect + // captured when it built the request context, not to the route's own — so + // neither the route nor a try/catch around `login()` ever sees it, and the + // raw failure reached the browser as a 500 quoting the provider's internal + // host. Registered after the routes it covers, and a no-op for every other + // error, which is what the mark is for. + app.use((err, req, res, next) => { + if (!isProviderSignIn(req) || res.headersSent) { + next(err); + return; + } + logger.error( + { err, issuer: oidc.issuer }, + 'Could not start a sign-in at the identity provider' + ); + next( + new ServiceUnavailableError( + 'The identity provider could not be reached.', + ErrorCodes.AUTH_OIDC_PROVIDER_UNAVAILABLE + ) + ); + }); - logger.info('Express OpenID Connect is configured'); - logger.debug('EOC middleware mounted'); + recordOidcReady(); + logger.info({ origins: oidcOrigins }, 'Express OpenID Connect is configured'); + logger.debug({ origins: oidcOrigins }, 'Origin-aware EOC middleware mounted'); } catch (e) { - logger.warn({ err: e }, 'Failed to configure Express OpenID Connect'); + // The settings were there and could not be made to work — a bad issuer URL, + // a secret the library refuses. Saying "not configured" for this is what + // sends an administrator to change a configuration that is already right. + if (settingsArePresent) recordOidcUnavailable(e?.message || null); + else recordOidcNotConfigured(e?.message || null); + logger.error({ err: e }, 'Failed to configure Express OpenID Connect'); } }; -module.exports = { configureOidc }; +module.exports = { + configureOidc, + // Exported for the tests. This module decides who someone is, and until now + // nothing exercised any of it; these are the decisions worth pinning, and + // reaching them through a real provider is not something a test can do. + deriveBaseUrl, + shouldOidcCookieBeSecure, + resolveOidcScopes, + createAfterCallbackHandler, + createLogoutHandler, +}; diff --git a/backend/src/middleware/session.js b/backend/src/middleware/session.js index e4ddb330a..d7eafcece 100644 --- a/backend/src/middleware/session.js +++ b/backend/src/middleware/session.js @@ -1,4 +1,3 @@ -const crypto = require('crypto'); const session = require('express-session'); const { auth: envAuthConfig } = require('../config/index'); @@ -6,10 +5,10 @@ const { localStore } = require('../utils/sessionStore'); const logger = require('../utils/logger'); const configureSession = (app) => { - const sessionSecret = - (envAuthConfig && envAuthConfig.sessionSecret) || - process.env.SESSION_SECRET || - crypto.randomBytes(32).toString('hex'); + // One source: the configuration resolved it, from SESSION_SECRET or from the + // copy kept in CONFIG_DIR. A second fallback drawing its own random secret + // here would have signed everyone out whenever it was the one that applied. + const sessionSecret = envAuthConfig.sessionSecret; logger.debug({ hasSessionSecret: Boolean(sessionSecret) }, 'Session secret resolved'); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index ba0ca37f1..1df37e6ca 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,5 +1,15 @@ const express = require('express'); const { auth, public: publicConfig, webauthn: webauthnConfig } = require('../config/index'); +const { + uniqueOrigins, + sanitizeReturnTo, + getConfiguredRequestOrigin, + callbackUrlForOrigin, + sanitizeOidcPrompt, + markProviderSignIn, +} = require('../utils/oidcRedirect'); +const { getOidcAvailability, oidcIsConfigured } = require('../utils/oidcAvailability'); +const logger = require('../utils/logger'); const { countUsers, @@ -19,7 +29,6 @@ const { verifySecondFactor, } = require('../services/users'); const { incrementFailedAttempts, clearLock, isLocked } = require('../services/users/lockout'); -const logger = require('../utils/logger'); const { issueCode, redeemCode, isValidChallenge } = require('../services/oidcMobileBridge'); const rateLimit = require('express-rate-limit'); const asyncHandler = require('../utils/asyncHandler'); @@ -34,14 +43,10 @@ const { RateLimitError, NotFoundError, ForbiddenError, + ServiceUnavailableError, } = require('../errors/AppError'); const { ErrorCodes } = require('../errors/errorCodes'); -/** Every address this deployment answers on, without repeats or empties. */ -const uniqueOrigins = (values) => [ - ...new Set((values || []).map((value) => String(value || '').trim()).filter(Boolean)), -]; - /** * The relying party: who is asking for a passkey, and where from. * @@ -220,6 +225,11 @@ const respondWithUser = async (req, res) => { router.get('/status', async (req, res) => { const oidcEnv = (auth && auth.oidc) || {}; + // What the configuration pass concluded, so the sign-in screen can say a + // provider is not on offer before somebody presses the button and travels + // there to find out. The status only: the reason names settings and library + // messages, and this answer is given to anybody who asks. + const { status: oidcStatus } = getOidcAvailability(); const authMode = auth.mode || 'both'; // Skip setup requirement if AUTH_MODE is 'oidc' only const requiresSetup = auth.enabled && authMode !== 'oidc' ? (await countUsers()) === 0 : false; @@ -253,6 +263,7 @@ router.get('/status', async (req, res) => { enabled: Boolean(oidcEnv.enabled), issuer: oidcEnv.issuer || null, scopes: oidcEnv.scopes || [], + status: oidcStatus, }, }); }); @@ -279,6 +290,10 @@ 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. + res.clearCookie('guestSession', { path: '/' }); res.clearCookie('guestSession', { path: '/api' }); res.status(201).json({ user }); @@ -330,6 +345,10 @@ 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. + res.clearCookie('guestSession', { path: '/' }); res.clearCookie('guestSession', { path: '/api' }); res.json({ user }); @@ -535,6 +554,10 @@ 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' }); logger.info( @@ -598,6 +621,10 @@ router.post( 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); @@ -730,12 +757,9 @@ router.post( } const { currentPassword, newPassword } = req.body || {}; - // Every other session of the account ends; this one stays signed in. The - // service takes the session to keep and says so, and nothing passed it, so - // the person changing their own password was signed out of their own - // browser along with everybody else. Only when this session is signed in - // here as this account: one the identity provider opened is not one a - // password could have opened, and is not this account's to keep. + // Every other session of the account ends; this one stays signed in, but + // only if it is signed in here as this account. A session the identity + // provider opened is not one a password could have opened. const signedInHere = Boolean(req.session) && req.session.localUserId === me.id; await changeLocalPassword({ userId: me.id, @@ -800,32 +824,28 @@ router.post('/logout', async (req, res) => { /* ignore */ } } - // Clear the EOC appSession cookie (local OIDC session) without redirecting - try { - // Attempt to clear both secure and non-secure variants to be robust. - res.clearCookie('appSession', { - path: '/', - sameSite: 'Lax', - secure: true, - httpOnly: true, - }); - } catch (_) { - /* ignore */ - } - try { - res.clearCookie('appSession', { - path: '/', - sameSite: 'Lax', - secure: false, - httpOnly: true, - }); - } catch (_) { - /* ignore */ + // Clear the EOC session cookie selected for this browser origin. The + // legacy name is also cleared during the origin-scoped cookie migration. + const cookieNames = new Set([req.nextExplorerOidcSessionCookieName, 'appSession']); + for (const cookieName of cookieNames) { + if (!cookieName) continue; + try { + if (cookieName in req) req[cookieName] = undefined; + const cookieOptions = { path: '/', sameSite: 'Lax', httpOnly: true }; + res.clearCookie(cookieName, { ...cookieOptions, secure: true }); + res.clearCookie(cookieName, { ...cookieOptions, secure: false }); + } catch (_) { + /* ignore */ + } } // For IdP/federated logout, the UI navigates to GET /logout separately. res.status(204).end(); }); +router.get('/me', async (req, res) => { + await respondWithUser(req, res); +}); + /** * API tokens: a credential for a script, issued from the account it belongs to. * @@ -965,23 +985,81 @@ router.delete( }) ); -router.get('/me', async (req, res) => { - await respondWithUser(req, res); -}); +/** + * The provider was asked to take the sign-in, and did not. + * + * The real reason goes to the log and never into the response: it is the + * network's own words, and they name the provider's internal host. + */ +const providerDidNotAnswer = (reason) => { + logger.error( + { issuer: auth?.oidc?.issuer, reason }, + 'Could not start a sign-in at the identity provider' + ); + return new ServiceUnavailableError( + 'The identity provider could not be reached.', + ErrorCodes.AUTH_OIDC_PROVIDER_UNAVAILABLE + ); +}; + +/** + * Nothing here can hand a sign-in over. Which of the two it is decides what an + * administrator should go and do. + * + * Answering 404 "OIDC is not configured" for both is the defect: it sends + * somebody whose provider is simply down to change a configuration that is + * already right. The configuration pass records which it was. + */ +const noProviderSignInAvailable = () => { + const { reason } = getOidcAvailability(); + + // Configured, and it could not be mounted: a bad issuer URL, a client secret + // the library insists on, a provider that did not answer discovery. Which of + // those it was is in the log; what matters here is that it is not the + // settings an administrator would be sent to fill in. + if (oidcIsConfigured()) { + logger.error( + { issuer: auth?.oidc?.issuer, reason }, + 'Single sign-on is configured and could not be started' + ); + return new ServiceUnavailableError( + 'Single sign-on could not be started.', + ErrorCodes.AUTH_OIDC_PROVIDER_UNAVAILABLE + ); + } + + logger.warn( + { missing: reason }, + 'A sign-in at the identity provider was asked for, and none is configured' + ); + return new NotFoundError('OIDC is not configured.', ErrorCodes.AUTH_OIDC_NOT_CONFIGURED); +}; router.get( '/oidc/login', asyncHandler(async (req, res) => { + if (!(res.oidc && typeof res.oidc.login === 'function')) { + throw noProviderSignInAvailable(); + } + + const redirect = sanitizeReturnTo(req.query?.redirect, '/browse/'); + const origins = uniqueOrigins([auth?.oidc?.callbackUrl, ...(publicConfig?.origins || [])]); + const origin = getConfiguredRequestOrigin(req, origins) || origins[0]; + const prompt = sanitizeOidcPrompt(req.query?.prompt); + const authorizationParams = { + ...(origin ? { redirect_uri: callbackUrlForOrigin(origin) } : {}), + ...(prompt ? { prompt } : {}), + }; + try { - if (res.oidc && typeof res.oidc.login === 'function') { - const redirect = typeof req.query?.redirect === 'string' ? req.query.redirect : '/'; - await res.oidc.login({ returnTo: redirect }); - return; - } + // Marked for the OIDC error middleware: the library usually reports a + // failure to a `next` of its own rather than throwing here. + markProviderSignIn(req); + await res.oidc.login({ returnTo: redirect, authorizationParams }); } catch (e) { - // ignore + // It was asked and it failed, so this is never the configuration. + throw providerDidNotAnswer(e?.message || null); } - throw new NotFoundError('OIDC is not configured.'); }) ); @@ -1011,7 +1089,7 @@ router.get( '/oidc/mobile/login', asyncHandler(async (req, res) => { if (!(res.oidc && typeof res.oidc.login === 'function')) { - throw new NotFoundError('OIDC is not configured.'); + throw noProviderSignInAvailable(); } const codeChallenge = req.query?.code_challenge; const method = req.query?.code_challenge_method || 'S256'; @@ -1025,7 +1103,12 @@ router.get( if (req.session) { req.session.oidcMobile = { codeChallenge, method, redirectUri }; } - await res.oidc.login({ returnTo: '/api/auth/oidc/mobile/complete' }); + try { + markProviderSignIn(req); + await res.oidc.login({ returnTo: '/api/auth/oidc/mobile/complete' }); + } catch (e) { + throw providerDidNotAnswer(e?.message || null); + } }) ); @@ -1091,6 +1174,10 @@ 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. + res.clearCookie('guestSession', { path: '/' }); res.clearCookie('guestSession', { path: '/api' }); res.json({ user }); }) diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index eb1058771..00d518031 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -7,7 +7,6 @@ const { setSystemSetting, getSettings, } = require('../services/settingsService'); -const logger = require('../utils/logger'); const activityLog = require('../services/activityLog'); const { ensureAdmin } = require('../middleware/ensureAdmin'); const { checkRulePath } = require('../services/accessControlService'); @@ -15,9 +14,9 @@ const { ValidationError } = require('../errors/AppError'); const folderSizeManager = require('../services/folderSizeManager'); const searchIndexManager = require('../services/searchIndexManager'); const asyncHandler = require('../utils/asyncHandler'); -const path = require('path'); -const fs = require('fs').promises; const multer = require('multer'); +const { explainMultipartRefusals, describeBytes } = require('../middleware/multipartRefusals'); +const { replaceLogo, forgetReplacedLogo } = require('../services/brandingLogo'); /** * A number somebody chose. @@ -36,42 +35,42 @@ const chosenNumber = (value) => Number.isFinite(value) && value > 0; const router = express.Router(); -const DEFAULT_LOGO_URL = '/logo.svg'; +// 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 deleteCustomLogoFiles = async () => { - const configDir = process.env.CONFIG_DIR || '/config'; - const logoDir = path.join(configDir, 'logos'); - const candidates = ['custom-logo.svg', 'custom-logo.png', 'custom-logo.jpg']; +const isBoolean = (value) => typeof value === 'boolean'; - await Promise.all( - candidates.map(async (filename) => { - const filePath = path.join(logoDir, filename); - try { - await fs.unlink(filePath); - logger.info('Deleted custom logo file', { filename }); - } catch (error) { - if (error && error.code === 'ENOENT') return; - logger.warn('Failed to delete custom logo file', { filename, error: error?.message }); - } - }) - ); -}; +// 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; -// Middleware to check if user is admin // Configure multer for logo uploads const upload = multer({ storage: multer.memoryStorage(), - limits: { fileSize: 2 * 1024 * 1024 }, // 2MB + limits: { fileSize: LOGO_MAX_BYTES }, fileFilter: (req, file, cb) => { const allowedMimes = ['image/svg+xml', 'image/png', 'image/jpeg']; if (allowedMimes.includes(file.mimetype)) { cb(null, true); } else { - cb(new Error('Invalid file type. Only SVG, PNG, and JPG are allowed.')); + // A ValidationError and not a plain Error: the wrong kind of file is the + // request's fault, and a plain Error reached the client as a 500. + cb(new ValidationError('Invalid file type. Only SVG, PNG, and JPG are allowed.')); } }, }); +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) @@ -100,54 +99,44 @@ router.get( }) ); +/** + * The rest of the branding, sent in the same form as a logo so that both are + * saved together. Held to the rules a PATCH holds it to; the logo address is + * the uploaded file's, whatever was sent. + */ +const brandingSentWithLogo = (field) => { + if (field === undefined) return {}; + let section; + try { + section = JSON.parse(field); + } catch { + section = null; + } + if (!section || typeof section !== 'object' || Array.isArray(section)) { + throw new ValidationError('The branding sent with the logo is not JSON.'); + } + return keepValid(section, { appName: isName, showPoweredBy: isBoolean }); +}; + /** * POST /api/settings/upload-logo - * Upload a custom logo file (admin only) + * + * Make an image the logo (admin only), with any other branding sent in the + * `branding` field. The upload is the save: the logo in use is replaced only + * once the new one is written and stored, and a failure leaves it as it was. + * Answers the settings, as a PATCH does, and the new logo's address. */ router.post( '/settings/upload-logo', ensureAdmin, - upload.single('logo'), + acceptLogo, asyncHandler(async (req, res) => { - if (!req.file) { - return res.status(400).json({ error: 'No file uploaded' }); - } - - try { - const configDir = process.env.CONFIG_DIR || '/config'; - const logoDir = path.join(configDir, 'logos'); - - // Create logos directory if it doesn't exist - await fs.mkdir(logoDir, { recursive: true }); - - // Generate filename based on MIME type - let filename = 'custom-logo'; - if (req.file.mimetype === 'image/svg+xml') { - filename += '.svg'; - } else if (req.file.mimetype === 'image/png') { - filename += '.png'; - } else if (req.file.mimetype === 'image/jpeg') { - filename += '.jpg'; - } - - const logoPath = path.join(logoDir, filename); - - // Write file to disk - await fs.writeFile(logoPath, req.file.buffer); + if (!req.file) throw new ValidationError('No file uploaded'); - logger.info('Logo uploaded successfully', { - filename, - size: req.file.size, - mimetype: req.file.mimetype, - }); + const { logoUrl } = await replaceLogo(req.file, brandingSentWithLogo(req.body?.branding)); - // Return the URL path for the uploaded logo - const logoUrl = `/static/logos/${filename}`; - res.json({ logoUrl }); - } catch (error) { - logger.error('Logo upload error', { error: error.message }); - res.status(500).json({ error: 'Failed to save logo' }); - } + const settings = await getSettingsForUser(req.user); + res.json({ ...settings, logoUrl }); }) ); @@ -363,6 +352,7 @@ router.patch( } // Branding settings + let previousLogoUrl; if (payload.branding && typeof payload.branding === 'object') { const brandingUpdate = {}; if (typeof payload.branding.appName === 'string') { @@ -376,6 +366,7 @@ router.patch( } if (Object.keys(brandingUpdate).length > 0) { const current = await getSettings(); + previousLogoUrl = current.branding?.appLogoUrl ?? null; await setSystemSetting('branding', 'branding', { ...current.branding, ...brandingUpdate, @@ -396,16 +387,13 @@ router.patch( }); } - // Handle logo deletion if resetting to default - const requestedLogoUrl = - typeof payload.branding?.appLogoUrl === 'string' - ? payload.branding.appLogoUrl.trim() - : null; - const resetToDefault = - requestedLogoUrl != null && - (requestedLogoUrl === '' || requestedLogoUrl === DEFAULT_LOGO_URL); - if (resetToDefault) { - await deleteCustomLogoFiles(); + // 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); } } else if (payload.thumbnails || payload.access || payload.branding) { // Non-admin trying to update system settings diff --git a/backend/src/server.js b/backend/src/server.js index 978fb4ed9..d55d7f597 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -19,6 +19,8 @@ const { purgeExpiredDocumentKeys } = require('./services/onlyofficeDocumentKeySe const editorSessions = require('./services/onlyofficeEditorSessionService'); const { sweepActivity } = require('./services/activityLog'); const capabilities = require('./services/capabilities'); +const { installProcessFailureHandlers } = require('./utils/processFailures'); +const { sweepUnreferencedLogos } = require('./services/brandingLogo'); let server = null; @@ -106,6 +108,13 @@ const startServer = async () => { expirySweep.unref?.(); void sweepExpiredRecords(); + // 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. + sweepUnreferencedLogos().catch((error) => { + logger.warn({ err: error }, 'Sweeping logos no longer in use failed'); + }); + // Cleanup on process termination const cleanup = () => { logger.info('Shutting down server...'); @@ -124,6 +133,9 @@ const startServer = async () => { process.on('SIGTERM', cleanup); process.on('SIGINT', cleanup); + // Installed last, so the shutdown it may need already exists. + installProcessFailureHandlers({ onFatal: cleanup }); + return server; }; diff --git a/backend/src/services/brandingLogo.js b/backend/src/services/brandingLogo.js new file mode 100644 index 000000000..e836bdf64 --- /dev/null +++ b/backend/src/services/brandingLogo.js @@ -0,0 +1,214 @@ +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); + +const { directories } = require('../config/index'); +const logger = require('../utils/logger'); +const { placeWithoutOverwrite } = require('../utils/placeWithoutOverwrite'); +const settingsService = require('./settingsService'); + +/** + * The custom logo, as files in `/config/logos` served at `/static/logos`. + * + * A logo used to be written under one fixed name per type, `custom-logo.png`, + * over whatever held it, the moment it was chosen. Nothing could undo that: + * the logo in use was gone before anyone pressed Save, and a PNG chosen over a + * PNG came back at the same address, so the settings page saw no change. + * + * Each logo now has a name of its own. It is written under a hidden name, put + * under its own without replacing anything, and made the logo in the settings; + * only then is the logo it replaced removed. When any step fails, what this + * logo wrote is removed and the logo in use stays as it was. The address + * changes with every logo, so no browser keeps showing the last one. + * + * Installations that still point at a fixed name keep being served from it; + * that file is removed like any other once a new logo replaces it. + */ + +const LOGO_URL_PREFIX = '/static/logos/'; + +const EXTENSIONS = { + 'image/svg+xml': '.svg', + 'image/png': '.png', + 'image/jpeg': '.jpg', +}; + +// "logo-.png", or "logo- (1).png" in the unlikely case its name +// was already held. +const OWN_NAME = + /^logo-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?: \(\d+\))?\.(?:svg|png|jpg)$/; + +// What earlier versions wrote, one name per type. Nothing writes them now. +const LEGACY_NAMES = ['custom-logo.svg', 'custom-logo.png', 'custom-logo.jpg']; + +// The name the bytes go to before the file takes its own. A write that +// finishes removes it either way; one interrupted by a stop, a full disk or a +// crash does not, and at up to 2 MB it stays for good — hidden, so nothing +// even lists it. +const PARTIAL_NAME = /^\.logo-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.part$/; + +const logoDirectory = () => path.join(directories.config, 'logos'); + +const logoUrlFor = (name) => `${LOGO_URL_PREFIX}${encodeURIComponent(name)}`; + +/** + * The file name behind a logo address, when the address is one of a logo this + * application wrote into its own directory; null for anything else — the + * default logo, an address elsewhere, or a name that is not one of ours. + */ +const ownLogoName = (url) => { + if (typeof url !== 'string' || !url.startsWith(LOGO_URL_PREFIX)) return null; + let name; + try { + name = decodeURIComponent(url.slice(LOGO_URL_PREFIX.length)); + } catch { + return null; + } + return OWN_NAME.test(name) || LEGACY_NAMES.includes(name) ? name : null; +}; + +const removeLogoFile = async (name) => { + try { + await fs.unlink(path.join(logoDirectory(), name)); + logger.info({ name }, 'Removed a logo no longer in use'); + } catch (error) { + if (error?.code === 'ENOENT') return; + logger.warn({ err: error, name }, 'Could not remove a logo no longer in use'); + } +}; + +/** + * Remove the logo a branding change replaced, once the change is stored. + * + * Only a file this application wrote, and never the logo now in use. The fixed + * names earlier versions wrote go as well, since nothing will serve them again: + * an installation that uploaded an SVG and then a PNG kept both. + */ +const forgetReplacedLogo = async (previousUrl, currentUrl) => { + if (previousUrl === currentUrl) return; + + const names = new Set(LEGACY_NAMES); + const previous = ownLogoName(previousUrl); + if (previous) names.add(previous); + names.delete(ownLogoName(currentUrl)); + + await Promise.all([...names].map(removeLogoFile)); +}; + +/** + * Remove the logos nothing points at, once, at start. + * + * A logo is written, placed under a name of its own, made the logo in the + * settings, and only then is the one it replaced removed. Two things leave a + * file behind: a stop between the placement and the settings write, and a + * removal that fails. Neither leaves anything that can be reached again — a + * logo's address is the name of its file, so a file no setting names is a file + * nobody can ask for — and at 2 MB apiece they stay until someone goes looking. + * + * Start is the moment to do it: nothing of ours is being placed, so a file + * under one of our names is a finished one rather than one in flight. + * + * Only the names this application writes are looked at, and never the logo in + * use. `/config/logos` is a directory on somebody's disk; whatever else is in + * it they put there, and it is not ours to tidy — which is why the names, and + * not the listing, decide what goes. + */ +const sweepUnreferencedLogos = async () => { + let entries; + try { + entries = await fs.readdir(logoDirectory(), { withFileTypes: true }); + } catch (error) { + // No logo has ever been uploaded: there is no directory yet. + if (error?.code === 'ENOENT') return; + logger.warn({ err: error }, 'Could not look for logos no longer in use'); + return; + } + + let inUse; + try { + const { branding } = await settingsService.getPublicSettings(); + inUse = ownLogoName(branding?.appLogoUrl); + } catch (error) { + // Not knowing which logo is in use, removing any of them could remove it. + logger.warn({ err: error }, 'Could not read the branding; leaving the logos alone'); + return; + } + + const unreferenced = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter( + (name) => + name !== inUse && + (OWN_NAME.test(name) || LEGACY_NAMES.includes(name) || PARTIAL_NAME.test(name)) + ); + + await Promise.all(unreferenced.map(removeLogoFile)); +}; + +/** + * Write the image under a name of its own, and answer that name. + * + * The bytes go to a hidden name first — the static handler does not serve + * one — and the file takes its name only once whole, through the placement + * that moves on to "name (1)" rather than replace what holds the name. + */ +const writeLogoFile = async (buffer, extension) => { + const directory = logoDirectory(); + await fs.mkdir(directory, { recursive: true }); + + const id = crypto.randomUUID(); + const partial = path.join(directory, `.logo-${id}.part`); + let created = false; + let placed = null; + + try { + const handle = await fs.open(partial, 'wx'); + created = true; + try { + await handle.writeFile(buffer); + } finally { + await handle.close(); + } + placed = await placeWithoutOverwrite(partial, directory, `logo-${id}${extension}`); + return placed.name; + } finally { + if (created && !placed) await fs.rm(partial, { force: true }); + } +}; + +/** + * Make an uploaded image the logo, with whatever else of the branding was sent + * along: both are stored, or neither. + * + * @param {{buffer: Buffer, mimetype: string}} file what multer kept of the upload + * @param {object} [brandingUpdate] the other branding fields, already checked + * @returns {Promise<{logoUrl: string}>} + */ +const replaceLogo = async ({ buffer, mimetype }, brandingUpdate = {}) => { + const extension = EXTENSIONS[mimetype]; + if (!extension) throw new Error(`Not a logo type: ${mimetype}`); + + const name = await writeLogoFile(buffer, extension); + const logoUrl = logoUrlFor(name); + + let change; + try { + change = await settingsService.replaceBranding({ ...brandingUpdate, appLogoUrl: logoUrl }); + } catch (error) { + // Not the logo, and nothing refers to it: the one in use stays. + await removeLogoFile(name); + throw error; + } + + await forgetReplacedLogo(change.previous.appLogoUrl, change.current.appLogoUrl); + logger.info({ name, size: buffer.length, mimetype }, 'Logo replaced'); + return { logoUrl }; +}; + +module.exports = { + forgetReplacedLogo, + ownLogoName, + replaceLogo, + sweepUnreferencedLogos, +}; diff --git a/backend/src/services/oidcMobileBridge.js b/backend/src/services/oidcMobileBridge.js index 246851280..b99bd42be 100644 --- a/backend/src/services/oidcMobileBridge.js +++ b/backend/src/services/oidcMobileBridge.js @@ -60,6 +60,9 @@ const redeemCode = ({ code, codeVerifier }) => { const entry = codes.get(code); if (!entry) return null; codes.delete(code); + // `sweep` above already dropped everything past its time, so this decides + // nothing on its own — it is the belt to that pair of braces, and the reason + // no test can tell the two apart. if (entry.expiresAt <= Date.now()) return null; if (!verifyChallenge(codeVerifier, entry.codeChallenge)) return null; return { userId: entry.userId }; diff --git a/backend/src/services/oidcService.js b/backend/src/services/oidcService.js index f152193a1..8a1ccd8d2 100644 --- a/backend/src/services/oidcService.js +++ b/backend/src/services/oidcService.js @@ -118,8 +118,7 @@ const fetchUserInfoClaims = async ({ // Guarded like the userinfo fetch below. The sign-in has already succeeded // when this runs and the caller falls back to the id token's claims on // null, so a provider whose discovery document is briefly unreachable is no - // reason to refuse someone — unguarded, a failure here failed the whole - // sign-in. + // reason to refuse someone — unguarded, a 503 here failed the whole sign-in. let configuration = null; try { configuration = await discoverOpenIdConfiguration({ @@ -158,7 +157,5 @@ const fetchUserInfoClaims = async ({ }; module.exports = { - discoverOpenIdConfiguration, fetchUserInfoClaims, - normalizeIssuer, }; diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index d06b4048a..b274ccd04 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -543,6 +543,52 @@ const setUserSetting = async (userId, key, value) => { /** * Set a system setting (admin only) */ +/** + * 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 }); + + 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); + } + + return { previous, current }; +}; + const setSystemSetting = async (category, key, value) => { if (category !== 'branding' && category !== 'system') { throw new Error('Invalid category. Must be "branding" or "system"'); @@ -671,6 +717,7 @@ const updateSettings = async (updater) => { }; module.exports = { + replaceBranding, USER_SETTING_KEYS, MAX_UPLOAD_CHUNK_SIZE_BYTES, getPublicSettings, diff --git a/backend/src/utils/authenticatedSession.js b/backend/src/utils/authenticatedSession.js index fc9b44233..dacbf012b 100644 --- a/backend/src/utils/authenticatedSession.js +++ b/backend/src/utils/authenticatedSession.js @@ -4,6 +4,10 @@ * Reusing the pre-login session id would let an attacker who managed to plant * a known id in the victim's browser keep using it once they sign in * (session fixation). Regenerating gives the authenticated user a new id. + * + * A password change goes through here too, for the same reason in reverse: a + * copy of the cookie taken along with the password would otherwise be the one + * session the change left signed in. */ const startAuthenticatedSession = (req, userId) => new Promise((resolve, reject) => { diff --git a/backend/src/utils/oidcAvailability.js b/backend/src/utils/oidcAvailability.js new file mode 100644 index 000000000..af9f5e9e1 --- /dev/null +++ b/backend/src/utils/oidcAvailability.js @@ -0,0 +1,58 @@ +/** + * What the OIDC configuration pass concluded, and why. + * + * A request that cannot start a sign-in has to say which of two things + * happened: nothing was configured, or what was configured could not be made + * to work. The first is answered by filling in OIDC_ISSUER and the rest; the + * second is answered by looking at the provider, and telling an administrator + * to go and change a configuration that is already right is the whole of the + * defect this records. + * + * Recorded here rather than worked out again at request time: only + * `configureOidc` knows what it decided and what it caught, and a second copy + * of the enablement check in the routes would drift from it. + * + * Lives in its own module because the routes read it and loading the OIDC + * middleware opens sessions.db, which nothing answering a question about the + * configuration should do. + */ + +/** Nothing usable was configured; the settings are what to look at. */ +const NOT_CONFIGURED = 'not-configured'; +/** The settings were there, and the provider hand-off is mounted. */ +const READY = 'ready'; +/** The settings were there and could not be made to work; see `reason`. */ +const UNAVAILABLE = 'unavailable'; + +// An instance that never calls `configureOidc` — AUTH_MODE=local, or a test +// that skips it — has nothing configured, which is what this says. +let state = { status: NOT_CONFIGURED, reason: null }; + +const recordOidcNotConfigured = (reason = null) => { + state = { status: NOT_CONFIGURED, reason }; +}; + +const recordOidcReady = () => { + state = { status: READY, reason: null }; +}; + +const recordOidcUnavailable = (reason = null) => { + state = { status: UNAVAILABLE, reason }; +}; + +/** @returns {{status: string, reason: string|null}} */ +const getOidcAvailability = () => ({ ...state }); + +/** Whether the settings for a sign-in at a provider are there at all. */ +const oidcIsConfigured = () => state.status !== NOT_CONFIGURED; + +module.exports = { + NOT_CONFIGURED, + READY, + UNAVAILABLE, + recordOidcNotConfigured, + recordOidcReady, + recordOidcUnavailable, + getOidcAvailability, + oidcIsConfigured, +}; diff --git a/backend/src/utils/oidcRedirect.js b/backend/src/utils/oidcRedirect.js new file mode 100644 index 000000000..700adadfb --- /dev/null +++ b/backend/src/utils/oidcRedirect.js @@ -0,0 +1,109 @@ +const crypto = require('crypto'); + +const normalizeOrigin = (value) => { + try { + return new URL(value).origin; + } catch (_) { + return null; + } +}; + +const uniqueOrigins = (origins) => [...new Set(origins.map(normalizeOrigin).filter(Boolean))]; + +/** + * Only relative, same-site paths may be stored in the OIDC transaction state. + * This protects the post-login redirect from becoming an open redirect. + */ +const sanitizeReturnTo = (candidate, fallback = '/browse/') => { + if (typeof candidate !== 'string') return fallback; + const value = candidate.trim(); + if (!value.startsWith('/') || value.startsWith('//') || value.includes('\\')) return fallback; + return value; +}; + +const isTrustedProxyRequest = (req) => { + const trustProxy = req.app?.get?.('trust proxy fn'); + const remoteAddress = req.socket?.remoteAddress || req.connection?.remoteAddress; + + return Boolean( + typeof trustProxy === 'function' && remoteAddress && trustProxy(remoteAddress, 0) === true + ); +}; + +const readForwardedHost = (req) => { + if (!isTrustedProxyRequest(req)) return null; + + const forwarded = req.headers?.['x-forwarded-host']; + if (typeof forwarded !== 'string') return null; + return forwarded.split(',')[0]?.trim() || null; +}; + +/** + * Resolve the browser-facing origin, but only when it exactly matches an + * operator-configured public or internal origin. A forwarded host is useful + * behind a trusted proxy; the allow-list prevents it from becoming a redirect + * target controlled by a request header. + */ +const getConfiguredRequestOrigin = (req, allowedOrigins) => { + const host = readForwardedHost(req) || req.get?.('host') || req.headers?.host; + if (!host) return null; + + try { + const origin = new URL(`${req.protocol || 'http'}://${host}`).origin; + return allowedOrigins.includes(origin) ? origin : null; + } catch (_) { + return null; + } +}; + +const absoluteReturnTo = (origin, candidate) => + new URL(sanitizeReturnTo(candidate, '/auth/login'), origin).toString(); + +const callbackUrlForOrigin = (origin) => new URL('/callback', origin).toString(); + +const oidcCookieNamesForOrigin = (origin) => { + const normalizedOrigin = normalizeOrigin(origin); + if (!normalizedOrigin) { + throw new Error('Cannot create OIDC cookie names for an invalid origin.'); + } + + // Cookies are scoped by host and path, not port. Separate names prevent a + // session or transaction from one configured origin being used by another. + const suffix = crypto.createHash('sha256').update(normalizedOrigin).digest('hex').slice(0, 16); + return { + session: `appSession.${suffix}`, + transaction: `auth_verification.${suffix}`, + }; +}; + +const sanitizeOidcPrompt = (candidate) => { + const allowedPrompts = new Set(['login', 'select_account']); + return typeof candidate === 'string' && allowedPrompts.has(candidate) ? candidate : null; +}; + +/** + * Mark a request as a hand-off to the identity provider, and read the mark. + * + * express-openid-connect reports a failure to start one to the `next` it + * captured when it built the request context, not to the route's own — so + * neither the route nor a try/catch around `login()` ever sees it. The mark is + * what lets the error handling tell "the provider did not answer" from any + * other error on any other route. + */ +const markProviderSignIn = (req) => { + if (req) req.nextExplorerOidcSignIn = true; +}; + +const isProviderSignIn = (req) => Boolean(req && req.nextExplorerOidcSignIn); + +module.exports = { + markProviderSignIn, + isProviderSignIn, + uniqueOrigins, + sanitizeReturnTo, + getConfiguredRequestOrigin, + absoluteReturnTo, + callbackUrlForOrigin, + oidcCookieNamesForOrigin, + sanitizeOidcPrompt, +}; diff --git a/backend/src/utils/processFailures.js b/backend/src/utils/processFailures.js new file mode 100644 index 000000000..7524e17be --- /dev/null +++ b/backend/src/utils/processFailures.js @@ -0,0 +1,96 @@ +const logger = require('./logger'); + +/** + * What a failure nobody caught should cost. + * + * Node's default for a rejected promise with no listener is to raise it as an + * uncaught exception and stop the process. So one forgotten `await` anywhere in + * the application — on a path check, a database read, a stat — answers a single + * bad request by taking the server down with it, and everybody else's work goes + * with it. + * + * That is a disproportionate price. A rejection raised while serving a request is + * almost always confined to that request: the connection fails, and nothing else + * is touched. Reporting it and carrying on is the proportionate answer. + * + * An uncaught exception is not the same thing and is not treated the same way. + * There the stack unwound through code that had no chance to put anything back, + * so what is in memory afterwards is unknown — a lock still held, a transaction + * half applied. Continuing to serve from that is worse than stopping, so this + * stops, deliberately and after saying why. + * + * None of this hides anything from development. The test suites never load this + * file, and the runner already fails a run that leaves an unhandled rejection + * behind. The quiet is bought only where the server runs, where staying up is + * worth more than dying loudly. + */ + +/** How long a shutdown may take before it is abandoned. */ +const FATAL_SHUTDOWN_TIMEOUT_MS = 5000; + +/** + * @param {object} [options] + * @param {object} [options.log] where to report, injected so a test can read it + * @param {() => Promise|void} [options.onFatal] the ordinary shutdown, tried + * before giving up on an uncaught exception + * @param {(code: number) => void} [options.exit] + * @param {number} [options.shutdownTimeoutMs] + * @param {NodeJS.EventEmitter} [options.target] the process to attach to + * @returns {() => void} removes both listeners again + */ +const installProcessFailureHandlers = ({ + log = logger, + onFatal = null, + exit = (code) => process.exit(code), + shutdownTimeoutMs = FATAL_SHUTDOWN_TIMEOUT_MS, + target = process, +} = {}) => { + const onUnhandledRejection = (reason) => { + // Normalised: a rejection carries whatever was thrown, which is often an + // Error and sometimes a string nobody meant to reject with. + const err = reason instanceof Error ? reason : new Error(String(reason)); + log.error( + { err }, + 'A promise was rejected with nobody listening. The request behind it has failed; the server has not.' + ); + }; + + const onUncaughtException = (error) => { + log.error( + { err: error }, + 'Uncaught exception. Shutting down: what is in memory after this cannot be trusted.' + ); + + // Bounded, because the shutdown runs in the same unknown state and may never + // finish. Whichever comes first wins, and the process ends either way. + let ended = false; + const end = () => { + if (ended) return; + ended = true; + exit(1); + }; + + const timer = setTimeout(end, shutdownTimeoutMs); + timer.unref?.(); + + Promise.resolve() + .then(() => onFatal?.()) + .catch((shutdownError) => { + log.error({ err: shutdownError }, 'Shutdown after an uncaught exception failed too'); + }) + .finally(() => { + clearTimeout(timer); + end(); + }); + }; + + target.on('unhandledRejection', onUnhandledRejection); + target.on('uncaughtException', onUncaughtException); + + return () => { + target.off('unhandledRejection', onUnhandledRejection); + target.off('uncaughtException', onUncaughtException); + }; +}; + +module.exports = { installProcessFailureHandlers, FATAL_SHUTDOWN_TIMEOUT_MS }; diff --git a/backend/tests/config/session-secret.test.js b/backend/tests/config/session-secret.test.js new file mode 100644 index 000000000..78d36d8b9 --- /dev/null +++ b/backend/tests/config/session-secret.test.js @@ -0,0 +1,146 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { clearApplicationModules, modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Whether a restart signs everyone out. + * + * The secret sessions are signed with was drawn at random on every start when + * SESSION_SECRET was not set. The sessions themselves survive — they are rows in + * CACHE_DIR/sessions.db — but a cookie signed with the previous secret no longer + * verifies, so the row was read as a stranger's and everyone was asked to sign + * in again after every restart, every upgrade and every crash. + * + * So the test that matters is not "the resolver returns the same string twice"; + * it is a session set before a restart still being that session after one, which + * is what a person notices. It is asserted through the real session middleware, + * against a real store, because the secret is only ever used for signing. + */ + +const require = createRequire(import.meta.url); +const load = (relative) => require(modulePath(relative)); +const RUNS_AS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0; + +let env; +const unlocked = []; + +afterEach(async () => { + vi.restoreAllMocks(); + while (unlocked.length) await fsp.chmod(unlocked.pop(), 0o755).catch(() => {}); + if (env) await env.cleanup(); + env = null; +}); + +/** Everything a logger spy was told, message and context both, as one string. */ +const said = (spy) => + spy.mock.calls + .map((call) => + call + .map((part) => + part && typeof part === 'object' + ? Object.entries(part) + .map(([key, value]) => `${key}=${value?.message ?? value}`) + .join(' ') + : String(part) + ) + .join(' ') + ) + .join('\n'); + +/** A test environment where nobody configured a secret. */ +const seed = (extra = {}) => + setupTestEnv({ tag: 'session-secret-', env: { SESSION_SECRET: undefined, ...extra } }); + +/** + * An application that can be asked to remember a name and to say it back. + * + * Built from a freshly loaded session middleware every time, so building one + * after `clearApplicationModules()` is exactly what a restart does: the same + * CONFIG_DIR and the same store, read by new module instances. + */ +const buildApp = () => { + const application = express(); + load('src/middleware/session').configureSession(application); + application.post('/remember', (req, res) => { + req.session.who = 'benjy'; + req.session.save(() => res.json({ ok: true })); + }); + application.get('/who', (req, res) => res.json({ who: req.session.who ?? null })); + return application; +}; + +describe('the secret sessions are signed with', () => { + it('keeps a session across a restart when nobody configured one', async () => { + env = await seed(); + + const before = buildApp(); + const set = await request(before).post('/remember'); + expect(set.status).toBe(200); + const cookie = set.headers['set-cookie']; + expect(cookie).toBeTruthy(); + // It is that session while the server is up, which was never in question. + expect((await request(before).get('/who').set('Cookie', cookie)).body.who).toBe('benjy'); + + // The restart: every module reloaded, the same CONFIG_DIR and the same store. + clearApplicationModules(); + const after = buildApp(); + + const asked = await request(after).get('/who').set('Cookie', cookie); + + expect(asked.body.who).toBe('benjy'); + }); + + it('is the one the operator set, and stores nothing then', async () => { + env = await seed({ SESSION_SECRET: 'chosen by the operator' }); + + expect(load('src/config/index').auth.sessionSecret).toBe('chosen by the operator'); + await expect(fsp.access(path.join(env.configDir, 'session-secret'))).rejects.toThrow(); + }); + + it('stores its own where nobody but the server can read it', async () => { + env = await seed(); + + const secret = load('src/config/index').auth.sessionSecret; + + expect(secret).toMatch(/^[0-9a-f]{64}$/); + const stored = await fsp.stat(path.join(env.configDir, 'session-secret')); + expect(stored.mode & 0o777).toBe(0o600); + expect((await fsp.readFile(path.join(env.configDir, 'session-secret'), 'utf8')).trim()).toBe( + secret + ); + }); + + it('replaces an unusable stored secret without putting it in the log', async () => { + env = await seed(); + // What a person editing the file by hand would leave behind. + await fsp.writeFile(path.join(env.configDir, 'session-secret'), 'hunter2\n'); + const warn = vi.spyOn(load('src/utils/logger'), 'warn'); + + const secret = load('src/config/index').auth.sessionSecret; + + expect(secret).toMatch(/^[0-9a-f]{64}$/); + expect(warn).toHaveBeenCalled(); + // The secret on its way to being replaced is still a secret. Everything the + // call carried, message and context flattened by hand: JSON.stringify leaves + // an Error as {} and would have passed a secret hidden inside one. + expect(said(warn)).not.toContain('hunter2'); + }); + + it.skipIf(RUNS_AS_ROOT)('still starts when CONFIG_DIR cannot be written', async () => { + env = await seed(); + await fsp.chmod(env.configDir, 0o555); + unlocked.push(env.configDir); + const warn = vi.spyOn(load('src/utils/logger'), 'warn'); + + const secret = load('src/config/index').auth.sessionSecret; + + expect(secret).toMatch(/^[0-9a-f]{64}$/); + // And says why the next restart will sign everyone out anyway. + expect(said(warn)).toContain('signed out'); + }); +}); diff --git a/backend/tests/frontend-strings.test.js b/backend/tests/frontend-strings.test.js new file mode 100644 index 000000000..41689ea26 --- /dev/null +++ b/backend/tests/frontend-strings.test.js @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Every string a screen asks for is one the catalogue has. + * + * A screen that arrives without its strings shows its own keys — + * `settings.about.tools.title` where a heading belongs — and nothing fails: + * the build succeeds, the page renders, and the words are missing. It is the + * one defect a batch of screens can ship with and pass every other gate, and + * it has happened: the About page's list of optional tools was ported with no + * catalogue entries at all. + * + * English is the one checked. The others are missing-translation fallbacks by + * design — a key absent there falls back to English, which is a reader seeing + * the wrong language rather than a key. + */ + +const FRONTEND = path.join(__dirname, '..', '..', 'frontend', 'src'); +const LOCALES = path.join(FRONTEND, 'i18n', 'locales'); +const CATALOGUE = path.join(LOCALES, 'en.json'); + +/** `t('a.b')`, `$t('a.b')`, `te('a.b')` — the literal calls, which is all that can be checked. */ +const KEY_CALL = /(? { + const found = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'locales') continue; + found.push(...sourcesUnder(full)); + } else if (/\.(vue|js)$/.test(entry.name) && !entry.name.endsWith('.spec.js')) { + found.push(full); + } + } + return found; +}; + +const has = (catalogue, key) => { + let node = catalogue; + for (const part of key.split('.')) { + if (!node || typeof node !== 'object' || !(part in node)) return false; + node = node[part]; + } + return typeof node === 'string' || typeof node === 'object'; +}; + +describe('the strings the interface asks for', () => { + it('are all in the English catalogue', () => { + const catalogue = JSON.parse(fs.readFileSync(CATALOGUE, 'utf8')); + const missing = new Set(); + + for (const file of sourcesUnder(FRONTEND)) { + const source = fs.readFileSync(file, 'utf8'); + for (const [, key] of source.matchAll(KEY_CALL)) { + // A key is a path with a dot in it; a bare word is some other `t(...)`. + if (!key.includes('.')) continue; + // `t('settings.categories.' + name)` — a prefix being built, not a key. + if (key.endsWith('.')) continue; + if (!has(catalogue, key)) { + missing.add(`${key} (${path.relative(FRONTEND, file)})`); + } + } + } + + expect([...missing].sort()).toEqual([]); + }); +}); + +/** Every key in a catalogue, as dotted paths, so two catalogues can be compared. */ +const pathsIn = (node, prefix = '') => + Object.entries(node).flatMap(([key, value]) => + value && typeof value === 'object' ? pathsIn(value, `${prefix}${key}.`) : [`${prefix}${key}`] + ); + +const read = (locale) => JSON.parse(fs.readFileSync(path.join(LOCALES, `${locale}.json`), 'utf8')); + +const at = (catalogue, key) => key.split('.').reduce((node, part) => node?.[part], catalogue); + +/** `{name}`, `{0}` — what vue-i18n will substitute, and what a translation must keep. */ +const placeholdersIn = (value) => + new Set(typeof value === 'string' ? [...value.matchAll(/\{(\w+)\}/g)].map((m) => m[1]) : []); + +const locales = fs + .readdirSync(LOCALES) + .filter((name) => name.endsWith('.json')) + .map((name) => name.replace(/\.json$/, '')) + .filter((locale) => locale !== 'en'); + +/** + * The catalogues, held to each other. + * + * A key added in English and nowhere else falls back to English, which is a + * reader seeing the wrong language — quieter than a raw key and just as wrong. A + * key left behind in one catalogue after it was renamed in English is dead weight + * nobody will ever see again. And a translation that drops a placeholder loses + * whatever it stood for: `{count} items` translated without `{count}` says + * "items", with the number silently gone. + * + * Asserted here rather than in a frontend suite because this is where the runner + * that CI executes lives, and a test nothing runs holds nothing. + */ +describe('the translation catalogues', () => { + it('has more than one language to keep aligned', () => { + expect(locales.length).toBeGreaterThan(1); + }); + + it('ships every English key in every language', () => { + const english = pathsIn(JSON.parse(fs.readFileSync(CATALOGUE, 'utf8'))); + const missing = []; + + for (const locale of locales) { + const theirs = new Set(pathsIn(read(locale))); + for (const key of english) if (!theirs.has(key)) missing.push(`${locale}: ${key}`); + } + + expect(missing).toEqual([]); + }); + + it('defines no key English does not', () => { + const english = new Set(pathsIn(JSON.parse(fs.readFileSync(CATALOGUE, 'utf8')))); + const extra = []; + + for (const locale of locales) { + for (const key of pathsIn(read(locale))) + if (!english.has(key)) extra.push(`${locale}: ${key}`); + } + + expect(extra).toEqual([]); + }); + + it('keeps the placeholders the English string had', () => { + const english = JSON.parse(fs.readFileSync(CATALOGUE, 'utf8')); + const keys = pathsIn(english); + const lost = []; + + for (const locale of locales) { + const theirs = read(locale); + for (const key of keys) { + const wanted = placeholdersIn(at(english, key)); + if (!wanted.size) continue; + const got = placeholdersIn(at(theirs, key)); + const dropped = [...wanted].filter((name) => !got.has(name)); + if (dropped.length) lost.push(`${locale}: ${key} lost {${dropped.join('}, {')}}`); + } + } + + expect(lost).toEqual([]); + }); +}); diff --git a/backend/tests/middleware/oidc-middleware.test.js b/backend/tests/middleware/oidc-middleware.test.js new file mode 100644 index 000000000..cdd8dce7c --- /dev/null +++ b/backend/tests/middleware/oidc-middleware.test.js @@ -0,0 +1,495 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createRequire } from 'node:module'; +import express from 'express'; +import { setupTestEnv, modulePath } from '../helpers/env-test-utils.js'; + +const require = createRequire(import.meta.url); + +/** + * This is the path that decides who someone is, in the deployments that use + * it, and nothing exercised any of it. A regression here would be both silent + * and serious — the roles half of it already was: membership was read once, at + * account creation, while the documentation said otherwise. + */ + +let envContext; + +const build = async (env = {}) => { + envContext = await setupTestEnv({ tag: 'oidc-middleware-', env }); + const middleware = envContext.requireFresh('src/middleware/oidc'); + const dbService = envContext.requireFresh('src/services/db'); + const db = await dbService.getDb(); + return { middleware, db }; +}; + +afterEach(async () => { + if (envContext) await envContext.cleanup(); + envContext = null; +}); + +describe('where the provider is told to come back to', () => { + it('takes the origin of the callback URL when there is one', async () => { + const { middleware } = await build(); + + expect(middleware.deriveBaseUrl({ callbackUrl: 'https://files.example.com/callback' })).toBe( + 'https://files.example.com' + ); + }); + + it('falls back to the public URL', async () => { + const { middleware } = await build({ PUBLIC_URL: 'https://files.example.com' }); + + expect(middleware.deriveBaseUrl({})).toBe('https://files.example.com'); + }); + + it('answers nothing rather than guessing', async () => { + const { middleware } = await build(); + + expect(middleware.deriveBaseUrl({})).toBeNull(); + }); + + // A malformed value is a misconfiguration, not a reason to fail at startup. + it('survives a callback URL that is not one', async () => { + const { middleware } = await build(); + + expect(middleware.deriveBaseUrl({ callbackUrl: 'https://' })).toBeNull(); + }); +}); + +describe('whether the session cookie is marked secure', () => { + it('marks it on https and not on http', async () => { + const { middleware } = await build(); + + expect(middleware.shouldOidcCookieBeSecure('https://files.example.com')).toBe(true); + // Marking it on plain http would send a cookie the browser never returns, + // and the login would loop. + expect(middleware.shouldOidcCookieBeSecure('http://localhost:3000')).toBe(false); + expect(middleware.shouldOidcCookieBeSecure(null)).toBe(false); + expect(middleware.shouldOidcCookieBeSecure('not a url')).toBe(false); + }); +}); + +describe('what is asked of the provider', () => { + it('always asks for openid, once', async () => { + const { middleware } = await build(); + + expect(middleware.resolveOidcScopes({ scopes: ['openid', 'profile'] })).toBe('openid profile'); + expect(middleware.resolveOidcScopes({ scopes: ['profile', 'email'] })).toBe( + 'openid profile email' + ); + }); + + it('has a usable default', async () => { + const { middleware } = await build(); + + expect(middleware.resolveOidcScopes({})).toBe('openid profile email'); + }); + + // Without the groups scope the provider returns no group claim, which looks + // exactly like a user who belongs to nothing. + it('asks for groups when they are configured', async () => { + const { middleware } = await build(); + + expect(middleware.resolveOidcScopes({ scopes: ['openid', 'profile', 'groups'] })).toContain( + 'groups' + ); + }); +}); + +describe('what happens when someone comes back from the provider', () => { + const callbackWith = async ({ claims, adminGroups = null, env = {} }) => { + const { middleware, db } = await build(env); + const handler = middleware.createAfterCallbackHandler( + { issuer: 'https://idp.example' }, + { oidc: { adminGroups } } + ); + + const req = { oidc: { user: claims } }; + const session = { id_token_claims: claims }; + const returned = await handler(req, {}, session); + + return { db, returned, session }; + }; + + const rolesOf = (db, email) => { + const row = db.prepare('SELECT roles FROM users WHERE email = ?').get(email); + return row ? JSON.parse(row.roles) : null; + }; + + it('creates the account the claims describe', async () => { + const { db } = await callbackWith({ + claims: { + sub: 'sub-1', + email: 'someone@example.com', + email_verified: true, + preferred_username: 'someone', + name: 'Some One', + }, + }); + + const row = db.prepare('SELECT * FROM users WHERE email = ?').get('someone@example.com'); + expect(row).toBeTruthy(); + expect(row.username).toBe('someone'); + expect(row.display_name).toBe('Some One'); + }); + + it('hands the session back untouched', async () => { + const { returned, session } = await callbackWith({ + claims: { sub: 'sub-1', email: 'someone@example.com', email_verified: true }, + }); + + expect(returned).toBe(session); + }); + + // Claims without a subject are not an identity. The login is refused rather + // than an account made from whatever else the provider sent — and refused as + // an authentication failure, which the error handler turns into a trip back + // to the login screen rather than a server fault. + it('refuses claims with no subject, and makes no account from them', async () => { + const { middleware, db } = await build(); + const handler = middleware.createAfterCallbackHandler( + { issuer: 'https://idp.example' }, + { oidc: { adminGroups: null } } + ); + const claims = { email: 'nobody@example.com', email_verified: true }; + + await expect( + handler({ oidc: { user: claims } }, {}, { id_token_claims: claims }) + ).rejects.toMatchObject({ statusCode: 401 }); + + expect(db.prepare('SELECT COUNT(*) AS n FROM users').get().n).toBe(0); + }); + + it('grants admin when the configured group is claimed', async () => { + const { db } = await callbackWith({ + claims: { + sub: 'sub-1', + email: 'boss@example.com', + email_verified: true, + groups: ['nx-admins'], + }, + adminGroups: ['nx-admins'], + }); + + expect(rolesOf(db, 'boss@example.com')).toContain('admin'); + }); + + // The regression this must never cause: with no group configured every login + // derives the plain `user` role, and applying it would demote the + // administrator promoted from Settings at their next sign-in. + it('leaves the roles alone when no group is configured', async () => { + const { middleware, db } = await build(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES ('u-1', 'boss@example.com', 1, 'boss', 'Boss', '["admin"]', ?, ?)` + ).run(now, now); + db.prepare( + `INSERT INTO auth_methods (id, user_id, method_type, provider_issuer, provider_sub, provider_name, created_at) + VALUES ('a-1', 'u-1', 'oidc', 'https://idp.example', 'sub-1', 'OIDC', ?)` + ).run(now); + + const handler = middleware.createAfterCallbackHandler( + { issuer: 'https://idp.example' }, + { oidc: { adminGroups: null } } + ); + const claims = { + sub: 'sub-1', + email: 'boss@example.com', + email_verified: true, + groups: ['staff'], + }; + await handler({ oidc: { user: claims } }, {}, { id_token_claims: claims }); + + expect(JSON.parse(db.prepare("SELECT roles FROM users WHERE id = 'u-1'").get().roles)).toEqual([ + 'admin', + ]); + }); +}); + +/** + * Signing out, which is more than forgetting the session here. + * + * The identity provider holds a session of its own, so the browser has to be + * sent there to end it — and where it comes back to afterwards travels in the + * URL. That makes the return address the interesting part: somewhere else's + * address in `post_logout_redirect_uri` turns signing out into a redirect + * anybody can aim. + * + * Fifty lines of it, untested until now, in a route reached by everybody who + * signs out. + */ +describe('signing out through the identity provider', () => { + const buildHandler = async ({ + logoutURL = 'https://idp.example/logout', + returnTo = '/browse/', + } = {}) => { + const { middleware } = await build(); + const handler = middleware.createLogoutHandler({ + logoutURL, + getReturnTo: () => returnTo, + getSessionCookieName: () => 'appSession', + }); + return handler; + }; + + /** A request that has a session, and a response that records what it was told. */ + const exchange = ({ idToken } = {}) => { + const res = { redirects: [], cleared: [] }; + res.redirect = (url) => res.redirects.push(url); + res.clearCookie = (name, options) => res.cleared.push({ name, options }); + const req = { + oidc: idToken ? { idToken } : {}, + session: { destroy: (cb) => cb(null) }, + appSession: { some: 'session' }, + }; + return { req, res }; + }; + + it('sends the browser to the provider', async () => { + const handler = await buildHandler(); + const { req, res } = exchange(); + + await handler(req, res); + + expect(res.redirects[0]).toContain('https://idp.example/logout'); + }); + + it('tells it where to come back to', async () => { + const handler = await buildHandler({ returnTo: 'https://files.example.com/browse/' }); + const { req, res } = exchange(); + + await handler(req, res); + + expect(res.redirects[0]).toContain( + `post_logout_redirect_uri=${encodeURIComponent('https://files.example.com/browse/')}` + ); + }); + + /** + * The provider will not end a session it cannot identify, so the hint is what + * makes the sign-out actually take effect rather than only appearing to. + */ + it('passes the identity token as a hint when it has one', async () => { + const handler = await buildHandler(); + const { req, res } = exchange({ idToken: 'the-id-token' }); + + await handler(req, res); + + expect(res.redirects[0]).toContain('id_token_hint=the-id-token'); + }); + + it('leaves the hint out when there is none', async () => { + const handler = await buildHandler(); + const { req, res } = exchange(); + + await handler(req, res); + + expect(res.redirects[0]).not.toContain('id_token_hint'); + }); + + it('clears the session cookie', async () => { + const handler = await buildHandler(); + const { req, res } = exchange(); + + await handler(req, res); + + expect(res.cleared.map((c) => c.name)).toContain('appSession'); + }); + + /** + * Two names, because a session begun before cookies were scoped per origin + * carries the old one — and a cookie nobody clears keeps somebody signed in + * after they asked not to be. + */ + it('clears the origin-scoped cookie and the older shared one', async () => { + const { middleware } = await build(); + const handler = middleware.createLogoutHandler({ + logoutURL: 'https://idp.example/logout', + getReturnTo: () => '/browse/', + getSessionCookieName: () => 'appSession.files', + }); + const { req, res } = exchange(); + + await handler(req, res); + + expect([...new Set(res.cleared.map((c) => c.name))].sort()).toEqual([ + 'appSession', + 'appSession.files', + ]); + }); + + /** + * Each name twice, once marked secure and once not. A cookie is only cleared + * by an attribute set that matches the one it was written with, and an + * instance reached over both http and https has written both. + */ + it('clears each name for a secure and an insecure connection alike', async () => { + const handler = await buildHandler(); + const { req, res } = exchange(); + + await handler(req, res); + + const forAppSession = res.cleared.filter((c) => c.name === 'appSession'); + expect(forAppSession.map((c) => c.options.secure).sort()).toEqual([false, true]); + }); + + it('drops the server-side session too', async () => { + const handler = await buildHandler(); + const { req, res } = exchange(); + + await handler(req, res); + + expect(req.appSession).toBeUndefined(); + }); + + /** + * A session that refuses to be destroyed must not leave somebody stuck on a + * page that no longer works: the sign-out carries on and the cookies still go. + */ + it('carries on when the session will not be destroyed', async () => { + const handler = await buildHandler(); + const res = { redirects: [], cleared: [] }; + res.redirect = (url) => res.redirects.push(url); + res.clearCookie = (name) => res.cleared.push({ name }); + const req = { + oidc: {}, + session: { destroy: (cb) => cb(new Error('store is down')) }, + }; + + await handler(req, res); + + expect(res.redirects).toHaveLength(1); + expect(res.cleared.length).toBeGreaterThan(0); + }); + + /** Somewhere is better than nowhere when the provider cannot be reached. */ + it('falls back to the return address when building the provider URL fails', async () => { + const handler = await buildHandler({ returnTo: '/browse/' }); + const res = { redirects: [], cleared: [] }; + res.redirect = (url) => res.redirects.push(url); + res.clearCookie = () => { + throw new Error('cannot clear'); + }; + const req = { oidc: {}, session: { destroy: (cb) => cb(null) } }; + + await handler(req, res); + + expect(res.redirects).toEqual(['/browse/']); + }); + + /** + * Configured with something that is not a URL, there is no provider to send + * anybody to — so no handler is installed, and the ordinary sign-out stands. + */ + it('is not installed at all when the configured URL is not one', async () => { + const { middleware } = await build(); + + expect( + middleware.createLogoutHandler({ + logoutURL: 'not-a-url', + getReturnTo: () => '/browse/', + getSessionCookieName: () => 'appSession', + }) + ).toBeNull(); + }); + + it('is not installed when no URL is configured', async () => { + const { middleware } = await build(); + + expect( + middleware.createLogoutHandler({ + logoutURL: '', + getReturnTo: () => '/browse/', + getSessionCookieName: () => 'appSession', + }) + ).toBeNull(); + }); +}); + +/** + * What the configuration pass concluded, which is the only place it is known. + * + * A sign-in refused later has to say whether the settings are missing or + * whether what was configured could not be made to work: the first is answered + * by filling them in, the second by looking at the provider, and answering the + * first for both is what sent administrators to change a configuration that was + * already right. Everything that fails here used to be one warning in the log + * and nothing else. + */ +describe('what a configuration pass records', () => { + const configured = { + OIDC_ENABLED: 'true', + OIDC_ISSUER: 'https://idp.example', + OIDC_CLIENT_ID: 'nextexplorer', + OIDC_CLIENT_SECRET: 'shhh', + PUBLIC_URL: 'https://files.example.com', + }; + + const runConfigure = async (env) => { + envContext = await setupTestEnv({ tag: 'oidc-configure-', env }); + const middleware = envContext.requireFresh('src/middleware/oidc'); + await middleware.configureOidc(express()); + // Required rather than required fresh: a fresh one would be a second + // instance, and the pass wrote to the one the middleware loaded. + return require(modulePath('src/utils/oidcAvailability')).getOidcAvailability(); + }; + + it('says nothing is configured, and which settings are missing', async () => { + const state = await runConfigure({}); + + expect(state.status).toBe('not-configured'); + expect(state.reason).toContain('OIDC_ENABLED'); + expect(state.reason).toContain('OIDC_ISSUER'); + }); + + /** Enabled, named, and with nowhere for the provider to come back to. */ + it('names the address it could not derive', async () => { + const state = await runConfigure({ + OIDC_ENABLED: 'true', + OIDC_ISSUER: 'https://idp.example', + OIDC_CLIENT_ID: 'nextexplorer', + }); + + expect(state.status).toBe('not-configured'); + expect(state.reason).toContain('PUBLIC_URL'); + }); + + it('says it is ready when the hand-off is mounted', async () => { + const state = await runConfigure(configured); + + expect(state.status).toBe('ready'); + }); + + /** + * The settings this server asks for are all there and the library refuses + * what it was handed. Nothing an administrator fixes by filling in + * OIDC_ISSUER, so it must not read as a configuration that is missing. + */ + it.each([['an issuer that is not a URL', { OIDC_ISSUER: 'not a url' }]])( + 'says the provider is unavailable, not unconfigured, for %s', + async (_name, broken) => { + const state = await runConfigure({ ...configured, ...broken }); + + expect(state.status).toBe('unavailable'); + expect(state.reason).toBeTruthy(); + } + ); + + /** + * The client secret used to be the other way round, and wrongly: it was left + * out of the enablement check, so the library was handed a configuration it + * refuses and the instance reported a provider that could not be started. + * An administrator was sent to look at a provider that was perfectly well, + * while the one setting they had missed was named in the log and nowhere + * else. The hand-off asks for the authorization code flow, which has no + * other way to prove which application is asking, so a missing secret is a + * configuration that is missing — like the issuer, and named like it. + */ + it('names the client secret rather than blaming the provider', async () => { + const state = await runConfigure({ ...configured, OIDC_CLIENT_SECRET: undefined }); + + expect(state.status).toBe('not-configured'); + expect(state.reason).toContain('OIDC_CLIENT_SECRET'); + }); +}); diff --git a/backend/tests/middleware/oidc-provider-flow.test.js b/backend/tests/middleware/oidc-provider-flow.test.js index b5e53c4fb..89add2f64 100644 --- a/backend/tests/middleware/oidc-provider-flow.test.js +++ b/backend/tests/middleware/oidc-provider-flow.test.js @@ -204,15 +204,6 @@ const signIn = async (browser, claims, options = {}) => { return callback(browser, { code, state }); }; -/** - * Where a completed sign-in lands. This application sends it to the site's own - * root; what matters is that it is this site, whatever the query asked for. - */ -const landsOnThisSite = (response) => { - expect(response.status).toBe(302); - expect(new URL(response.headers.location, PUBLIC_URL).origin).toBe(PUBLIC_URL); -}; - const errorShownAtLogin = (response) => { expect(response.status).toBe(302); const landing = new URL(response.headers.location, PUBLIC_URL); @@ -248,7 +239,8 @@ describe('where a sign-in is sent, and where it ends', () => { { returnTo } ); - landsOnThisSite(response); + expect(response.status).toBe(302); + expect(response.headers.location).toBe('/browse/'); expect((await browser.get('/whoami')).body.signedIn).toBe(true); } ); @@ -270,10 +262,7 @@ describe('where a sign-in is sent, and where it ends', () => { const comesBackTo = logout ? landing.searchParams.get('post_logout_redirect_uri') : landing.toString(); - // The library's own sign-out ends on the site; the provider's is told to - // send the browser back to the sign-in page. Neither to the address given. - expect(new URL(comesBackTo).origin).toBe(PUBLIC_URL); - if (logout) expect(comesBackTo).toBe(`${PUBLIC_URL}/auth/login`); + expect(comesBackTo).toBe(`${PUBLIC_URL}/auth/login`); } ); }); @@ -285,11 +274,11 @@ describe('a callback that does not belong to a sign-in this browser started', () const response = await callback(browser, { code: 'code-x', state: 'forged' }); - // The library names the missing transaction in its own words, which - // changed between its versions. - expect(errorShownAtLogin(response)).toMatch( - /cookie not found|checks\.state argument is missing/ - ); + // The wording is the library's, and it has changed between versions of + // express-openid-connect — "cookie not found" in one, "checks.state argument is + // missing" in another. What the test is about is that the callback is refused, + // so it asserts that something was refused and leaves the sentence to the library. + expect(errorShownAtLogin(response)).toMatch(/cookie not found|state argument is missing/); expect(provider.tokenRequests).toBe(0); expect((await browser.get('/whoami')).body.signedIn).toBe(false); }); @@ -360,7 +349,7 @@ describe('the identity a sign-in is based on', () => { email_verified: true, }); - landsOnThisSite(response); + expect(response.headers.location).toBe('/browse/'); expect((await browser.get('/whoami')).body).toEqual({ signedIn: true, sub: 'sub-1' }); expect( db.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('someone@example.com').n @@ -395,7 +384,7 @@ describe('the identity a sign-in is based on', () => { email_verified: true, }); - landsOnThisSite(response); + expect(response.headers.location).toBe('/browse/'); expect((await browser.get('/whoami')).body).toEqual({ signedIn: true, sub: 'sub-2' }); expect( db.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('second@example.com').n @@ -417,7 +406,7 @@ describe('who a completed sign-in makes someone', () => { groups: ['nx-admins'], }); - landsOnThisSite(response); + expect(response.headers.location).toBe('/browse/'); expect(provider.tokenRequests).toBe(1); expect((await browser.get('/whoami')).body).toEqual({ signedIn: true, sub: 'sub-1' }); const row = db.prepare('SELECT * FROM users WHERE email = ?').get('boss@example.com'); @@ -425,44 +414,6 @@ describe('who a completed sign-in makes someone', () => { expect(JSON.parse(row.roles)).toEqual(['admin']); }); - /** - * Membership is read at every sign-in, not only when the account is made: - * someone taken out of the admin group at the provider stops being an - * administrator here, and someone put into it becomes one. - */ - it('follows the admin group from one sign-in to the next, in both directions', async () => { - const { app, db } = await build(); - const person = { sub: 'sub-1', email: 'boss@example.com', email_verified: true }; - const rolesNow = () => - JSON.parse( - db.prepare('SELECT roles FROM users WHERE email = ?').get('boss@example.com').roles - ); - - await signIn(request.agent(app), { ...person, groups: ['nx-admins'] }); - expect(rolesNow()).toEqual(['admin']); - - await signIn(request.agent(app), { ...person, groups: ['staff'] }); - expect(rolesNow()).toEqual(['user']); - - await signIn(request.agent(app), { ...person, groups: ['staff', 'nx-admins'] }); - expect(rolesNow()).toEqual(['admin']); - }); - - /** - * A provider that says nothing about groups is not saying the person belongs - * to none — a missing `groups` scope looks exactly like that. The role stays. - */ - it('leaves the role alone when the provider sends no group claim at all', async () => { - const { app, db } = await build(); - const person = { sub: 'sub-1', email: 'boss@example.com', email_verified: true }; - - await signIn(request.agent(app), { ...person, groups: ['nx-admins'] }); - await signIn(request.agent(app), person); - - const row = db.prepare('SELECT roles FROM users WHERE email = ?').get('boss@example.com'); - expect(JSON.parse(row.roles)).toEqual(['admin']); - }); - it.each([[['nx-admins-readonly']], [['not-nx-admins']], [['nx']]])( 'does not make an administrator of a group that only resembles the admin group: %j', async (groups) => { diff --git a/backend/tests/middleware/oidcOrigin.test.js b/backend/tests/middleware/oidcOrigin.test.js new file mode 100644 index 000000000..f84ffcda2 --- /dev/null +++ b/backend/tests/middleware/oidcOrigin.test.js @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + sanitizeReturnTo, + getConfiguredRequestOrigin, + absoluteReturnTo, + callbackUrlForOrigin, + oidcCookieNamesForOrigin, + sanitizeOidcPrompt, +} = require('../../src/utils/oidcRedirect'); + +const configuredOrigins = ['https://files.example.test', 'http://192.168.1.250:3017']; + +const makeRequest = ({ protocol = 'https', host, forwardedHost, trustedProxy = false } = {}) => ({ + protocol, + headers: { + ...(host ? { host } : {}), + ...(forwardedHost ? { 'x-forwarded-host': forwardedHost } : {}), + }, + socket: { remoteAddress: '127.0.0.1' }, + app: { + get: (name) => (name === 'trust proxy fn' ? () => trustedProxy : undefined), + }, + get: (name) => (name.toLowerCase() === 'host' ? host : undefined), +}); + +describe('OIDC origin-aware redirects', () => { + it('keeps only same-site return paths in OIDC state', () => { + expect(sanitizeReturnTo('/browse/Media?sort=name')).toBe('/browse/Media?sort=name'); + expect(sanitizeReturnTo('https://attacker.example')).toBe('/browse/'); + expect(sanitizeReturnTo('//attacker.example')).toBe('/browse/'); + expect(sanitizeReturnTo('/\\attacker.example')).toBe('/browse/'); + }); + + it('selects the exact configured origin used by the browser', () => { + expect( + getConfiguredRequestOrigin( + makeRequest({ protocol: 'https', host: 'files.example.test' }), + configuredOrigins + ) + ).toBe('https://files.example.test'); + expect( + getConfiguredRequestOrigin( + makeRequest({ protocol: 'http', host: '192.168.1.250:3017' }), + configuredOrigins + ) + ).toBe('http://192.168.1.250:3017'); + }); + + it('uses a configured forwarded host only from a trusted proxy', () => { + expect( + getConfiguredRequestOrigin( + makeRequest({ + protocol: 'https', + host: 'next-explorer:3000', + forwardedHost: 'files.example.test', + trustedProxy: true, + }), + configuredOrigins + ) + ).toBe('https://files.example.test'); + expect( + getConfiguredRequestOrigin( + makeRequest({ + protocol: 'https', + host: 'next-explorer:3000', + forwardedHost: 'files.example.test', + }), + configuredOrigins + ) + ).toBeNull(); + expect( + getConfiguredRequestOrigin( + makeRequest({ protocol: 'https', host: 'attacker.example' }), + configuredOrigins + ) + ).toBeNull(); + }); + + it('builds an absolute logout return URL from the selected origin', () => { + expect(absoluteReturnTo('http://192.168.1.250:3017', '/auth/login?expired=1')).toBe( + 'http://192.168.1.250:3017/auth/login?expired=1' + ); + }); + + it('builds the OIDC callback URL from the selected origin', () => { + expect(callbackUrlForOrigin('https://files.example.test')).toBe( + 'https://files.example.test/callback' + ); + expect(callbackUrlForOrigin('http://192.168.1.250:3017')).toBe( + 'http://192.168.1.250:3017/callback' + ); + }); + + it('isolates OIDC session and transaction cookies per origin', () => { + const publicCookies = oidcCookieNamesForOrigin('https://files.example.test'); + const internalCookies = oidcCookieNamesForOrigin('http://192.168.1.250:3017'); + + expect(publicCookies).not.toEqual(internalCookies); + expect(publicCookies.session).toMatch(/^appSession\.[a-f0-9]{16}$/); + expect(publicCookies.transaction).toMatch(/^auth_verification\.[a-f0-9]{16}$/); + }); + + it('allows only safe OIDC prompts after logout', () => { + expect(sanitizeOidcPrompt('login')).toBe('login'); + expect(sanitizeOidcPrompt('select_account')).toBe('select_account'); + expect(sanitizeOidcPrompt('none')).toBeNull(); + }); +}); diff --git a/backend/tests/routes/auth-oidc-routes.test.js b/backend/tests/routes/auth-oidc-routes.test.js new file mode 100644 index 000000000..ed9ff683d --- /dev/null +++ b/backend/tests/routes/auth-oidc-routes.test.js @@ -0,0 +1,396 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createRequire } from 'node:module'; +import crypto from 'node:crypto'; +import express from 'express'; +import session from 'express-session'; +import request from 'supertest'; +import { setupTestEnv, modulePath } from '../helpers/env-test-utils.js'; + +const require = createRequire(import.meta.url); + +/** + * The sign-in routes under /api/auth that hand over to the identity provider. + * + * Starting a sign-in puts two addresses in play: where the provider sends the + * browser back with a code, and where the application sends it once signed + * in. Both come from the request, so both have to be pinned to this site — a + * Host header or a `redirect` parameter naming somewhere else would otherwise + * deliver a code, or a signed-in visitor, to whoever wrote it. + * + * The mobile hand-off mints a single-use code for whoever completed the + * provider sign-in in that browser. It must mint nothing for a sign-in that did + * not finish, for one this browser never started, for one already spent, or + * for an identity that has no account yet. + * + * The provider itself is not here: `req.oidc` and `res.oidc` are stood in for, + * with the signed-in subject taken from a test header. What the real library + * does with the addresses is covered in `middleware/oidc-provider-flow.test.js`. + */ + +const ISSUER = 'https://idp.example'; +const PUBLIC_URL = 'https://files.example.com'; + +let currentEnv; + +afterEach(async () => { + if (currentEnv) { + await currentEnv.cleanup(); + currentEnv = null; + } +}); + +/** + * @param {object} [options] + * @param {boolean} [options.providerReachable] whether the hand-off succeeds + * @param {boolean} [options.providerMounted] whether there is a hand-off at + * all: `res.oidc` is what `configureOidc` attaches, and an installation it + * declined to configure — or could not — has none. + */ +const build = async ({ providerReachable = true, providerMounted = true } = {}) => { + currentEnv = await setupTestEnv({ + tag: 'auth-oidc-routes-', + env: { + AUTH_ENABLED: 'true', + OIDC_ENABLED: 'true', + OIDC_ISSUER: ISSUER, + OIDC_CLIENT_ID: 'nextexplorer', + PUBLIC_URL, + }, + }); + const authRoutes = currentEnv.requireFresh('src/routes/auth'); + const errorHandlers = currentEnv.requireFresh('src/middleware/errorHandler'); + const bridge = require(modulePath('src/services/oidcMobileBridge')); + // The same instance the routes read: the configuration pass writes what it + // concluded here, and there is no configuration pass in this suite. + const availability = require(modulePath('src/utils/oidcAvailability')); + const db = await require(modulePath('src/services/db')).getDb(); + + const logins = []; + const app = express(); + app.use(express.json()); + app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false })); + app.use((req, res, next) => { + const sub = req.get('x-test-oidc-sub'); + req.oidc = { + isAuthenticated: () => Boolean(sub), + user: sub ? { sub, email: `${sub}@example.com`, email_verified: true } : undefined, + }; + if (providerMounted) { + res.oidc = { + login: async (options) => { + logins.push(options); + if (!providerReachable) { + throw new Error('getaddrinfo ENOTFOUND idp.internal.example'); + } + res.redirect(options.returnTo); + }, + }; + } + next(); + }); + app.use('/api/auth', authRoutes); + app.use(errorHandlers.notFoundHandler); + app.use(errorHandlers.errorHandler); + + return { app, bridge, db, logins, availability }; +}; + +/** An account linked to the provider subject `sub-1`. */ +const linkedAccount = (db) => { + 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', 'someone@example.com', 1, 'someone', 'Someone', '["user"]', ?, ?)` + ).run(now, now); + db.prepare( + `INSERT INTO auth_methods (id, user_id, method_type, provider_issuer, provider_sub, provider_name, created_at) + VALUES ('auth-1', 'user-1', 'oidc', ?, 'sub-1', 'OIDC', ?)` + ).run(ISSUER, now); + return 'user-1'; +}; + +const makePkce = () => { + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +}; + +describe('starting a sign-in at the provider', () => { + it.each([['https://evil.example/steal'], ['//evil.example/steal'], ['/\\evil.example/steal']])( + 'keeps the return address on this site when given %s', + async (redirect) => { + const { app, logins } = await build(); + + await request(app).get('/api/auth/oidc/login').query({ redirect }); + + expect(logins).toHaveLength(1); + expect(logins[0].returnTo).toBe('/browse/'); + } + ); + + it('keeps a return address that is on this site', async () => { + const { app, logins } = await build(); + + await request(app).get('/api/auth/oidc/login').query({ redirect: '/browse/Projects' }); + + expect(logins[0].returnTo).toBe('/browse/Projects'); + }); + + /** The code the provider hands back goes to this address. */ + it('tells the provider to come back to the configured address, whatever Host the request names', async () => { + const { app, logins } = await build(); + + await request(app).get('/api/auth/oidc/login').set('Host', 'evil.example'); + + expect(logins[0].authorizationParams.redirect_uri).toBe(`${PUBLIC_URL}/callback`); + }); + + /** + * An unreachable provider fails inside the library with the network's own + * words, which name the provider's internal host. + */ + it('answers a provider that cannot be reached with a refusal that names nothing of the network', async () => { + const { app } = await build({ providerReachable: false }); + + const response = await request(app).get('/api/auth/oidc/login'); + + expect(response.status).toBeGreaterThanOrEqual(400); + expect(response.headers.location).toBeUndefined(); + expect(JSON.stringify(response.body)).not.toMatch(/ENOTFOUND|idp\.internal/); + }); +}); + +/** + * Which of the two it is. + * + * Every refusal here used to be 404 "OIDC is not configured", including the one + * meant for a provider that is simply down — so the administrator was sent to + * change a configuration that was already right, and the reason the sign-in + * failed was never written anywhere. The two answers are told apart by their + * status and by a code, because the sign-in screen says what the code means in + * the reader's own language and a server sentence cannot. + */ +describe('a sign-in that cannot be started', () => { + const codeOf = (response) => response.body?.error?.code; + + it('says the configuration is missing when nothing was configured', async () => { + const { app } = await build({ providerMounted: false }); + + const response = await request(app).get('/api/auth/oidc/login'); + + expect(response.status).toBe(404); + expect(codeOf(response)).toBe('AUTH_OIDC_NOT_CONFIGURED'); + }); + + /** + * Settings that are all there and could not be made to work — a bad issuer + * URL, discovery that did not answer at startup. Nothing to change in the + * configuration, so nothing that should read as a missing one. + */ + it('says the provider is unavailable when the configuration was there and failed', async () => { + const { app, availability } = await build({ providerMounted: false }); + availability.recordOidcUnavailable('getaddrinfo ENOTFOUND idp.internal.example'); + + const response = await request(app).get('/api/auth/oidc/login'); + + expect(response.status).toBe(503); + expect(codeOf(response)).toBe('AUTH_OIDC_PROVIDER_UNAVAILABLE'); + expect(JSON.stringify(response.body)).not.toMatch(/ENOTFOUND|idp\.internal/); + }); + + it('says the same of a provider that was asked and did not answer', async () => { + const { app } = await build({ providerReachable: false }); + + const response = await request(app).get('/api/auth/oidc/login'); + + expect(response.status).toBe(503); + expect(codeOf(response)).toBe('AUTH_OIDC_PROVIDER_UNAVAILABLE'); + }); + + /** The native app gets the same two answers, for the same reason. */ + it('tells the mobile hand-off apart as well', async () => { + const { app } = await build({ providerMounted: false }); + const challenge = makePkce().challenge; + + const notConfigured = await request(app) + .get('/api/auth/oidc/mobile/login') + .query({ code_challenge: challenge }); + expect(notConfigured.status).toBe(404); + expect(codeOf(notConfigured)).toBe('AUTH_OIDC_NOT_CONFIGURED'); + }); + + /** + * A browser is sent here, not a script: it navigates, and a JSON body becomes + * a standalone error page it cannot read. It goes back to the sign-in screen + * with the code, which is where the difference between the two is finally + * shown to the person who can act on it. + */ + it('sends a browser back to the sign-in screen, carrying which of the two it was', async () => { + const { app } = await build({ providerReachable: false }); + + const response = await request(app) + .get('/api/auth/oidc/login') + .set('Accept', 'text/html,application/xhtml+xml'); + + expect(response.status).toBe(302); + const landing = new URL(response.headers.location, PUBLIC_URL); + expect(landing.pathname).toBe('/auth/login'); + expect(landing.searchParams.get('error_code')).toBe('AUTH_OIDC_PROVIDER_UNAVAILABLE'); + expect(landing.search).not.toMatch(/ENOTFOUND|idp\.internal/); + }); + + it('sends it back saying the configuration is missing when that is what it is', async () => { + const { app } = await build({ providerMounted: false }); + + const response = await request(app).get('/api/auth/oidc/login').set('Accept', 'text/html'); + + expect(response.status).toBe(302); + const landing = new URL(response.headers.location, PUBLIC_URL); + expect(landing.searchParams.get('error_code')).toBe('AUTH_OIDC_NOT_CONFIGURED'); + }); +}); + +describe('handing a sign-in back to the mobile app', () => { + const start = async (agent, challenge) => { + const response = await agent + .get('/api/auth/oidc/mobile/login') + .query({ code_challenge: challenge }); + expect(response.status).toBe(302); + }; + + /** Where the app is sent, as a URL, with the signed-in subject when there is one. */ + const complete = async (agent, sub) => { + const pending = agent.get('/api/auth/oidc/mobile/complete'); + const response = sub ? await pending.set('x-test-oidc-sub', sub) : await pending; + expect(response.status).toBe(302); + return new URL(response.headers.location); + }; + + it('gives the app a code for the signed-in account, which its verifier redeems', async () => { + const { app, db } = await build(); + const userId = linkedAccount(db); + const { verifier, challenge } = makePkce(); + const browser = request.agent(app); + await start(browser, challenge); + + const landing = await complete(browser, 'sub-1'); + + expect(`${landing.protocol}//${landing.host}`).toBe('nextexplorer://oidc-callback'); + const code = landing.searchParams.get('code'); + expect(code).toBeTruthy(); + const exchange = await request(app) + .post('/api/auth/oidc/exchange') + .send({ code, code_verifier: verifier }); + expect(exchange.status).toBe(200); + expect(exchange.body.user.id).toBe(userId); + }); + + it('gives no code when the provider sign-in did not finish', async () => { + const { app, db } = await build(); + linkedAccount(db); + const browser = request.agent(app); + await start(browser, makePkce().challenge); + + const landing = await complete(browser, null); + + expect(landing.searchParams.get('error')).toBe('auth_failed'); + expect(landing.searchParams.get('code')).toBeNull(); + }); + + it('gives no code when this browser never started a sign-in for the app', async () => { + const { app, db } = await build(); + linkedAccount(db); + + const landing = await complete(request.agent(app), 'sub-1'); + + expect(landing.searchParams.get('error')).toBe('auth_failed'); + expect(landing.searchParams.get('code')).toBeNull(); + }); + + /** A second visit to the same page would otherwise mint a second code. */ + it('spends the pending sign-in, so a second visit gets no second code', async () => { + const { app, db } = await build(); + linkedAccount(db); + const browser = request.agent(app); + await start(browser, makePkce().challenge); + expect((await complete(browser, 'sub-1')).searchParams.get('code')).toBeTruthy(); + + const again = await complete(browser, 'sub-1'); + + expect(again.searchParams.get('error')).toBe('auth_failed'); + expect(again.searchParams.get('code')).toBeNull(); + }); + + /** + * Until the provider sign-in has created a row, the account is a stand-in + * built from the claims, and a code bound to its made-up id would name + * nobody the exchange can find. + */ + it('gives no code for an identity that has no account yet', async () => { + const { app } = await build(); + const browser = request.agent(app); + await start(browser, makePkce().challenge); + + const landing = await complete(browser, 'sub-without-account'); + + expect(landing.searchParams.get('error')).toBe('no_profile'); + expect(landing.searchParams.get('code')).toBeNull(); + }); +}); + +describe('exchanging a code for a session', () => { + it('refuses a code whose account was deleted after it was issued, and leaves nobody signed in', async () => { + const { app, bridge, db } = await build(); + const userId = linkedAccount(db); + const { verifier, challenge } = makePkce(); + const code = bridge.issueCode({ userId, codeChallenge: challenge }); + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + const browser = request.agent(app); + + const response = await browser + .post('/api/auth/oidc/exchange') + .send({ code, code_verifier: verifier }); + + expect(response.status).toBe(401); + expect(response.body.error.message).toBe('User no longer exists.'); + expect((await browser.get('/api/auth/me')).body.user).toBeNull(); + }); +}); + +/** + * What the sign-in screen is told before anybody presses anything. + * + * A provider that cannot be reached, or settings that were never filled in, + * used to be discovered by pressing the button: the browser travelled to the + * provider, or to a hand-off that was not mounted, and came back here with the + * answer. The screen asks once, at load, and says the same two things without + * the round trip. + * + * The reason stays behind: it names settings and library messages, and this + * answer is given to anybody who can reach the server. + */ +describe('what /status says about single sign-on', () => { + it.each([ + ['ready', (availability) => availability.recordOidcReady()], + ['not-configured', (availability) => availability.recordOidcNotConfigured('OIDC_ISSUER')], + ['unavailable', (availability) => availability.recordOidcUnavailable('ENOTFOUND idp.example')], + ])('reports %s', async (expected, record) => { + const { app, availability } = await build(); + record(availability); + + const response = await request(app).get('/api/auth/status'); + + expect(response.status).toBe(200); + expect(response.body.oidc.status).toBe(expected); + }); + + it('keeps the reason to itself', async () => { + const { app, availability } = await build(); + availability.recordOidcUnavailable('client secret is required, and the issuer is idp.internal'); + + const response = await request(app).get('/api/auth/status'); + + expect(JSON.stringify(response.body)).not.toContain('idp.internal'); + expect(response.body.oidc).not.toHaveProperty('reason'); + }); +}); diff --git a/backend/tests/routes/oidcMobileBridge.test.js b/backend/tests/routes/oidcMobileBridge.test.js index 72084477f..714537ab4 100644 --- a/backend/tests/routes/oidcMobileBridge.test.js +++ b/backend/tests/routes/oidcMobileBridge.test.js @@ -175,6 +175,26 @@ describe('OIDC mobile bridge routes', () => { expect(sessionId(after)).not.toBe(before); }); + /** + * Signing in ends any guest session the same browser was carrying. The + * cookie was once set on /api and is now set on /, and a browser holding + * the older one keeps it unless both are cleared. + */ + it('clears a guest session on both the paths it may have been set on', async () => { + const { verifier, challenge } = makePkce(); + const code = bridge.issueCode({ userId: admin.id, codeChallenge: challenge }); + + const response = await request(app) + .post('/api/auth/oidc/exchange') + .send({ code, code_verifier: verifier }); + + const cleared = [] + .concat(response.headers['set-cookie'] || []) + .filter((c) => c.startsWith('guestSession=')); + expect(cleared.some((c) => /Path=\/(;|$)/.test(c))).toBe(true); + expect(cleared.some((c) => /Path=\/api/.test(c))).toBe(true); + }); + it('rejects an unknown code with 401', async () => { const res = await request(app) .post('/api/auth/oidc/exchange') diff --git a/backend/tests/routes/public-endpoints-hardening.test.js b/backend/tests/routes/public-endpoints-hardening.test.js index f109cdf30..dcb6ed5b9 100644 --- a/backend/tests/routes/public-endpoints-hardening.test.js +++ b/backend/tests/routes/public-endpoints-hardening.test.js @@ -147,7 +147,12 @@ describe('an upload', () => { .attach('filedata', Buffer.alloc(4096, 1), 'big.bin'); expect(response.status).toBe(413); - expect(response.body.error.message).toMatch(/larger than this server accepts/); + // The upload route's own sentence, which names the ceiling and the variable + // that raises it. It sets that sentence through `explainMultipartRefusals` and + // the error handler was discarding it, so this used to read the generic + // "larger than this server accepts" — green, for the wrong reason. + expect(response.body.error.message).toMatch(/larger than the .* a direct upload accepts/); + expect(response.body.error.message).toMatch(/MAX_DIRECT_UPLOAD_SIZE/); expect(await fs.readdir(path.join(envContext.volumeDir, 'Drop'))).toEqual([]); }); diff --git a/backend/tests/routes/settings-logo.test.js b/backend/tests/routes/settings-logo.test.js new file mode 100644 index 000000000..5f5918186 --- /dev/null +++ b/backend/tests/routes/settings-logo.test.js @@ -0,0 +1,384 @@ +import { describe, it, expect, afterEach, vi } 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 custom logo: a file an administrator uploads, written into the config + * directory and served to everyone, the sign-in page included. + * + * What is written is decided by the server, not by the upload: a name of its + * own for every logo, and one of the three image types the page can show, at + * no more than two megabytes. A name taken from the upload would let it choose + * where on disk it lands. + * + * A logo used to be written over the one in use, under a fixed name per type, + * as soon as it was chosen, so nothing could bring the old one back. The upload + * is now the save: the file is written under a new name, the settings are + * switched to it, and only then is the logo it replaced removed. A step that + * fails leaves the logo in use as it was and nothing of the new one behind, and + * nothing already in the directory is ever replaced. + * + * The admin gate on the upload route is pinned in `admin-guards.test.js`. + */ + +const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex'); +const OTHER_PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000002000000020806000000', 'hex'); +const OWN_NAME = /^logo-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/; +const FIXED_ID = '0b7f7c1e-3d44-4c55-9a8e-1f2a3b4c5d6e'; + +let currentEnv; + +afterEach(async () => { + vi.restoreAllMocks(); + if (currentEnv) { + await currentEnv.cleanup(); + currentEnv = null; + } +}); + +/** + * @param {object} [options] + * @param {() => void} [options.beforeRoutes] runs with fresh modules, before the + * routes load, so that a test can stand in for one step of the save + */ +const seed = async ({ beforeRoutes } = {}) => { + currentEnv = await setupTestEnv({ tag: 'settings-logo-' }); + const db = await currentEnv.requireFresh('src/services/db').getDb(); + beforeRoutes?.(); + + 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: 'admin-1', email: 'admin@example.com', roles: ['admin'] }; + next(); + }); + app.use('/api', routes); + app.use(errorHandler); + return { app, db }; +}; + +const logoDir = () => path.join(currentEnv.configDir, 'logos'); + +/** The files in the logo directory, hidden ones included, or none when it was never created. */ +const logoFiles = async () => { + try { + return (await fs.readdir(logoDir())).sort(); + } catch (error) { + if (error.code === 'ENOENT') return []; + throw error; + } +}; + +const exists = (file) => + fs.access(file).then( + () => true, + () => false + ); + +const upload = ( + app, + buffer, + { filename = 'logo.png', contentType = 'image/png', branding } = {} +) => { + let pending = request(app).post('/api/settings/upload-logo'); + if (branding !== undefined) { + pending = pending.field( + 'branding', + typeof branding === 'string' ? branding : JSON.stringify(branding) + ); + } + return pending.attach('logo', buffer, { filename, contentType }); +}; + +const storedLogo = async (app) => (await request(app).get('/api/branding')).body.appLogoUrl; + +/** Branding as an earlier version left it, written straight into the database. */ +const storeBranding = (db, branding) => + db + .prepare( + `INSERT INTO system_settings (id, category, key, value, updated_at) + VALUES ('legacy-branding', 'branding', 'branding', ?, ?)` + ) + .run(JSON.stringify(branding), new Date().toISOString()); + +describe('uploading a logo', () => { + it('writes it under a name of its own, whatever the upload was called, and makes it the logo', async () => { + const { app } = await seed(); + + const response = await upload(app, PNG, { filename: '../../escape.png' }); + + expect(response.status).toBe(200); + const files = await logoFiles(); + expect(files).toEqual([expect.stringMatching(OWN_NAME)]); + expect(response.body.logoUrl).toBe(`/static/logos/${files[0]}`); + expect(response.body.branding.appLogoUrl).toBe(response.body.logoUrl); + expect(await storedLogo(app)).toBe(response.body.logoUrl); + expect(await fs.readFile(path.join(logoDir(), files[0]))).toEqual(PNG); + expect(await exists(path.join(currentEnv.tmpRoot, 'escape.png'))).toBe(false); + }); + + it('saves the name and the footer link sent with it, in the same request', async () => { + const { app } = await seed(); + + const response = await upload(app, PNG, { + branding: { appName: 'Files', showPoweredBy: true }, + }); + + expect(response.status).toBe(200); + expect(response.body.branding).toEqual({ + appName: 'Files', + appLogoUrl: response.body.logoUrl, + showPoweredBy: true, + }); + }); + + it('keeps the stored name when the one sent with it is blank, as a patch does', async () => { + const { app } = await seed(); + await request(app) + .patch('/api/settings') + .send({ branding: { appName: 'Files' } }); + + const response = await upload(app, PNG, { branding: { appName: ' ' } }); + + expect(response.status).toBe(200); + expect(response.body.branding.appName).toBe('Files'); + }); + + it('refuses branding sent with it that is not JSON, writing nothing and changing nothing', async () => { + const { app } = await seed(); + + const response = await upload(app, PNG, { branding: '{not json' }); + + expect(response.status).toBe(400); + expect(response.body.error.message).toBe('The branding sent with the logo is not JSON.'); + expect(await logoFiles()).toEqual([]); + expect(await storedLogo(app)).toBe('/logo.svg'); + }); + + it('refuses a file that is not an SVG, PNG or JPEG, and writes nothing', async () => { + const { app } = await seed(); + + const response = await upload(app, Buffer.from(''), { + filename: 'logo.html', + contentType: 'text/html', + }); + + // 400, not the 500 a plain Error from the file filter used to become. + expect(response.status).toBe(400); + expect(response.body.error.message).toBe( + 'Invalid file type. Only SVG, PNG, and JPG are allowed.' + ); + expect(await logoFiles()).toEqual([]); + }); + + it('refuses a logo over two megabytes, and writes nothing', async () => { + const { app } = await seed(); + + const response = await upload(app, Buffer.alloc(2 * 1024 * 1024 + 1), { filename: 'huge.png' }); + + // 413 with the limit named, not multer's "File too large" as a 500. + expect(response.status).toBe(413); + expect(response.body.error.message).toBe('A logo can be at most 2 MB.'); + expect(await logoFiles()).toEqual([]); + }); + + it('refuses a logo sent in a field the route does not read, as a malformed request', async () => { + const { app } = await seed(); + + const response = await request(app) + .post('/api/settings/upload-logo') + .attach('image', PNG, { filename: 'logo.png', contentType: 'image/png' }); + + expect(response.status).toBe(400); + expect(response.body.error.message).toBe( + 'A file was sent in a field this request does not take.' + ); + expect(await logoFiles()).toEqual([]); + }); +}); + +describe('replacing a logo', () => { + it('puts a PNG chosen over a PNG at a new address, and removes the old one only once the new one is the logo', async () => { + let oldFileWhenSwitched = null; + const { app } = await seed({ + beforeRoutes: () => { + const settings = currentEnv.requireFresh('src/services/settingsService'); + const replaceBranding = settings.replaceBranding; + vi.spyOn(settings, 'replaceBranding').mockImplementation(async (update) => { + const before = (await settings.getPublicSettings()).branding.appLogoUrl; + if (before.startsWith('/static/logos/')) { + oldFileWhenSwitched = await exists(path.join(logoDir(), before.slice(14))); + } + return replaceBranding(update); + }); + }, + }); + const first = await upload(app, PNG); + const [firstName] = await logoFiles(); + + const second = await upload(app, OTHER_PNG); + + expect(second.status).toBe(200); + expect(second.body.logoUrl).not.toBe(first.body.logoUrl); + expect(oldFileWhenSwitched).toBe(true); + const files = await logoFiles(); + expect(files).toEqual([expect.stringMatching(OWN_NAME)]); + expect(files[0]).not.toBe(firstName); + expect(await fs.readFile(path.join(logoDir(), files[0]))).toEqual(OTHER_PNG); + expect(await storedLogo(app)).toBe(second.body.logoUrl); + }); + + it('removes what an earlier version wrote under its fixed names once a new logo is in place', async () => { + const { app, db } = await seed(); + await fs.mkdir(logoDir(), { recursive: true }); + await fs.writeFile(path.join(logoDir(), 'custom-logo.png'), PNG); + await fs.writeFile(path.join(logoDir(), 'custom-logo.svg'), ''); + storeBranding(db, { appName: 'Old', appLogoUrl: '/static/logos/custom-logo.png' }); + + const response = await upload(app, OTHER_PNG); + + expect(response.status).toBe(200); + expect(await logoFiles()).toEqual([expect.stringMatching(OWN_NAME)]); + expect(response.body.branding.appName).toBe('Old'); + }); + + it('takes the next name rather than replace a file already holding the one it chose', async () => { + const { app } = await seed(); + await fs.mkdir(logoDir(), { recursive: true }); + await fs.writeFile(path.join(logoDir(), `logo-${FIXED_ID}.png`), 'not ours'); + vi.spyOn(require('node:crypto'), 'randomUUID').mockReturnValueOnce(FIXED_ID); + + const response = await upload(app, PNG); + + expect(response.status).toBe(200); + expect(response.body.logoUrl).toBe(`/static/logos/logo-${FIXED_ID}%20(1).png`); + expect(await fs.readFile(path.join(logoDir(), `logo-${FIXED_ID}.png`), 'utf8')).toBe( + 'not ours' + ); + expect(await fs.readFile(path.join(logoDir(), `logo-${FIXED_ID} (1).png`))).toEqual(PNG); + + // Replaced in turn, the logo goes, and the file that was never the logo stays. + await upload(app, OTHER_PNG); + + const files = await logoFiles(); + expect(files).toContain(`logo-${FIXED_ID}.png`); + expect(files).not.toContain(`logo-${FIXED_ID} (1).png`); + }); +}); + +describe('a logo that cannot be put in place', () => { + it('leaves the logo in use, and removes the new file, when the settings cannot be switched to it', async () => { + let failing = false; + const { app } = await seed({ + beforeRoutes: () => { + const settings = currentEnv.requireFresh('src/services/settingsService'); + const replaceBranding = settings.replaceBranding; + vi.spyOn(settings, 'replaceBranding').mockImplementation(async (update) => { + if (failing) throw new Error('database is locked'); + return replaceBranding(update); + }); + }, + }); + const first = await upload(app, PNG); + const [firstName] = await logoFiles(); + failing = true; + + const second = await upload(app, OTHER_PNG, { branding: { appName: 'Renamed' } }); + + expect(second.status).toBe(500); + expect(await logoFiles()).toEqual([firstName]); + expect(await fs.readFile(path.join(logoDir(), firstName))).toEqual(PNG); + const branding = (await request(app).get('/api/branding')).body; + expect(branding.appLogoUrl).toBe(first.body.logoUrl); + expect(branding.appName).toBe('Explorer'); + }); + + it('leaves the logo in use, and nothing of the new one, when the file cannot take its name', async () => { + let failing = false; + const { app } = await seed({ + beforeRoutes: () => { + const placement = currentEnv.requireFresh('src/utils/placeWithoutOverwrite'); + const place = placement.placeWithoutOverwrite; + vi.spyOn(placement, 'placeWithoutOverwrite').mockImplementation(async (...args) => { + if (failing) throw Object.assign(new Error('input/output error'), { code: 'EIO' }); + return place(...args); + }); + }, + }); + const first = await upload(app, PNG); + const [firstName] = await logoFiles(); + failing = true; + + const second = await upload(app, OTHER_PNG); + + expect(second.status).toBe(500); + // The hidden file it was being written under is gone too. + expect(await logoFiles()).toEqual([firstName]); + expect(await storedLogo(app)).toBe(first.body.logoUrl); + }); +}); + +describe('the logo files when branding is saved', () => { + it.each([['/logo.svg'], ['']])( + 'are removed when the logo is reset to %j, with what earlier versions left', + async (appLogoUrl) => { + const { app } = await seed(); + await upload(app, PNG); + await fs.writeFile(path.join(logoDir(), 'custom-logo.svg'), ''); + + const response = await request(app).patch('/api/settings').send({ branding: { appLogoUrl } }); + + expect(response.status).toBe(200); + expect(await logoFiles()).toEqual([]); + } + ); + + it('are kept when branding changes something other than the logo', async () => { + const { app } = await seed(); + await upload(app, PNG); + const before = await logoFiles(); + + const response = await request(app) + .patch('/api/settings') + .send({ branding: { appName: 'Renamed' } }); + + expect(response.status).toBe(200); + expect(await logoFiles()).toEqual(before); + }); + + it('keep serving a logo an earlier version stored under a fixed name, with nothing to migrate', async () => { + const { app, db } = await seed(); + await fs.mkdir(logoDir(), { recursive: true }); + await fs.writeFile(path.join(logoDir(), 'custom-logo.png'), PNG); + storeBranding(db, { appName: 'Old', appLogoUrl: '/static/logos/custom-logo.png' }); + + await request(app) + .patch('/api/settings') + .send({ branding: { appName: 'Renamed' } }); + + expect(await storedLogo(app)).toBe('/static/logos/custom-logo.png'); + expect(await logoFiles()).toEqual(['custom-logo.png']); + }); + + it.each([['/static/logos/notes.txt'], ['/static/logos/../app.db']])( + 'never remove a file the application did not write as a logo, even when pointed at as %j', + async (appLogoUrl) => { + const { app } = await seed(); + await fs.mkdir(logoDir(), { recursive: true }); + await fs.writeFile(path.join(logoDir(), 'notes.txt'), 'someone else’s'); + await request(app).patch('/api/settings').send({ branding: { appLogoUrl } }); + + await request(app) + .patch('/api/settings') + .send({ branding: { appLogoUrl: '/logo.svg' } }); + + expect(await logoFiles()).toEqual(['notes.txt']); + expect(await exists(path.join(currentEnv.configDir, 'app.db'))).toBe(true); + } + ); +}); diff --git a/backend/tests/services/branding-logo-sweep.test.js b/backend/tests/services/branding-logo-sweep.test.js new file mode 100644 index 000000000..b244b0f17 --- /dev/null +++ b/backend/tests/services/branding-logo-sweep.test.js @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * The logos nothing points at any more. + * + * A branding change writes the new logo, places it under a name of its own, + * makes it the logo in the settings, and only then removes the one it + * replaced. A stop in the middle of that, or a removal that fails, leaves a + * file no setting names — and a logo's address is the name of its file, so + * nothing can ask for it again. At up to 2 MB each they stayed for good. + * + * The sweep runs at start, where nothing of ours is being placed. What it may + * remove is decided by the names this application writes, never by the + * listing: `/config/logos` is a directory on somebody's disk, and what else + * they keep there is theirs. + */ + +const A_LOGO = 'logo-0b7f7c1e-3d44-4c55-9a8e-1f2a3b4c5d6e.png'; +const ANOTHER_LOGO = 'logo-2c9a4d55-1b22-4e33-8f44-5a6b7c8d9e0f.svg'; +const A_PLACED_ALONGSIDE = 'logo-3d0b5e66-2c33-4f44-9055-6b7c8d9e0f11 (1).jpg'; + +let env; + +afterEach(async () => { + if (env) await env.cleanup(); + env = null; +}); + +const logoDir = () => path.join(env.configDir, 'logos'); + +const seed = async (names, branding) => { + env = await setupTestEnv({ tag: 'logo-sweep-' }); + const db = await env.requireFresh('src/services/db').getDb(); + if (branding !== undefined) { + db.prepare( + `INSERT INTO system_settings (id, category, key, value, updated_at) + VALUES ('branding', 'branding', 'branding', ?, ?)` + ).run(JSON.stringify(branding), new Date().toISOString()); + } + await fs.mkdir(logoDir(), { recursive: true }); + for (const name of names) { + await fs.writeFile(path.join(logoDir(), name), name); + } + return env.requireFresh('src/services/brandingLogo'); +}; + +const remaining = async () => (await fs.readdir(logoDir())).sort(); + +describe('sweeping the logos no longer in use', () => { + it('removes the ones this application wrote, and keeps the one in use', async () => { + const service = await seed([A_LOGO, ANOTHER_LOGO, A_PLACED_ALONGSIDE], { + appName: 'Explorer', + appLogoUrl: `/static/logos/${A_LOGO}`, + showPoweredBy: false, + }); + + await service.sweepUnreferencedLogos(); + + expect(await remaining()).toEqual([A_LOGO]); + }); + + it('removes the fixed names earlier versions wrote, unless one is still the logo', async () => { + const service = await seed(['custom-logo.svg', 'custom-logo.png', 'custom-logo.jpg', A_LOGO], { + appName: 'Explorer', + appLogoUrl: '/static/logos/custom-logo.png', + showPoweredBy: false, + }); + + await service.sweepUnreferencedLogos(); + + expect(await remaining()).toEqual(['custom-logo.png']); + }); + + it('removes every one of them when the branding is back to the default logo', async () => { + const service = await seed([A_LOGO, 'custom-logo.jpg'], { + appName: 'Explorer', + appLogoUrl: '/logo.svg', + showPoweredBy: false, + }); + + await service.sweepUnreferencedLogos(); + + expect(await remaining()).toEqual([]); + }); + + /** + * The names are the whole guard. Anything else in that directory was put + * there by whoever owns the disk — including a file that merely looks like + * one of ours, which is why the shape is matched exactly rather than by + * prefix. + */ + it('never touches a file somebody else put there', async () => { + const theirs = [ + 'branding-guidelines.pdf', + 'logo.png', + 'logo-old.png', + 'logo-0b7f7c1e-3d44-4c55-9a8e-1f2a3b4c5d6e.gif', + 'custom-logo.webp', + 'my-custom-logo.png', + `${A_LOGO}.bak`, + ]; + const service = await seed([...theirs, ANOTHER_LOGO], { + appName: 'Explorer', + appLogoUrl: '/logo.svg', + showPoweredBy: false, + }); + + await service.sweepUnreferencedLogos(); + + expect(await remaining()).toEqual([...theirs].sort()); + }); + + /** + * A write interrupted before the file took its name. + * + * The bytes go to a hidden `.part` first, and the write removes it whether + * it succeeded or not — unless the process does not get to: a stop, a full + * disk, a crash. Hidden, so nothing lists it; up to 2 MB, so it is worth + * removing; and at start nothing of ours is being placed, which is what + * makes one of these safe to take. + */ + it('removes what an interrupted write left behind', async () => { + const partial = '.logo-7e8f9a01-4b55-4c66-8d77-9e0f1a2b3c4d.part'; + const service = await seed([partial, A_LOGO], { + appName: 'Explorer', + appLogoUrl: `/static/logos/${A_LOGO}`, + showPoweredBy: false, + }); + + await service.sweepUnreferencedLogos(); + + expect(await remaining()).toEqual([A_LOGO]); + }); + + /** The shape is matched exactly here too: a `.part` of somebody else's stays. */ + it('leaves a part file that is not one of ours', async () => { + const theirs = ['.logo-not-a-uuid.part', '.logo-7e8f9a01-4b55-4c66-8d77-9e0f1a2b3c4d.part.bak']; + const service = await seed(theirs, { appName: 'Explorer', appLogoUrl: '/logo.svg' }); + + await service.sweepUnreferencedLogos(); + + expect(await remaining()).toEqual([...theirs].sort()); + }); + + it('leaves a directory alone, whatever it is called', async () => { + const service = await seed([], { appName: 'Explorer', appLogoUrl: '/logo.svg' }); + await fs.mkdir(path.join(logoDir(), A_LOGO)); + + await service.sweepUnreferencedLogos(); + + expect(await remaining()).toEqual([A_LOGO]); + }); + + it('passes quietly when no logo was ever uploaded', async () => { + env = await setupTestEnv({ tag: 'logo-sweep-' }); + await env.requireFresh('src/services/db').getDb(); + const service = env.requireFresh('src/services/brandingLogo'); + + await expect(service.sweepUnreferencedLogos()).resolves.toBeUndefined(); + await expect(fs.access(logoDir())).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); diff --git a/backend/tests/services/oidc-role-sync.test.js b/backend/tests/services/oidc-role-sync.test.js index a45b655b6..cd0945cbb 100644 --- a/backend/tests/services/oidc-role-sync.test.js +++ b/backend/tests/services/oidc-role-sync.test.js @@ -122,3 +122,64 @@ describe('roles of a returning OIDC user', () => { expect(rolesOf(db)).toEqual(['admin']); }); }); + +/** + * The regression this fix must not cause. + * + * On an instance with OIDC but no OIDC_ADMIN_GROUPS, every login derives the + * plain `user` role. If that were applied, the administrator promoted from + * Settings → Users — or created by AUTH_ADMIN_EMAIL at bootstrap — would lose + * the role at their next sign-in, and nobody would be able to administer + * anything. These follow the middleware's own sequence: work out whether the + * provider is authoritative, then sign in with that answer. + */ +describe('an instance with no admin group configured', () => { + const signInAsMiddlewareWould = async (users, claims, adminGroups, currentRoles) => { + const authoritative = users.rolesFromClaimsAreAuthoritative(claims, adminGroups); + const roles = users.deriveRolesFromClaims(claims, adminGroups); + await signIn(users, { roles, rolesAreAuthoritative: authoritative }); + return currentRoles; + }; + + it('keeps an administrator promoted from the interface', async () => { + const { users, db } = await build(); + seedOidcUser(db, { roles: ['admin'] }); + + // No OIDC_ADMIN_GROUPS, and the provider reports groups the app knows + // nothing about — the everyday case. + await signInAsMiddlewareWould(users, { groups: ['staff', 'everyone'] }, null); + + expect(rolesOf(db)).toEqual(['admin']); + }); + + it('keeps it across repeated sign-ins', async () => { + const { users, db } = await build(); + seedOidcUser(db, { roles: ['admin'] }); + + for (let i = 0; i < 3; i += 1) { + await signInAsMiddlewareWould(users, { groups: ['staff'] }, []); + } + + expect(rolesOf(db)).toEqual(['admin']); + }); + + it('keeps it when the provider returns no group claim at all', async () => { + const { users, db } = await build(); + seedOidcUser(db, { roles: ['admin'] }); + + await signInAsMiddlewareWould(users, { email: 'someone@example.com' }, null); + + expect(rolesOf(db)).toEqual(['admin']); + }); + + // The same protection has to hold where a group IS configured but the + // provider stayed silent about groups — a missing scope, not a demotion. + it('keeps it when a group is configured but the claim is missing', async () => { + const { users, db } = await build(); + seedOidcUser(db, { roles: ['admin'] }); + + await signInAsMiddlewareWould(users, { email: 'someone@example.com' }, ['admins']); + + expect(rolesOf(db)).toEqual(['admin']); + }); +}); diff --git a/backend/tests/services/oidcMobileBridge.test.js b/backend/tests/services/oidcMobileBridge.test.js index 0d1c0b341..6cd690074 100644 --- a/backend/tests/services/oidcMobileBridge.test.js +++ b/backend/tests/services/oidcMobileBridge.test.js @@ -71,13 +71,21 @@ describe('oidcMobileBridge', () => { expect(bridge.redeemCode({ code, codeVerifier: verifier })).toBeNull(); }); + /** + * A wrong guess destroys the code, so there is no second guess against a + * live one — which is the whole of what stands between a sixty-second code + * and an online brute force. The retry below uses the *correct* verifier: + * retrying with another wrong one would fail whether the code had been + * burned or not, and prove nothing. + */ it('rejects a wrong verifier and burns the code', () => { - const { challenge } = makePkce(); + const { verifier, challenge } = makePkce(); const wrong = makePkce().verifier; const code = bridge.issueCode({ userId: 'u1', codeChallenge: challenge }); + expect(bridge.redeemCode({ code, codeVerifier: wrong })).toBeNull(); - // even the correct verifier now fails, the code is gone - expect(bridge.redeemCode({ code, codeVerifier: challenge })).toBeNull(); + + expect(bridge.redeemCode({ code, codeVerifier: verifier })).toBeNull(); }); it('rejects an unknown code', () => { diff --git a/backend/tests/utils/process-failures.test.js b/backend/tests/utils/process-failures.test.js new file mode 100644 index 000000000..bc698f930 --- /dev/null +++ b/backend/tests/utils/process-failures.test.js @@ -0,0 +1,156 @@ +import { EventEmitter } from 'node:events'; +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { describe, expect, it, vi } from 'vitest'; + +/** + * What a failure nobody caught costs the server. + * + * Node stops the process for a rejected promise with no listener, so one + * forgotten `await` answers a single bad request by ending the server for + * everybody. The first test here is a real Node process rather than an injected + * emitter, because that default is the thing being changed and nothing short of + * a process shows it. + * + * The rest use an injected emitter and an injected `exit`: an uncaught exception + * must still stop the server — after the shutdown has had a bounded chance to + * run — and a test that really exited would take the runner with it. + */ + +const require = createRequire(import.meta.url); +const MODULE = path.join(__dirname, '..', '..', 'src', 'utils', 'processFailures.js'); +const { installProcessFailureHandlers } = require(MODULE); + +/** Run a snippet in its own Node process and report how it ended. */ +const run = (source) => + new Promise((resolve) => { + execFile(process.execPath, ['-e', source], { timeout: 10_000 }, (error, stdout, stderr) => { + resolve({ code: error?.code ?? 0, stdout, stderr }); + }); + }); + +// A rejection nobody listens to, then a line printed once the queue has drained. +const STRAY_REJECTION = ` + Promise.reject(new Error('nobody awaited this')); + setTimeout(() => { console.log('still here'); }, 50); +`; + +describe('a promise rejected with nobody listening', () => { + it('ends a Node process that has not installed the handlers', async () => { + const { code, stdout } = await run(STRAY_REJECTION); + + // Node's default, and the behaviour being changed. + expect(code).toBe(1); + expect(stdout).not.toContain('still here'); + }); + + it('leaves the server running once they are installed', async () => { + const { code, stdout, stderr } = await run(` + require(${JSON.stringify(MODULE)}).installProcessFailureHandlers(); + ${STRAY_REJECTION} + `); + + expect(code).toBe(0); + expect(stdout).toContain('still here'); + // And it is not silent about it. + expect(`${stdout}${stderr}`).toContain('nobody awaited this'); + }); + + it('reports what was rejected with, even when it was not an Error', () => { + const target = new EventEmitter(); + const log = { error: vi.fn() }; + const remove = installProcessFailureHandlers({ target, log, exit: vi.fn() }); + + target.emit('unhandledRejection', 'a bare string'); + + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error.mock.calls[0][0].err).toBeInstanceOf(Error); + expect(log.error.mock.calls[0][0].err.message).toBe('a bare string'); + remove(); + }); +}); + +describe('an uncaught exception', () => { + const uncaught = (overrides = {}) => { + const target = new EventEmitter(); + const log = { error: vi.fn() }; + const exit = vi.fn(); + const remove = installProcessFailureHandlers({ target, log, exit, ...overrides }); + return { target, log, exit, remove }; + }; + + it('shuts the server down, in that order', async () => { + const order = []; + const onFatal = vi.fn(() => { + order.push('shutdown'); + }); + const { target, exit, log, remove } = uncaught({ onFatal }); + exit.mockImplementation(() => order.push('exit')); + + target.emit('uncaughtException', new Error('the stack unwound')); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + + // What is in memory cannot be trusted, so it goes — but the shutdown is + // given its chance first, or the server would leave its locks behind. + expect(order).toEqual(['shutdown', 'exit']); + expect(log.error).toHaveBeenCalled(); + remove(); + }); + + it('stops even when the shutdown never finishes', async () => { + const { target, exit, remove } = uncaught({ + onFatal: () => new Promise(() => {}), + shutdownTimeoutMs: 20, + }); + + target.emit('uncaughtException', new Error('and the shutdown hangs')); + + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + remove(); + }); + + it('stops even when the shutdown fails too, and says so', async () => { + const { target, exit, log, remove } = uncaught({ + onFatal: () => Promise.reject(new Error('the store was already closed')), + }); + + target.emit('uncaughtException', new Error('the stack unwound')); + + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + // Through the error itself: JSON.stringify flattens an Error to {} and + // would have passed whatever the shutdown reported. + const reported = log.error.mock.calls.map( + ([context, message]) => `${message} ${context?.err?.message ?? ''}` + ); + expect(reported.some((line) => line.includes('already closed'))).toBe(true); + remove(); + }); + + it('exits once, not once per path that could end it', async () => { + const { target, exit, remove } = uncaught({ onFatal: () => {}, shutdownTimeoutMs: 5 }); + + target.emit('uncaughtException', new Error('the stack unwound')); + await vi.waitFor(() => expect(exit).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(exit).toHaveBeenCalledTimes(1); + remove(); + }); +}); + +describe('the handlers', () => { + it('can be taken off again, leaving the process as it was', () => { + const target = new EventEmitter(); + const log = { error: vi.fn() }; + + const remove = installProcessFailureHandlers({ target, log, exit: vi.fn() }); + expect(target.listenerCount('unhandledRejection')).toBe(1); + expect(target.listenerCount('uncaughtException')).toBe(1); + + remove(); + + expect(target.listenerCount('unhandledRejection')).toBe(0); + expect(target.listenerCount('uncaughtException')).toBe(0); + }); +}); diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 6cae79bea..baab11daa 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -42,6 +42,8 @@ export default defineConfig({ }, { text: 'Admin & Access', link: '/admin/guide' }, { text: 'User volumes', link: '/admin/user-volumes' }, + { text: 'Trash', link: '/admin/trash' }, + { text: 'File versions', link: '/admin/versions' }, { text: 'OIDC', link: '/integrations/oidc' }, { text: 'Authelia', link: '/integrations/authelia' }, { text: 'ONLYOFFICE', link: '/integrations/onlyoffice' }, @@ -110,6 +112,8 @@ export default defineConfig({ items: [ { text: 'Administrator Guide', link: '/admin/guide' }, { text: 'User volumes', link: '/admin/user-volumes' }, + { text: 'Trash', link: '/admin/trash' }, + { text: 'File versions', link: '/admin/versions' }, ], }, { diff --git a/docs/admin/guide.md b/docs/admin/guide.md index 980766021..f193f7573 100644 --- a/docs/admin/guide.md +++ b/docs/admin/guide.md @@ -11,8 +11,38 @@ Administrators control users, folders, and security policies through Settings. T ## User management - Navigate to **Settings → Admin → Users** to add local users, assign roles, and reset passwords. +- Resetting a password signs that account out of every session it has open, on + every device — the sessions opened with a password and the ones opened through + the identity provider alike. Someone changing their own password from + **Settings → Password** is signed out everywhere except where they made the + change. A changed `AUTH_ADMIN_PASSWORD` does the same to the administrator at + the next start; the same value set again at each restart signs nobody out. +- One thing a password change does **not** end: the tokens already handed to + ONLYOFFICE and Collabora for documents open at that moment. Those are signed + rather than stored, so there is nothing on the server to withdraw. Each + reaches the one file it was minted for, with the rights it was minted with, + and expires on its own — within 12 hours for ONLYOFFICE, 6 for Collabora. + Reopening the document asks for a new one, which the new password governs. To + end them sooner, change `ONLYOFFICE_SECRET` or `COLLABORA_SECRET` and restart: + every token signed with the old value stops working at once, for everyone. - When `USER_VOLUMES=true`, each user profile includes a **Volumes** tab for assigning per-user volumes. See [User volumes](/admin/user-volumes). - Local users store credentials in the SQLite database inside your `/config` mount. +- People sign in with **either their email address or their username**, in the + same box. Case does not matter for either. +- A username has to name one account to be usable for signing in. NextExplorer + refuses a new account, or a rename, that would take a username another account + already has — but an installation upgraded from an older version may already + hold duplicates, because the username is derived from the local part of the + address (`alice@example.com` and `alice@other.org` both become `alice`). Where + a username answers for more than one account it signs nobody in, and those + accounts use their email address instead. Give one of them a different + username in **Settings → Admin → Users** to free it. +- **Two-factor authentication** is each person's to turn on, in **Settings → Two-factor**: a QR code for any authenticator app, one code to prove the phone kept the secret, and ten recovery codes shown once. From then on that account's sign-in asks for a code after the password. The user list says who has it on. When somebody loses both the phone and the codes, an administrator takes it off for them — the same person who could already reset that account's password — and the account signs in with its password alone until it is set up again. With OIDC the second factor belongs to the provider, and this is for local passwords only. +- **Passkeys** are each person's to add, in **Settings → Passkeys**: a fingerprint, a face or a device PIN instead of a password. The device keeps the key and never hands it over, and what it signs names this site — so a passkey cannot be typed into a page pretending to be this one, read over a shoulder, or replayed anywhere else. A passkey that was unlocked to be used is already two things (the device, and whoever can unlock it), so it answers an account's second factor on its own; one used without unlocking still asks for the code. Passkeys are for local accounts, like the second factor; with `AUTH_MODE=oidc` they are not offered. + - **The browser decides where this can work.** A passkey is bound to a hostname, and browsers refuse to make one on a page that is not secure or on an address that is not a name: over plain `http`, or at `https://192.168.1.10`, the page says so instead of offering a button. Reach the server over https, or through `localhost`, to add one. + - **An installation reached through two hostnames has to pick one.** A passkey made on `files.example.com` is refused on `files.lan`, by design. `PUBLIC_URL` settles it where it is set; `WEBAUTHN_RP_ID` settles it explicitly. Change either one and the passkeys already made stop working — they belong to the name they were made on. + - **The last way in is protected.** An account with no password is refused the removal of its last passkey, and removing any passkey asks for the password when the account has one. + - **When the device that held the passkey is gone**, an administrator takes every passkey off that account — the same deliberate act, by the same person, as taking somebody's second factor off. What is left is an account that signs in with its password and adds a passkey again from whatever device is in front of it. - Promote trusted accounts to admin inside the UI—note that demotions are blocked when it would remove the last admin. - When OIDC is enabled, users are created automatically on first login (unless `OIDC_AUTO_CREATE_USERS=false`) and elevated to admin if their `groups`, `roles`, or `entitlements` claims match any of the names inside `OIDC_ADMIN_GROUPS`. @@ -20,9 +50,10 @@ Administrators control users, folders, and security policies through Settings. T - **Settings → Access Control** lets you define rules with the following types: - `rw` – Read/write access (default). Applies when no rule matches. - - `ro` – Read-only access; uploads and edits are disabled. - - `hidden` – Keeps the volume/folder out of listings; only accessible via direct path. -- Rules use the logical root (e.g., `Projects/Team`) and evaluate in defined order, so place more specific rules above general ones. + - `ro` – Read-only access; uploads and edits are disabled for every account except administrators. + - `hidden` – Keeps the volume/folder out of listings and search, and refuses it by its address too — for everyone, administrators included. +- Rules use the logical root (e.g., `Projects/Team`), the path NextExplorer shows with the volume first — not the path of the mount on the host or in the container. The folder button beside the field chooses it for you, and a path that names no folder is flagged with the one probably meant. +- Rules evaluate in defined order, so place more specific rules above general ones. - Recursive rules apply to subfolders when the recursion checkbox is enabled. ## Sharing & guest access @@ -34,13 +65,24 @@ Administrators control users, folders, and security policies through Settings. T ## Security & logging - Authentication can be fully disabled for trusted networks via **Settings → Security**, but enabling protects all API routes. -- Session persistence uses `SESSION_SECRET`; set this environment variable to ensure sessions persist across container restarts and multi-node deployments. +- Sessions survive restarts: without `SESSION_SECRET`, the secret generated at the first start is kept in `/config/session-secret`. Set `SESSION_SECRET` (or `SESSION_SECRET_FILE`) to choose it, or for several replicas. - **Persistent sessions:** By default, users stay logged in for 30 days even after closing their browser. Configure `SESSION_MAX_AGE_DAYS` to adjust this duration (e.g., `7` for weekly re-authentication, `90` for extended sessions). +- **Two-factor secrets are kept unreadable in `app.db`**, under a key drawn into `/config/totp-key` at the first use. A copy of the database on its own — a backup, a support ticket — is not a set of working authenticators. Losing that key costs authenticators, not accounts: recovery codes are hashed and go on working, and whoever uses one signs in and sets their phone up again. +- **The activity log is off unless you ask for it**, in **Settings → Activity log**. On, it writes down sign-ins (including the ones that were refused, and the name that was tried) and sign-outs; files downloaded, uploaded, sent to the trash, restored and removed for good; what left through a share link — and what arrived through one — and from which link; shares created and deleted; account changes, whether somebody's own (password, second factor, passkey) or an administrator's (an account created, renamed, given roles, or removed); and which settings were changed, including this switch itself. Each line carries who, what, when, the address it came from, and whether it worked. Behind a reverse proxy — or in a container reached from its own host — that address is only the person's if `TRUST_PROXY` says the proxy may be believed; see [the reverse proxy guide](/installation/reverse-proxy). Only administrators can read it. + - **Nothing before it was switched on is in it.** The log is a record kept from the moment it is asked for, not a history reconstructed afterwards. + - Lines are kept for the retention set beside the switch (90 days by default, `ACTIVITY_RETENTION_DAYS` to start elsewhere) and swept hourly, whether the log is on or off — switching it off lets the disk go back rather than freezing yesterday's rows. **Empty the log** removes everything at once — and leaves one line saying who emptied it, when, and how many rows went, because a record that can be taken away without a trace is worth less than the rows it lost. + - It lives in `app.db`, so it is in the same backup as everything else. It has no foreign key to the accounts: what somebody did while their account existed is exactly what a log is for, and deleting the account does not take it away. + - Nothing here can fail a request. A line that cannot be written is reported in the server's own log and the download, the sign-in or the deletion carries on. - Http logging toggles (`ENABLE_HTTP_LOGGING`, `LOG_LEVEL`, `DEBUG`) help surface suspicious activity; send container logs to a centralized system for audits. ## Backups & persistence -- `/config` houses `app.db`, `app-config.json`, and extension packages. Back these files up before upgrades or migrations. -- `/cache` contains generated thumbnails and search indexes that can be deleted if needed; the app recreates them as you browse. -- An upload, a copy, an extraction or a compression stopped half-way by a restart leaves a hidden temporary file, folder or archive in the volume: what is still being written never sits under the name it is meant to take. Each is recorded in `/cache/in-flight` while it runs, and the next start removes what an interrupted one left, and only that. A `/cache` cleared in between loses the record, and the leftover stays for you to delete. -- When upgrading, run `docker compose pull` followed by `docker compose up -d`; the entrypoint preserves `CONFIG_DIR` while migrating legacy `/cache` configs. +- `/config` houses `app.db` — accounts, shares, settings, and the records of the trash and file versions — `logos/`, the logo uploaded in Branding, `session-secret`, the secret sessions are signed with when `SESSION_SECRET` is not set — a `/config` restored without it signs everyone out once — and `totp-key`, which is what makes the two-factor secrets in `app.db` readable. Back it up before upgrades. Copy `app.db` with the container stopped, or together with `app.db-wal`: a copy of `app.db` alone can miss what was written last. +- Back up `app.db` and the volumes together. The trash and file versions keep their content in each volume’s `.nextexplorer` folder and their records in `app.db`; one restored without the other leaves items that cannot be restored, or content nothing lists. +- `app-config.json` in `/config` held settings and favorites in early releases, and is only read when `app.db` is created, to carry them into it. Settings are read from `app.db` alone: a read that fails is refused rather than answered from that file, and a current installation keeps nothing in it. +- `app.db` gives back the space its deletions free. SQLite keeps freed pages inside the file, so a large deletion used to leave `app.db` — and every backup of it — at its largest size. An hourly pass now hands free space back once more than 16 MB of it has built up, and the write-ahead log is cut back to 64 MB after a checkpoint. A database created by an earlier release is rewritten once, at the first start, to make this possible; the log says how large it was before and after. The same pass covers `index.db` and `sessions.db` in `/cache`. +- Records nobody needs any more are purged too: ONLYOFFICE document keys past their expiry, every hour, and each trash zone’s events beyond its newest thousand. +- `/cache` holds what can be made again: thumbnails and RAW previews — each kept within its limit, with what a crash left of them removed — sessions, and `index.db` — the search index and the folder sizes. Nothing in it needs a backup. Deleting it signs everyone out and costs a pass over the volumes to rebuild the indexes, so keep it on a persistent mount: without one, every new container reads the volumes again. +- A save, an ONLYOFFICE download, an extraction, a compression, or a copy or move across disks stopped half-way by a restart leaves a hidden temporary file, extraction folder or archive in the volume: what is still being written never sits under the name it is meant to take. Each is recorded in `/cache/in-flight` while it runs, and the next start removes what an interrupted one left, and only that. A `/cache` cleared in between loses the record, and the leftover stays for you to delete. +- An installation upgraded from 3.6.0 or earlier has its indexes moved out of `app.db` into `/cache/index.db` at the first start, as they are — the volumes are not read again for it — and `app.db` is rewritten without them. +- When upgrading, run `docker compose pull` followed by `docker compose up -d`. An installation that started on 1.1.7 or earlier kept `app.db` in `/cache`; nothing moves it to `/config` any more, so copy it there by hand first; the server warns at start when it finds such a file there. Links named `app.db`, `app-config.json` or `extensions` left in `/cache` by 1.1.8 to 2.0.2 are unused and can be deleted. diff --git a/docs/admin/trash.md b/docs/admin/trash.md new file mode 100644 index 000000000..c4cbf6394 --- /dev/null +++ b/docs/admin/trash.md @@ -0,0 +1,89 @@ +# Trash + +Deleting a file or folder moves it to the trash instead of removing it. It stays there for a retention period (30 days by default), can be restored from the **Trash** page in the sidebar, and is then removed for good. + +## How it works + +- **No copy, ever.** Each volume keeps its trash in a hidden `.nextexplorer` folder at its root. Deleting is a rename on the same disk: a 40 GB folder goes to the trash as fast as a small file, and needs no free space to do so. Personal folders and volumes assigned to users outside `VOLUME_ROOT` have their own `.nextexplorer` folder the same way. +- **Nobody browses the zone.** The `.nextexplorer` folder never appears in listings or search, whatever the hidden-file settings say, and no path through it can be opened, downloaded or shared. Nothing can be named `.nextexplorer`. +- **Shares go at once.** A share pointing at a deleted item is removed when the item goes to the trash, as before: nothing in the trash stays public. Restoring does not bring shares back. +- **Crash-safe.** Every operation writes what it is about to do before it touches the disk. After a crash or a power cut, the next start finishes or undoes whatever was interrupted. Content found in a zone without a record — a database restored from an older backup — is adopted rather than deleted; each item carries a small description beside it for that purpose. + +## Who sees what + +- Each person sees what they deleted, and what came from their own personal folder or from shares they own. +- Administrators see everything. +- Share visitors have no trash: what they delete through a share link goes to the share owner's trash. +- Restoring puts an item back where it was. A parent folder that no longer exists is recreated; a name that is now taken gets a suffix, like a copy. Someone who has lost write access to the original location since the deletion cannot restore into it — an administrator can. + +## Acting on an item + +Right-click an item on the **Trash** page — or hold it on a touch screen, or press the menu key on its checkbox — for what can be done with it: open a deleted folder, preview a file, restore it where it was, open its original location, or delete it for good. With several items selected, the menu acts on all of them. A double click opens a deleted folder, or previews a file. + +**Preview** shows a text file — plain text, Markdown, scripts, code: the extensions the editor opens — in the editor, **read only**: nothing can be typed, there is no Save, and Close goes back to the trash. The file is read with the editor's limits, so a file that is too large, or not text, is not shown. Nothing in the trash can be changed this way. + +## Restoring part of a deleted folder + +A deleted folder is one item in the trash, however much it holds. Click its name on the **Trash** page to open it, go further in if needed, select what you want back, and **Restore**: each selected file or folder goes back to its own place inside the original folder, and the rest stays in the trash. **Restore whole folder** puts back everything that is left. + +- Nothing extra is recorded for what is inside a folder. The folder's own record gives its original path, and an entry at `drafts/v2.txt` inside it goes back to `/drafts/v2.txt`. +- The original folder and the folders on the way are recreated if they are gone. What exists there now is never replaced: a name that is taken gets a suffix, as for a whole item. +- Whoever may restore the folder may restore what is inside it, under the same conditions. +- A symbolic link inside a deleted folder is listed and restored as the link it is; nothing is ever opened through it. A restore is refused when the place it would go back to now leads outside the volume through a link. + +## Share links + +- When shared content goes to the trash, its share links — and those of anything inside a deleted folder — stop working at once: nothing in the trash stays public. The delete dialog says so beforehand. The links are kept with the item. +- When it is restored, you choose: **Restore the share links** brings them back as they were — same link, password, expiry, permitted people and label — or **Delete the share links** lets them go. Without a choice, they are deleted. +- A share link that expired while in the trash, or whose owner no longer exists, cannot come back. +- Deleted for good — from the trash, by emptying it, at the end of its retention, or straight away — an item takes its share links with it for good. +- Visits opened through a link are not kept: whoever had it open opens it again. + +## What a restore keeps + +Deleting and restoring on the same disk are renames: a file or folder comes back with its owner, permissions, ACLs, extended attributes and modification times as they were. A copy to another disk goes through the same copy as a transfer: in the container, `rsync` keeps permissions and modification times, and the copied files belong to the user the application runs as. + +Some things do not come back: + +- a folder recreated on the way back, because it no longer existed, is new, with the permissions the application gives new folders; +- the favorites pointing at an item are forgotten when it goes to the trash, and a restore does not bring them back. Share links are the exception: see [Share links](#share-links). + +Access rules and assigned volumes are set on paths, not on items, so they apply again as soon as an item is back under the path they name. + +## When an item cannot go to the trash + +The delete dialog says, before anyone confirms, which items would be removed for good and why: + +- the item is on **another disk** than its volume's trash (a network share or a separate mount inside a volume); +- it is **larger than the whole trash** of its volume; +- it is **a volume itself**, which cannot go into its own trash. + +A folder's size is only known once it is measured, during the deletion. If it turns out too large then, it is left where it is and the person is asked again. A deletion is never permanent without the person having been told. + +The dialog also offers **Delete permanently** to skip the trash on purpose. + +## Space and retention + +The trash of each volume may hold at most a share of the volume (10% by default), optionally capped by a size. [File versions](/admin/versions) are kept in the same space, and counted with the trash. A maintenance pass runs at startup, every hour, and shortly after deletions: + +1. items past their retention are removed for good, whatever the space; +2. while the space is over its budget, or the volume is below the upload reserve (`UPLOAD_STORAGE_RESERVE`), what matters least goes first: versions that are not the latest of their file, then the oldest items in the trash, then the latest version of each file, and pinned versions last. + +Before an upload is refused for lack of space, the destination volume gives back that space in the same order — but only when that is enough for the upload to fit. + +Every early removal (before the retention), recovery or failure is recorded in the zone's journal, shown in **Settings → Trash and versions**. + +## Settings → Trash and versions + +Administrators can: + +- switch the trash on or off, and set the retention and the size limits (the defaults come from [environment variables](/configuration/environment#trash)); versions are switched on and off, and thinned, in their own section — see [File versions](/admin/versions#settings-trash-and-versions); +- see, for each volume, what its trash holds, its budget, and what the last maintenance did; +- **Verify** that every zone's records and files agree; +- **Run maintenance now**. + +A zone whose disk is not mounted, or has been replaced by another one, is shown as unavailable and left untouched: an unmounted disk and an emptied trash look the same from a path, and nextExplorer never removes records on that basis. An administrator who knows the disk is gone for good can delete those items from the Trash page to forget them. + +## Backups + +The `.nextexplorer` folder is inside each volume, so a backup of the volume includes the trash. Exclude `.nextexplorer/` from backups if you do not want to back up deleted items. diff --git a/docs/admin/versions.md b/docs/admin/versions.md new file mode 100644 index 000000000..7addc0f4f --- /dev/null +++ b/docs/admin/versions.md @@ -0,0 +1,104 @@ +# File versions + +Saving over a file keeps what the save replaces as a version. Earlier versions can be listed, opened, downloaded, restored, taken out as a copy or put over another file, named, pinned and deleted from the **Versions** panel — right-click a file, or open its details. + +A file that has versions carries a small mark in the folder listing, with how many; clicking it opens the panel. It is on by default and each person can turn it off under **Settings → Preferences → Mark files that have versions**. It appears only where its file's history would be shown anyway, so a share that does not hand out histories does not hand out the mark either. + +## What is kept + +- **Every save through NextExplorer.** The text editor, the editor opened through a share link, ONLYOFFICE and Collabora all keep the content they replace. A file changed outside NextExplorer — over SMB, by a script — keeps its history, but those changes leave no version: nothing saw them happen. +- **No copy, ever.** The content a save replaces is moved into the volume's hidden `.nextexplorer` folder, the same zone the [trash](/admin/trash) uses, and the new content takes its place in one rename. Saving a 2 GB file does not need 2 GB of free space for its version. +- **The same content once.** A save that changes nothing keeps nothing, and content identical to the latest version is not kept twice. +- **One version per office editing session.** ONLYOFFICE and Collabora save on their own every few seconds while someone types; kept one by one, those saves would fill a volume with near copies of the same document. What is kept is the document as it was before the session, a save someone asked for (the editor's Save, closing the document), and, in a long session, a checkpoint at most every 10 minutes. +- **Crash-safe.** A save writes what it is about to do before it touches the disk. After a crash or a power cut, the next start either finishes the save or puts the file back as it was. + +## Thinning + +Versions are thinned out as they age, so a file edited every day keeps a useful history without keeping every save: + +| Age | Kept | +| -------------- | ----------------------- | +| up to 24 hours | every version | +| up to 7 days | the newest of each hour | +| up to 30 days | the newest of each day | +| older | the newest of each week | + +A file keeps at most 50 versions. The tiers and the limit are set under **Settings → Trash and versions**. + +**Pinned** versions escape the thinning and the per-file limit. Pin a version to keep it — the one sent to a client, the one before a large rewrite — and give it a name so it is easy to find. + +## Space + +Versions and the trash share each volume's reserved space (`TRASH_MAX_PERCENT` and `TRASH_MAX_SIZE`). When that space runs short, or the volume falls below the upload reserve, what goes first is what matters least: + +1. versions that are not the latest of their file, oldest first; +2. items in the trash, oldest first; +3. the latest version of each file; +4. pinned versions, last. + +A version larger than the whole reserved space is not kept, and the zone's journal says so. Every version removed early is recorded in the journal too. + +Versions and the trash are switched on and off separately: versions can be kept without a trash, and the other way round. + +## Restoring + +- **Restore** puts the file back as the version had it. The content it replaces becomes a version like any other save, and the restored version stays in the history: nothing is lost by restoring the wrong one. +- **Restore as a copy…** writes the version as a new file in a folder you choose, named after the file and the version's date. +- **Replace another file…** puts the version over an existing file you choose; that file's own content becomes one of its versions. +- **Download** saves the version without restoring anything. +- **Open read-only** shows a text file's version in the editor, and a document's in ONLYOFFICE or Collabora. Nothing can be saved from it. + +An editor that was already open on the file when it was restored still holds what the restore replaced. Its next save does not undo the restore: it is set aside as a version marked **Set aside**, from which it can be restored in turn. + +## Inside the office editors + +- **ONLYOFFICE**: the editor's **History** shows the document's versions. Click one to see it; **Restore** is offered to whoever may change the document and works like a restore from the panel. +- **Collabora**: **File → Revision history** opens the Versions panel over the document. A restore made there reopens the document in the editor. + +## Who may do what + +- **See** a file's history: whoever may read the file. +- **Download** a version, or take it out as a copy or over another file: whoever may also download the file. +- **Restore**, **name** or **pin** a version: whoever may change the file. +- **Delete** versions: whoever may delete the file. Deleting versions is permanent; **Delete all** includes pinned versions. The file itself stays. + +### Through a share + +A share's owner decides what it shows of its files' history, with two options in the share dialog: + +- **Show file versions** — visitors see the history, and may restore versions if the share lets them edit; +- **Allow downloading versions** — visitors may also download versions or take them out as copies. It needs the history to be shown. + +Both are on for a new share with named people, who could see the history anyway, and off for a link for anyone. Shares with named people made before versions existed have both switched on. + +## Following the file + +- **Renamed or moved** in NextExplorer, a file keeps its history, to another disk included. +- **Copied**, the copy starts with no history. +- **Sent to the trash**, a file takes its history with it, and gets it back when restored. +- **Deleted for good** — from the trash, at the end of its retention, or straight away — a file's versions go with it. +- **Deleted outside NextExplorer**, a file's history is kept for the trash retention, in case the file comes back to the same place, then removed. + +## Settings → File versions + +Every file in the installation that has a history, in one list, for administrators. The panel answers "what happened to this file"; this answers "where has the space gone", which no path can be asked about — a file deleted outside NextExplorer leaves its versions behind, and those are the histories least likely to be found by looking. + +Each row gives the file, the space it is in, how many versions it has and what they hold, and the date of the most recent. The list is ordered by space used by default, and can be searched by path, narrowed to one space, and narrowed by what became of the file: + +- **Present** — the file is still there; +- **In the trash** — it was deleted and can still be restored, its history with it; +- **Gone** — it disappeared outside NextExplorer, and its versions are the only copy left. They are kept for the trash retention in case it comes back. + +Open a row to see its versions, and delete any of them, or the whole history, from there. Deleting is permanent and includes pinned versions; the file itself is never touched. When a history whose file is gone loses its last version, its entry goes too. + +This list shows paths from every space, personal folders included — which no account can otherwise see of another. That is why the page is for administrators only, and why deleting from it is not something to do on somebody else's behalf without telling them. + +## Settings → Trash and versions + +Administrators can switch versions on or off, set the thinning tiers, the most versions per file and the office checkpoint. Switched off, saves keep nothing new; the versions already kept stay until they are thinned out, removed for space or deleted. The defaults come from [environment variables](/configuration/environment#file-versions). + +Each volume shows how many versions it holds and how much space they take, counted with the trash in its usage bar. + +## Backups + +Versions live in `.nextexplorer/versions` inside each volume, so a backup of the volume includes them. Exclude `.nextexplorer/` from backups if you do not want to back up versions and deleted items. diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 007be1a45..214a4357e 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -2,16 +2,54 @@ nextExplorer is configured almost entirely through environment variables. The backend (`backend/src/config/env.js`) centralizes the defaults you see here. Use this reference when you want to tune ports, paths, auth, integrations, or feature flags. +## Secrets + +Every credential listed below can be read from a file instead of the environment. Append `_FILE` to the variable name and point it at the file holding the value: + +| Variable | File variant | +| ------------------------------------------- | -------------------------- | +| `SESSION_SECRET` (or `AUTH_SESSION_SECRET`) | `SESSION_SECRET_FILE` | +| `AUTH_ADMIN_PASSWORD` (or `ADMIN_PASSWORD`) | `AUTH_ADMIN_PASSWORD_FILE` | +| `OIDC_CLIENT_SECRET` | `OIDC_CLIENT_SECRET_FILE` | +| `ONLYOFFICE_SECRET` | `ONLYOFFICE_SECRET_FILE` | +| `COLLABORA_SECRET` | `COLLABORA_SECRET_FILE` | + +`docker inspect` prints every environment variable a container was started with, so a secret passed inline is readable by anyone who can reach the Docker daemon and stays in the container's stored configuration. Mounting it as a file keeps it out of both: + +```yaml +services: + nextexplorer: + environment: + ONLYOFFICE_SECRET_FILE: /run/secrets/onlyoffice_secret + secrets: + - onlyoffice_secret + +secrets: + onlyoffice_secret: + file: ./secrets/onlyoffice_secret +``` + +The plain variable wins when both are set. Surrounding whitespace is stripped, so a file written with `echo secret > file` behaves as expected. A `_FILE` naming a missing or empty file stops the server at startup instead of quietly running without the secret. + ## Server & networking -| Variable | Default | Description | -| ------------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | `3000` | Port the Express API and frontend listen on inside the container. | -| `HTTP_TIMEOUT` | `0` | Node.js HTTP `requestTimeout` (ms). Use `0` to disable (avoids the Node 5-minute default that can abort large uploads). | -| `PUBLIC_URL` | _none_ | External URL (no trailing slash). Drives cookie settings, CORS defaults, and derived callback URLs (OIDC/OnlyOffice). | -| `INTERNAL_URL` | _none_ | Additional origin(s) the app may also be reached from (e.g. a LAN IP for fast local uploads), comma-separated. Treated as valid (no public-URL mismatch warning) and accepted by CORS; `PUBLIC_URL` stays canonical for share links / OIDC. | -| `TRUST_PROXY` | `loopback,uniquelocal` when `PUBLIC_URL` is set | Express trust proxy configuration. Accepts `false`, numbers, CIDRs, or lists. | -| `CORS_ORIGIN`, `CORS_ORIGINS`, `ALLOWED_ORIGINS` | _empty_ | Comma-separated list of allowed CORS origins. Defaults to the `PUBLIC_URL` / `INTERNAL_URL` origins; with none of them set, no cross-origin caller is allowed (same-origin use is unaffected). `*` reflects any origin. | +| Variable | Default | Description | +| ------------------------------------------------ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | `3000` | Port the Express API and frontend listen on inside the container. | +| `ADDRESS` | `0.0.0.0` | Interface the server binds to. Leave it alone unless you have a reason to reach only one network. | +| `HTTP_TIMEOUT` | `0` | Node.js HTTP `requestTimeout` (ms). Use `0` to disable (avoids the Node 5-minute default that can abort large uploads). | +| `UPLOAD_INACTIVITY_TIMEOUT` | `120000` | Classic upload inactivity timeout (ms). If no bytes are received for this delay, the request is aborted and `.uploading` is cleaned up. Use `0` to disable. | +| `UPLOAD_CHUNKED_ENABLED` | `false` | Default for the admin upload setting. When enabled, browser uploads use TUS chunked transfer instead of one large request. | +| `UPLOAD_CHUNK_SIZE` | `8M` | Default TUS chunk size. Supports byte-size suffixes such as `4M`, `16M`, or `64M`; keep it below reverse proxy body limits. | +| `MAX_CHUNK_SIZE_MIB` | `512` | Upper bound (MiB) an admin may set for the chunk size; caps the settings slider/input and clamps saved values. Hard ceiling of 512 MiB. | +| `UPLOAD_STORAGE_RESERVE` | `64M` | Free-space reserve kept when accepting uploads. An upload is rejected with `507` when the destination — or, for a chunked upload, the temporary storage — cannot fit what is coming plus this reserve. The reserve is what keeps a full volume from taking the database down with it, where `/config` shares the filesystem. | +| `TUS_UPLOAD_DIR` | `/tus-uploads` | Temporary storage directory for TUS chunked uploads. Put it on a volume large enough for the biggest in-progress uploads, and — importantly — on the **same filesystem as the destination**: chunks are assembled here and the finished file is then moved into place, which is instant within one filesystem but becomes a full byte-for-byte copy across two. With the default under `CACHE_DIR`, a multi-gigabyte upload appears to stall at 100% while that copy runs. Across filesystems the copy is written under a hidden `.upload-.uploading` name beside the destination and takes its name only once whole, never replacing a file already there (it becomes “name (1)”); one left by a killed process is removed when the next upload to that folder is created. | +| `TUS_INCOMPLETE_UPLOAD_TTL_MS` | `3600000` | Age after which an abandoned chunked upload, or a finished one that could not be moved into its folder, is deleted from the temporary directory (1 hour). An upload being moved into place is never deleted, whatever its age. | +| `TUS_CLEANUP_INTERVAL_MS` | `600000` | Delay between sweeps of the chunked-upload temporary directory (10 minutes). It is also swept at startup and when an upload is created; `0` sweeps only then. | +| `PUBLIC_URL` | _none_ | External URL (no trailing slash). Drives cookie settings, CORS defaults, and derived callback URLs (OIDC/OnlyOffice). | +| `INTERNAL_URL` | _none_ | Additional comma-separated origins. They are accepted by CORS and OIDC returns to the configured origin where login began. | +| `TRUST_PROXY` | `loopback,uniquelocal` when `PUBLIC_URL` is set | Express trust proxy configuration. Accepts `false`, numbers, CIDRs, or lists. | +| `CORS_ORIGIN`, `CORS_ORIGINS`, `ALLOWED_ORIGINS` | _empty_ | Comma-separated list of allowed CORS origins. Defaults to the `PUBLIC_URL` / `INTERNAL_URL` origins when set. When none of them is set, no cross-origin caller is allowed — same-origin use (frontend and API on one host) is unaffected. Use `*` only if you deliberately want to reflect any origin. | ## Logging & debugging @@ -23,27 +61,69 @@ nextExplorer is configured almost entirely through environment variables. The ba ## Paths & volumes -| Variable | Default | Description | -| ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `VOLUME_ROOT` | `/mnt` | Root directory that houses all mounted volumes. | -| `CONFIG_DIR` | `/config` | Location for SQLite, `app-config.json`, extensions, and settings. | -| `CACHE_DIR` | `/cache` | Location for thumbnails, ripgrep indexes, and temporary data. | -| `USER_ROOT` | `/_users` when unset | Root directory for **per-user personal folders**. Each authenticated user gets their own subdirectory under this path. | -| `USER_FOLDER_NAME_ORDER` | `id,username,email_local` | Controls how per-user folder names are derived for personal folders (e.g. set `username,id` to reuse `/home/` when `USER_ROOT=/home`). | -| `HIDDEN_FILE_PATTERNS` | `.` | Comma- or space-separated hidden filename patterns used by directory listings, volume pickers, and search. Plain values are fast filename prefixes, e.g. `.,@` hides dotfiles and Synology `@...` entries. Advanced entries can use `regex:` or `/source/flags`. Set to an empty value to disable pattern hiding. | +| Variable | Default | Description | +| ------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `VOLUME_ROOT` | `/mnt` | Root directory that houses all mounted volumes. | +| `CONFIG_DIR` | `/config` | Location for `app.db` (accounts, shares, settings), `logos/` and `session-secret`. The folder to back up. | +| `CACHE_DIR` | `/cache` | Location for thumbnails, RAW previews, sessions, `index.db` (the search index and folder sizes), and temporary data, including `in-flight/`: what a save, an extraction or a compression is writing, so that the next start removes what a stop interrupted. Everything in it can be rebuilt, but the indexes take a pass over the volumes to do so: mount it persistently. | +| `USER_ROOT` | `/_users` when unset | Root directory for **per-user personal folders**. Each authenticated user gets their own subdirectory under this path. | +| `USER_FOLDER_NAME_ORDER` | `id,username,email_local` | Preference order for per-user folder names (e.g. set `username,id` to reuse `/home/` when `USER_ROOT=/home`). A name is given once and kept; an account whose preferred name is already taken takes the next in the order, so two accounts never share a folder. See [personal folders](./personal-folders.md). | +| `HIDDEN_FILE_PATTERNS` | `.,regex:\\.download$,regex:\\.uploading$` | Comma- or space-separated hidden filename patterns used by directory listings, volume pickers, and search. Plain values are fast filename prefixes, e.g. `.,@` hides dotfiles and Synology `@...` entries. Advanced entries can use `regex:` or `/source/flags`; by default, the artifacts of a transfer in progress — `.download` while a file is being fetched, `.uploading` while one is being written — are hidden through this same configurable policy. Overriding this variable replaces the defaults, so include those two patterns in your own list to keep them hidden. Set to an empty value to disable pattern hiding. | + +## Copying & moving + +| Variable | Default | Description | +| ---------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FILE_TRANSFER_ENGINE` | native on Linux, `stream` elsewhere | `stream` copies in the application rather than through `rsync`; `native` asks for `rsync` and `rm` whatever the platform. Slower on large transfers, and useful where a native tool is unwanted. Setting it is rarely necessary: a native tool that is missing, or too old to understand what it is asked for — `--info=progress2` arrived in rsync 3.1, and RHEL 7 ships 3.0.9 — is detected on the first copy and the application falls back on its own, saying so in the log. Either way, a copy is written under a hidden `.nextexplorer-copying-*` name beside where it goes and takes its name only once whole, never replacing what holds it (it becomes “name (1)”); a cancelled or failed copy removes only that hidden entry, and one left by a stop is removed at the next start. | + +## Folder-size index + +| Variable | Default | Description | +| --------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FOLDER_SIZE_MODE` | `off` | Enables indexed folder sizes: `full` is recursive, `shallow` counts direct entries only. Also a choice in **Settings → Folder sizes**: when this variable is set it decides, and the page shows it and cannot change it; when it is not, the page does. Moving between `shallow` and `full` measures again, since a size counted one way is wrong read the other. | +| `FOLDER_SIZE_EXCLUDE_PATHS` | empty | Comma- or newline-separated paths relative to `VOLUME_ROOT` excluded from folder-size scans. | +| `FOLDER_SIZE_RECONCILE_BATCH` | `100` | Number of indexed folders checked per periodic reconciliation page. | +| `FOLDER_SIZE_RECONCILE_PAUSE_MS` | `200` | Delay between reconciliation pages, used to smooth background I/O. | +| `FOLDER_SIZE_RECONCILE_MAX_DIRECTORIES` | `200` | Maximum indexed folders checked by one scheduled reconciliation slice. `0` restores a full sweep. | +| `FOLDER_SIZE_IO_TIMEOUT_MS` | `30000` | Deadline for one indexed folder-size filesystem operation; `0` disables this protection. | +| `FOLDER_SIZE_MAX_STALLED_IO` | `2` | Timed-out folder-size operations allowed before the indexer pauses further filesystem work. | +| `FOLDER_SIZE_SUBTREE_BATCH` | reconciliation batch | Metadata checks per batch while recovering a folder tree created or changed outside NextExplorer. | +| `FOLDER_SIZE_CONCURRENCY` | `6` | Parallel folder-size scans on local storage. | +| `FOLDER_SIZE_NETWORK_CONCURRENCY` | `2` | Parallel folder-size scans on network storage, where seek latency dominates. | +| `FOLDER_SIZE_FLUSH_MS` | `3000` | Delay before pending folder-size updates are written to the index. | +| `FOLDER_SIZE_RECONCILE_MS` | `0` | Fixed interval between reconciliation sweeps. `0` uses the adaptive interval below. | +| `FOLDER_SIZE_RECONCILE_MIN_MS` | `900000` | Shortest adaptive reconciliation interval (15 minutes). | +| `FOLDER_SIZE_RECONCILE_MAX_MS` | `43200000` | Longest adaptive reconciliation interval (12 hours). | +| `FOLDER_SIZE_REBUILD` | `false` | Drop and rebuild the folder-size index at startup. | +| `FOLDER_SIZE_SUBTREE_PAUSE_MS` | reconciliation pause | Delay between targeted recovery batches. Leave unset to inherit the reconciliation pacing. | +| `FOLDER_SIZE_SUBTREE_SLOW_LOG_MS` | `5000` | Duration after which a targeted recovery emits one `info` performance summary. | + +Targeted subtree recoveries are always serialized so concurrent external changes cannot race their SQLite ancestor updates. The batch and pause settings govern their I/O intensity without affecting the normal list-view reads. ## Authentication -| Variable | Default | Description | -| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `AUTH_ENABLED` | `true` (in prod) | Toggles authentication; disabling makes all APIs public. **Deprecated:** use `AUTH_MODE=disabled` instead. | -| `AUTH_MODE` | `both` (or `local` if OIDC not configured) | Controls which authentication methods are available: `local` (username/password only), `oidc` (SSO only), `both` (both methods), or `disabled` (skip login entirely, same as `AUTH_ENABLED=false`). | -| `SESSION_SECRET`, `AUTH_SESSION_SECRET` | _auto-generated_ | Cryptographic secret used by Express to sign and encrypt session cookies and related tokens. In production, set this to a long, random, **stable** value (at least 32 characters) so sessions remain valid across restarts and multiple replicas; if left unset, a new random secret is generated on each start and all users will be logged out after every restart. | -| `SESSION_MAX_AGE_DAYS` | `30` | Duration (in days) that user sessions remain valid. Sessions persist across browser restarts and server reboots. Set to a lower value (e.g., `7`) for stricter security, or higher (e.g., `90`) for convenience. Applies to both local authentication and OIDC sessions. | -| `AUTH_MAX_FAILED` | `5` | Failed login attempts before temporary lockout. | -| `AUTH_LOCK_MINUTES` | `15` | Lockout duration in minutes when max failures reached. | -| `AUTH_ADMIN_EMAIL` | _none_ | Optional first-run bootstrap for local auth: when set with `AUTH_ADMIN_PASSWORD`, the backend creates an admin user on startup (and the setup wizard is skipped). | -| `AUTH_ADMIN_PASSWORD` | _none_ | Password used for `AUTH_ADMIN_EMAIL` bootstrap. If a user already exists with the same email, this value **overrides/resets** the local password on startup. (Minimum 6 chars; avoid leaving this set unless you want the password enforced on every restart.) | +| Variable | Default | Description | +| --------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `AUTH_ENABLED` | `true` (in prod) | Toggles authentication; disabling makes all APIs public. **Deprecated:** use `AUTH_MODE=disabled` instead. | +| `AUTH_MODE` | `both` (or `local` if OIDC not configured) | Controls which authentication methods are available: `local` (username/password only), `oidc` (SSO only), `both` (both methods), or `disabled` (skip login entirely, same as `AUTH_ENABLED=false`). | +| `SESSION_SECRET`, `AUTH_SESSION_SECRET` | _generated once, kept in `CONFIG_DIR/session-secret`_ | Cryptographic secret used by Express to sign session cookies and related tokens. When unset, one is generated at the first start and kept in `CONFIG_DIR/session-secret`, readable by the server’s user only, so sessions survive restarts. A configured value always wins, and nothing is written then: set one — long, random, at least 32 characters — to choose it, or when several replicas share the sessions. If `CONFIG_DIR` cannot be written, a warning is logged and the secret lasts until the next restart. | +| `SESSION_MAX_AGE_DAYS` | `30` | Duration (in days) that user sessions remain valid. Sessions persist across browser restarts and server reboots. Set to a lower value (e.g., `7`) for stricter security, or higher (e.g., `90`) for convenience. Applies to both local authentication and OIDC sessions. | +| `AUTH_MAX_FAILED` | `5` | Failed login attempts before temporary lockout. | +| `AUTH_LOCK_MINUTES` | `15` | Lockout duration in minutes when max failures reached. | +| `AUTH_ADMIN_EMAIL` | _none_ | Optional first-run bootstrap for local auth: when set with `AUTH_ADMIN_PASSWORD`, the backend creates an admin user on startup (and the setup wizard is skipped). | +| `AUTH_ADMIN_PASSWORD` | _none_ | Password used for `AUTH_ADMIN_EMAIL` bootstrap. If a user already exists with the same email, this value **overrides/resets** the local password on startup. (Minimum 6 chars; avoid leaving this set unless you want the password enforced on every restart.) | + +## Passkeys + +A passkey is bound to the hostname it was made on. Nothing here is required for +a single-hostname installation: `PUBLIC_URL` already answers it where it is +set, and the name the request arrived on answers it where it is not. See +[Admin & Access](/admin/guide). + +| Variable | Default | Description | +| ------------------ | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `WEBAUTHN_RP_ID` | _(from `PUBLIC_URL`, else the request's host)_ | The hostname passkeys are bound to. Set it where an installation is reached through more than one name, so a passkey made on one works on the others. Changing it stops the passkeys already made from working. | +| `WEBAUTHN_RP_NAME` | `NextExplorer` | The name the browser shows while asking for a fingerprint or a PIN. | ## Activity log @@ -57,94 +137,80 @@ what is in force under **Settings → Activity log**. ## OIDC & SSO -| Variable | Default | Description | -| --------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `OIDC_ENABLED` | `false` | Enable Express OpenID Connect authentication flow. | -| `OIDC_ISSUER` | _none_ | IdP issuer URL (discovery). | -| `OIDC_AUTHORIZATION_URL`, `OIDC_TOKEN_URL`, `OIDC_USERINFO_URL` | _none_ | Optional overrides for discovery endpoints. | -| `OIDC_LOGOUT_URL` | _none_ | Optional custom IdP logout URL. When set, logout requests redirect to this URL with a `post_logout_redirect_uri` parameter (OIDC standard). If not set, logout only clears the local session. | -| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | _none_ | IdP credentials. | -| `OIDC_CALLBACK_URL` | `${PUBLIC_URL}/callback` when `PUBLIC_URL` is set | Explicit callback path; defaults to `/callback` under `PUBLIC_URL`. | -| `OIDC_SCOPES` | `openid profile email` | Default scopes; add `groups` to propagate group claims. | -| `OIDC_ADMIN_GROUPS` | _none_ | Space/comma-separated names that grant admin rights when found in `groups`, `roles`, or `entitlements`. | -| `OIDC_REQUIRE_EMAIL_VERIFIED` | `false` | When `true`, requires the IdP to verify the user's email before allowing user creation or auto-linking. Some providers like newer Authentik versions set `email_verified` to `false` by default. | -| `OIDC_AUTO_CREATE_USERS` | `true` | When `false`, the user must already exist in the nextExplorer database (local or previously OIDC-linked), otherwise OIDC login is denied. | -| `OIDC_MOBILE_REDIRECT_URIS` | `nextexplorer://oidc-callback` | Comma-separated allowlist of native-app custom-scheme redirect URIs for the mobile PKCE bridge. HTTP(S) URIs are rejected; only an allowlisted URI can receive the one-time authorization code. | - -## Search - -| Variable | Default | Description | -| --------------------- | ------- | ----------------------------------------------------------------------------------------------------- | -| `SEARCH_DEEP` | `true` | Enables deep content search; ripgrep is used when `SEARCH_RIPGREP` is true. | -| `SEARCH_RIPGREP` | `true` | Prefer ripgrep for fast searches; fallback search is used when unavailable. | -| `SEARCH_MAX_FILESIZE` | `5MB` | Skip content search for files larger than this. Accepts a byte count or `K`, `M`, `G`, or `T` suffix. | -| `SEARCH_TIMEOUT_MS` | `5000` | Maximum time a live search may run before returning the results collected so far. | - -## Optional content search index - -The index stores file metadata and extracted search terms, not file contents. It is disabled by default; set `SEARCH_INDEX=true` to build it. The live search remains available while the index catches up. - -| Variable | Default | Description | -| --------------------------- | --------- | ---------------------------------------------------------------------------------------- | -| `SEARCH_INDEX` | `false` | Enables the resumable contentless search index. | -| `SEARCH_INDEX_BATCH` | `25` | Documents committed per index transaction. | -| `SEARCH_INDEX_CPU_PERCENT` | `25` | Maximum share of one CPU core used while indexing (`1`–`100`). | -| `SEARCH_INDEX_MEMORY_MB` | `256` | Extra process-memory budget for an indexing pass when no container memory limit applies. | -| `SEARCH_INDEX_EXCLUDE` | _empty_ | Comma- or newline-separated relative paths that the index must not read. | -| `SEARCH_INDEX_REBUILD` | `false` | When `true`, clears the derived index at startup and rebuilds it. | -| `SEARCH_INDEX_RECONCILE_MS` | `3600000` | Interval for reconciling the index with filesystem changes. | - -## Archives - -The official image includes 7-Zip. Archive operations stream to disk, report progress, can be cancelled, and reject archives that exceed the configured extraction limits. - -| Variable | Default | Description | -| ---------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `ARCHIVE_EXTENSIONS` | Built-in archive list | Comma-separated extraction allowlist. Prefix with `+` to extend the built-in list instead of replacing it (for example, `+udf,squashfs`). | -| `MAX_EXTRACTED_ARCHIVE_SIZE` | `32GB` | Maximum total uncompressed size allowed during extraction. Accepts a byte count or `K`, `M`, `G`, or `T` suffix. | -| `MAX_ARCHIVE_ENTRIES` | `100000` | Maximum number of archive entries allowed during extraction. | +| Variable | Default | Description | +| --------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OIDC_ENABLED` | `false` | Enable Express OpenID Connect authentication flow. | +| `OIDC_ISSUER` | _none_ | IdP issuer URL (discovery). | +| `OIDC_AUTHORIZATION_URL`, `OIDC_TOKEN_URL`, `OIDC_USERINFO_URL` | _none_ | Optional overrides for discovery endpoints. | +| `OIDC_LOGOUT_URL` | _none_ | Optional custom IdP logout URL. When set, logout requests redirect to this URL with a `post_logout_redirect_uri` parameter (OIDC standard). If not set, logout only clears the local session. | +| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | _none_ | IdP credentials. | +| `OIDC_CALLBACK_URL` | `${PUBLIC_URL}/callback` when `PUBLIC_URL` is set | Explicit canonical callback path; defaults to `/callback` under `PUBLIC_URL`. Register every `/callback` with the IdP when internal origins are configured. | +| `OIDC_SCOPES` | `openid profile email` | Default scopes; add `groups` to propagate group claims. | +| `OIDC_ADMIN_GROUPS` | _none_ | Space/comma-separated names that grant admin rights when found in `groups`, `roles`, or `entitlements`. | +| `OIDC_REQUIRE_EMAIL_VERIFIED` | `false` | When `true`, requires the IdP to verify the user's email before allowing user creation or auto-linking. Some providers like newer Authentik versions set `email_verified` to `false` by default. | +| `OIDC_AUTO_CREATE_USERS` | `true` | When `false`, the user must already exist in the nextExplorer database (local or previously OIDC-linked), otherwise OIDC login is denied. | +| `OIDC_MOBILE_REDIRECT_URIS` | `nextexplorer://oidc-callback` | Comma-separated allowlist of custom-scheme URIs a native app may receive the mobile sign-in code at. `http(s)` URIs are refused, so the code can never be handed to a web address. Only used by the mobile bridge; see [OIDC](/integrations/oidc#signing-in-from-a-native-app). | + +## Upload & archive limits + +These are safety ceilings, not tuning knobs: they exist so a single request cannot fill the volume. The defaults are high enough for normal use. + +| Variable | Default | Description | +| ---------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MAX_DIRECT_UPLOAD_SIZE` | `64GB` | Largest single file accepted by a direct (non-chunked) upload, e.g. `10GB`. Chunked/TUS uploads are bounded by their storage guard. | +| `MAX_FILES_PER_UPLOAD` | `50` | Maximum number of files in one direct upload request. | +| `MAX_JSON_BODY_SIZE` | `8MB` | Largest JSON request body accepted. These carry lists of paths — deleting or copying a few thousand files needs a few hundred kB — not file content. Saving from the text editor is the exception: the file travels in one, so leaving this unset lets it rise to carry whatever `EDITOR_MAX_FILESIZE` opens, while a value set here is a ceiling that is kept and lowers the editor instead. | +| `MAX_EXTRACTED_ARCHIVE_SIZE` | `32GB` | Refuse to extract an archive whose declared uncompressed size exceeds this ("zip bomb" guard). | +| `MAX_ARCHIVE_ENTRIES` | `100000` | Refuse to extract an archive holding more entries than this. | -## Recursive folder sizes - -Folder-size indexing is off by default. It calculates recursive byte totals and entry counts in the background; scans resume after interruption and use timeouts and circuit breakers to avoid overloading slow filesystems. - -| Variable | Default | Description | -| --------------------------------- | ------- | --------------------------------------------------------------------------------------------------------- | -| `FOLDER_SIZE_MODE` | `off` | `off` disables indexing; `shallow` indexes listed folders; `full` recursively indexes the available tree. | -| `FOLDER_SIZE_EXCLUDE_PATHS` | _empty_ | Comma- or newline-separated paths to omit from folder-size scans. | -| `FOLDER_SIZE_CONCURRENCY` | `6` | Maximum concurrent local filesystem operations. | -| `FOLDER_SIZE_NETWORK_CONCURRENCY` | `2` | Maximum concurrent operations on network filesystems. | -| `FOLDER_SIZE_FLUSH_MS` | `3000` | Delay before queued index updates are flushed to storage. | -| `FOLDER_SIZE_RECONCILE_MS` | `0` | Optional fixed reconciliation interval; `0` uses adaptive scheduling. | -| `FOLDER_SIZE_REBUILD` | `false` | When `true`, rebuilds the derived folder-size index at startup. | +## Feature toggles -## Upload limits +| Variable | Default | Description | +| --------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SEARCH_DEEP` | _false_ | Enables deep content search; ripgrep is used when `SEARCH_RIPGREP` is true. | +| `SEARCH_RIPGREP` | _true_ | Prefer ripgrep for fast searches; fallback search is used when unavailable. | +| `SEARCH_MAX_FILESIZE` | `5M` | Skip files larger than this when searching their contents. Accepts `5MB`, `5M`, `5mb` or a plain byte count. | +| `SEARCH_TIMEOUT_MS` | `5000` | How long one search may spend looking before answering with what it has. Reading a large tree to be certain there is nothing more is worse than an answer that arrives; the response is marked `truncated` when this ended it, and the panel says so rather than letting a short list look like the whole answer. | +| `SEARCH_INDEX` | `false` | Keep an index of the volume instead of reading it on every search: the words inside documents, and the name of every file and folder whether or not any words could be taken out of them — a folder nobody has filled yet is findable, and one the application has just made is findable at once. Searching a name then costs a query rather than a walk of the storage, which is what makes it bearable on a network share — half a million names answer in about forty milliseconds, where the walk is one round trip per folder. Built by a paced background pass that skips anything it has already read, and stops when the server is asked to. Results are as fresh as the last pass, except in the folder being searched, which is read directly so that a file dropped there a moment ago is still found; a share, a personal folder or an assigned volume is answered from the index too when it lies inside the volume, and read as the search goes when it is mounted somewhere else, since the pass indexes the volume and nothing outside it; so is a search by a reader who has asked to see hidden files, since the pass does not walk into dot-folders. Reckon about 240 bytes of index per file or folder. Searching contents through the index matches whole words and the beginnings of them, where reading the files matches any run of characters: `azul` finds `azules` either way, `ules` only by reading. Also a switch in **Settings → Search index**: when this variable is set it decides, and the switch shows it and cannot move it; when it is not, the switch does. | +| `SEARCH_INDEX_BATCH` | `25` | Documents written per transaction while indexing. | +| `SEARCH_INDEX_CPU_PERCENT` | `25` | The share of one core a background pass may take. It works for a slice of time and then stands aside for the rest, so the load is what you chose whatever the files are. Raising it shortens the first pass and is felt while it runs. | +| `SEARCH_INDEX_EXCLUDE` | _(none)_ | Folders search leaves alone, comma or newline separated, relative to the volume root. Neither the index nor a filename search walks into them — the exception being when one of them is the folder the search was started from, since navigating into it is asking to look. A build tree, a mail spool, a machine backup — hundreds of thousands of files nobody searches by content, and reading them is the whole overhead. Set here they cannot be removed from the interface; **Settings → Search index** holds a second list an administrator can edit. Nothing is excluded by default: with the index answering in place of the live scan, a folder left out is one that cannot be found by content. | +| `SEARCH_INDEX_REBUILD` | `false` | Empty the index at startup and read everything again. It is derived data — every row was read from a file that is still there — so the only cost of being wrong about needing this is one pass. Unset it once the rebuild has finished, or it happens on every start. | +| `SEARCH_INDEX_MEMORY_MB` | `256` | What a background pass may add to the process before it stops and carries on a couple of minutes later. Only consulted when the container enforces no memory limit of its own — where it does, three quarters of that limit is the ceiling instead. What the pass wrote is kept either way, so the next one resumes from there. | +| `SEARCH_INDEX_RECONCILE_MS` | `3600000` | How often to walk the volume again, for changes made outside the application — an rsync, a network share. | +| `SHOW_VOLUME_USAGE` | `false` | Show volume usage badges in the sidebar. | +| `FAVORITES_DEFAULT_ICON` | `outline:StarIcon` | Icon a new favorite starts with, as `variant:IconName` (`outline` or `solid`, and any Heroicons name). Each favorite can be given its own icon afterwards from the sidebar's edit mode. | +| `USER_DIR_ENABLED` | `false` | When `true`, enables a **personal “My Files” space** for each authenticated user under `USER_ROOT`. The frontend shows a “My Files” entry when this flag is on. | +| `USER_VOLUMES` | `false` | When `true`, non-admin users only see volumes assigned to them by an admin. See [User volumes](/admin/user-volumes). | +| `SKIP_HOME` | `false` | When `true`, visits to the home view (`/browse/`) automatically redirect into the first volume instead. | +| `TERMINAL_ENABLED` | `true` | Controls the admin terminal feature. When `false`, terminal routes/UI are disabled. When `true`, nextExplorer attempts to load terminal dependencies and automatically hides/disables terminal if dependencies are unavailable (startup continues). | +| `TERMINAL_FILE_EXTENSIONS` | `sh` | Comma-separated list of file extensions that show the context-menu action to open the file in the admin terminal (for example `sh,bash` or `.sh,.bash`). | -Safety ceilings rather than tuning knobs: they exist so a single request cannot fill the volume, and the defaults are high enough for normal use. +The sharing system (toolbar **Share** button, guest links such as `/share/:token`, and the **Shared with me** page) works out of the box with the feature flags above. Advanced share tuning knobs are documented under **Sharing (advanced)** below. -| Variable | Default | Description | -| ------------------------ | ------- | --------------------------------------------------- | -| `MAX_DIRECT_UPLOAD_SIZE` | `64GB` | Largest single file an upload accepts, e.g. `10GB`. | -| `MAX_FILES_PER_UPLOAD` | `50` | Maximum number of files in one upload request. | +## Trash -## Copying & moving +Deleting moves an item into a hidden `.nextexplorer` folder at the root of its volume — a rename on the same disk, never a copy. These variables set the defaults; administrators change what is in force under **Settings → Trash**. See [Trash](/admin/trash). -| Variable | Default | Description | -| ---------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `FILE_TRANSFER_ENGINE` | `native` on Linux, else `stream` | Which engine copies a folder: `native` hands the tree to `rsync`, which does the work in one process off the event loop; `stream` copies it in JavaScript. The image carries rsync; without it the JavaScript path runs anyway. | +| Variable | Default | Description | +| ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TRASH_ENABLED` | `true` | Send deleted items to the trash. When `false`, deleting removes items for good; items already in the trash still expire. | +| `TRASH_RETENTION_DAYS` | `30` | How long an item stays in the trash before it is removed for good, from 1 to 3650 days. | +| `TRASH_MAX_PERCENT` | `10` | The most the trash may hold on each volume, as a share of that volume's size (1 to 90). The oldest items go first when it is exceeded. | +| `TRASH_MAX_SIZE` | _(none)_ | An optional size cap per volume, such as `50G`. The smaller of this and `TRASH_MAX_PERCENT` applies. An item larger than the whole trash is never silently removed: the person deleting it is asked. | -## Feature toggles +## File versions -| Variable | Default | Description | -| -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `SHOW_VOLUME_USAGE` | `false` | Show volume usage badges in the sidebar. | -| `USER_DIR_ENABLED` | `false` | When `true`, enables a protected personal **My Files** space for each authenticated user under `USER_ROOT`. | -| `USER_VOLUMES` | `false` | When `true`, non-admin users only see volumes assigned to them by an admin. See [User volumes](/admin/user-volumes). | -| `SKIP_HOME` | `false` | When `true`, visits to the home view (`/browse/`) automatically redirect into the first volume. | -| `TERMINAL_ENABLED` | `true` | Controls the admin terminal feature. When `false`, terminal routes/UI are disabled. | -| `TERMINAL_FILE_EXTENSIONS` | `sh` | Comma-separated extensions that show the context-menu action to open a file in the admin terminal (for example `sh,bash` or `.sh,.bash`). | +Saving over a file keeps what the save replaces as a version, in the same `.nextexplorer` zone and the same reserved space as the trash. These variables set the defaults; administrators change what is in force under **Settings → Trash and versions**. See [File versions](/admin/versions). -The sharing system (toolbar **Share** button, guest links such as `/share/:token`, and the **Shared with me** page) works out of the box with the feature flags above. Advanced share tuning knobs are documented under **Sharing (advanced)** below. +| Variable | Default | Description | +| ------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- | +| `VERSIONS_ENABLED` | `true` | Keep earlier versions of files. Independent of `TRASH_ENABLED`. | +| `VERSIONS_KEEP_ALL_HOURS` | `24` | Every version is kept for this many hours (1 to 720). | +| `VERSIONS_HOURLY_DAYS` | `7` | Then the newest of each hour, up to this many days (1 to 365). | +| `VERSIONS_DAILY_DAYS` | `30` | Then the newest of each day, up to this many days (1 to 3650); after that, the newest of each week. | +| `VERSIONS_MAX_PER_FILE` | `50` | The most versions a file keeps (1 to 1000). Pinned versions do not count. | +| `VERSIONS_SESSION_CHECKPOINT_MINUTES` | `10` | In a long ONLYOFFICE or Collabora session, a version is kept at most this often (1 to 1440 minutes). | ## Editor @@ -153,19 +219,48 @@ The sharing system (toolbar **Share** button, guest links such as `/share/:token | `EDITOR_EXTENSIONS` | _empty_ | Comma-separated list of additional file extensions to support in the inline text editor (e.g., `toml,proto,graphql` or `.toml,.proto`). These are **added to** the built-in defaults (txt, md, json, js, ts, py, etc.), not replacing them. Changes take effect on container restart—no frontend rebuild required. | | `EDITOR_MAX_FILESIZE` | `2M` | Maximum file size allowed to open in the inline text editor. Accepts a byte count or a size with `K`, `M`, `G`, `T` suffix (base 1024), e.g. `512K`, `2M`, `1G`. Files larger than this will show “This file is too large to open in the text editor.” | +## Archives + +| Variable | Default | Description | +| -------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ARCHIVE_EXTENSIONS` | `7z,zip,iso,rar,tar,gz,tgz,bz2,tbz2,xz,txz,cab,wim,cpio,rpm,deb,z,lzh,arj,zst` | Extensions offered for the “Extract archive” action, and for looking inside one. A plain list (e.g. `zip,iso,7z`) **replaces** the defaults; prefix the list with `+` (e.g. `+udf,squashfs`) to **extend** them instead. Whatever the list says, a format is only offered when the bundled 7-Zip build actually supports it (probed at startup). Password-protected ZIP, 7z and RAR archives are supported through the extraction dialog; passwords are not persisted. | + ## OnlyOffice & thumbnails -| Variable | Default | Description | -| ----------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ONLYOFFICE_URL` | _none_ | Public URL for Document Server (must reach your app's `PUBLIC_URL`). | -| `ONLYOFFICE_SECRET` | _none_ | JWT secret shared with OnlyOffice Document Server for `/api/onlyoffice` calls. | -| `ONLYOFFICE_DOWNLOAD_ORIGINS` | _none_ | Comma-separated extra origins a saved document may be fetched from. Set it when the Document Server reports itself under another host than `ONLYOFFICE_URL`; that one is always allowed. | -| `ONLYOFFICE_LANG` | `en` | Language code for the editor UI. | -| `ONLYOFFICE_FORCE_SAVE` | `false` | When true, OnlyOffice forces users to save via the editor UI. | -| `ONLYOFFICE_FILE_EXTENSIONS` | _default list_ | Extra file extensions to surface to the Document Server. | -| `FFMPEG_PATH`, `FFPROBE_PATH` | _bundled binaries_ | Point to custom ffmpeg/ffprobe if the bundle doesn't suit your needs. | -| `FFMPEG_HWACCEL` | _none_ | Optional ffmpeg `-hwaccel` value used for video thumbnail generation when supported by your ffmpeg build (e.g. `vaapi`, `qsv`, `cuda`). | -| `FFMPEG_HWACCEL_DEVICE` | _none_ | Optional ffmpeg `-hwaccel_device` value used with `FFMPEG_HWACCEL` (e.g. `0` or `/dev/dri/renderD128`). | +| Variable | Default | Description | +| ------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ONLYOFFICE_URL` | _none_ | Public URL for Document Server (must reach your app's `PUBLIC_URL`). | +| `ONLYOFFICE_SECRET` | _none_ | JWT secret shared with OnlyOffice Document Server for `/api/onlyoffice` calls. | +| `ONLYOFFICE_DOWNLOAD_ORIGINS` | _none_ | Comma-separated extra origins the Document Server may serve saved documents from. Set it when the callback URL host differs from `ONLYOFFICE_URL`; that origin is always allowed. | +| `ONLYOFFICE_LANG` | `en` | Language code for the editor UI. | +| `ONLYOFFICE_FORCE_SAVE` | `false` | When true, the OnlyOffice Save button writes the current version immediately. | +| `ONLYOFFICE_AUTO_SAVE_INTERVAL_MS` | `30000` | Minimum delay in milliseconds between background force-saves after OnlyOffice has synchronized changes. Set `0` to save only when closing; capped at `300000`. | +| `ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS` | `10000` | Retry window in milliseconds when a force-save reaches Document Server before its final changes. Minimum `7000`; the interface does not wait for the callback. | +| `ONLYOFFICE_FILE_EXTENSIONS` | _default list_ | Extra file extensions to surface to the Document Server. | +| `FFMPEG_PATH`, `FFPROBE_PATH` | _bundled binaries_ | Point to custom ffmpeg/ffprobe if the bundle doesn't suit your needs. | +| `SEVEN_ZIP_PATH` | `7z` _on PATH_ | The 7-Zip to run. Which archive formats can be opened is read from `7z i` at startup and written to the log, so a build without the RAR codec loses RAR and nothing else. | +| `EXIFTOOL_PATH` | _bundled ExifTool_ | The ExifTool to run, for RAW photo metadata. Rarely needed: the bundled copy is used when it is there, and when it is not — the minimal archive leaves its 21 MB of Perl behind — `/usr/bin/exiftool`, `/usr/local/bin/exiftool` and `/opt/homebrew/bin/exiftool` are tried in that order, so `apt install libimage-exiftool-perl` is the whole of it. Set this only for one kept somewhere else. Absolute paths and not `PATH`, which a service inherits from whatever started it. | +| `FFMPEG_HWACCEL` | _none_ | Optional ffmpeg `-hwaccel` value used for video thumbnail generation when supported by your ffmpeg build (e.g. `vaapi`, `qsv`, `cuda`). | +| `FFMPEG_HWACCEL_DEVICE` | _none_ | Optional ffmpeg `-hwaccel_device` value used with `FFMPEG_HWACCEL` (e.g. `0` or `/dev/dri/renderD128`). | +| `FFMPEG_HWACCEL_OUTPUT_FORMAT` | _none_ | Optional ffmpeg `-hwaccel_output_format` value, used with `FFMPEG_HWACCEL`. Some hardware pipelines need it (for example `vaapi`) to hand frames back in a format the encoder accepts. | +| `THUMBNAILS_ENABLED` | `true` | Set to `false` to disable thumbnail generation globally, regardless of the UI setting. | +| `THUMBNAIL_CACHE_MAX_FILES` | `3000` | Maximum number of files kept in the thumbnail cache; past it, the least recently written go first. Thumbnails written by releases up to 2.0.3, and temporary files untouched for an hour, are removed too. Set `0` to lift the limit on the count; outdated, expired and abandoned files are still removed. | +| `THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS` | `3600000` | Delay between thumbnail and RAW preview cache cleanup passes. They run on their own, whether or not anything is being generated. | +| `THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE` | `500` | Maximum number of thumbnail or RAW preview cache files deleted per cleanup pass. | +| `THUMBNAIL_CACHE_TTL_DAYS` | `30` | Remove thumbnails and RAW previews older than this age during the periodic cleanup. Set `0` to keep entries until the file-count limit is reached. | +| `RAW_PREVIEW_CACHE_MAX_FILES` | `500` | Maximum number of embedded RAW previews kept in `/cache/raw-previews`, full-size JPEGs; the oldest go first. Set `0` to lift the limit on the count; outdated, expired and abandoned previews are still removed. | +| `THUMBNAIL_SHARP_CACHE_MEMORY_MB` | `0` | Memory in MB allowed for Sharp/libvips thumbnail cache. Keep `0` to minimize idle RSS after thumbnail generation. | +| `THUMBNAIL_VIDEO_CONCURRENCY` | `1` | Maximum number of concurrent ffmpeg thumbnail jobs. Keep low on small hosts to avoid memory spikes. | +| `THUMBNAIL_DIAGNOSTICS_ENABLED` | `false` | Enable periodic thumbnail diagnostics logs with queue, memory, active job, external process, and cache cleanup counters. | +| `THUMBNAIL_DIAGNOSTICS_INTERVAL_MS` | `30000` | Interval between thumbnail diagnostics logs when diagnostics are enabled. | +| `THUMBNAIL_BACKGROUND_QUEUE_LIMIT` | `200` | Maximum thumbnails queued for background generation before new requests are dropped. | +| `THUMBNAIL_PROCESS_NICE` | `10` | `nice` value applied to external thumbnail processes, so they yield to interactive work. | +| `THUMBNAIL_VIDEO_SEEK_PERCENT` | `10` | Position in the video, as a percentage of its duration, used to grab the thumbnail frame. | +| `THUMBNAIL_VIDEO_SCALE_FLAGS` | `fast_bilinear` | ffmpeg scaling algorithm for video thumbnails. Slower flags give a sharper image. | +| `THUMBNAIL_VIDEO_SEEK_SECONDS` | `5` | Fixed position in the video used to grab the thumbnail frame, when no percentage is set. | +| `THUMBNAIL_VIDEO_THREADS` | `2` | Threads allowed to one ffmpeg thumbnail job. | +| `THUMBNAIL_SLOW_JOB_MS` | `10000` | Duration threshold after which a thumbnail job/process is logged even when diagnostics are disabled. | +| `THUMBNAIL_FFMPEG_TIMEOUT_MS` | `300000` | Longest one ffmpeg may take over a single thumbnail before it is killed and the thumbnail marked failed. Raise it if very large videos on slow storage are being cut short; the minimum is `1000`. | ## Collabora (WOPI) @@ -194,6 +289,54 @@ These variables are available for tuning the share system. The defaults are suit ## Container user mapping -| Variable | Description | -| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PUID`, `PGID` | Map container processes to host user/group IDs so created files have consistent ownership. Defaults to `1000`. The entrypoint adjusts ownership of `/app`, `/config`, and `/cache` accordingly. | +| Variable | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PUID`, `PGID` | Map container processes to host user/group IDs so created files have consistent ownership. Defaults to `1000`. The entrypoint adjusts ownership of `/app`, `/config`, and `/cache` accordingly. Set both to `0` to run as root — see [Running as root](#running-as-root). | + +## Running as root + +The entrypoint always finishes with `gosu appuser`, so the application runs as +`appuser` whatever Compose's `user:` says. Setting `user: root` therefore +changes who runs the entrypoint — which was already root — and not who runs the +server. The knob is `PUID` and `PGID`: + +```yaml +environment: + - PUID=0 + - PGID=0 +``` + +The entrypoint renumbers `appuser` to those ids before dropping to it, so the +server runs as root and can read and write anything the mounts expose. + +Compose's `user:` is a different thing and does not do this. It decides who runs +the entrypoint, which was already root; the entrypoint then drops to `appuser` +whatever it says. + +That is a real choice rather than a default, and worth making deliberately: + +- Every file NextExplorer creates on the host is owned by root. +- A mount of `/` gives it the whole host, `/etc` included, with write access + wherever the share or the volume allows writing. + +Where the aim is only to reach a folder the container cannot currently read, +matching its owner is usually enough — `PUID` and `PGID` set to that owner's +ids, which is what they are for. + +## Starting the container as a fixed user + +Giving the container a user of its own — `docker run --user`, Compose's +`user: 1000:1000`, a Kubernetes `securityContext` — is supported and means +something different from `PUID`/`PGID`. + +The entrypoint notices, and does none of the things only root can do: it does +not renumber `appuser`, does not take ownership of `/config` and `/cache`, and +does not drop to another user. The application runs as whoever the container was +started as, which is what was asked for. `PUID` and `PGID` are ignored in that +case, and the log says so when they were set. + +What that leaves to the deployment is ownership of the mounts. Under Docker, +the directories must already be readable and writable by that user. Under +Kubernetes, `fsGroup` does the job `PUID`/`PGID` do here — which is also what +makes the image usable on a cluster enforcing the `restricted` Pod Security +Standard, where running as root is refused outright. diff --git a/docs/configuration/personal-folders.md b/docs/configuration/personal-folders.md index 11c19fd35..e06523362 100644 --- a/docs/configuration/personal-folders.md +++ b/docs/configuration/personal-folders.md @@ -7,7 +7,6 @@ This section explains how it works and how to enable it. ## How personal folders work - Each authenticated user gets a private directory under a common root (`USER_ROOT`). -- The resolver confines every personal path to that user’s directory. It rejects attempts to traverse through symlinks, access another user’s folder, or exploit a colliding folder name. - Logical paths for personal items always start with `personal/`: - Example: `personal`, `personal/photos`, `personal/docs/report.docx`. - The backend maps those logical paths to the filesystem: @@ -49,8 +48,34 @@ USER_FOLDER_NAME_ORDER=id,username,email_local - Default: `id,username,email_local`. - Controls how `` is chosen for personal folders. Valid values: `id`, `username`, `email`, `email_local`, `displayname`. - Example (reuse existing Linux home directories): set `USER_ROOT=/home` and `USER_FOLDER_NAME_ORDER=username,id`. - -> Note: Changing `USER_FOLDER_NAME_ORDER` after users already have personal folders will make nextExplorer look in a different location. Move/rename directories (or create symlinks) if you need to migrate existing data. + - The order is a preference, not a formula. The first account to be given a + name keeps it, and an account whose preferred name is already taken walks + down the rest of the order to the next free one — `id` is always last, and + ids are unique, so it always ends somewhere. Nothing about `username` or + `email_local` is unique on its own (`bob@a.com` and `bob@b.com` both yield + `bob`), and without this two accounts would share a folder and see each + other's files. + - Deleting an account does not free its name while its folder is still on + disk: the folder stays, with whatever was in it, and the next account whose + preferred name it is takes the next free one instead of opening the + deleted account's files. Once the folder is removed or renamed, the name + can be given again. + +> Note: the name an account is given is kept. Changing `USER_FOLDER_NAME_ORDER` +> afterwards applies to accounts created from then on, and leaves the existing +> ones where they are — so the change cannot silently take a folder away from +> whoever is using it. +> +> To move existing accounts to a new order deliberately, move or rename their +> directories on disk, then clear the assignment so it is worked out again at +> their next sign-in: +> +> ```sql +> UPDATE users SET personal_folder_name = NULL; +> ``` +> +> Where two accounts then prefer the same name, the first to sign in gets it. +> Assign the names yourself in the same statement if that matters. > Note: The personal folder UI will not appear unless `USER_DIR_ENABLED=true`. The app also refuses to resolve `personal/...` when this flag is off. @@ -94,7 +119,7 @@ To enable personal folders in a production deploy (see the full example in [Depl ```yaml services: nextexplorer: - image: nxzai/explorer:latest + image: ghcr.io/cerede2000/explorer:latest environment: - NODE_ENV=production - PUBLIC_URL=https://files.example.com @@ -116,7 +141,6 @@ services: ## Permissions and access control - Personal folders are designed to be **per-user** homes. -- Users cannot browse another user’s personal folder, even when directory names derived from usernames or email addresses are similar. - By default, access control rules (Settings → Access Control) apply to logical paths: - You can create rules targeting `personal` or `personal/` if you want to further restrict access. - Favorites and quick access: diff --git a/docs/experience/features.md b/docs/experience/features.md index 0aed8f0ad..0531ef0d6 100644 --- a/docs/experience/features.md +++ b/docs/experience/features.md @@ -5,11 +5,21 @@ nextExplorer mixes a modern browser experience with secure access controls and f ## File browsing & previews - **Dual views:** Switch between responsive grid, list, and column modes while keeping breadcrumbs, toolbar, and search accessible. -- **Inline previews:** Images, videos, PDFs, and text files preview instantly without downloads. Media previews identify unsupported codecs and support embedded or sidecar subtitles, converting compatible subtitle tracks to WebVTT. Image/video thumbnails are generated automatically using FFmpeg (`FFMPEG_PATH`/`FFPROBE_PATH` can override binaries). -- **Drag-to-move (desktop):** Select one or more items, then drag them onto a destination folder to move them. +- **A document has an address:** Every document that opens — a photograph, a video, a PDF, a spreadsheet in ONLYOFFICE or Collabora — has a URL of its own, `/open/`. It can be linked to, kept as a bookmark, and opened in a browser tab of its own. Settings → Preferences → **Open documents in a new tab** makes that the default for every kind of file at once, so several stay open while you go on browsing; left off, documents open over the folder as they always have. Closing the tab ends the editing session exactly as closing the panel does, so nothing is left marked as being edited by somebody who has gone. +- **A file that has a history says so:** A small mark on the row, with how many earlier versions there are; clicking it opens the [Versions](/admin/versions) panel. Settings → Preferences → **Mark files that have versions** turns it off. It appears only where the history itself would be shown, so a share that does not hand out histories does not hand out the mark either. Administrators get the other end of it under Settings → **File versions**: every file in the installation that has one, ordered by the space it takes, with the versions deletable from there. +- **Inline previews:** Images, videos, PDFs, and text files preview instantly without downloads. Image/video thumbnails are generated automatically using FFmpeg (`FFMPEG_PATH`/`FFPROBE_PATH` can override binaries). +- **Media gallery:** Pictures and videos open in one viewer and are browsed together — swipe on a touch device, arrow keys or on-screen arrows elsewhere. Pictures zoom by pinch, double-tap or ctrl-wheel; while zoomed, dragging pans the picture instead of turning the page. +- **Subtitles:** A video offers the subtitle tracks inside it and any subtitle files sitting beside it — `film.srt`, `film.fr.srt`, `film.en.forced.srt` — converted to WebVTT and listed in the browser's own captions menu. Blu-ray and DVD subtitles are pictures of words rather than text, so they are not offered; turning those into captions would need OCR. +- **Why a video is silent:** Playback hands the file to the browser and never transcodes — that is a media server's job, not a file explorer's. The consequence used to be invisible: a film whose soundtrack is AC-3, E-AC-3, DTS or TrueHD plays perfectly with no sound in Chrome or Firefox, because those browsers will not decode them. The player now says so, naming the codec, and says the same when the picture itself is one the browser cannot decode — HEVC, most often. Switching between audio tracks appears only in browsers that support it, which today means Safari. + +- **Drag-to-move (desktop):** Select one or more items, then drag them onto a destination folder to move them. Hold Alt (Option on macOS) to copy instead, and drop onto a favorite in the sidebar to send items there without navigating. +- **Move to / Copy to:** From the context menu, pick a destination and the transfer runs in the background. Nothing is ever replaced: a name already taken gets a suffix. - **Drag-to-upload:** Drop files or folders from your device onto the main pane to upload them. - **Mobile selection mode:** On touch devices, use **Select** to enable checkbox selection for batch actions. -- **Context menus:** Right-click the background or individual items for quick shortcuts (New Folder/File, Paste, Rename, Get Info, download, delete). +- **Context menus:** Right-click the background or individual items for quick shortcuts (New Folder/File, Paste, Move to, Rename, Get Info, download, delete). +- **Per-folder sorting:** A folder reopens sorted the way you left it. +- **Folder sizes:** Switched on from **Settings → Folder sizes** (or `FOLDER_SIZE_MODE`), folders show their recursive size — or only the size of their own files — computed in the background and kept up to date as files move. +- **Keyboard navigation:** Move through a folder with the up and down arrows, open with Enter or the right arrow, and go up a level with Backspace or the left arrow. ## Editing, sharing & document workflows @@ -17,29 +27,204 @@ nextExplorer mixes a modern browser experience with secure access controls and f - **Link-based sharing:** Use the **Share** button in the toolbar to create share links for any folder or file you can access (including items under **My Files** when personal folders are enabled). Shares can be: - **Read-only** or **read/write**. - **Anyone with the link** or **specific users**. + - **Downloadable, or read-only in the stricter sense.** Turning downloads off leaves the share readable while withholding the file itself — the download button is gone and the endpoint refuses. It is deliberately independent of read/write, because "collaborate on this, but do not take a copy home" is a coherent thing to ask for. Shares created before this existed, and any share where it is not set, allow downloads. - Optionally **password-protected** and **time-limited** with an expiration date. After creation, the dialog shows a friendly label, final URL (based on `PUBLIC_URL` when set), and a one-click **Copy link** button. -- **Guest access to shares:** Public “anyone with the link” shares use short tokens (for example, `/share/aBc123XyZ`) and create a limited **guest session** so visitors can browse just the shared item. Password-protected shares prompt for the password first — every visitor but the share's owner, signed-in accounts included, since being signed in is not knowing the password — and ask once per visit; setting or changing the password signs out everyone who opened the link before; user-specific shares redirect to the login screen and apply normal access checks after authentication. +- **Guest access to shares:** Public “anyone with the link” shares use short tokens (for example, `/share/aBc123XyZ`) and create a limited **guest session** so visitors can browse just the shared item. Password-protected shares prompt for the password first; user-specific shares redirect to the login screen and apply normal access checks after authentication. The password applies to everyone except the share's owner — being signed in, including as an administrator, is not the same as knowing it. This matters for shares pointing at a personal folder, which no other account can reach any other way. With `AUTH_MODE=disabled` there are no accounts to tell apart and every visitor already browses the whole filesystem, so the prompt is skipped. - **“Shared with me” view:** The **Shares** section in the sidebar links to a **Shared with me** page showing items other people have shared with you, including status (active/expired), access mode, and last accessed time. -- **ONLYOFFICE integration:** When `ONLYOFFICE_URL` and the JWT `ONLYOFFICE_SECRET` are configured, docx/xlsx/pptx/odt/ods/odp files open with co-editing capabilities via `/api/onlyoffice/*` endpoints. +- **ONLYOFFICE integration:** When `ONLYOFFICE_URL` and the JWT `ONLYOFFICE_SECRET` are configured, docx/xlsx/pptx/odt/ods/odp files open for editing via `/api/onlyoffice/*`. Two people opening the same document join the same session and edit it together. The editor follows the app's theme, closes with its own button (saving on the way out), and can rename the open document, save it under a new name, share it, mention other users, compare against another version, and insert files picked from your own storage. Work is saved in the background while the document stays open. +- **New office documents:** The drawer beside **New file** creates a blank Word, Excel or PowerPoint document and opens it straight in the editor. - **Favorites:** Pin folders to the sidebar with a star so critical paths stay in reach across sessions. -- **Archive operations:** Extract supported 7-Zip formats or create archives from the context menu. Password-protected archives, progress, cancellation, and extraction safety limits are supported. ## Search & metadata -- **Smart search:** Search filenames, Office documents, PDFs, and file contents from one interface. Filename glob patterns such as `*.pdf` match names without scanning contents; `SEARCH_RIPGREP`, `SEARCH_DEEP`, `SEARCH_MAX_FILESIZE`, and `SEARCH_TIMEOUT_MS` tune live searches. Set `SEARCH_INDEX=true` for an optional, bounded background index. +- **Smart search:** The search bar finds names and contents inside the current folder and its children, from three characters on. With the search index switched on (**Settings → Search index**, or `SEARCH_INDEX`), both are answered from the index, so a search on a network share costs a query rather than a walk of the storage; without it, ripgrep reads the files (`SEARCH_RIPGREP`, `SEARCH_DEEP`, `SEARCH_MAX_FILESIZE`). Each result says whether its name or its contents matched. The line shown under a content match is read back from the file, a few files at a time and only for the results on the page; on a slow share, those not read within two seconds are listed without their line rather than holding the answer. Names are ranked — the whole name, then a name that begins with the term, then one that holds it — and contents by relevance when the index answers, by folder otherwise, so that a folder's files arrive together. A search that ran out of time says so, rather than passing a short list off as the whole answer, and an accented name is found however the machine that wrote it encoded the accent. +- **Filename patterns:** `*` and `?` in a search term match filenames rather than text — `*.ps1` finds the scripts, `conf?g.json` finds either spelling, and `Stacks/*/logs/*.log` reaches across folders. A pattern names a shape, so nothing is read inside files for it, which is also what makes it immediate. +- **Inside documents:** Word, Excel and PowerPoint files are archives of XML and PDFs keep their words in compressed streams, so a plain content search finds nothing in either. Their text is read and searched — including a word an author emphasised halfway through, which Word stores in pieces. A scanned PDF is a picture of a page and stays unsearchable: that would need OCR. - **Metadata overlays:** List view shows size, kind, modified date, owner, and volume stats (volume usage visibility flips on with `SHOW_VOLUME_USAGE`). -- **Thumbnail cache:** `/cache` holds thumbnails and search indexes that regenerate when cleared. +- **Thumbnail cache:** `/cache` holds thumbnails, RAW previews and search indexes that regenerate when cleared; thumbnails and previews are kept within their limits (`THUMBNAIL_CACHE_MAX_FILES`, `RAW_PREVIEW_CACHE_MAX_FILES`). ## Access & security - **Local users & groups:** Create local accounts from Settings → Admin; the first account becomes admin and can’t be removed while others exist. -- **OIDC SSO:** Express OpenID Connect exposes `/login`, `/logout`, and `/callback`, so you can federate with Keycloak, Authentik, Authelia, or any compliant provider. Native iOS and Android clients can use the PKCE-secured mobile bridge. Admin elevation happens when the IdP groups/roles intersect `OIDC_ADMIN_GROUPS`. +- **Passkeys:** Any local account can add one in Settings → Passkeys, and sign in with a fingerprint, a face or the device's PIN instead of a password. The key stays on the device and what it signs names this site, so it cannot be phished, watched or replayed. A passkey that was unlocked to be used answers the second factor as well; one that was not still asks for the code. Browsers only allow this on a secure page served from a hostname, which the page says when it cannot be offered. +- **Two-factor authentication:** Any local account can turn on a second factor in Settings → Two-factor — a QR code for any authenticator app, a code to confirm the phone kept the secret, and ten recovery codes shown once. Signing in then asks for a code after the password; six digits are worth one sign-in, and a recovery code one use. With OIDC the second factor is the provider's. +- **OIDC SSO:** Express OpenID Connect exposes `/login`, `/logout`, and `/callback`, so you can federate with Keycloak, Authentik, Authelia, or any compliant provider. Admin elevation happens when the IdP groups/roles intersect `OIDC_ADMIN_GROUPS`. +- **Per-user access control:** Grant or deny paths per user or group, with read, write and delete kept apart. Personal folders (`USER_DIR_ENABLED`) and per-user volumes build on the same rules. +- **Secrets from files:** Every credential can be read from a file instead of the environment, so nothing sensitive appears in `docker inspect`. See [Secrets](/configuration/environment#secrets). - **Workspace lock:** A workspace password (set on first run) gates access, and admin-only sections (Files & Thumbnails, Security, Access Control, Admin Users) appear only when your role allows it. ## Operational helpers +- **The language you read in:** The interface follows the browser, which is right for most people and wrong for anybody whose browser is not in their language — a shared machine, a company image, a second account. Settings → Preferences → **Language** chooses one for the account, so it travels with you to whichever browser you sign in from; left on _Follow the browser_, nothing changes. The globe on the sign-in page still chooses a language for that browser, which is the one thing an account cannot do before anybody has signed in. - **Resizeable sidebar:** The sidebar can be dragged to different widths for wide or narrow monitors. -- **Notifications & uploads:** A floating footer panel tracks uploads, providing pause/resume/cancel controls plus multi-file progress. -- **Nothing is ever replaced:** A copy, a move, an upload, an extraction, a new archive or a new folder takes the name it asks for only when nothing holds it — even something that arrives while it runs — and otherwise takes “name (1)”, or “name 2” for a new folder. It never replaces a file and never pours into a folder that is already there. +- **Notifications & transfers:** A floating panel tracks uploads, copies, moves and archive work, with pause, resume and cancel, the transfer rate, and per-file detail when several run at once. +- **Chunked uploads:** Large files can be uploaded in resumable chunks (`UPLOAD_CHUNKED_ENABLED`), which survives a dropped connection and gets past reverse proxies that refuse large bodies — a fallback switches to chunks automatically when one does. Once the transfer ends, the server may still be writing the file into place; that phase is reported separately rather than appearing to stall at 100%, and a file that arrived but could not be put in its folder is reported as a failure, with the reason, never as done. +- **Cancellable file operations:** Copy and move run natively with real progress and can be stopped mid-way, leaving nothing half-written. +- **Nothing is ever replaced:** A copy, a move, an upload, an extraction, a new archive, a new folder, a Save as, a copy of a version or a restore from the trash takes the name it asks for only when nothing holds it — even something that arrives while it runs — and otherwise takes “name (1)”, or “name 2” for a new folder. It never replaces a file and never pours into a folder that is already there, and undoing one that failed removes only what it wrote itself: a file someone saved in the meantime stays. - **Keyboard shortcuts:** ⌘/Ctrl+C/X/V for clipboard actions, plus quick navigation via breadcrumbs and toolbar icons. + +- **Activity log:** Off unless an administrator turns it on, in Settings → Activity log. On, it writes down who signed in — including who tried and failed — what was downloaded, uploaded, deleted, restored and removed for good, what left through which share link and what arrived through one, every change to a password, a second factor or a passkey, and every account or setting an administrator changed. Administrators read it, and it keeps each line for as long as the retention says. + +## How it compares + +Two projects solve the same problem from a different angle: +[FileBrowser Quantum](https://github.com/gtsteffaniak/filebrowser), the active +fork of File Browser, and [Filestash](https://www.filestash.app/), which speaks +every storage protocol there is. Every cell below was read on **16 September +2026** from the project it describes — its repository, its documentation, its +pricing page — rather than from anybody's marketing or anybody's comparison +chart. The sources are listed underneath, including the ones about NextExplorer. + +✅ shipped · 🚧 announced by that project as coming · ❌ not offered · 💰 paid +tier · — not documented + +### The project + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| --------------------------------- | -------------------- | ----------------------- | ----------------------------------------------- | +| Licence | GPL-3.0 | Apache-2.0 | AGPL-3.0 (core) | +| Price | Free | Free | Free — Pro from $50/mo, Enterprise from $290/mo | +| Interface languages | 15 | 26 | — | +| Docker image, amd64 and arm64 | ✅ | ✅ | ✅ | +| Official installer outside Docker | ✅ Linux archive | ✅ | 💰 | + +### Archives, without unpacking them + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| --------------------------------- | ---------------------------------- | ----------------------- | ---------------- | +| Browse one like a folder | ✅ zip, 7z, rar, iso, tar, tar.gz… | 🚧 | ✅ viewer plugin | +| Read a file inside one | ✅ text, Markdown, images | 🚧 | ✅ viewer plugin | +| Take one entry — or several — out | ✅ into any folder you pick | ❌ | ❌ | +| Compress a selection | ✅ | ✅ | ❌ | + +### Getting data in and out + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| ------------------------------ | ----------------------------- | ----------------------- | ------------- | +| Chunked, resumable uploads | ✅ | ✅ | ✅ | +| Upload a whole folder | ✅ | ✅ | ✅ | +| Never replaces a file silently | ✅ “name (1)”, and it says so | — | — | + +### When something goes wrong + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| --------------------------------------------- | --------------------------- | ----------------------- | ------------- | +| Trash, with restore | ✅ | 🚧 | ❌ | +| Restore part of a deleted folder | ✅ | ❌ | ❌ | +| Earlier versions of a file | ✅ | ❌ | 💰 Enterprise | +| Versions from the office editors' own history | ✅ ONLYOFFICE and Collabora | ❌ | ❌ | + +### Finding things + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| ------------------------------------ | -------------------------------- | ----------------------- | ------------- | +| Search by name, indexed, as you type | ✅ | ✅ | ✅ | +| Search inside file contents | ✅ Office documents and PDFs too | ❌ | ✅ | + +### Viewing and editing + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| --------------------------------------- | ------------------------- | ----------------------- | ------------- | +| Images, video and audio, in the browser | ✅ | ✅ | ✅ | +| Office documents | ✅ ONLYOFFICE / Collabora | ✅ | ✅ | +| Text and code editor | ✅ | ✅ | ✅ | +| Folder sizes in the listing | ✅ | ✅ | — | + +### Who gets in + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| --------------------------------------- | ------------------------------------ | ----------------------- | ------------- | +| Local accounts | ✅ | ✅ | ✅ | +| OIDC single sign-on | ✅ | ✅ | 💰 Enterprise | +| LDAP sign-on | ❌ | ✅ | 💰 Enterprise | +| Second factor from an authenticator app | ✅ with recovery codes | ✅ | 💰 Enterprise | +| Passkeys (WebAuthn) | ✅ and they answer the second factor | ✅ | 💰 Enterprise | +| Brute force on the sign-in | ✅ account lockout | ✅ rate limiting | — | +| Access rules per path | ✅ read, write and delete apart | ✅ | 💰 RBAC | + +### Sharing + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| ----------------------------------- | -------------------- | ----------------------- | ------------- | +| Links with a password and an expiry | ✅ | ✅ | ✅ | +| Per-operation permissions on a link | ✅ | ✅ | ✅ | +| Guests can upload into a share | ✅ | ✅ | ✅ | + +### Running it + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| ----------------------------------- | --------------------------- | ----------------------- | --------------------- | +| WebDAV | ❌ by choice | ✅ | ✅ | +| Storage beyond the local filesystem | ❌ by choice | ❌ | ✅ about 25 protocols | +| API tokens for scripts | ✅ read-only or read-write | ✅ | ✅ | +| Activity log | ✅ optional, off by default | ✅ | 💰 | +| Space quotas | 🚧 | 🚧 | 💰 | +| Terminal in the browser | ✅ switchable | ❌ removed deliberately | ❌ | + +### Where each answer comes from + +- **NextExplorer**: the pages on this site — [archives](/experience/workflows), + [trash](/admin/trash), [file versions](/admin/versions), + [search](/experience/features), [two-factor, passkeys and + access](/admin/guide) — and the suites in the repository. The 🚧 are recorded + in `TODO.md` with what each would take; they are intentions, not dates. The ❌ + are honest: there is no LDAP here, and the two marked _by choice_ are settled + positions rather than a backlog nobody got to. +- **FileBrowser Quantum**: its [README](https://github.com/gtsteffaniak/filebrowser) + states OIDC, LDAP, JWT, password + 2FA and proxy sign-in, WebDAV, folder + sizes, API tokens, granular permissions, share expiry and permissions, and + that shell commands were removed on purpose. Its own comparison chart is + where trash, quotas and browsing archives are marked as coming, and + content-aware search as absent. In its source: chunked uploads, TOTP, + WebAuthn passkeys, rate limiting on the auth routes, archive creation as zip + or tar.gz, and twenty-six interface languages — and no extraction from an + archive, and nothing about versioning. +- **Filestash**: its [pricing page](https://www.filestash.app/pricing/) is + where free ends and paid begins — the self-hosted Hobby edition is AGPL and + free, Pro starts at $50/month, Enterprise at $290/month. In its own feature + table, OIDC, SAML, LDAP sign-on, MFA, RBAC and versioning are Enterprise; + quotas and the audit journal are Pro; resumable uploads, shared links, the + editors and Docker are free, and the Debian and RHEL installers are not. Its + [README](https://github.com/mickael-kerjean/filestash) is the source for the + storage protocols and the viewer plugin that opens `tar`, `tgz` and `zip`. + Nothing in either describes a trash or an extraction. +- **The original [File Browser](https://github.com/filebrowser/filebrowser)** + is left out: its README says it was archived on 1 September 2026, that there + will be no further releases, and that two classes of security issue — the + command runner, and sessions that are self-contained JWTs and therefore + cannot be revoked — will not be fixed. Quantum is its active fork, and stands + in the table instead. + +### API tokens + +That row was a 🚧 until a script had a credential of its own. A token is issued +from the settings of the account it belongs to, shown once and stored hashed, +and revoked on its own without disturbing the account or the other tokens. It +is deliberately **less** than the account: a read-only token reaches `GET` and +nothing that changes anything, and no token at all — whatever its scope, and +even when its owner is an administrator — reaches the account's own settings, +any administrative route, or the terminal. [Driving the API](/reference/api) has +the whole of it. + +### The intention left, and the two crosses that stay + +Space quotas are what the comparison still says is missing here, and it is in +the backlog with the shape it would take: the recursive folder-size index +already counts what a quota would hold people to; what it needs is a decision +about what a quota applies to, and one place where a write is refused rather +than ten. The activity log and the API tokens that were here beside it are +done — see [Admin & Access](/admin/guide). + +WebDAV is a cross rather than a 🚧, and stays one. NextExplorer is a file +browser, not a server: something you open and use, not something other software +mounts. A protocol is a second permanent way in — its own way of proving who is +asking, its own locks, its own clients writing whenever they like — on top of a +filesystem this already reaches, and every rule about who may read, write or +delete a path would have to hold on that side too. What is mounted into the +container is what this browses, the way a drive is a volume in Windows Explorer: +an NFS or SMB share mounted on the host is already here, without this project +speaking a protocol of its own. Twenty-five storage protocols are a cross for +the same reason from the other end: that is Filestash's ground, and arriving +second on it would cost the thing this does well, which is knowing one +filesystem deeply. diff --git a/docs/experience/workflows.md b/docs/experience/workflows.md index 5da14b144..7760dc78e 100644 --- a/docs/experience/workflows.md +++ b/docs/experience/workflows.md @@ -12,10 +12,11 @@ These are the day-to-day actions your team will take in nextExplorer. Every work - **Create a folder/file:** Use the `Create` menu, context menu (right-click background → New Folder/File), or press the `+` toolbar button. A new folder takes “Untitled Folder 2” when the name is taken, even by one created at the same moment. - **Rename:** Right-click an item and choose Rename or use F2 key to rename. -- **Move (desktop drag-and-drop):** Select one or more items (Ctrl/⌘-click, Shift-click, or drag a selection rectangle), then drag any selected item onto a destination folder and drop to move everything selected. -- **Move (touch devices):** Drag-to-move is disabled on touch devices; use the context menu Cut → Paste instead. +- **Move (desktop drag-and-drop):** Select one or more items (Ctrl/⌘-click, Shift-click, or drag a selection rectangle), then drag any selected item onto a destination folder and drop to move everything selected. Hold Alt (Option on macOS) while dropping to copy instead, and drop onto a favorite in the sidebar to send items there without navigating to it. +- **Move to / Copy to:** Right-click a selection and choose **Move to** or **Copy to**. The dialog lists the destinations you have used recently, then your favorites, then the storage to browse; destinations that cannot work — the root, a folder inside itself — are refused before the transfer rather than after. What is copied or moved never replaces a file or merges into a folder already at the destination, including one that appears during the transfer: it takes “name (1)”. Nothing appears under the name until a copy is whole; a cancelled copy leaves nothing behind, and what someone put in the destination meanwhile stays. A symbolic link is copied as a link, not as what it points to, whichever engine copies it; one that leaves its volume is shown as such and opens nothing. +- **Move (touch devices):** Drag-and-drop is disabled on touch devices, so use **Move to** from the item menu (long-press to open it). Cut → Paste still works if you prefer it. - **Delete:** The context menu’s Delete option (or toolbar action) prompts for confirmation and supports multi-select deletions. -- **Clipboard shortcuts:** ⌘/Ctrl+C/X/V work just like desktop file managers and respect Access Control rules (read-only folders can’t be written). What is pasted or moved never replaces a file or merges into a folder already at the destination, including one that appears during the transfer: it takes “name (1)”. +- **Clipboard shortcuts:** ⌘/Ctrl+C/X/V work just like desktop file managers and respect Access Control rules (read-only folders can’t be written). - **Mobile multi-select (checkboxes):** Tap **Select** in the toolbar to enter selection mode, then tap items to toggle selection without opening them; tap **Done** to exit (selection clears on exit). Long-press opens the item menu. ## Uploads & downloads @@ -23,31 +24,35 @@ These are the day-to-day actions your team will take in nextExplorer. Every work - **Drag-and-drop upload:** Drop files/folders from your device onto the main pane to upload; the floating footer upload panel shows per-file and total progress. An upload never replaces a file already there, even one that arrives while it is sent: it takes “name (1)”. - **Create menu upload:** Select Upload files/folders from the Create menu if you prefer a dialog. - **Download:** Select one or more items and hit the Download button; multiple items or folders produce a ZIP archive. -- **Transfer control:** Pause, resume, or cancel uploads directly from the footer panel. +- **Transfer control:** Pause, resume, or cancel uploads directly from the footer panel, which also shows the current rate. With several files in flight, the summary adds them up and expanding the list gives each file its own figure. +- **Large files:** With chunked uploads enabled, a transfer resumes from where it stopped rather than starting over, and gets past reverse proxies that reject large bodies. Once every byte is sent, the server may still be writing the file into place — that phase is shown separately, so a long copy is not mistaken for a stalled upload. If the file cannot be put in its folder, the upload fails with the server’s reason instead of showing as done. ## Search - Click the search icon in the toolbar, type a query, and press Enter. - Search covers filenames and file contents thanks to ripgrep; disable deep search with `SEARCH_DEEP=false` if you want faster scans. - Large files respect `SEARCH_MAX_FILESIZE`; if ripgrep isn’t available, the app falls back to a built-in indexer that still searches filenames. -- Use glob patterns to search names without searching contents: `*.pdf` finds PDFs and `reports/*.xlsx` matches files in that relative path. Set `SEARCH_TIMEOUT_MS` when a search should return partial results sooner; enable `SEARCH_INDEX=true` for a resumable background index. +- Type `*` or `?` to search by filename shape rather than by text: `*.ps1` for the scripts, `*.xlsx` for the spreadsheets. A pattern is matched against the whole name, so `*.ps1` does not return `deploy.ps1.bak`. +- With `SEARCH_INDEX` on, content searches are answered from the index rather than by reading the volume, and a search names any folder it did not have time to finish looking through. ## Previews & editing -- **Preview:** Click images/videos/PDFs to open them inside the app (previews are cached in `/cache`). Compatible embedded and sidecar subtitles are available in media previews; unsupported codecs are identified clearly. +- **Preview:** Click images/videos/PDFs to open them inside the app (previews are cached in `/cache`). - **Editor:** Double-click text/code files to open the inline editor with syntax highlighting, line numbers, and Save/Cancel actions. The editor supports 50+ file types by default including common text formats (txt, md, log), data files (json, yaml, xml, csv), programming languages (js, ts, py, java, go, rust, etc.), config files (ini, env, properties), shell scripts (sh, bash, ps1), and web formats (html, css, scss, vue). Add support for custom file types (e.g., `.toml`, `.proto`, `.graphql`) at runtime using the `EDITOR_EXTENSIONS` environment variable—no rebuild needed, changes apply on container restart. -- **ONLYOFFICE:** When configured, office documents (DOCX, XLSX, PPTX, ODT, ODS, ODP) launch in the embedded ONLYOFFICE editor; nextExplorer signs requests with `ONLYOFFICE_SECRET` and calls `/api/onlyoffice/config`, `/api/onlyoffice/file`, and `/api/onlyoffice/callback` to orchestrate editing. - -## Archives - -- **Extract:** Right-click a supported archive and choose **Extract**. Password-protected archives prompt for a password; progress is shown while extraction runs and the operation can be cancelled. -- **Create:** Select files or folders, then use the context-menu archive action to create an archive in the current folder. -- **Safety limits:** Extractions are refused when their declared entry count or expanded size exceeds `MAX_ARCHIVE_ENTRIES` or `MAX_EXTRACTED_ARCHIVE_SIZE`. +- **ONLYOFFICE:** When configured, office documents (DOCX, XLSX, PPTX, ODT, ODS, ODP) launch in the embedded ONLYOFFICE editor; nextExplorer signs requests with `ONLYOFFICE_SECRET` and calls `/api/onlyoffice/config`, `/api/onlyoffice/file`, and `/api/onlyoffice/callback` to orchestrate editing. Leaving the document — closing the panel, or closing the browser tab it was opened in — calls `/api/onlyoffice/session-end`, which asks Document Server for one last save and then lets the editing session go. One route for both, because a tab being closed has time for exactly one request and the two have to leave the server in the same state. +- **Inside an archive:** Open a zip, 7z, rar, iso or tar to see what is in it without extracting anything, listed the way a folder is. Folders open, the trail at the top walks back, and every row offers two things: take this one file out onto the volume, or download it. The rest of the archive is never unpacked. Which formats open is the same list the Extract action offers, and it depends on the 7-Zip the image was built with. + - **Reading one, without taking it out:** the name of an entry the panel can show is a link — text and code, Markdown, and the images a browser decodes on its own (JPEG, PNG, GIF, WebP, BMP, SVG, ICO, AVIF). It opens in the same window, with the file as the last step of the trail and the folder it is in as the way back. A camera's raw file and a HEIC are not offered: what is shown here comes straight out of the archive, and nothing converts it on the way. Text stops at 2 MB and an image at 32 MB — past that the panel says the size and leaves downloading or extracting as the way to open it. + - **Extract here** puts that entry — a file, or a folder with everything under it — in the folder the archive is in. Nothing is ever replaced: a name already held becomes “name (1)”, and the panel says which name it landed under. + - **Several at once, and somewhere else:** every row has a tick box, and the box in the column header takes everything at this level. What is ticked goes out in one request — **Extract here** for the folder the archive is in, or **Extract to…** for the same “Move to” dialog the rest of the application uses, opened on that folder. Ticks are forgotten when another folder is opened, and once what was ticked has come out. + - A `.tar.gz` and its family (`.tbz2`, `.txz`, `.tar.zst`) are two archives, so the tar inside is decompressed once into `CACHE_DIR/archives` and read from there. Past a certain size the answer is to extract the archive instead. + - A solid `.7z` — one where every file went into a single compressed stream — is read the same way until somebody reads a second entry from it. At that point it is extracted once into `CACHE_DIR/archives` and every read after it comes from there: measured on a real 7-Zip, reading ten entries one at a time costs five times what extracting the whole archive costs, and the second read costs about the same either way. Nothing is extracted for a first read, or for an archive past that size; what is kept shares the cache's budget and is swept the same way. + - An archive whose table of contents is itself password-protected says so rather than opening empty; entries whose contents are encrypted are listed but not handed over. Extraction is where a password is asked for. + - An archive can hold names that point outside itself. Those are never shown as a place inside it, and the panel says how many were left out. ## Sharing items -- **Create a share link:** Select a single file or folder in any browse view (including **My Files** when personal folders are enabled) and click the **Share** button in the toolbar. Configure access mode (read-only vs read/write), choose whether the link is open to **anyone with the link** or restricted to **specific users**, optionally set a password and expiration date, then create the link and copy it from the success screen. -- **Open a share link as a guest:** Visitors open URLs like `https://files.example.com/share/aBc123XyZ`. The Share access page shows basic information (label, type, expiration) and either auto-opens the shared item, prompts for a password (of anyone but the share's owner, whether signed in or not), or redirects to the login page for user-specific shares. Guest sessions are limited so they can only browse within the shared item. +- **Create a share link:** Select a single file or folder in any browse view (including **My Files** when personal folders are enabled) and click the **Share** button in the toolbar. Configure access mode (read-only vs read/write), choose whether the link is open to **anyone with the link** or restricted to **specific users**, optionally set a password and expiration date, optionally withhold downloads so the share can be read but not copied, then create the link and copy it from the success screen. +- **Open a share link as a guest:** Visitors open URLs like `https://files.example.com/share/aBc123XyZ`. The Share access page shows basic information (label, type, expiration) and either auto-opens the shared item, prompts for a password, or redirects to the login page for user-specific shares. Guest sessions are limited so they can only browse within the shared item. - **Review items shared with you:** Use the **Shares → Shared with me** entry in the sidebar to see folders/files other users have shared with your account, filter by status (active/expired), and click into a share to open it in the normal browser view. ## Favorites & quick access @@ -58,6 +63,7 @@ These are the day-to-day actions your team will take in nextExplorer. Every work ## Access control & admin actions - **Access Control rules:** Settings → Access Control defines per-folder policies (`rw`, `ro`, `hidden`). The first matched rule applies. -- **Hidden folders:** Use `hidden` to hide sensitive folders from listings; they remain accessible via direct paths if you know them. +- **Hidden folders:** Use `hidden` to keep a folder out of listings _and_ refuse it when asked for by name — it is a denial, not a cosmetic filter. This page used to say the opposite, which would have talked an administrator out of the one control that stops a path being read. - **Admin users:** Settings → Admin lets you add local users, reset passwords, and grant the admin role. Demoting an admin via UI is disabled to avoid lockouts. +- **Changing a password:** Settings → Password changes your own. Every other session of your account is signed out — another browser, another device, anyone who had the old password — and the one you changed it from stays signed in. A reset by an administrator signs the account out everywhere. - **Sign-out:** Use the user menu in the sidebar to log out or manage user-specific settings. diff --git a/docs/installation/deployment.md b/docs/installation/deployment.md index 6a75506f9..d3666093e 100644 --- a/docs/installation/deployment.md +++ b/docs/installation/deployment.md @@ -5,24 +5,52 @@ Deploy nextExplorer via Docker Compose for reproducible self-hosted workflows. T ## Prerequisites - **Docker Engine 24+ and Docker Compose v2** (or later). The official image depends on modern orchestration features. -- **Host directories** for data volumes, `/config`, and optional `/cache` (make sure the Docker user can read/write these paths). +- **Host directories** for data volumes, `/config`, and `/cache` (make sure the Docker user can read/write these paths). `/cache` can be left out, but it holds the search index and the folder sizes: without a persistent mount, every new container reads the volumes again to rebuild them. - **TLS-capable reverse proxy** if you need HTTPS, custom domains, or sticky sessions. +## Image variants + +Two images are published, on both registries: + +| Tag | Contains | +| ---------------------------- | -------------------------------------------------------------------------------- | +| `latest`, `3.11.0` | Everything, including hardware video acceleration (VA-API) and RAW photo support | +| `latest-lean`, `3.11.0-lean` | The same application without VA-API or RAW — a considerably smaller image | + +Take the full image unless you know you need neither: VA-API only helps where the host exposes a render device to the container, and RAW support only matters if you keep camera files. Both variants are built for `linux/amd64` and `linux/arm64`. + +``` +ghcr.io/cerede2000/explorer:latest +ghcr.io/cerede2000/explorer:latest-lean +``` + +They are also on Docker Hub under the same tags. + +`latest` and `latest-lean` follow `main`, so a fix reaches them without waiting +for a release. Every build is also published under the version in +`package.json` — `3.11.0`, `3.11.0-lean` — republished for as long as that +version is current, and left alone once the next one is cut. + +Only the last two versions stay published: on Docker Hub the older one is +removed as the next is published, and on GHCR a weekly job does the same. Pin a +version you intend to keep running and move it forward deliberately rather than +expecting an old tag to still be there. + ## Host folder layout -| Purpose | Container path | Notes | -| ---------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| Configuration, user DB, extensions | `/config` | Holds SQLite, `app-config.json`, and upgrades. Back this directory up before changes. | -| Thumbnail/search cache | `/cache` | Regenerable; safe to delete when troubleshooting. | -| Browsable data | `/mnt/Label` | Each mount appears as a top-level volume with the given label. | -| Personal user data (optional) | `/srv/users` (or any path set as `USER_ROOT`) | When `USER_DIR_ENABLED=true`, each authenticated user gets their own private folder inside this root. | +| Purpose | Container path | Notes | +| ----------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Accounts, shares, settings | `/config` | Holds `app.db`, `logos/` and `session-secret`. The folder to back up — see [Backups](/admin/guide#backups-persistence). | +| Thumbnails, sessions, indexes | `/cache` | Regenerable and needs no backup, but mount it persistently: it holds `index.db`, the search index and folder sizes, and deleting it signs everyone out and reads every volume again. | +| Browsable data | `/mnt/Label` | Each mount appears as a top-level volume with the given label. | +| Personal user data (optional) | `/srv/users` (or any path set as `USER_ROOT`) | When `USER_DIR_ENABLED=true`, each authenticated user gets their own private folder inside this root. | ## Production compose example ```yaml services: nextexplorer: - image: nxzai/explorer:latest + image: ghcr.io/cerede2000/explorer:latest container_name: nextexplorer restart: unless-stopped ports: @@ -46,7 +74,7 @@ services: ``` - `PUBLIC_URL` informs the backend's cookie settings, CORS, and default OIDC callback (see `backend/src/config/env.js`). -- `SESSION_SECRET` ensures sessions persist across restarts; without it, the app generates a random secret each time. +- `SESSION_SECRET` sets the session secret yourself. Without it, one is generated at the first start and kept in `/config/session-secret`, so sessions survive restarts all the same; set it when several replicas share the sessions. - Optional first-run bootstrap: set `AUTH_ADMIN_EMAIL` and `AUTH_ADMIN_PASSWORD` to auto-create the first local admin on startup (skips the setup wizard). ## Launching and validating @@ -63,8 +91,8 @@ docker compose pull docker compose up -d ``` -- Persistent state (`app.db`, `app-config.json`, extensions) stays inside `/config`. Always back this up before major upgrades. -- The default entrypoint moves legacy config files from `/cache` to `/config` on first run; keep `/config` mounted to avoid data loss. +- Persistent state (`app.db`, `logos/`, `session-secret`) stays inside `/config`. Back it up before upgrading, with the container stopped or together with `app.db-wal`. +- Installations that started on 1.1.7 or earlier kept `app.db` in `/cache`. Nothing moves it any more: copy it to `/config` by hand before upgrading such an installation; the server warns at start when it finds such a file there. Links named `app.db`, `app-config.json` or `extensions` left in `/cache` by 1.1.8 to 2.0.2 are unused and can be deleted. ## Monitoring & logs diff --git a/docs/installation/reverse-proxy.md b/docs/installation/reverse-proxy.md index a3393116b..9340a84d8 100644 --- a/docs/installation/reverse-proxy.md +++ b/docs/installation/reverse-proxy.md @@ -7,9 +7,156 @@ When exposing nextExplorer on a custom domain, a reverse proxy keeps the UI secu | Variable | Purpose | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PUBLIC_URL` | External URL (no trailing slash) used to set cookies, determine OIDC callbacks, and drive CORS defaults. Example: `https://files.example.com`. | +| `INTERNAL_URL` | Optional comma-separated LAN origins. They are accepted by CORS and can each complete OIDC login without redirecting through `PUBLIC_URL`. | | `TRUST_PROXY` | Controls Express’s trust level; accepts `false`, a number (hops), or lists such as `loopback,uniquelocal`. If unset and `PUBLIC_URL` exists, defaults to `loopback,uniquelocal`. (`backend/config/trustProxy.js` documents this mapping.) | | `CORS_ORIGIN(S)` / `ALLOWED_ORIGINS` | Explicit CORS origins when they differ from `PUBLIC_URL`. Defaults to the origin of `PUBLIC_URL` when provided. | +## HTTPS with a Let's Encrypt certificate + +NextExplorer does not terminate TLS itself, and does not read a certificate +from disk. A Let's Encrypt certificate lasts a few months at most and has to be +renewed before it runs out, and a server that reads the file once at start goes +on serving the old one until somebody restarts it. A reverse proxy asks for the +certificate, renews it on time, redirects port 80 to 443 and speaks HTTP/2 — +all of it without the application knowing — so that is the way to serve it over +HTTPS. + +Two proxies that do all of this on their own are shown below: Traefik, which +reads its routes from Docker labels, and Caddy, which needs two lines. Both +assume: + +- a DNS record for `files.example.com` pointing at the machine; +- ports **80** and **443** reachable from the internet — Let's Encrypt checks + that you own the name through them, at every renewal; +- `PUBLIC_URL=https://files.example.com` on NextExplorer, which is what makes + its cookies `Secure` and its links point at the right place. + +NextExplorer is not published on a port of its own in either example: the +proxy reaches it over the Compose network, and nothing else should. With +`PUBLIC_URL` set, `TRUST_PROXY` defaults to `loopback,uniquelocal`, which +believes a proxy on a Docker network and nobody on the internet — nothing to +set for the addresses in the [activity log](#the-address-that-gets-recorded) +to be the visitors' own. + +### Traefik + +```yaml +services: + traefik: + image: traefik:v3.7 + restart: unless-stopped + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entryPoints.web.address=:80 + - --entryPoints.web.http.redirections.entryPoint.to=websecure + - --entryPoints.web.http.redirections.entryPoint.scheme=https + - --entryPoints.websecure.address=:443 + # Traefik gives a request 60 seconds to arrive, body included, and then + # cuts it: a large file sent in one request is refused half-way. 0 is no + # limit, which is what NextExplorer itself applies (HTTP_TIMEOUT). + - --entryPoints.websecure.transport.respondingTimeouts.readTimeout=0 + - --certificatesresolvers.letsencrypt.acme.httpchallenge=true + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + - --certificatesresolvers.letsencrypt.acme.email=you@example.com + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + ports: + - '80:80' + - '443:443' + volumes: + - ./letsencrypt:/letsencrypt + - /var/run/docker.sock:/var/run/docker.sock:ro + + nextexplorer: + image: ghcr.io/cerede2000/explorer:latest + restart: unless-stopped + environment: + - PUBLIC_URL=https://files.example.com + volumes: + - /srv/nextexplorer/config:/config + - /srv/nextexplorer/cache:/cache + - /srv/data/Projects:/mnt/Projects + labels: + - traefik.enable=true + - traefik.http.routers.nextexplorer.rule=Host(`files.example.com`) + - traefik.http.routers.nextexplorer.entrypoints=websecure + - traefik.http.routers.nextexplorer.tls.certresolver=letsencrypt + - traefik.http.services.nextexplorer.loadbalancer.server.port=3000 +``` + +`./letsencrypt/acme.json` holds the account and the certificates; keep it, or +every restart asks Let's Encrypt again and runs into its rate limits. While +trying things out, add +`--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory` +to use the staging service, whose certificates browsers do not trust but whose +limits are far wider — and remove it, with `acme.json`, once it works. + +### Caddy + +```yaml +services: + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - '80:80' + - '443:443' + - '443:443/udp' + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + + nextexplorer: + image: ghcr.io/cerede2000/explorer:latest + restart: unless-stopped + environment: + - PUBLIC_URL=https://files.example.com + volumes: + - /srv/nextexplorer/config:/config + - /srv/nextexplorer/cache:/cache + - /srv/data/Projects:/mnt/Projects + +volumes: + caddy_data: + caddy_config: +``` + +With this `Caddyfile` beside it: + +``` +{ + email you@example.com +} + +files.example.com { + reverse_proxy nextexplorer:3000 +} +``` + +A site address with a domain name is all Caddy needs to ask Let's Encrypt for +the certificate, renew it and redirect port 80 to 443. `caddy_data` holds the +certificates and must outlive the container, for the same reason as Traefik's +`acme.json`. + +### What NextExplorer asks of the proxy, and where each stands + +| What | Traefik | Caddy | +| ------------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------------------------------------- | +| Large uploads in one request | cut at 60 s unless `readTimeout` is raised, as above | no limit on reading a body, and no size limit | +| Progress of a copy, a move, a deletion — streamed as it goes | sent as it comes | sent as it comes: a response of unknown length is flushed at once | +| The terminal, over a WebSocket at `/api/terminal` | nothing to set | nothing to set | +| `X-Forwarded-For`, `-Proto`, `-Host` | sent | sent | + +A proxy that does limit the size of a request — Cloudflare's, at 100 MB on the +free plan — is what chunked uploads are for: turn them on in **Settings → +Uploads**, or let the automatic fallback find a size that passes + +To check the result: `https://files.example.com/healthz` answers +`{"status":"ok"}` through the proxy, the browser shows the certificate as +issued by Let's Encrypt, and `http://files.example.com` lands on the `https` +address. + ## Sample Nginx Proxy Manager block - Point `files.example.com` to the container’s internal `3000` port. @@ -22,23 +169,69 @@ When exposing nextExplorer on a custom domain, a reverse proxy keeps the UI secu - Override with values such as `1` (trust one hop) or CIDRs (`10.0.0.0/8,172.16.0.0/12`). - Avoid `TRUST_PROXY=true` alone; the entrypoint maps it to `loopback,uniquelocal` for safety. +## The address that gets recorded + +The activity log, share access counters and the server's own warnings all +write down one address per request, and which one that is depends entirely on +this page. + +- **No proxy.** Whoever opened the socket. In a container that is often not the + person: a connection made from the Docker host itself, or relayed by Docker's + userland proxy — which is every connection on Docker Desktop — arrives from + the bridge (`172.17.0.1`, `172.18.0.1`). From another machine on the LAN to a + published port on Linux, the real address survives. Nothing in the + application can recover an address the kernel already replaced; that is a + Docker networking matter, not a setting here. +- **Behind a proxy.** `TRUST_PROXY` decides whether the address the proxy + announces is believed. `loopback` alone is not enough when the proxy is + another container: it speaks from the bridge network, so use + `loopback,uniquelocal` or the proxy's own CIDR. +- **A chain is read from the right**, and stops at the first hop that is not + trusted — a hop nobody vouches for could have written everything to its left. + Trust one proxy and three appear in the chain, and what you get is the third + one, not the person. +- **`CF-Connecting-IP` wins where Cloudflare is in front**, then + `X-Forwarded-For`, then `X-Real-IP` (nginx's own example configuration sends + that one and not the first). `True-Client-IP` is read as well. +- **A Cloudflare tunnel only helps when it carries HTTP.** A public hostname + route goes through Cloudflare's edge, which adds `CF-Connecting-IP`, so the + person is named. A private network route — reaching the machine through WARP + by its own address and port — forwards raw TCP: there is no HTTP for a header + to be added to, the origin sees `cloudflared` itself, and nothing on this + page recovers an address that never arrived. +- **Never trust a proxy that is not yours.** With `TRUST_PROXY` set, anybody who + can reach the port directly can choose what the log says about them. + +When a proxy announces a client and nothing here believes it, the server says +so once in its own log, naming the address it was told and the one it is +recording instead — the alternative is a log where every line says +`172.18.0.1` and nothing anywhere says why. + +To settle it from a browser rather than from the logs, an administrator can +open `/api/activity/address`. It answers with the address that would be +recorded, the machine at the other end of the socket, whether that machine is +believed, the rule in force — which is also how to see that `TRUST_PROXY` never +reached the process — and every forwarding header that arrived. An empty list +of headers is the answer to the hardest version of the question: nobody +announced a client, so there is nothing to believe. + ## CORS & headers - Set `CORS_ORIGINS`/`ALLOWED_ORIGINS` when the app is accessed from multiple domains. - For a full walkthrough (including `PUBLIC_URL` and origin mismatch behavior), see [Fixing CORS errors](/reference/cors). - Ensure the proxy forwards `X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-For` so the backend derives the correct `PUBLIC_URL` origin and TLS state. -- The application sets its own baseline response headers — `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: same-origin`, a `Permissions-Policy` and `X-Robots-Tag: noindex` — and does not advertise Express. It does not set `Strict-Transport-Security`: whether every hostname is HTTPS-only is the proxy's decision, so set HSTS there if you want it. ## Networking health checklist - Proxy has TLS termination and forwards headers. Without headers, session cookies may appear as `Insecure`. - POST, PUT, DELETE operations work through the proxy; test with uploads and metadata edits. -- If using OIDC, verify the IdP’s redirect URI matches `${PUBLIC_URL}/callback` or your manually supplied `OIDC_CALLBACK_URL`. +- If using OIDC with `INTERNAL_URL`, register `${PUBLIC_URL}/callback` and every `/callback` with the IdP. The browser returns to the exact configured origin where login started. ## Troubleshooting proxies -| Symptom | Fix | -| ---------------------------- | ---------------------------------------------------------------------------------------------- | -| CORS errors | Add the proxy domain to `CORS_ORIGINS` or set `PUBLIC_URL`. | -| Sessions drop | Confirm `TRUST_PROXY` lets Express read `X-Forwarded-Proto` and `COOKIE` is not stripped. | -| Redirect URI mismatch (OIDC) | Ensure the IdP redirect equals `${PUBLIC_URL}/callback` or the configured `OIDC_CALLBACK_URL`. | +| Symptom | Fix | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CORS errors | Add the proxy domain to `CORS_ORIGINS` or set `PUBLIC_URL`. | +| Sessions drop | Confirm `TRUST_PROXY` lets Express read `X-Forwarded-Proto` and `COOKIE` is not stripped. | +| Redirect URI mismatch (OIDC) | Register `${PUBLIC_URL}/callback` and each configured internal `/callback` with the IdP. | +| Every logged address is the same `172.x` | The proxy is not trusted, or there is no proxy and Docker replaced the source. Set `TRUST_PROXY=loopback,uniquelocal`; the server warns once when it is ignoring an announced address. | diff --git a/docs/integrations/authelia.md b/docs/integrations/authelia.md index 43357e9ec..80938afb3 100644 --- a/docs/integrations/authelia.md +++ b/docs/integrations/authelia.md @@ -65,5 +65,5 @@ The backend discovers `/login`, `/callback`, `/logout` automatically once OIDC i | --------------------------- | --------------------------------------------------------------------------------------------------------------- | | Redirect URI mismatch | Ensure Authelia’s `redirect_uris` entry equals `${PUBLIC_URL}/callback` (or `OIDC_CALLBACK_URL` if overridden). | | Users not admin | Include the group claim in Authelia (via `groups` scope/mapping) and add the group name to `OIDC_ADMIN_GROUPS`. | -| Session issue after restart | Set a fixed `SESSION_SECRET` so the Express session stays valid. | +| Session issue after restart | Keep `/config` persistent, where the generated session secret is kept, or set a fixed `SESSION_SECRET`. | | Behind proxy | Set `PUBLIC_URL`, configure `TRUST_PROXY`, and forward `X-Forwarded-*` headers from your proxy. | diff --git a/docs/integrations/oidc.md b/docs/integrations/oidc.md index 578bf4160..c6a1722cb 100644 --- a/docs/integrations/oidc.md +++ b/docs/integrations/oidc.md @@ -9,11 +9,10 @@ nextExplorer uses Express OpenID Connect (EOC) to federate authentication with e - `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` store client credentials. - `OIDC_SCOPES` defaults to `openid profile email`; add `groups` if you want nextExplorer to inspect group claims. - `OIDC_ADMIN_GROUPS` contains comma/space-separated group names that grant the admin role when present in `groups`, `roles`, or `entitlements` claims. -- `OIDC_REQUIRE_EMAIL_VERIFIED` (default `false`) — when `true`, requires the IdP to verify the user's email before allowing user creation or auto-linking. Some providers like newer versions of Authentik set `email_verified` to `false` by default; keep this setting as `false` to allow those users to log in. Whatever it is set to, an address the IdP has **not** verified never attaches a sign-in to an account that already exists here: that would let whoever controls an unverified address at the IdP sign in as the account holding it. Such a sign-in gets an account of its own; to reach an existing one, have the IdP mark the address verified. Only a boolean `true` counts — the string `"false"` is not verified. +- `OIDC_REQUIRE_EMAIL_VERIFIED` (default `false`) — when `true`, requires the IdP to verify the user's email before allowing user creation or auto-linking. Some providers like newer versions of Authentik set `email_verified` to `false` by default; keep this setting as `false` to allow those users to log in. - `OIDC_AUTO_CREATE_USERS` (default `true`) — when `false`, the user must already exist in the nextExplorer database (local or previously OIDC-linked), otherwise OIDC login is denied. -- `OIDC_MOBILE_REDIRECT_URIS` — optional comma-separated allowlist of native-app custom-scheme callbacks for the mobile bridge. The default is `nextexplorer://oidc-callback`; HTTP(S) URLs are deliberately rejected. - Optional overrides: `OIDC_AUTHORIZATION_URL`, `OIDC_TOKEN_URL`, `OIDC_USERINFO_URL`, `OIDC_LOGOUT_URL`, and an explicit `OIDC_CALLBACK_URL` (defaults to `${PUBLIC_URL}/callback`). - - `OIDC_LOGOUT_URL` — optional custom IdP logout URL. When set, logout requests redirect to this URL with a `post_logout_redirect_uri` parameter (OIDC standard). If not set, logout only clears the local session without redirecting to the IdP. A `returnTo` given to `/logout` is honoured only as a path on this site; anything else ends on the sign-in page. + - `OIDC_LOGOUT_URL` — optional custom IdP logout URL. When set, logout requests redirect to this URL with a `post_logout_redirect_uri` parameter (OIDC standard). If not set, logout only clears the local session without redirecting to the IdP. ## Choosing authentication modes @@ -53,30 +52,52 @@ to stop a misconfiguration from locking everyone out: Where neither holds, roles stay as they are and are managed from **Settings → Users** instead. If you do lock yourself out, setting `AUTH_ADMIN_EMAIL` to your -address and `AUTH_ADMIN_PASSWORD`, with `AUTH_MODE` at `local` or `both`, and -restarting restores the admin role on that account. - -**Who signs in is the person the id token names.** The claims are read from the -id token the library verified; a userinfo answer about a different subject -refuses the sign-in, and when userinfo or the discovery document is briefly -unavailable, the sign-in goes ahead on the id token alone. - -## Native iOS & Android clients - -The mobile bridge lets a native client use the device’s system browser (such as `ASWebAuthenticationSession` on iOS or Custom Tabs on Android) while keeping the authorization result bound to the app with PKCE. - -1. Generate a PKCE verifier and its `S256` challenge in the app. -2. Open `GET /api/auth/oidc/mobile/login` in the system browser with `code_challenge`, `code_challenge_method=S256`, and an allowlisted `redirect_uri`. -3. After the IdP completes sign-in, nextExplorer redirects to that custom-scheme URI with a short-lived, single-use `code`. -4. Send the `code` and original `code_verifier` to `POST /api/auth/oidc/exchange` to establish the normal nextExplorer session. - -Only custom-scheme URIs listed in `OIDC_MOBILE_REDIRECT_URIS` can receive the code. Do not use an embedded web view, reuse an authorization code, or send the PKCE verifier through the redirect URI. +address and restarting restores the admin role on that account. + +## Signing in from a native app + +A native iOS or Android client cannot complete an OIDC sign-in the way the web +app does. On iOS, passkeys only work inside `ASWebAuthenticationSession`, and +that system web view never hands the `HttpOnly` session cookie back to the +application. The web view that _can_ read cookies does not do passkeys +reliably. So against a passkey-only provider, an app is stuck: the person signs +in successfully and the application never learns of it. + +Three routes bridge that gap, and they exist only for native clients — nothing +in the web interface uses them: + +1. `GET /api/auth/oidc/mobile/login` — the app opens this in the system web + session with a PKCE `code_challenge` (S256 only) and one of the allowlisted + redirect URIs. Standard OIDC login follows. +2. `GET /api/auth/oidc/mobile/complete` — once the provider has authenticated + the person, the server mints a single-use code, valid for sixty seconds and + bound to that PKCE challenge, and redirects to + `nextexplorer://oidc-callback?code=…`. +3. `POST /api/auth/oidc/exchange` — the app sends the code and its + `code_verifier` and receives an ordinary session cookie, the same one a + password sign-in produces. + +The code is destroyed by the first attempt to redeem it, right or wrong, so +there is no second guess against a live one. `OIDC_MOBILE_REDIRECT_URIS` +restricts where it can be delivered to custom schemes an app has registered; +`http(s)` addresses are refused, so the code cannot be redirected to a web page. + +Nothing here is reachable unless OIDC is configured — the routes answer 404 +otherwise — and no configuration is needed to keep it off. ## Common troubleshooting - **Invalid redirect URI**: Ensure your IdP’s redirect URI matches `${PUBLIC_URL}/callback` or the explicitly configured `OIDC_CALLBACK_URL`. -- **Sessions drop after restart**: Supply a stable `SESSION_SECRET` instead of letting the app generate one dynamically. +- **Sessions drop after restart**: Keep `/config` persistent, since the session secret generated when `SESSION_SECRET` is unset is kept there, or supply a stable `SESSION_SECRET`. - **Not an admin after login**: Verify the IdP includes the expected group claim (e.g., `groups` scope) and that `OIDC_ADMIN_GROUPS` contains the group name exactly. Both are required before the IdP may set roles at all — without them the role stored on the account is kept, whatever the claims say. -- **"Email must be verified before linking an existing account"**: the IdP reported the address as unverified and an account here already holds it. Have the IdP mark it verified (in Authentik, map `email_verified` to `true`), then sign in again. - **Cookies flagged Insecure**: Run the app over HTTPS (`PUBLIC_URL` must use `https`) and confirm your proxy forwards `X-Forwarded-Proto`/`Host` headers (see the Reverse Proxy guide). -- **Mobile callback rejected**: Add the app’s exact custom-scheme URI to `OIDC_MOBILE_REDIRECT_URIS`, and ensure the request uses an `S256` PKCE challenge. +- **The sign-in screen says single sign-on is not configured**: it means what it + says — one of `OIDC_ENABLED`, `OIDC_ISSUER`, `OIDC_CLIENT_ID` or a public + address (`PUBLIC_URL`, or `OIDC_CALLBACK_URL`) is missing. The start-up log + names which. +- **The sign-in screen says single sign-on could not be started**: the settings + are all there and the hand-off failed — the provider did not answer, discovery + failed, or the library refused what it was given (a missing + `OIDC_CLIENT_SECRET` is the usual one). Nothing to change in the configuration + before reading the server log, which carries the reason; the message shown in + the browser never does, because it would name the provider's internal host. diff --git a/docs/reference/contributing.md b/docs/reference/contributing.md index 50a115624..7be3791fe 100644 --- a/docs/reference/contributing.md +++ b/docs/reference/contributing.md @@ -19,13 +19,13 @@ Thanks for helping improve nextExplorer! This guide keeps contributions smooth, ## Project Layout - `frontend/` – Vue 3 + Vite app (Pinia, TailwindCSS, Vitest, ESLint). -- `backend/` – Express API (Node 18+, Pino logging, OIDC via express-openid-connect). +- `backend/` – Express API (Node 24, Pino logging, OIDC via express-openid-connect). - `docs/` – VitePress docs (site content and guides). - `Dockerfile` – Multi-stage build packaging the full app. ## Prerequisites -- Node.js 18+ and npm 9+. +- Node.js 24 (the version the image and CI run) and npm 9+. - Docker + Docker Compose v2 (optional, recommended for end-to-end dev). - FFmpeg/ffprobe available if running backend outside Docker. @@ -60,7 +60,7 @@ Environment tips: ## Tests & Linting -- Backend tests (Node test runner): +- Backend tests (Vitest + supertest): ``` cd backend && npm test @@ -78,6 +78,41 @@ cd frontend && npm run test:unit cd frontend && npm run lint ``` +- Browser tests (Playwright). The `app` project starts the real server serving + the real build, on a throwaway install, and walks through setting it up, + signing in, opening a volume, uploading and sharing — so build first: + +``` +npm run build && npm run test:e2e +``` + +### What CI holds every push to + +**A change in behaviour arrives with its test, in the same commit.** A commit +that touches `backend/src` or `frontend/src` without touching a test is +refused. Some changes rightly carry none — a refactor under tests that already +exist, a move, a rename — and those say so with a trailer, so the exception is +a decision written down rather than something nobody noticed: + +``` +Split the share decision into the three questions it asks + +No-test: pure extraction, held by tests/routes/shares.test.js +``` + +Run the same check before pushing with +`scripts/check-commit-tests.sh origin/main..HEAD`. + +**Coverage does not go down.** The floors live in `coverage-thresholds.json` +and fail the test run when a figure drops below them. The frontend floors apply +everywhere; the backend ones apply in CI only, because several backend suites +skip themselves without 7-Zip, ffmpeg, ripgrep or pdftotext, and a machine +without those covers a little less for no fault of the change. Each floor keeps +half a point of room under the CI figure, so a run that happens to miss a few +lines does not turn red. When a figure climbs far enough to raise its floor and +still keep that room, CI says so — raise it in the same pull request, so the +ground gained cannot be lost again. + ## Build - Production container: @@ -95,7 +130,7 @@ cd frontend && npm run build && npm run preview ## Pull Requests - Keep PRs small and atomic. Describe the problem and the approach. -- Include tests for new behavior when practical (backend: Node test runner + supertest; frontend: Vitest). +- A change in behaviour comes with its test in the same commit, or a `No-test:` trailer saying why (see above). - Update docs in `docs/` and user-facing `README.md` when behavior or settings change. - Run tests and linters locally before submitting. diff --git a/docs/reference/faq.md b/docs/reference/faq.md index bd7912098..38c12376a 100644 --- a/docs/reference/faq.md +++ b/docs/reference/faq.md @@ -3,7 +3,7 @@ ## What do I need before installing? - Docker Engine 24+ and Docker Compose v2. -- Host folders to mount under `/mnt` and persistent storage for `/config` (back it up) plus optional `/cache`. +- Host folders to mount under `/mnt`, persistent storage for `/config` (back it up), and for `/cache` (no backup needed, but it holds the search index and folder sizes). - Optional environment variables for your preferred authentication, reverse proxy, and feature toggles; see the [Environment Reference](../configuration/environment) for the full list. ## How do I unlock the workspace after first setup? @@ -16,11 +16,11 @@ Check the [Troubleshooting](./troubleshooting) page for proxy/CORS tips, session ## How can I keep my deployment updated? -The app stores persistent state in the `/config` bind mount. Back up `/config/app-config.json` and `/config/app.db` before updating. Run `docker compose pull` and `docker compose up -d` to refresh the image, then verify volumes and settings in the UI. +The app stores persistent state in the `/config` bind mount. Back up `/config` — `app.db`, with its `app.db-wal` or with the container stopped, `logos/` and `session-secret` — before updating. Run `docker compose pull` and `docker compose up -d` to refresh the image, then verify volumes and settings in the UI. ## Who handles metadata and search indexing? -Thumbnails and ripgrep backed search results live in `/cache`. You can clear/recreate this mount without losing settings. If thumbnails aren't appearing, ensure FFmpeg/ffprobe are available (provided in the official image) and `FFMPEG_PATH`/`FFPROBE_PATH` point to valid binaries. +Thumbnails, RAW previews, sessions and `index.db` — the search index and folder sizes — live in `/cache`. You can clear or recreate this mount without losing settings; everyone is signed out and the indexes are rebuilt. If thumbnails aren't appearing, ensure FFmpeg/ffprobe are available (provided in the official image) and `FFMPEG_PATH`/`FFPROBE_PATH` point to valid binaries. ## How do I add support for custom file types in the editor? @@ -40,4 +40,36 @@ environment: - EDITOR_MAX_FILESIZE=10M ``` +That is the only setting to change. Saving sends the file back through a JSON +request body, so `MAX_JSON_BODY_SIZE` has to stay above what the editor opens — +it rises on its own to carry it, and says so in the log. + +The exception is where you have set `MAX_JSON_BODY_SIZE` yourself. A ceiling +you chose is never raised behind your back: the editor is lowered to what that +ceiling can carry instead, with a warning naming both values. If you want to +edit large files _and_ keep a body ceiling, set the ceiling to a little over +twice the file size you want to open. + See the [Environment Reference](../configuration/environment#editor) for details. + +## The editor says my text file is binary + +It used to say that about any file written in UTF-16, which is most text +produced on Windows: PowerShell's `Out-File` wrote UTF-16LE by default until +PowerShell 6, and Notepad still offers it as "Unicode". In UTF-16 every ASCII +character is stored with a zero byte beside it, and a zero byte is what the +binary test looks for. + +Files are now read in whatever they are written in — UTF-8, UTF-16LE or +UTF-16BE, with or without a byte-order mark — and saved back in the same +encoding, so a file a script reads with a fixed encoding keeps working. + +If a file you believe is text is still refused, check what it actually starts +with: + +```bash +head -c 16 /path/to/file.txt | xxd +``` + +`ef bb bf` is UTF-8 with a mark, `ff fe` is UTF-16LE, `fe ff` is UTF-16BE. +Anything else with zero bytes early in the file really is binary. diff --git a/frontend/src/api/auth.api.js b/frontend/src/api/auth.api.js index 889638df5..3c188d37e 100644 --- a/frontend/src/api/auth.api.js +++ b/frontend/src/api/auth.api.js @@ -12,10 +12,17 @@ const setupAccount = ({ email, username, password }) => const fetchCurrentUser = () => requestJson('/api/auth/me', { method: 'GET' }); -const login = ({ email, password }) => +/** + * Sign in with an email address or a username. + * + * One box on screen, one field on the wire: the server decides which of the + * two it was handed, because only the server can tell whether a name belongs + * to exactly one account. + */ +const login = ({ identifier, password }) => requestJson('/api/auth/login', { method: 'POST', - body: JSON.stringify({ email, password }), + body: JSON.stringify({ identifier, password }), }); /** @@ -68,9 +75,9 @@ export { setupAccount, fetchCurrentUser, login, + submitTotpCode, logout, changePassword, - submitTotpCode, fetchTwoFactorStatus, startTwoFactorEnrolment, confirmTwoFactorEnrolment, diff --git a/frontend/src/components/CreateNew.vue b/frontend/src/components/CreateNew.vue index b4acb5342..e1f855acf 100644 --- a/frontend/src/components/CreateNew.vue +++ b/frontend/src/components/CreateNew.vue @@ -1,40 +1,169 @@ - -
+ +

+ {{ t('auth.login.sessionExpired') }} +

+ + + +

{{ $t('auth.login.totpExplain') }}

+

{{ loginError }}

+ +
@@ -314,7 +431,7 @@ const handleOidcLogin = () => {
-
+
-
+
{{ $t('common.or') }}
-
+
+

+ {{ oidcUnavailableMessage }} +

diff --git a/frontend/src/views/ShareLoginView.vue b/frontend/src/views/ShareLoginView.vue index bb36e3c3a..13e4b0104 100644 --- a/frontend/src/views/ShareLoginView.vue +++ b/frontend/src/views/ShareLoginView.vue @@ -14,10 +14,14 @@ import { } from '@heroicons/vue/24/outline'; import LoadingIcon from '@/icons/LoadingIcon.vue'; import logger from '@/utils/logger'; +import { usePageTitle } from '@/composables/usePageTitle'; +import { folderRoute } from '@/utils/folderRoute'; const { t } = useI18n(); const route = useRoute(); const router = useRouter(); +// The instance's name in the tab, as on its own sign-in page. +usePageTitle(''); const auth = useAuthStore(); const shareToken = computed(() => route.params.token || ''); @@ -32,12 +36,10 @@ const verificationError = ref(''); // Computed const isExpired = computed(() => shareInfo.value?.isExpired || false); -// requiresPassword comes from the server and already accounts for the viewer: -// the owner of a share is not asked for its password. An older server that -// does not send it leaves hasPassword to decide, as before. -const needsPassword = (info) => Boolean(info?.requiresPassword ?? info?.hasPassword); -const requiresPassword = computed( - () => needsPassword(shareInfo.value) && shareInfo.value?.sharingType === 'anyone' +// requiresPassword comes from the backend and already accounts for the viewer: +// the owner of a protected link is not asked for their own password. +const requiresPassword = computed(() => + Boolean(shareInfo.value?.requiresPassword && shareInfo.value?.sharingType === 'anyone') ); const redirectTarget = computed(() => { const value = route.query.redirect; @@ -68,7 +70,7 @@ async function loadShareInfo() { shareInfo.value = info; // If share doesn't require password and is public, auto-access - if (!needsPassword(info) && info.sharingType === 'anyone' && !info.isExpired) { + if (!info.requiresPassword && info.sharingType === 'anyone' && !info.isExpired) { logger.debug('Auto-accessing share (no password required)'); await handleAutoAccess(); } @@ -110,10 +112,7 @@ function navigateAfterShareAccess() { return; } - router.push({ - name: 'FolderView', - params: { path: `share/${shareToken.value}` }, - }); + router.push(folderRoute(`share/${shareToken.value}`)); } async function handleAutoAccess() { @@ -125,7 +124,7 @@ async function handleAutoAccess() { if (result.guestSessionId) { logger.debug('Setting guest session', result.guestSessionId); - setGuestSession(result.guestSessionId); + setGuestSession(result.guestSessionId, shareToken.value); } navigateAfterShareAccess(); @@ -153,7 +152,7 @@ async function handlePasswordSubmit() { if (result.success) { if (result.guestSessionId) { logger.debug('Setting guest session', result.guestSessionId); - setGuestSession(result.guestSessionId); + setGuestSession(result.guestSessionId, shareToken.value); } navigateAfterShareAccess(); diff --git a/frontend/src/views/settings/SettingsBranding.vue b/frontend/src/views/settings/SettingsBranding.vue index 0c096aef9..53ab48593 100644 --- a/frontend/src/views/settings/SettingsBranding.vue +++ b/frontend/src/views/settings/SettingsBranding.vue @@ -1,21 +1,41 @@ @@ -185,13 +161,18 @@ const useDefaultLogo = () => {

{{ t('common.unsavedChanges') }}
@@ -287,13 +266,10 @@ const useDefaultLogo = () => {

- {{ - t('settings.branding.appNameHelp') || - 'The name displayed in the header and login page' - }} + {{ t('settings.branding.appNameHelp') }}

{

{{ local.appName.length }}/100

+

+ {{ t('settings.branding.appNameRequired') }} +

@@ -314,12 +297,12 @@ const useDefaultLogo = () => { class="rounded-md border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900/50" >

- {{ t('settings.branding.preview') || 'Preview' }} + {{ t('settings.branding.preview') }}

@@ -339,13 +322,10 @@ const useDefaultLogo = () => {
- {{ t('settings.branding.showPoweredBy') || 'Show Powered by NextExplorer' }} + {{ t('settings.branding.showPoweredBy') }}

- {{ - t('settings.branding.showPoweredByHelp') || - 'Display a link to NextExplorer in the footer' - }} + {{ t('settings.branding.showPoweredByHelp') }}

diff --git a/frontend/src/views/settings/SettingsPassword.vue b/frontend/src/views/settings/SettingsPassword.vue index 1e484889b..bb7bbb853 100644 --- a/frontend/src/views/settings/SettingsPassword.vue +++ b/frontend/src/views/settings/SettingsPassword.vue @@ -48,7 +48,9 @@ const submit = async () => { currentPassword: currentPassword.value, newPassword: newPassword.value, }); - successMsg.value = t('settings.password.success'); + // The server has just signed out every other session of this account, which + // someone would otherwise discover on another device without knowing why. + successMsg.value = t('settings.password.successOtherSessionsEnded'); currentPassword.value = ''; newPassword.value = ''; confirmPassword.value = '';