diff --git a/backend/package.json b/backend/package.json index 5811af099..184a21e95 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,8 @@ "license": "ISC", "dependencies": { "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", + "@tus/file-store": "^2.1.0", + "@tus/server": "^2.4.1", "adm-zip": "^0.5.16", "archiver": "^6.0.2", "axios": "^1.7.7", @@ -36,8 +38,8 @@ "fluent-ffmpeg": "^2.1.2", "jsonwebtoken": "^9.0.2", "memorystore": "^1.6.7", - "p-limit": "^3.1.0", "multer": "^2.0.2", + "p-limit": "^3.1.0", "p-queue": "^7.4.1", "pino": "^10.1.0", "pino-http": "^11.0.0", diff --git a/backend/src/app.js b/backend/src/app.js index f70ecc0bb..fbb7eb312 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -22,6 +22,7 @@ const { bootstrap } = require('./utils/bootstrap'); const { configureSession } = require('./middleware/session'); const logger = require('./utils/logger'); const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); +const { uploads } = require('./config'); /** * Creates and configures the Express application. @@ -49,8 +50,10 @@ const createApp = async (options = {}) => { configureHttpLogging(app); configureCors(app); - app.use(express.json()); - app.use(express.urlencoded({ extended: true })); + // Large enough to carry back whatever the text editor was allowed to open; + // see the reasoning beside the two limits in the configuration. + app.use(express.json({ limit: uploads.maxJsonBodyBytes })); + app.use(express.urlencoded({ extended: true, limit: uploads.maxJsonBodyBytes })); app.use(cookieParser()); app.use(requestContextMiddleware); logger.debug('Mounted cookie parser middleware'); diff --git a/backend/src/config/env.js b/backend/src/config/env.js index d83856103..841449b7b 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -100,6 +100,14 @@ module.exports = { ONLYOFFICE_SECRET: process.env.ONLYOFFICE_SECRET || null, ONLYOFFICE_LANG: process.env.ONLYOFFICE_LANG?.trim() || 'en', ONLYOFFICE_FORCE_SAVE: normalizeBoolean(process.env.ONLYOFFICE_FORCE_SAVE) || false, + ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS: Number(process.env.ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS) || 10000, + // 0 disables proactive writes to the external storage. A bounded interval + // keeps the Document Server's internal autosave from becoming a full document + // conversion on every edit. + ONLYOFFICE_AUTO_SAVE_INTERVAL_MS: (() => { + const value = Number(process.env.ONLYOFFICE_AUTO_SAVE_INTERVAL_MS); + return Number.isFinite(value) && value >= 0 ? value : 30000; + })(), ONLYOFFICE_FILE_EXTENSIONS: process.env.ONLYOFFICE_FILE_EXTENSIONS || '', ONLYOFFICE_DOWNLOAD_ORIGINS: process.env.ONLYOFFICE_DOWNLOAD_ORIGINS || '', @@ -139,6 +147,83 @@ module.exports = { // Uploads (direct, non-chunked) MAX_DIRECT_UPLOAD_SIZE: process.env.MAX_DIRECT_UPLOAD_SIZE?.trim() || null, UPLOAD_STORAGE_RESERVE: process.env.UPLOAD_STORAGE_RESERVE?.trim() || '64M', + UPLOAD_CHUNK_SIZE: process.env.UPLOAD_CHUNK_SIZE, + UPLOAD_CHUNKED_ENABLED: normalizeBoolean(process.env.UPLOAD_CHUNKED_ENABLED), + MAX_CHUNK_SIZE_MIB: process.env.MAX_CHUNK_SIZE_MIB, + UPLOAD_INACTIVITY_TIMEOUT: process.env.UPLOAD_INACTIVITY_TIMEOUT, + THUMBNAILS_ENABLED: normalizeBoolean(process.env.THUMBNAILS_ENABLED) ?? true, + THUMBNAIL_CACHE_MAX_FILES: + process.env.THUMBNAIL_CACHE_MAX_FILES != null + ? Number(process.env.THUMBNAIL_CACHE_MAX_FILES) + : 3000, + THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS: + process.env.THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS != null + ? Number(process.env.THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS) + : 60 * 60 * 1000, + THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE: + process.env.THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE != null + ? Number(process.env.THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE) + : 500, + THUMBNAIL_CACHE_TTL_DAYS: + process.env.THUMBNAIL_CACHE_TTL_DAYS != null + ? Number(process.env.THUMBNAIL_CACHE_TTL_DAYS) + : 30, + // ExifTool from the machine rather than the one in the archive: 23 MB of + // Perl somebody who already has it would rather not carry twice (#9). + // Empty means the bundled copy, which is the default and needs nothing. + EXIFTOOL_PATH: process.env.EXIFTOOL_PATH || '', + // Embedded RAW previews are full-size JPEGs, far larger than a thumbnail, and + // a new one is extracted whenever a RAW file changes. + RAW_PREVIEW_CACHE_MAX_FILES: + process.env.RAW_PREVIEW_CACHE_MAX_FILES != null + ? Number(process.env.RAW_PREVIEW_CACHE_MAX_FILES) + : 500, + THUMBNAIL_SHARP_CACHE_MEMORY_MB: + process.env.THUMBNAIL_SHARP_CACHE_MEMORY_MB != null + ? Number(process.env.THUMBNAIL_SHARP_CACHE_MEMORY_MB) + : 0, + THUMBNAIL_VIDEO_CONCURRENCY: + process.env.THUMBNAIL_VIDEO_CONCURRENCY != null + ? Number(process.env.THUMBNAIL_VIDEO_CONCURRENCY) + : 3, + THUMBNAIL_VIDEO_SEEK_SECONDS: + process.env.THUMBNAIL_VIDEO_SEEK_SECONDS != null + ? Number(process.env.THUMBNAIL_VIDEO_SEEK_SECONDS) + : 5, + THUMBNAIL_VIDEO_SEEK_PERCENT: + process.env.THUMBNAIL_VIDEO_SEEK_PERCENT != null && + process.env.THUMBNAIL_VIDEO_SEEK_PERCENT.trim() !== '' + ? Number(process.env.THUMBNAIL_VIDEO_SEEK_PERCENT) + : null, + THUMBNAIL_VIDEO_THREADS: + process.env.THUMBNAIL_VIDEO_THREADS != null ? Number(process.env.THUMBNAIL_VIDEO_THREADS) : 2, + THUMBNAIL_VIDEO_SCALE_FLAGS: process.env.THUMBNAIL_VIDEO_SCALE_FLAGS?.trim() || 'fast_bilinear', + THUMBNAIL_BACKGROUND_QUEUE_LIMIT: + process.env.THUMBNAIL_BACKGROUND_QUEUE_LIMIT != null + ? Number(process.env.THUMBNAIL_BACKGROUND_QUEUE_LIMIT) + : 16, + THUMBNAIL_DIAGNOSTICS_ENABLED: + normalizeBoolean(process.env.THUMBNAIL_DIAGNOSTICS_ENABLED) ?? false, + THUMBNAIL_DIAGNOSTICS_INTERVAL_MS: + process.env.THUMBNAIL_DIAGNOSTICS_INTERVAL_MS != null + ? Number(process.env.THUMBNAIL_DIAGNOSTICS_INTERVAL_MS) + : 30000, + THUMBNAIL_SLOW_JOB_MS: + process.env.THUMBNAIL_SLOW_JOB_MS != null ? Number(process.env.THUMBNAIL_SLOW_JOB_MS) : 10000, + // How long one ffmpeg may take over a single thumbnail before it is killed. + // Not a deadline anything waits on — the queue has given up long before — + // but the only thing that ends a process that has stopped making progress. + THUMBNAIL_FFMPEG_TIMEOUT_MS: + process.env.THUMBNAIL_FFMPEG_TIMEOUT_MS != null + ? Number(process.env.THUMBNAIL_FFMPEG_TIMEOUT_MS) + : 5 * 60 * 1000, + // Niceness applied to child ffmpeg/convert processes (0 = disabled, 1-19 lowers + // their CPU priority so the Node event loop stays responsive during generation). + THUMBNAIL_PROCESS_NICE: + process.env.THUMBNAIL_PROCESS_NICE != null ? Number(process.env.THUMBNAIL_PROCESS_NICE) : 10, + TUS_UPLOAD_DIR: process.env.TUS_UPLOAD_DIR?.trim() || null, + TUS_INCOMPLETE_UPLOAD_TTL_MS: process.env.TUS_INCOMPLETE_UPLOAD_TTL_MS, + TUS_CLEANUP_INTERVAL_MS: process.env.TUS_CLEANUP_INTERVAL_MS, MAX_FILES_PER_UPLOAD: Number(process.env.MAX_FILES_PER_UPLOAD) || 50, // Editor diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 703e6f669..c14c4a657 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -4,6 +4,8 @@ const env = require('./env'); const constants = require('./constants'); const loggingConfig = require('./logging'); const { parseByteSize } = require('../utils/env'); +// logger reads config/logging, never this file — requiring it here makes no cycle. +const logger = require('../utils/logger'); const parseCommaOrSpaceList = (raw) => { if (!raw) return []; @@ -277,16 +279,110 @@ const searchMaxFileSizeBytes = (() => { })(); // --- Uploads --- +// --- Editor --- +/** + * What the inline text editor opens, and what a JSON request body may weigh. + * + * They are one decision rather than two. The editor sends a file back through a + * JSON body when it saves it, so a body limit under the size the editor opens + * produces a file that opens and cannot be saved — answered with "request + * entity too large", which names neither setting. Express's own default is + * 100 kB, against an editor that opens two megabytes. + * + * Escaping is why the body has to be worth more than the file: in the worst + * case every character of the content is a quote, a backslash or a newline and + * becomes two, and the path travels in the same body. A file whose bytes would + * expand further than that is one the editor refuses to open anyway, as binary. + */ +const JSON_ESCAPE_WORST_CASE = 2; +const JSON_BODY_OVERHEAD_BYTES = 64 * 1024; +const DEFAULT_JSON_BODY_BYTES = 8 * 1024 * 1024; + +const bodyNeededFor = (fileBytes) => fileBytes * JSON_ESCAPE_WORST_CASE + JSON_BODY_OVERHEAD_BYTES; +const fileAllowedBy = (bodyBytes) => + Math.max(0, Math.floor((bodyBytes - JSON_BODY_OVERHEAD_BYTES) / JSON_ESCAPE_WORST_CASE)); + +const { editorMaxFileSizeBytes, maxJsonBodyBytes } = (() => { + const parsedEditor = parseByteSize(env.EDITOR_MAX_FILESIZE); + // Default: 2 MiB if not configured or invalid + const editorAsked = + Number.isFinite(parsedEditor) && parsedEditor > 0 ? parsedEditor : 2 * 1024 * 1024; + + const parsedBody = parseByteSize(env.MAX_JSON_BODY_SIZE); + const bodyWasChosen = Number.isFinite(parsedBody) && parsedBody > 0; + + // A body limit someone set is a ceiling they meant — it is a guard, not a + // detail — so it is never raised from here. The editor is what gives way, and + // it gives way by refusing to open what it could not save back. + if (bodyWasChosen) { + const allowed = fileAllowedBy(parsedBody); + if (editorAsked > allowed) { + logger.warn( + { editorAsked, loweredTo: allowed, maxJsonBodyBytes: parsedBody }, + 'EDITOR_MAX_FILESIZE is larger than MAX_JSON_BODY_SIZE can carry back and has been ' + + 'lowered to match; the editor would otherwise open files it could not save' + ); + } + return { editorMaxFileSizeBytes: Math.min(editorAsked, allowed), maxJsonBodyBytes: parsedBody }; + } + + // Nobody chose the body limit, so the editor's size is the only wish there is + // to honour: the default body limit rises to carry it. + const needed = bodyNeededFor(editorAsked); + if (needed > DEFAULT_JSON_BODY_BYTES) { + logger.info( + { editorMaxFileSizeBytes: editorAsked, maxJsonBodyBytes: needed }, + 'Raised the JSON body limit above its default so the text editor can save what it opens' + ); + } + + return { + editorMaxFileSizeBytes: editorAsked, + maxJsonBodyBytes: Math.max(DEFAULT_JSON_BODY_BYTES, needed), + }; +})(); + // Ceilings for direct (non-chunked) uploads. They exist so a single request // cannot stream until the disk is full; they are generous on purpose, since // large files are a normal use of a file manager. Chunked uploads have their // own storage guard in the TUS service. +// How long a direct upload may go without a byte arriving before it is given +// up on. A client that goes away mid-body otherwise holds the request, and the +// half-written file with it, until the socket itself times out. +const uploadInactivityTimeoutMs = (() => { + const value = Number(env.UPLOAD_INACTIVITY_TIMEOUT); + return Number.isFinite(value) && value >= 0 ? value : 120000; +})(); + +// Where a chunked upload's parts live until the whole file is there, and how +// long an unfinished one is kept. Under the cache rather than beside the +// destination: a part file is not a file anybody asked for, and a volume should +// never show one. +const tusUploadDir = env.TUS_UPLOAD_DIR + ? path.resolve(env.TUS_UPLOAD_DIR) + : path.join(cacheDir, 'tus-uploads'); + +const tusIncompleteUploadTtlMs = (() => { + const value = Number(env.TUS_INCOMPLETE_UPLOAD_TTL_MS); + return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 60 * 60 * 1000; +})(); + +const tusCleanupIntervalMs = (() => { + const value = Number(env.TUS_CLEANUP_INTERVAL_MS); + return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 10 * 60 * 1000; +})(); + const uploads = { + maxJsonBodyBytes, maxDirectUploadBytes: (() => { const parsed = parseByteSize(env.MAX_DIRECT_UPLOAD_SIZE); return Number.isFinite(parsed) && parsed > 0 ? parsed : 64 * 1024 * 1024 * 1024; })(), maxFilesPerRequest: env.MAX_FILES_PER_UPLOAD, + inactivityTimeoutMs: uploadInactivityTimeoutMs, + tusUploadDir, + tusIncompleteUploadTtlMs, + tusCleanupIntervalMs, // Free space kept in reserve when accepting writes, so a full volume never // takes the database down with it. The trash gives space back before this // floor is crossed. @@ -320,6 +416,8 @@ const onlyoffice = { secret: env.ONLYOFFICE_SECRET || deriveSecret('onlyoffice'), lang: env.ONLYOFFICE_LANG, forceSave: env.ONLYOFFICE_FORCE_SAVE, + forceSaveTimeoutMs: Math.min(30000, Math.max(7000, env.ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS)), + autoSaveIntervalMs: Math.min(300000, Math.max(0, env.ONLYOFFICE_AUTO_SAVE_INTERVAL_MS)), extensions: env.ONLYOFFICE_FILE_EXTENSIONS.split(',') .map((s) => s.trim().toLowerCase()) .filter(Boolean), @@ -365,13 +463,6 @@ const collabora = { .filter(Boolean), }; -// --- Editor --- -const editorMaxFileSizeBytes = (() => { - const parsed = parseByteSize(env.EDITOR_MAX_FILESIZE); - // Default: 2 MiB if not configured or invalid - return Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 1024 * 1024; -})(); - const editor = { extensions: parseExtensionList(env.EDITOR_EXTENSIONS), maxFileSizeBytes: editorMaxFileSizeBytes, diff --git a/backend/src/errors/errorCodes.js b/backend/src/errors/errorCodes.js index bf567198d..f70260f54 100644 --- a/backend/src/errors/errorCodes.js +++ b/backend/src/errors/errorCodes.js @@ -8,6 +8,7 @@ const ErrorCodes = { AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS', AUTH_ACCOUNT_LOCKED: 'AUTH_ACCOUNT_LOCKED', AUTH_PASSWORD_INCORRECT: 'AUTH_PASSWORD_INCORRECT', + AUTH_INVALID_TOTP_CODE: 'AUTH_INVALID_TOTP_CODE', // Validation (400) VALIDATION_EMAIL_REQUIRED: 'VALIDATION_EMAIL_REQUIRED', diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js index ada2afc7b..8e334000f 100644 --- a/backend/src/middleware/errorHandler.js +++ b/backend/src/middleware/errorHandler.js @@ -187,4 +187,8 @@ const notFoundHandler = (req, res, next) => { module.exports = { errorHandler, notFoundHandler, + // Used by the routes that stream their progress: an NDJSON stream has already + // answered 200 by the time something fails, so it says why in a line of its + // own rather than through the error handler — and says it the same way. + sanitizeClientMessage, }; diff --git a/backend/src/middleware/multipartRefusals.js b/backend/src/middleware/multipartRefusals.js new file mode 100644 index 000000000..5c706005e --- /dev/null +++ b/backend/src/middleware/multipartRefusals.js @@ -0,0 +1,36 @@ +const multer = require('multer'); + +/** + * A size as a person reads it: "2 MB", "64 GB". + */ +const describeBytes = (bytes) => { + const units = ['bytes', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${Math.round(value * 10) / 10} ${units[unit]}`; +}; + +/** + * Run a multer middleware whose refusals say what this route's limits are. + * + * multer names the limit a request met and not its value — "File too large" — + * and only the route knows the value, and the setting that governs it. The + * sentence for a code is attached here; the status comes from the error + * handler, which knows every code whichever route raised it. + * + * @param {import('express').RequestHandler} middleware what `upload.single()` or `.fields()` returned + * @param {Record} sentences what to tell the client, by multer error code + */ +const explainMultipartRefusals = (middleware, sentences) => (req, res, next) => + middleware(req, res, (error) => { + if (error instanceof multer.MulterError && sentences[error.code]) { + error.clientMessage = sentences[error.code]; + } + next(error); + }); + +module.exports = { describeBytes, explainMultipartRefusals }; diff --git a/backend/src/middleware/responseEndCompat.js b/backend/src/middleware/responseEndCompat.js new file mode 100644 index 000000000..2633eb7dc --- /dev/null +++ b/backend/src/middleware/responseEndCompat.js @@ -0,0 +1,64 @@ +/** + * @tus/server hands its responses to srvx, which finishes them with + * `res.end(callback)`. Node accepts that form — a function in first position is + * the completion callback, not a body — but express-session replaces res.end + * with a two-argument `(chunk, encoding)` wrapper that has no notion of it. On + * any request where the session has to be saved or touched, that wrapper writes + * the body out before ending, and the callback reaches res.write() as a chunk: + * + * TypeError [ERR_INVALID_ARG_TYPE]: The "chunk" argument must be of type + * string or an instance of Buffer or Uint8Array. Received function + * + * Nothing catches it, so the process exits. Every chunked upload by a + * signed-in user took the server down with it: a 502 in the browser, and on a + * deployment whose storage is not persistent, a database recreated empty on the + * restart — favourites, shares and preferences gone with it. + * + * The store implements touch and `resave` is false, so this is the ordinary + * path for an established session rather than a rare one. + * + * Mounted after the session middleware, this wrapper is the one srvx reaches + * first: it moves the callback onto the response's own completion event and + * passes the plain `(chunk, encoding)` form down the chain, which is all + * express-session ever expects to see. + */ +const responseEndCompat = (req, res, next) => { + const end = res.end; + + res.end = function normalizedEnd(chunk, encoding, callback) { + if (typeof chunk === 'function') { + callback = chunk; + chunk = undefined; + encoding = undefined; + } else if (typeof encoding === 'function') { + callback = encoding; + encoding = undefined; + } + + if (typeof callback === 'function') { + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + callback(); + }; + + // A response that has already finished emits nothing further, and the + // caller would wait for ever on a reply that has gone. + if (res.writableEnded) { + setImmediate(settle); + } else { + // 'close' as well as 'finish': a client that walks away mid-upload + // still has to release whoever is awaiting the response. + res.once('finish', settle); + res.once('close', settle); + } + } + + return end.call(this, chunk, encoding); + }; + + next(); +}; + +module.exports = { responseEndCompat }; diff --git a/backend/src/routes/archive.js b/backend/src/routes/archive.js new file mode 100644 index 000000000..8698c2b19 --- /dev/null +++ b/backend/src/routes/archive.js @@ -0,0 +1,326 @@ +const express = require('express'); +const fs = require('fs/promises'); +const path = require('path'); + +const { normalizeRelativePath } = require('../utils/pathUtils'); +const { resolvePathWithAccess } = require('../services/accessManager'); +const { ACTIONS, authorizeAndResolve } = require('../services/authorizationService'); +const { track: trackInFlight } = require('../services/inFlightFiles'); +const { removeInventoried } = require('../utils/ownedTree'); +const { mapWithConcurrency } = require('../utils/mapWithConcurrency'); +const { startNdjsonStream, throttlePercent } = require('../utils/ndjsonStream'); +const { sanitizeClientMessage } = require('../middleware/errorHandler'); +const { ensureStorageAvailable } = require('../services/uploadStorageGuard'); +const { + ensureArchiveWithinLimits, + extractIntoCurrentFolder, +} = require('../services/archiveExtraction'); +const { extractArchiveEntries } = require('../services/archiveService'); +const { getSupportedArchiveExtensions } = require('../services/archiveService'); +const { + browseArchive, + findArchiveEntry, + readArchiveEntry, + readBrowsableArchive, + entryPathOf, +} = require('../services/archiveBrowseService'); +const { toExtension, resolveMimeType } = require('../utils/fileTypes'); +const { encodeContentDisposition } = require('./files/utils'); +const logger = require('../utils/logger'); +const asyncHandler = require('../utils/asyncHandler'); +const { ValidationError, ForbiddenError, NotFoundError } = require('../errors/AppError'); + +const router = express.Router(); + +/** + * Looking inside an archive, without unpacking it. + * + * The address of the archive is a real path on disk, and the position inside + * it is a separate parameter. Not one virtual path like + * `/Work/pack.zip/inner/file`: twenty-six files resolve a path and every one + * of them takes what comes back for a real file — renaming, deleting, + * uploading, thumbnails, shares, folder sizes, the search index. Teaching all + * of them a second kind of path is where the holes would be. Here the archive + * goes through the same resolution as any other file, and what is inside it + * never leaves this route. + */ + +/** The archive named by the request, once the caller is allowed to read it. */ +const resolveArchive = async (req, named = req.query.path) => { + const relativePath = normalizeRelativePath(typeof named === 'string' ? named : ''); + if (!relativePath) { + throw new ValidationError('The path of an archive is required.'); + } + + const context = { user: req.user, guestSession: req.guestSession }; + let accessInfo; + let resolved; + try { + ({ accessInfo, resolved } = await resolvePathWithAccess(context, relativePath)); + } catch (_) { + throw new NotFoundError('Path not found.'); + } + + if (!accessInfo || !accessInfo.canAccess || !accessInfo.canRead) { + throw new ForbiddenError(accessInfo?.denialReason || 'Path is not accessible.'); + } + + let stats; + try { + stats = await fs.stat(resolved.absolutePath); + } catch (error) { + if (error?.code === 'ENOENT') throw new NotFoundError('Path not found.'); + throw error; + } + if (!stats.isFile()) { + throw new ValidationError('That is not an archive.'); + } + + // Only what this build of 7-Zip can actually read, and only what the + // configuration offers as an archive: the same list extraction is held to, + // so nothing is browsable that could not then be extracted. + const extension = path.extname(resolved.relativePath).slice(1).toLowerCase(); + const supported = await getSupportedArchiveExtensions(); + if (!supported.includes(extension)) { + throw new ValidationError('That kind of file cannot be opened as an archive.'); + } + + return { absolutePath: resolved.absolutePath, relativePath: resolved.relativePath }; +}; + +router.get( + '/archive/list', + asyncHandler(async (req, res) => { + const archive = await resolveArchive(req); + const inside = typeof req.query.inside === 'string' ? req.query.inside : ''; + + const listing = await browseArchive(archive.absolutePath, inside); + + return res.json({ + path: archive.relativePath, + name: path.basename(archive.relativePath), + ...listing, + }); + }) +); + +/** + * One file out of an archive, without unpacking the rest of it. + * + * Always as an attachment. A file inside somebody's archive is somebody else's + * HTML as easily as their photograph, and served inline it would run on this + * application's origin: that is a decision about previewing, not about reading, + * and it is not made here. The type is still declared, so a saved file arrives + * named and typed as what it is, and `nosniff` stops the browser arguing. + */ +router.get( + '/archive/entry', + asyncHandler(async (req, res) => { + const archive = await resolveArchive(req); + const wanted = typeof req.query.entry === 'string' ? req.query.entry : ''; + // `source` is the archive the entry is actually read from, which for a + // compound one is the decompressed copy rather than the file on the volume. + const found = await findArchiveEntry(archive.absolutePath, wanted); + const { entry } = found; + + const name = entry.path.slice(entry.path.lastIndexOf('/') + 1); + res.setHeader('Content-Type', resolveMimeType(toExtension(name))); + res.setHeader('Content-Disposition', encodeContentDisposition(name, 'attachment')); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Robots-Tag', 'noindex'); + // What the archive says the entry weighs, which is what `-so` writes. A + // damaged archive that writes less ends the download short, which is what + // it is, rather than looking like a file that arrived whole. + if (Number.isFinite(entry.size)) res.setHeader('Content-Length', String(entry.size)); + + // From the archive, or from the tree a solid one was extracted into. + const reading = await readArchiveEntry(found); + + // Whoever closed the tab is not waiting for the rest of it, and 7-Zip would + // otherwise go on decompressing into a pipe nobody reads. + res.once('close', () => { + if (!res.writableEnded) reading.stop(); + }); + + reading.stdout.pipe(res); + + try { + await reading.finished; + } catch (error) { + reading.stop(); + if (res.headersSent) { + // The answer was already on its way: there is no status left to send, + // and ending it short is the only honest thing available. + logger.warn({ err: error, entry: entry.path }, 'Reading an archive entry stopped short'); + res.destroy(); + return; + } + throw error; + } + }) +); + +/** + * Take some of an archive out onto the volume, without unpacking the rest. + * + * The other half of looking inside one: a folder of photographs in a backup is + * found here and wanted *there*, and downloading it and putting it back is not + * an answer on a server somebody reaches from a phone. + * + * What comes out goes into the folder the archive is in unless the body names + * another one, which is the folder somebody picked in the dialog. A named + * destination is not a shortcut around anything: it is authorized exactly like + * the default is, so the answer to "may I write here" is the same whoever asks + * and wherever they point. + */ +router.post( + '/archive/extract', + asyncHandler(async (req, res) => { + const controller = new AbortController(); + const abort = () => controller.abort(); + const onClose = () => { + if (!res.writableEnded) abort(); + }; + req.once('aborted', abort); + res.once('close', onClose); + + const archive = await resolveArchive(req, req.body?.path); + const asked = Array.isArray(req.body?.entries) ? req.body.entries : []; + if (asked.length === 0) { + throw new ValidationError('At least one entry is required.'); + } + + // The folder the archive is in, and the right to write in it. Read on the + // archive is not enough: what comes out of it is a new file on somebody's + // volume, and a folder an administrator made read-only stays read-only. + const context = { user: req.user, guestSession: req.guestSession }; + const asDestination = typeof req.body?.destination === 'string' ? req.body.destination : ''; + const destinationRelativePath = normalizeRelativePath( + asDestination.trim() ? asDestination : path.posix.dirname(archive.relativePath || '') + ); + for (const action of [ACTIONS.createFolder, ACTIONS.createFile]) { + const { allowed, accessInfo } = await authorizeAndResolve( + context, + destinationRelativePath, + action + ); + if (!allowed) { + throw new ForbiddenError(accessInfo?.denialReason || 'Destination is read-only.'); + } + } + const { resolved: destinationResolved } = await authorizeAndResolve( + context, + destinationRelativePath, + ACTIONS.createFile + ); + const destinationAbsolutePath = destinationResolved?.absolutePath; + if (!destinationAbsolutePath) throw new ForbiddenError('Cannot resolve destination folder.'); + + // A folder that is not there, or is not a folder, is said plainly here: the + // first thing the extraction does is create a staging directory inside it, + // and that failure arrives halfway through a stream of progress events. + const destinationStat = await fs.stat(destinationAbsolutePath).catch(() => null); + if (!destinationStat?.isDirectory()) { + throw new NotFoundError('That destination folder does not exist.'); + } + + const { source, listing } = await readBrowsableArchive(archive.absolutePath); + + // Each name is looked up in the listing rather than taken on trust, and a + // folder stands for everything under it: what is extracted is entries this + // archive holds, named as it names them. + const selected = new Map(); + for (const name of asked) { + const wanted = entryPathOf(typeof name === 'string' ? name : ''); + if (wanted === null) { + throw new ValidationError('That is not something inside this archive.'); + } + const under = listing.entries.filter( + (entry) => entry.path === wanted || entry.path.startsWith(`${wanted}/`) + ); + if (under.length === 0) { + throw new NotFoundError('That is not in this archive.'); + } + for (const entry of under) selected.set(entry.path, entry); + } + + const chosen = [...selected.values()]; + if (chosen.some((entry) => entry.encrypted)) { + throw new ForbiddenError( + 'This file is encrypted and cannot be extracted without its password.' + ); + } + + const totalBytes = chosen.reduce((sum, entry) => sum + (entry.size || 0), 0); + ensureArchiveWithinLimits({ entryCount: chosen.length, totalBytes }); + await ensureStorageAvailable(destinationAbsolutePath, totalBytes, 'destination storage'); + + // Written into a hidden folder of its own first, and moved out of it only + // once whole: a name that appears on the volume meanwhile is never replaced, + // and a failure has nothing of its own under a real name to take back. + const stagingAbsolutePath = await fs.mkdtemp( + path.join(destinationAbsolutePath, '.nextexplorer-extract-') + ); + const movedPaths = []; + const inFlight = trackInFlight(stagingAbsolutePath, 'staging-directory'); + + // Everything above throws before a byte is written, so a refusal is still an + // ordinary HTTP error. From here the answer is the same stream of events the + // other archive operations write. + const writeEvent = startNdjsonStream(res); + writeEvent({ type: 'start', name: path.posix.basename(chosen[0].path) }); + const onPercent = throttlePercent(writeEvent); + + try { + await extractArchiveEntries( + source, + stagingAbsolutePath, + chosen.map((entry) => entry.path), + onPercent, + { signal: controller.signal } + ); + + const items = await extractIntoCurrentFolder({ + stagingDirectory: stagingAbsolutePath, + destinationDirectory: destinationAbsolutePath, + relativeParentPath: destinationRelativePath, + movedPaths, + }); + // The staging directory is this route's own, created a moment ago under + // the cache: it never holds anything anybody put there, so it does not + // go through the trash. + // eslint-disable-next-line no-restricted-properties + await fs.rm(stagingAbsolutePath, { recursive: true, force: true }); + writeEvent({ + type: 'done', + success: true, + item: items.length === 1 ? items[0] : null, + items, + }); + } catch (error) { + logger.warn( + { err: error, archive: archive.relativePath }, + 'Extracting from an archive failed' + ); + // eslint-disable-next-line no-restricted-properties + await fs.rm(stagingAbsolutePath, { recursive: true, force: true }); + // What this placed, and only that: a file somebody saved into a placed + // folder in the meantime stays, with the folders holding it. + await mapWithConcurrency(movedPaths, (moved) => + removeInventoried(moved.path, moved.inventory) + ); + writeEvent({ + type: 'error', + message: sanitizeClientMessage(error.message || 'Extraction failed.'), + code: error.code || 'EXTRACT_FAILED', + }); + } finally { + inFlight.release(); + req.off('aborted', abort); + res.off('close', onClose); + res.end(); + } + }) +); + +module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 27030c95c..4683eafc9 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -9,7 +9,17 @@ const { addLocalPassword, getUserAuthMethods, getRequestUser, + verifyLocalPassword, + beginTwoFactorEnrolment, + confirmTwoFactorEnrolment, + disableTwoFactor, + replaceRecoveryCodes, + twoFactorRequired, + twoFactorStatus, + 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'); @@ -23,6 +33,56 @@ const { } = require('../errors/AppError'); const { ErrorCodes } = require('../errors/errorCodes'); +/** + * How long the second step stays open. + * + * Long enough to find a phone, pick the app and read the digits; short enough + * that a machine walked away from is not a sign-in waiting to be finished by + * whoever sits down next. + */ +const SECOND_STEP_MS = 5 * 60 * 1000; + +/** + * The password was right, and the account wants a code as well. + * + * Deliberately not a signed-in session with a flag on it: nothing but + * `localUserId` signs anybody in, and this state does not set it. The session + * is regenerated here for the same reason it is regenerated at the end — an id + * somebody planted in the browser must not be the one that finishes the + * sign-in. + */ +const startSecondStep = (req, userId) => + new Promise((resolve, reject) => { + if (!req.session) { + reject(new Error('No session to hold the second step in.')); + return; + } + req.session.regenerate((error) => { + if (error) { + reject(error); + return; + } + req.session.pendingTotpUserId = userId; + req.session.pendingTotpSince = Date.now(); + req.session.save((saveError) => (saveError ? reject(saveError) : resolve())); + }); + }); + +/** The account halfway through signing in here, or null. */ +const secondStepUserId = (req) => { + const userId = req.session?.pendingTotpUserId; + if (!userId) return null; + const since = Number(req.session.pendingTotpSince) || 0; + if (Date.now() - since > SECOND_STEP_MS) return null; + return userId; +}; + +const forgetSecondStep = (req) => { + if (!req.session) return; + delete req.session.pendingTotpUserId; + delete req.session.pendingTotpSince; +}; + const rateLimitHandler = (req, res, next, options) => { const retryAfterSeconds = Math.ceil(options.windowMs / 1000); const retryAfterMinutes = Math.ceil(retryAfterSeconds / 60); @@ -108,6 +168,9 @@ router.get('/status', async (req, res) => { res.json({ requiresSetup, + // A reload in the middle of signing in lands back on the code, rather than + // on a password screen that would start the whole thing again. + totpPending: Boolean(secondStepUserId(req)), strategies, authEnabled: auth.enabled, authMode, @@ -171,6 +234,17 @@ router.post( if (!user) { throw new UnauthorizedError('Invalid credentials.', ErrorCodes.AUTH_INVALID_CREDENTIALS); } + + // The password was right and the account asks for a code as well. Nothing + // about who they are is answered here: that an account has a second factor + // is not something to tell whoever guessed a password correctly, so the + // answer carries the question and nothing else. + if (await twoFactorRequired(user.id)) { + await startSecondStep(req, user.id); + res.json({ totpRequired: true }); + return; + } + await startAuthenticatedSession(req, user.id); // Clear guest session cookie when user logs in @@ -180,6 +254,152 @@ router.post( }) ); +/** + * The second step: the code from the phone, or one off the paper. + * + * Wrong codes count against the same lockout a wrong password does, so the + * second factor is not a place to guess a million times at six digits while + * the first one is bounded. + */ +router.post( + '/login/totp', + loginLimiter, + asyncHandler(async (req, res) => { + refuseWithoutPasswordSignIn(); + const userId = secondStepUserId(req); + if (!userId) { + forgetSecondStep(req); + throw new UnauthorizedError( + 'That sign-in is no longer waiting for a code. Sign in again.', + ErrorCodes.AUTH_INVALID_CREDENTIALS + ); + } + + if (await isLocked(userId)) { + throw new RateLimitError( + 'Account is temporarily locked due to failed login attempts.', + null, + ErrorCodes.AUTH_ACCOUNT_LOCKED + ); + } + + const { code } = req.body || {}; + const outcome = await verifySecondFactor({ userId, code }); + if (!outcome.ok) { + await incrementFailedAttempts(userId); + throw new UnauthorizedError('That code is not right.', ErrorCodes.AUTH_INVALID_TOTP_CODE); + } + + await clearLock(userId); + forgetSecondStep(req); + await startAuthenticatedSession(req, userId); + res.clearCookie('guestSession', { path: '/api' }); + + res.json({ + user: await getRequestUser(req), + usedRecoveryCode: Boolean(outcome.usedRecoveryCode), + recoveryCodesLeft: outcome.recoveryCodesLeft ?? null, + }); + }) +); + +/** Whether this account asks for a code, and how many recovery codes are left. */ +router.get( + '/totp', + asyncHandler(async (req, res) => { + const me = await getRequestUser(req); + if (!me) throw new UnauthorizedError('Authentication required.'); + res.json(await twoFactorStatus(me.id)); + }) +); + +/** + * Draw a secret and show it, which turns nothing on. + * + * What comes back is shown once and never again: the phone keeps it, and the + * copy here is unreadable the moment it is written. + */ +router.post( + '/totp/start', + passwordLimiter, + asyncHandler(async (req, res) => { + refuseWithoutPasswordSignIn(); + const me = await getRequestUser(req); + if (!me) throw new UnauthorizedError('Authentication required.'); + if (!req.session || req.session.localUserId !== me.id) { + throw new ForbiddenError('Sign in with your password to set up a second factor.'); + } + + res.json( + await beginTwoFactorEnrolment({ + userId: me.id, + account: me.email || me.username || me.id, + }) + ); + }) +); + +/** Turn it on, once a code proves the phone holds the same secret. */ +router.post( + '/totp/confirm', + passwordLimiter, + asyncHandler(async (req, res) => { + const me = await getRequestUser(req); + if (!me) throw new UnauthorizedError('Authentication required.'); + + const confirmed = await confirmTwoFactorEnrolment({ userId: me.id, code: req.body?.code }); + if (!confirmed) { + throw new UnauthorizedError('That code is not right.', ErrorCodes.AUTH_INVALID_TOTP_CODE); + } + logger.info({ userId: me.id }, 'Two-factor authentication turned on'); + res.json(confirmed); + }) +); + +/** + * New recovery codes, and the password to prove it is still the same person. + * + * A browser left unlocked is the case this is about: drawing new codes throws + * the old ones away, and somebody who sat down at a signed-in screen should not + * be able to leave with the only working set. + */ +router.post( + '/totp/recovery-codes', + passwordLimiter, + asyncHandler(async (req, res) => { + const me = await getRequestUser(req); + if (!me) throw new UnauthorizedError('Authentication required.'); + if (!(await verifyLocalPassword({ userId: me.id, password: req.body?.password }))) { + throw new UnauthorizedError( + 'That password is not right.', + ErrorCodes.AUTH_PASSWORD_INCORRECT + ); + } + + res.json({ recoveryCodes: await replaceRecoveryCodes(me.id) }); + }) +); + +/** Off, with the password for the same reason. */ +router.delete( + '/totp', + passwordLimiter, + asyncHandler(async (req, res) => { + const me = await getRequestUser(req); + if (!me) throw new UnauthorizedError('Authentication required.'); + if (!(await verifyLocalPassword({ userId: me.id, password: req.body?.password }))) { + throw new UnauthorizedError( + 'That password is not right.', + ErrorCodes.AUTH_PASSWORD_INCORRECT + ); + } + + await disableTwoFactor(me.id); + logger.info({ userId: me.id }, 'Two-factor authentication turned off'); + res.status(204).end(); + }) +); + // Change password (for users with password auth) router.post( '/password', diff --git a/backend/src/routes/browse.js b/backend/src/routes/browse.js index 95df16aa3..a1895a820 100644 --- a/backend/src/routes/browse.js +++ b/backend/src/routes/browse.js @@ -10,6 +10,42 @@ const { NotFoundError } = require('../errors/AppError'); const router = express.Router(); const { resolvePathWithAccess } = require('../services/accessManager'); const { listDirectoryItems } = require('../services/directoryListingService'); +const versions = require('../services/versions'); +const { rightsFrom: versionRights } = versions; + +/** + * The mark that says a file has earlier versions, for a whole listing. + * + * Counted once for the folder rather than once per row, and only when somebody + * asked to see it — the preference is on by default, and turning it off takes + * the query away as well as the icon, so it costs nothing to somebody who does + * not want it. + * + * The right to see a history is the row's own and not the folder's: a share + * hands out histories only when its owner said so, and that is decided here + * from each child's access rather than from the folder's. + */ +const versionMarks = async (directoryPath, userSettings) => { + if (userSettings?.showVersionMarks === false) return null; + + let marks; + try { + marks = await versions.marksForFolder(directoryPath); + } catch (error) { + // A listing is not worth failing over a count. Nothing is marked, and the + // history is still one right-click away. + logger.warn({ err: error, directoryPath }, 'File versions were not counted for a listing'); + return null; + } + if (!marks || marks.size === 0) return null; + + return ({ name, stats, access }) => { + if (!stats?.isFile()) return null; + const mark = marks.get(name); + if (!mark || !versionRights(access).see) return null; + return { versions: { count: mark.versions, bytes: mark.bytes, newest: mark.newest } }; + }; +}; router.get( '/browse/{*splat}', @@ -49,6 +85,7 @@ router.get( excludeDownloadArtifacts: true, includeHiddenFiles, permissionRules: settings?.access?.rules || [], + itemExtras: await versionMarks(directoryPath, userSettings), }); const response = { @@ -60,6 +97,9 @@ router.get( canDelete: accessInfo.canDelete, canShare: accessInfo.canShare, canDownload: accessInfo.canDownload, + // Whether the files here show their history, which a share hands out + // only when its owner said so. + canSeeVersions: versionRights(accessInfo).see, }, current: { isDirectory: true, diff --git a/backend/src/routes/editor.js b/backend/src/routes/editor.js index 1cbff0885..946fe26ce 100644 --- a/backend/src/routes/editor.js +++ b/backend/src/routes/editor.js @@ -2,49 +2,29 @@ const express = require('express'); const path = require('path'); const fs = require('fs/promises'); -const config = require('../config'); const { normalizeRelativePath } = require('../utils/pathUtils'); const { ensureDir } = require('../utils/fsUtils'); const { ACTIONS, authorizeAndResolve } = require('../services/authorizationService'); const versions = require('../services/versions/operations'); const asyncHandler = require('../utils/asyncHandler'); +const { sendTextFile } = require('../utils/textFileResponse'); +const { ValidationError, ForbiddenError, NotFoundError } = require('../errors/AppError'); const { - ValidationError, - ForbiddenError, - NotFoundError, - UnsupportedMediaTypeError, -} = require('../errors/AppError'); + readFileEncoding, + encodeText, + MAX_EDITOR_FILE_SIZE, +} = require('../services/textEditorService'); const router = express.Router(); -const MAX_EDITOR_FILE_SIZE = config.editor?.maxFileSizeBytes ?? 1 * 1024 * 1024; -const VIDEO_EXTENSIONS = Array.isArray(config.extensions?.videos) ? config.extensions.videos : []; - -function isProbablyBinaryBuffer(buffer) { - const length = Math.min(buffer.length, 4096); - if (!length) return false; - - let suspicious = 0; - for (let index = 0; index < length; index += 1) { - const byte = buffer[index]; - if (byte === 0) { - return true; - } - if (byte < 7 || (byte > 13 && byte < 32)) { - suspicious += 1; - } - } - - return suspicious / length > 0.3; -} - -async function readTextFileBuffer(req, relative) { +async function resolveReadableFile(req, relative) { if (typeof relative !== 'string' || !relative) { throw new ValidationError('A valid file path is required.'); } const relativePath = normalizeRelativePath(relative); const context = { user: req.user, guestSession: req.guestSession }; + let accessInfo; let resolved; try { @@ -65,51 +45,43 @@ async function readTextFileBuffer(req, relative) { throw new ForbiddenError(accessInfo?.denialReason || 'Access denied.'); } - const { absolutePath } = resolved; - const stats = await fs.stat(absolutePath); - - if (stats.isDirectory()) { - throw new ValidationError('Cannot open a directory in the editor.'); - } - - if (typeof stats.size === 'number' && stats.size > MAX_EDITOR_FILE_SIZE) { - throw new ValidationError('This file is too large to open in the text editor.'); - } - - const ext = path.extname(absolutePath).slice(1).toLowerCase(); - if (VIDEO_EXTENSIONS.includes(ext)) { - throw new UnsupportedMediaTypeError('This file type cannot be opened in the text editor.'); - } + return resolved.absolutePath; +} - const buffer = await fs.readFile(absolutePath); - if (isProbablyBinaryBuffer(buffer)) { - throw new UnsupportedMediaTypeError( - 'This file appears to be binary and cannot be opened in the text editor.' - ); - } +/** + * The editor's read. By GET, which the browser keeps and revalidates, so the + * editor opened from the Markdown preview does not download the file again; by + * POST for the clients written against it, which nothing keeps. + */ +const sendEditorText = async (req, res, relative) => { + const absolutePath = await resolveReadableFile(req, relative); + await sendTextFile(req, res, { absolutePath, render: ({ text }) => ({ content: text }) }); +}; - return { buffer, absolutePath }; -} +router.get( + '/editor', + asyncHandler(async (req, res) => { + await sendEditorText(req, res, req.query?.path); + }) +); router.post( '/editor', asyncHandler(async (req, res) => { const { path: relative = '' } = req.body || {}; - const { buffer } = await readTextFileBuffer(req, relative); - const data = buffer.toString('utf-8'); - res.send({ content: data }); + await sendEditorText(req, res, relative); }) ); router.get( '/raw', asyncHandler(async (req, res) => { - const relative = req.query?.path; - const { buffer } = await readTextFileBuffer(req, relative); - - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.send(buffer.toString('utf-8')); + const absolutePath = await resolveReadableFile(req, req.query?.path); + await sendTextFile(req, res, { + absolutePath, + headers: { 'X-Content-Type-Options': 'nosniff' }, + render: ({ text }) => text, + }); }) ); @@ -161,6 +133,22 @@ router.put( const { absolutePath } = resolved; await ensureDir(path.dirname(absolutePath)); + const existed = await fs + .stat(absolutePath) + .then((stats) => stats.isFile()) + .catch(() => false); + + // Written back in the encoding it already had: a UTF-16 file saved as UTF-8 + // reads perfectly well here and breaks whatever wrote it. + const payload = encodeText(content, existed ? await readFileEncoding(absolutePath) : undefined); + // Refused for the same reason the editor refuses to open it. Without this + // the editor wrote whatever it was given — paste two megabytes into a small + // file, save, and the next attempt to open it answered that the file is too + // large. Measured on the bytes actually written, which is what the size + // limit is about. + if (payload.length > MAX_EDITOR_FILE_SIZE) { + throw new ValidationError('This file is too large to save in the text editor.'); + } // Written beside the file and put in place once whole, with what it // replaces kept as a version: a save used to go straight over the file, so @@ -169,7 +157,7 @@ router.put( // session here to group it with, as there is in the office editors. await versions.saveFile( absolutePath, - (temporaryPath) => fs.writeFile(temporaryPath, content, { encoding: 'utf-8', flag: 'wx' }), + (temporaryPath) => fs.writeFile(temporaryPath, payload, { flag: 'wx' }), { purpose: 'editor', author: versions.authorOf({ user: req.user, guestSession: req.guestSession }), diff --git a/backend/src/routes/features.js b/backend/src/routes/features.js index c0d0c5fd7..e3ed39b0d 100644 --- a/backend/src/routes/features.js +++ b/backend/src/routes/features.js @@ -11,12 +11,16 @@ const { const terminalService = require('../services/terminalService'); const { getTrashSettings } = require('../services/trash/settings'); const { getVersionSettings } = require('../services/versions/settings'); +const { MAX_UPLOAD_CHUNK_SIZE_BYTES } = require('../services/settingsService'); +const { getSupportedArchiveExtensions } = require('../services/archiveService'); const packageJson = require('../../package.json'); const router = express.Router(); // GET /api/features -> returns enabled/disabled feature flags derived from env router.get('/features', async (_req, res) => { + // Probed once at startup, then cached — this await is effectively free. + const archiveExtensions = await getSupportedArchiveExtensions().catch(() => ['zip']); const payload = { public: { url: publicConfig?.url || null, @@ -54,6 +58,17 @@ router.get('/features', async (_req, res) => { (settings) => ({ enabled: settings.enabled }), () => ({ enabled: false }) ), + archives: { + // What the 7-Zip build on this machine can actually open, rather than a + // list kept in the browser that a different image would make wrong. + extensions: archiveExtensions, + }, + uploads: { + // The ceiling an administrator may raise the chunk size to + // (MAX_CHUNK_SIZE_MIB), so the screen can say what it is rather than + // refusing a number without explaining. + maxChunkSizeBytes: MAX_UPLOAD_CHUNK_SIZE_BYTES, + }, personal: { enabled: Boolean(features?.personalFolders), }, diff --git a/backend/src/routes/files/rename.js b/backend/src/routes/files/rename.js index 19a83acb7..a576e128b 100644 --- a/backend/src/routes/files/rename.js +++ b/backend/src/routes/files/rename.js @@ -1,23 +1,6 @@ -const path = require('path'); -const fs = require('fs/promises'); -const { - normalizeRelativePath, - combineRelativePath, - ensureValidName, -} = require('../../utils/pathUtils'); -const { pathExists } = require('../../utils/fsUtils'); -const { - ACTIONS, - authorizeAndResolve, - authorizePath, -} = require('../../services/authorizationService'); +const { normalizeRelativePath } = require('../../utils/pathUtils'); +const { renameEntry } = require('../../services/renameService'); const asyncHandler = require('../../utils/asyncHandler'); -const { - ValidationError, - ForbiddenError, - NotFoundError, - ConflictError, -} = require('../../errors/AppError'); const { buildItemMetadata } = require('./utils'); const router = require('express').Router(); @@ -25,77 +8,16 @@ const router = require('express').Router(); router.post( '/files/rename', asyncHandler(async (req, res) => { - const parentPath = req.body?.path ?? ''; - const originalName = req.body?.name; - const newNameRaw = req.body?.newName; + const parentRelative = normalizeRelativePath(req.body?.path ?? ''); - if (typeof originalName !== 'string' || !originalName) { - throw new ValidationError('Original name is required.'); - } + const renamed = await renameEntry({ + context: { user: req.user, guestSession: req.guestSession }, + parentRelative, + currentName: req.body?.name, + newName: req.body?.newName, + }); - const parentRelative = normalizeRelativePath(parentPath); - const context = { user: req.user, guestSession: req.guestSession }; - - const { - allowed: parentAllowed, - accessInfo: parentAccess, - resolved: parentResolved, - } = await authorizeAndResolve(context, parentRelative, ACTIONS.write); - if (!parentAllowed || !parentResolved) { - throw new ForbiddenError(parentAccess?.denialReason || 'Destination path is read-only.'); - } - - const { absolutePath: parentAbsolute } = parentResolved; - - const currentRelative = combineRelativePath(parentRelative, originalName); - const { - allowed: currentAllowed, - accessInfo: currentAccess, - resolved: currentResolved, - } = await authorizeAndResolve(context, currentRelative, ACTIONS.write); - if (!currentAllowed || !currentResolved) { - throw new ForbiddenError(currentAccess?.denialReason || 'Cannot rename items in this path.'); - } - - const { absolutePath: currentAbsolute } = currentResolved; - - if (!(await pathExists(currentAbsolute))) { - throw new NotFoundError('Item not found.'); - } - - const validatedNewName = typeof newNameRaw === 'string' ? ensureValidName(newNameRaw) : null; - - if (!validatedNewName) { - throw new ValidationError('A new name is required.'); - } - - if (validatedNewName === originalName) { - const item = await buildItemMetadata(currentAbsolute, parentRelative, originalName); - res.json({ success: true, item }); - return; - } - - const targetRelative = combineRelativePath(parentRelative, validatedNewName); - const { allowed: targetAllowed, accessInfo: targetAccess } = await authorizePath( - context, - targetRelative, - ACTIONS.write - ); - if (!targetAllowed) { - throw new ForbiddenError(targetAccess?.denialReason || 'Destination path is not accessible.'); - } - const targetAbsolute = path.join(parentAbsolute, validatedNewName); - - if (await pathExists(targetAbsolute)) { - throw new ConflictError(`The name "${validatedNewName}" is already taken.`); - } - - await fs.rename(currentAbsolute, targetAbsolute); - // The history follows the file, or everything in the folder, to its new name. - // eslint-disable-next-line global-require - await require('../../services/versions/lifecycle').onMoved(currentAbsolute, targetAbsolute); - - const item = await buildItemMetadata(targetAbsolute, parentRelative, validatedNewName); + const item = await buildItemMetadata(renamed.absolutePath, parentRelative, renamed.name); res.json({ success: true, item }); }) ); diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 38680b704..f204875e2 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -18,11 +18,13 @@ const terminalRoutes = require('./terminal'); const permissionsRoutes = require('./permissions'); const sharesRoutes = require('./shares'); const zipRoutes = require('./zip'); +const archiveRoutes = require('./archive'); const healthRoutes = require('./health'); const userVolumesRoutes = require('./userVolumes'); const folderSizeRoutes = require('./folderSize'); const trashRoutes = require('./trash'); const versionsRoutes = require('./versions'); +const versionsAdminRoutes = require('./versionsAdmin'); const { onlyoffice, collabora } = require('../config/index'); const registerRoutes = (app) => { @@ -44,9 +46,11 @@ const registerRoutes = (app) => { app.use('/api', metadataRoutes); app.use('/api', permissionsRoutes); app.use('/api', zipRoutes); + app.use('/api', archiveRoutes); app.use('/api', folderSizeRoutes); app.use('/api', trashRoutes); app.use('/api', versionsRoutes); + app.use('/api', versionsAdminRoutes); // User volumes management (admin only, requires USER_VOLUMES feature) app.use('/api', userVolumesRoutes); // Share routes (supports guest sessions) diff --git a/backend/src/routes/onlyoffice.js b/backend/src/routes/onlyoffice.js index 8c88b9dce..1dc26d506 100644 --- a/backend/src/routes/onlyoffice.js +++ b/backend/src/routes/onlyoffice.js @@ -8,30 +8,44 @@ const axios = require('axios'); const jwt = require('jsonwebtoken'); const { onlyoffice, public: publicConfig, mimeTypes } = require('../config/index'); -const { normalizeRelativePath } = require('../utils/pathUtils'); +const { + combineRelativePath, + ensureValidName, + normalizeRelativePath, +} = require('../utils/pathUtils'); const { ensureDir } = require('../utils/fsUtils'); +const { placeWithoutOverwrite } = require('../utils/placeWithoutOverwrite'); +const { track: trackInFlight } = require('../services/inFlightFiles'); const { resolvePathWithAccess } = require('../services/accessManager'); +const { renameEntry } = require('../services/renameService'); const versions = require('../services/versions/operations'); +const onlyofficeActivity = require('../services/onlyofficeActivityService'); +const documentKeys = require('../services/onlyofficeDocumentKeyService'); +const editorSessions = require('../services/onlyofficeEditorSessionService'); +const { getDocumentType } = require('../utils/onlyofficeDocumentTypes'); const logger = require('../utils/logger'); const asyncHandler = require('../utils/asyncHandler'); const { ValidationError, UnauthorizedError, ForbiddenError } = require('../errors/AppError'); const router = express.Router(); -// Helpers -const SUPPORTED_TEXT = new Set(['docx', 'doc', 'odt', 'rtf', 'txt']); -const SUPPORTED_SHEET = new Set(['xlsx', 'xls', 'ods', 'csv']); -const SUPPORTED_PRESENTATION = new Set(['pptx', 'ppt', 'odp']); +// The backend token is signed with the same secret as the Document Server +// tokens, so it carries a type claim to keep the two apart, and a lifetime long +// enough for an editing session but not indefinite. It used to have neither: a +// token from the Document Server would have been accepted as one of ours, and +// one of ours never expired. +const BACKEND_TOKEN_TYPE = 'nextexplorer-backend'; +const BACKEND_TOKEN_TTL_SECONDS = 12 * 60 * 60; -const toExt = (filename = '') => String(filename).split('.').pop().toLowerCase(); +// In-flight force-save requests only: these are meaningless once the process +// that issued them is gone, unlike the sessions they refer to. +const pendingForceSaves = new Map(); +const pendingForceSavesBySession = new Map(); -const getDocumentType = (ext) => { - // ONLYOFFICE expects: 'word' | 'cell' | 'slide' - if (SUPPORTED_TEXT.has(ext)) return 'word'; - if (SUPPORTED_SHEET.has(ext)) return 'cell'; - if (SUPPORTED_PRESENTATION.has(ext)) return 'slide'; - return 'word'; -}; +const FORCE_SAVE_RETRY_DELAYS_MS = [250, 750, 1500, 2500]; + +// Helpers +const toExt = (filename = '') => String(filename).split('.').pop().toLowerCase(); const resolveMime = (ext) => mimeTypes[ext] || 'application/octet-stream'; @@ -108,6 +122,29 @@ const fetchDocumentInto = async (downloadUrl, temporaryPath, mode) => { * session was opened for is the answer. Somebody who came through a share link * is credited as the link: there is no account to name. */ +/** + * Pull a document the Document Server prepared into a new file in `directory`. + * + * Never over anything: a name already taken, before the download or during it, + * gets the same "(1)" treatment as everywhere else, and the caller is told the + * name actually used. + */ +const downloadDocumentInto = async (downloadUrl, directory, desiredName, mode = 0o600) => { + const temporaryPath = path.join( + directory, + `.${desiredName}.onlyoffice-${crypto.randomUUID()}.tmp` + ); + + const inFlight = trackInFlight(temporaryPath, 'temporary-file'); + try { + await fetchDocumentInto(downloadUrl, temporaryPath, mode); + return await placeWithoutOverwrite(temporaryPath, directory, desiredName); + } finally { + await fsp.unlink(temporaryPath).catch(() => {}); + inFlight.release(); + } +}; + const authorFromCallback = (body, backendCtx) => { const changes = Array.isArray(body?.history?.changes) ? body.history.changes : []; const user = changes.length ? changes[changes.length - 1]?.user : null; @@ -118,6 +155,241 @@ const authorFromCallback = (body, backendCtx) => { return { id, label: user?.name ? String(user.name) : null }; }; +/** + * Read a backend token from the query string. + * + * Returns null unless the token is valid, is a backend token — not a Document + * Server one signed with the same secret — and carries an absolute path. + */ +const readBackendToken = (req) => { + const raw = typeof req.query?.backend === 'string' ? req.query.backend : null; + if (!raw || !onlyoffice.secret) return null; + try { + const payload = jwt.verify(raw, onlyoffice.secret, { algorithms: ['HS256'] }); + if (!payload || typeof payload !== 'object') return null; + if (payload.typ !== BACKEND_TOKEN_TYPE) return null; + if (typeof payload.absolutePath !== 'string' || !payload.absolutePath) return null; + return payload; + } catch (error) { + logger.warn({ err: error }, 'ONLYOFFICE backend token verification failed'); + return null; + } +}; + +/** + * A backend token lives twelve hours; the share it was issued for may not. + * Confirm the share still exists before honouring the token's write claim. + */ +const assertShareStillValid = async (backendCtx) => { + if (!backendCtx?.shareToken) return; + // Required here rather than at the top: the shares service reaches back into + // routes for its own helpers. + // eslint-disable-next-line global-require + const { getShareByToken, isShareExpired } = require('../services/sharesService'); + const share = await getShareByToken(backendCtx.shareToken); + if (!share || isShareExpired(share)) { + throw new ForbiddenError('The share for this editing session is no longer available.'); + } +}; + +const getSessionOwner = (req) => ({ + userId: req.user?.id ? String(req.user.id) : null, + guestSessionId: req.guestSession?.id ? String(req.guestSession.id) : null, +}); + +const matchesSessionOwner = (session, req) => { + const owner = getSessionOwner(req); + return owner.userId === session.userId && owner.guestSessionId === session.guestSessionId; +}; + +const describeSessionUser = (req) => { + const owner = getSessionOwner(req); + return { + id: owner.userId || (owner.guestSessionId ? `guest_${owner.guestSessionId}` : null), + name: req.user?.displayName || req.user?.username || (owner.guestSessionId ? 'Guest' : 'User'), + }; +}; + +const getCommandServiceUrl = (key, legacy = false) => { + const commandUrl = new URL( + legacy ? 'coauthoring/CommandService.ashx' : 'command', + `${onlyoffice.serverUrl.replace(/\/+$/, '')}/` + ); + if (!legacy) commandUrl.searchParams.set('shardkey', key); + return commandUrl.toString(); +}; + +/** + * Ask the Document Server to write what the editor holds, now. + * + * Closing the preview used to rely on the status-2 callback the Document Server + * sends when it decides the document is finished with, which arrives seconds + * later — long enough for the folder to be listed again with the old content, + * and for the tab to be gone before anything was written. + */ +const enqueueForceSave = ({ sessionId, key, relativePath, reason }) => { + const requestId = `nextexplorer-force-save:${crypto.randomUUID()}`; + const timeout = setTimeout( + () => finishForceSave(requestId, { saved: false, timedOut: true }), + onlyoffice.forceSaveTimeoutMs + ); + timeout.unref?.(); + pendingForceSaves.set(requestId, { + sessionId, + key, + relativePath, + reason, + requestedAt: Date.now(), + timeout, + retryTimer: null, + followUpReason: null, + }); + pendingForceSavesBySession.set(sessionId, requestId); + + setImmediate(() => { + void dispatchForceSave({ requestId, key, relativePath, reason }); + }); + return requestId; +}; + +const finishForceSave = (requestId, result) => { + if (!requestId) return; + const pending = pendingForceSaves.get(requestId); + if (!pending) return; + pendingForceSaves.delete(requestId); + if (pendingForceSavesBySession.get(pending.sessionId) === requestId) { + pendingForceSavesBySession.delete(pending.sessionId); + } + clearTimeout(pending.timeout); + if (pending.retryTimer) clearTimeout(pending.retryTimer); + logger.debug( + { requestId, reason: pending.reason, elapsedMs: Date.now() - pending.requestedAt, ...result }, + 'ONLYOFFICE force-save finished' + ); + + // A close may arrive while an automatic save is still assembling an earlier + // version. Queue one final command so the most recent edits do not depend on + // the delayed callback. + if (pending.followUpReason) { + const { sessionId, key, relativePath, followUpReason } = pending; + enqueueForceSave({ sessionId, key, relativePath, reason: followUpReason }); + } +}; + +const dispatchForceSave = async ({ requestId, key, relativePath, reason, attempt = 0 }) => { + try { + const command = { c: 'forcesave', key, userdata: requestId }; + command.token = jwt.sign(command, onlyoffice.secret, { algorithm: 'HS256' }); + + let response = await axios.post(getCommandServiceUrl(key), command, { + timeout: 8000, + validateStatus: () => true, + }); + // ONLYOFFICE Docs 8.2 introduced /command. Keep older Document Server + // installations working when they explicitly report the new route absent. + if (response.status === 404) { + response = await axios.post(getCommandServiceUrl(key, true), command, { + timeout: 8000, + validateStatus: () => true, + }); + } + + const code = Number(response.data?.error ?? 0); + if (response.status >= 200 && response.status < 300 && code === 0) return; + + // Code 4 means the editor has not yet sent its last changes to the Document + // Server. Retried here so that closing the preview stays instant. + if (code === 4 && attempt < FORCE_SAVE_RETRY_DELAYS_MS.length) { + const pending = pendingForceSaves.get(requestId); + if (!pending) return; + pending.retryTimer = setTimeout(() => { + void dispatchForceSave({ requestId, key, relativePath, reason, attempt: attempt + 1 }); + }, FORCE_SAVE_RETRY_DELAYS_MS[attempt]); + pending.retryTimer.unref?.(); + return; + } + + logger.debug( + { path: relativePath, reason, status: response.status, code, requestId, attempt }, + 'ONLYOFFICE force-save was not queued' + ); + finishForceSave(requestId, { saved: false, code }); + } catch (err) { + logger.warn( + { err, path: relativePath, reason, requestId, attempt }, + 'ONLYOFFICE force-save request failed' + ); + finishForceSave(requestId, { saved: false }); + } +}; + +/** + * The key to hand this editor. + * + * Whether anyone currently has the document open is what separates "the file + * changed because we are editing it" from "the file changed while nobody was + * looking": the first must keep the key, the second must not. + */ +const resolveKeyForOpen = async ({ absolutePath, relativePath, stat, documentType }) => { + const presence = onlyofficeActivity.get(absolutePath); + return documentKeys.resolveDocumentKey({ + relativePath, + stat, + documentType, + inUse: Boolean(presence?.active), + }); +}; + +/** + * Presence is deliberately not recorded here. + * + * This runs when the editor asks for its configuration, which says nothing + * about whether the document will open. A file the editor then refused — a + * drawing announced with the wrong editor, say — would still be displayed as + * being edited, by everyone, until the session expired. The client reports + * presence once ONLYOFFICE says the document is ready, through the heartbeat. + */ +const createEditorSession = async (req, relativePath, key, absolutePath) => { + void editorSessions.purgeExpired(); + const sessionId = crypto.randomUUID(); + await editorSessions.create({ + sessionId, + key, + relativePath, + // Where the document is *now*. The backend token carries the path as it was + // when the editor opened, and renaming makes that copy wrong; a save + // arriving afterwards would recreate the old name beside the new one. + absolutePath, + ...getSessionOwner(req), + }); + return sessionId; +}; + +const getEditorSession = async (req, sessionId, relativePath) => { + const session = await editorSessions.get(sessionId); + if (!session || session.relativePath !== relativePath || !matchesSessionOwner(session, req)) { + throw new ForbiddenError( + 'The ONLYOFFICE editing session is no longer valid. Reopen the document.' + ); + } + await editorSessions.touch(sessionId); + return session; +}; + +/** + * Where a save should be written for this token. + * + * The token is minted once and handed to the Document Server, which returns it + * unchanged however long the editing session lasts. The session is what follows + * the document if it is renamed meanwhile — and it is stored, so a restart does + * not forget the rename and put the save back under the old name. The token + * remains the fallback for a session that has genuinely expired. + */ +const resolveSaveTarget = async (backendCtx) => { + const session = backendCtx?.sessionId ? await editorSessions.get(backendCtx.sessionId) : null; + return session?.absolutePath || backendCtx.absolutePath; +}; + const getDsJwtFromReq = (req) => { const auth = (req.headers['authorization'] || req.headers['authorizationjwt'] || '').toString(); if (auth.toLowerCase().startsWith('bearer ')) { @@ -150,6 +422,15 @@ router.post( } const relativePath = normalizeRelativePath(relativeRaw); + + // An earlier version, opened to be read: a viewer with nothing to save, and + // a key of its own so it never touches the one the document is open under. + const requestedVersion = typeof req.body?.versionId === 'string' ? req.body.versionId : ''; + if (requestedVersion) { + res.json(await versionViewConfig(req, relativePath, requestedVersion, null)); + return; + } + const context = { user: req.user, guestSession: req.guestSession }; const { accessInfo, resolved } = await resolvePathWithAccess(context, relativePath); @@ -165,9 +446,26 @@ router.post( // Check if this is a readonly share const isReadonlyShare = resolved.shareInfo && resolved.shareInfo.accessMode === 'readonly'; + // Disable editing for readonly shares, readonly locations, or view mode. + // Computed before the backend token is signed: the token carries this + // decision, so a viewer never receives one that allows writing. It used to + // ignore the location's own rights, so somebody who could only read a + // folder was handed an editing session on the documents in it. + const canEdit = mode !== 'view' && !isReadonlyShare && accessInfo.canWrite === true; + const filename = path.basename(abs); const ext = toExt(filename); const documentType = getDocumentType(ext); + if (!documentType) { + // Refused here rather than left for the Document Server to open with the + // wrong editor: everything used to fall back to 'word', so a drawing was + // answered "the file content does not match the file extension" — true, + // unhelpful, and several steps from the setting that caused it. + throw new ValidationError( + `ONLYOFFICE has no editor for .${ext} files. Remove it from ONLYOFFICE_FILE_EXTENSIONS, ` + + 'or open it with Collabora instead.' + ); + } const fileUrl = new URL(`/api/onlyoffice/file`, publicConfig.url); fileUrl.searchParams.set('path', relativePath); @@ -175,34 +473,46 @@ router.post( const callbackUrl = new URL(`/api/onlyoffice/callback`, publicConfig.url); callbackUrl.searchParams.set('path', relativePath); + // Shared with anyone already in this document, so they edit together rather + // than in two sessions that overwrite each other. It used to be recomputed + // from the file's own state on every open, which changed it under the + // people already editing. + const key = await resolveKeyForOpen({ + absolutePath: abs, + relativePath, + stat, + documentType, + }); + + // Only an editing session gets one: it is what a save is written through. + const editorSessionId = canEdit ? await createEditorSession(req, relativePath, key, abs) : null; + // Backend context for storage requests (signed separately and passed via query) let backendToken = null; if (onlyoffice.secret) { const backendPayload = { + typ: BACKEND_TOKEN_TYPE, absolutePath: abs, logicalPath: resolved.relativePath, space: resolved.space, + // The callback trusts this flag instead of re-resolving permissions, so + // it must say what this session is actually allowed to do. + canWrite: canEdit, + // Lets a save find the document again if it was renamed while open; the + // path above is only what it was called when the editor started. + sessionId: editorSessionId, userId: req.user && req.user.id ? String(req.user.id) : null, guestSessionId: req.guestSession?.id || null, shareToken: resolved.shareInfo?.shareToken || null, }; backendToken = jwt.sign(backendPayload, onlyoffice.secret, { algorithm: 'HS256', + expiresIn: BACKEND_TOKEN_TTL_SECONDS, }); fileUrl.searchParams.set('backend', backendToken); callbackUrl.searchParams.set('backend', backendToken); } - // Unique key should change when file changes to bust DS cache - const key = crypto - .createHash('sha256') - .update(relativePath) - .update(String(stat.mtimeMs)) - .digest('hex'); - - // Disable editing for readonly shares or when mode is view - const canEdit = mode !== 'view' && !isReadonlyShare; - const config = { documentType, // text | spreadsheet | presentation type: 'desktop', @@ -223,6 +533,10 @@ router.post( callbackUrl: callbackUrl.toString(), customization: { anonymous: { request: false }, + // Expose ONLYOFFICE's own Save action as a force-save when it has + // been asked for. Closing the document is flushed by the route + // above, whether or not this is on. + forcesave: Boolean(onlyoffice.forceSave && canEdit), }, lang: onlyoffice.lang || 'en', // Optionally attach current user info if available @@ -257,7 +571,551 @@ router.post( res.json({ documentServerUrl: onlyoffice.serverUrl, config, + editorSessionId, + autoSaveIntervalMs: canEdit ? onlyoffice.autoSaveIntervalMs : 0, + }); + }) +); + +/** + * The client says the document is really open, and goes on saying so. + * + * Presence starts here rather than when the configuration is handed out: that + * says nothing about whether the document opened, and a file the editor then + * refused was still shown to everybody as being edited until it expired. + */ +router.post( + '/onlyoffice/session-heartbeat', + asyncHandler(async (req, res) => { + const relativePath = normalizeRelativePath(req.body?.path || ''); + const sessionId = req.body?.sessionId || ''; + if (!relativePath || typeof sessionId !== 'string' || !sessionId) { + throw new ValidationError('A valid ONLYOFFICE editing session is required.'); + } + const context = { user: req.user, guestSession: req.guestSession }; + const { accessInfo, resolved } = await resolvePathWithAccess(context, relativePath); + if (!accessInfo?.canAccess || !accessInfo.canRead) throw new ForbiddenError('Access denied.'); + await getEditorSession(req, sessionId, relativePath); + + const active = onlyofficeActivity.touch({ + absolutePath: resolved.absolutePath, + sessionId, + user: describeSessionUser(req), + }); + res.json({ active }); + }) +); + +/** + * The editor was closed. The session ends, so the document stops being reported + * as open by somebody who has left. + */ +router.post( + '/onlyoffice/session-end', + asyncHandler(async (req, res) => { + const relativePath = normalizeRelativePath(req.body?.path || ''); + const sessionId = req.body?.sessionId || ''; + if (!relativePath || typeof sessionId !== 'string' || !sessionId) { + throw new ValidationError('A valid ONLYOFFICE editing session is required.'); + } + const context = { user: req.user, guestSession: req.guestSession }; + const { accessInfo, resolved } = await resolvePathWithAccess(context, relativePath); + if (!accessInfo?.canAccess || !accessInfo.canRead) throw new ForbiddenError('Access denied.'); + const session = await getEditorSession(req, sessionId, relativePath); + + // Somebody who was only reading has nothing to flush, and an integration + // with no Document Server has nowhere to ask. Neither is a reason to refuse + // the close — the session still has to end, or the document goes on being + // reported as open by somebody who has left. + let requestId = null; + if (onlyoffice.serverUrl && accessInfo.canWrite) { + requestId = + pendingForceSavesBySession.get(sessionId) || + enqueueForceSave({ sessionId, key: session.key, relativePath, reason: 'close' }); + } + + onlyofficeActivity.close({ absolutePath: resolved.absolutePath, sessionId }); + await editorSessions.remove(sessionId); + res.json({ ended: true, flushed: Boolean(requestId), requestId }); + }) +); + +/** + * Rename the open document from the editor's title bar. + * + * The rename itself is the ordinary one, with the ordinary permission checks. + * What is specific here is keeping the editing session pointed at the file + * afterwards: the Document Server holds a token naming the path as it was when + * the editor opened, and returns it unchanged with every save. Left alone, the + * next autosave would recreate the old name beside the new one. + */ +router.post( + '/onlyoffice/rename', + asyncHandler(async (req, res) => { + const relativePath = normalizeRelativePath(req.body?.path || ''); + const sessionId = req.body?.sessionId || ''; + if (!relativePath || typeof sessionId !== 'string' || !sessionId) { + throw new ValidationError('A valid ONLYOFFICE editing session is required.'); + } + + // Only the session that opened this document may rename it from inside the + // editor, and only sessions allowed to write ever get one. + const session = await getEditorSession(req, sessionId, relativePath); + + const parentPath = path.posix.dirname(relativePath); + const renamed = await renameEntry({ + context: { user: req.user, guestSession: req.guestSession }, + parentRelative: parentPath === '.' ? '' : parentPath, + currentName: path.posix.basename(relativePath), + newName: req.body?.newName, + }); + + if (renamed.changed) { + // Three records follow the file: the session decides where a save lands, + // presence decides which row shows as being edited, and the key decides + // whether the people already in the document stay together. + const previousRelativePath = session.relativePath; + await editorSessions.move(sessionId, { + relativePath: renamed.relativePath, + absolutePath: renamed.absolutePath, + }); + onlyofficeActivity.rename({ + from: renamed.previousAbsolutePath, + to: renamed.absolutePath, + }); + await documentKeys.renameDocumentKey({ + from: previousRelativePath, + to: renamed.relativePath, + }); + } + + res.json({ path: renamed.relativePath, name: renamed.name }); + }) +); + +const versionHistory = () => require('../services/versions'); + +const versionKeyFor = (versionId) => `version-${versionId}`; + +const editorUserOf = (req) => + req.user && req.user.id + ? { id: String(req.user.id), name: req.user.displayName || req.user.username || 'User' } + : req.guestSession + ? { id: `guest_${req.guestSession.id}`, name: 'Guest User' } + : undefined; + +/** A token for `/onlyoffice/file` that serves one content, and never writes. */ +const readOnlyFileUrl = (req, relativePath, absolutePath, ttlSeconds) => { + const backendToken = jwt.sign( + { + typ: BACKEND_TOKEN_TYPE, + absolutePath, + logicalPath: relativePath, + canWrite: false, + sessionId: null, + userId: req.user?.id ? String(req.user.id) : null, + guestSessionId: req.guestSession?.id || null, + shareToken: null, + }, + onlyoffice.secret, + { algorithm: 'HS256', expiresIn: ttlSeconds } + ); + const fileUrl = new URL('/api/onlyoffice/file', publicConfig.url); + fileUrl.searchParams.set('path', relativePath); + fileUrl.searchParams.set('backend', backendToken); + return fileUrl.toString(); +}; + +const requirePublicUrl = () => { + if (!publicConfig?.url) { + throw new ValidationError( + 'PUBLIC_URL is required on the server to build absolute URLs for ONLYOFFICE.' + ); + } +}; + +/** The key the document is open under now, as the configuration hands it out. */ +const currentKeyOf = async (req, relativePath) => { + const context = { user: req.user, guestSession: req.guestSession }; + const { accessInfo, resolved } = await resolvePathWithAccess(context, relativePath); + if (!accessInfo?.canAccess || !accessInfo.canRead || !resolved) { + throw new ForbiddenError(accessInfo?.denialReason || 'Access denied.'); + } + const stat = await fsp.stat(resolved.absolutePath); + const documentType = getDocumentType(toExt(resolved.absolutePath)); + const key = await resolveKeyForOpen({ + absolutePath: resolved.absolutePath, + relativePath, + stat, + documentType, + }); + return { key, absolutePath: resolved.absolutePath }; +}; + +/** + * A version opened on its own, to be read: a viewer, with nothing to save. + * + * This is what lets an office document's history be looked at at all — the + * panel could only offer a text file until now, because nothing could put an + * earlier .docx in front of anybody. + */ +const versionViewConfig = async (req, relativePath, versionId, uiTheme) => { + requirePublicUrl(); + const context = { user: req.user, guestSession: req.guestSession }; + const located = await versionHistory().locateVersion(context, relativePath, versionId, { + download: false, + }); + const ext = toExt(located.name); + const documentType = getDocumentType(ext); + if (!documentType) { + throw new ValidationError(`ONLYOFFICE has no editor for .${ext} files.`); + } + const mayCopy = located.target.rights.download; + const config = { + documentType, + type: 'desktop', + document: { + fileType: ext, + key: versionKeyFor(located.version.id), + title: located.name, + url: readOnlyFileUrl(req, relativePath, located.absolutePath, BACKEND_TOKEN_TTL_SECONDS), + permissions: { + edit: false, + comment: false, + review: false, + // Printing or downloading a version is taking a copy of it. + download: mayCopy, + print: mayCopy, + }, + }, + // No callback: nothing is saved from a version, and the one the document + // has would release the key everyone editing it now shares. + editorConfig: { + mode: 'view', + customization: { + anonymous: { request: false }, + ...(uiTheme ? { uiTheme } : {}), + }, + lang: onlyoffice.lang || 'en', + user: editorUserOf(req), + }, + }; + config.token = jwt.sign(config, onlyoffice.secret, { algorithm: 'HS256' }); + return { + documentServerUrl: onlyoffice.serverUrl, + config, + editorSessionId: null, + autoSaveIntervalMs: 0, + version: { id: located.version.id, modifiedAt: located.version.modifiedAt }, + }; +}; + +/** + * The document's history, as the editor's own history panel reads it. + * + * The editor numbers versions from the oldest and expects the current state to + * be the last of them; the history this application keeps comes newest first + * and does not count the current state as a version, so the two are reconciled + * here rather than in the editor. + */ +router.post( + '/onlyoffice/history', + asyncHandler(async (req, res) => { + const relativePath = normalizeRelativePath(req.body?.path || ''); + if (!relativePath) throw new ValidationError('A valid file path is required.'); + const context = { user: req.user, guestSession: req.guestSession }; + const listed = await versionHistory().listVersions(context, relativePath); + const { key } = await currentKeyOf(req, relativePath); + + const history = [...listed.versions].reverse().map((version, index) => ({ + version: index + 1, + versionId: version.id, + key: versionKeyFor(version.id), + created: version.modifiedAt, + user: { id: version.author?.id || '', name: version.author?.label || '' }, + label: version.label, + available: version.available !== false, + })); + history.push({ + version: history.length + 1, + versionId: null, + key, + created: listed.file.modifiedAt, + user: { id: listed.file.author?.id || '', name: listed.file.author?.label || '' }, + label: null, + available: true, }); + + res.set('Cache-Control', 'no-store'); + res.json({ currentVersion: history.length, history, canRestore: listed.rights.restore }); + }) +); + +/** Where one entry of that history is fetched from. */ +router.post( + '/onlyoffice/history-data', + asyncHandler(async (req, res) => { + requirePublicUrl(); + const relativePath = normalizeRelativePath(req.body?.path || ''); + if (!relativePath) throw new ValidationError('A valid file path is required.'); + const version = Number(req.body?.version); + if (!Number.isInteger(version) || version < 1) { + throw new ValidationError('A version number is required.'); + } + const context = { user: req.user, guestSession: req.guestSession }; + const versionId = typeof req.body?.versionId === 'string' ? req.body.versionId : ''; + + let key; + let absolutePath; + let name; + if (versionId) { + const located = await versionHistory().locateVersion(context, relativePath, versionId, { + download: false, + }); + key = versionKeyFor(located.version.id); + absolutePath = located.absolutePath; + name = located.name; + } else { + // The current state, from inside the history: the same rights decide. + await versionHistory().listVersions(context, relativePath); + ({ key, absolutePath } = await currentKeyOf(req, relativePath)); + name = path.basename(absolutePath); + } + + const payload = { + fileType: toExt(name), + key, + url: readOnlyFileUrl(req, relativePath, absolutePath, STORAGE_FILE_TOKEN_TTL_SECONDS), + version, + }; + payload.token = jwt.sign(payload, onlyoffice.secret, { algorithm: 'HS256' }); + res.set('Cache-Control', 'no-store'); + res.json(payload); + }) +); + +/** + * "Save as" from inside the editor. + * + * ONLYOFFICE does not write anything itself: it converts the document, then + * hands the integration a URL to fetch the result from. Without a route to + * receive it the menu entry is hidden, which left Download as the only way out + * — through the browser, into the person's downloads, not their volume. + * + * Deliberately not tied to an editing session: saving a copy is not a change to + * the original, so a reader may do it too. What it does require is the right to + * read the document it came from and to write into the folder it lands in, + * exactly as an upload would. + */ +router.post( + '/onlyoffice/save-as', + asyncHandler(async (req, res) => { + const relativePath = normalizeRelativePath(req.body?.path || ''); + if (!relativePath) { + throw new ValidationError('A valid file path is required.'); + } + + // The URL comes from the editor, so it is only ever fetched when it points + // at the configured Document Server — the same rule as the save callback. + const downloadUrl = ensureAllowedDownloadUrl(req.body?.url); + + let desiredName; + try { + // Refused, not trimmed down to its last segment. A title carrying a + // separator means the request is not what this route is for, and quietly + // reinterpreting it would turn "../invoice.pdf" into a silent success in + // a folder the caller never named. + desiredName = ensureValidName(String(req.body?.title || '')); + } catch (error) { + throw new ValidationError(error.message); + } + + const context = { user: req.user, guestSession: req.guestSession }; + + // Reading the source is what entitles somebody to save a copy of it. + const { accessInfo: sourceAccess } = await resolvePathWithAccess(context, relativePath); + if (!sourceAccess?.canAccess || !sourceAccess.canRead) { + throw new ForbiddenError(sourceAccess?.denialReason || 'Access denied.'); + } + + const parentPath = path.posix.dirname(relativePath); + const targetFolder = parentPath === '.' ? '' : parentPath; + const { accessInfo: folderAccess, resolved: folder } = await resolvePathWithAccess( + context, + targetFolder + ); + if (!folderAccess?.canAccess || !folderAccess.canWrite) { + throw new ForbiddenError(folderAccess?.denialReason || 'Access denied.'); + } + + await ensureDir(folder.absolutePath); + const { name, path: absolute } = await downloadDocumentInto( + downloadUrl, + folder.absolutePath, + desiredName + ); + + const written = await fsp.stat(absolute); + const savedPath = combineRelativePath(targetFolder, name); + logger.info( + { path: savedPath, size: written.size }, + 'ONLYOFFICE document saved under a new name' + ); + + res.json({ path: savedPath, name, size: written.size }); + }) +); + +// How long a token naming one file for the editor is good for: long enough to +// fetch it, short enough that the link is not worth keeping. +const STORAGE_FILE_TOKEN_TTL_SECONDS = 15 * 60; + +/** + * Who can be mentioned in a comment. + * + * ONLYOFFICE asks for the whole list and filters it in the editor as the + * comment is typed, so this answers with names and addresses rather than to a + * query. Only signed-in people get it: a visitor editing through a share link + * has no business being handed the user directory. + */ +router.get( + '/onlyoffice/users', + asyncHandler(async (req, res) => { + if (!req.user?.id) { + throw new ForbiddenError('Mentions require a signed-in user.'); + } + // Required here rather than at the top: the search service reaches into the + // database, which the route file does not otherwise touch. + // eslint-disable-next-line global-require + const { listUsersForMentions } = require('../services/userSearchService'); + res.json({ users: await listUsersForMentions() }); + }) +); + +/** + * A comment mentioning somebody was posted. + * + * ONLYOFFICE has already written the comment into the document; this is the + * separate "tell them about it" step, which it leaves entirely to the + * integration. There is no notification channel to deliver it on, so the + * mention is recorded and nothing is sent — said plainly, rather than leaving + * the editor waiting on a handler that silently does nothing. + */ +router.post( + '/onlyoffice/notify', + asyncHandler(async (req, res) => { + if (!req.user?.id) { + throw new ForbiddenError('Mentions require a signed-in user.'); + } + const relativePath = normalizeRelativePath(req.body?.path || ''); + if (!relativePath) { + throw new ValidationError('A valid file path is required.'); + } + const context = { user: req.user, guestSession: req.guestSession }; + const { accessInfo } = await resolvePathWithAccess(context, relativePath); + if (!accessInfo?.canAccess || !accessInfo.canRead) { + throw new ForbiddenError(accessInfo?.denialReason || 'Access denied.'); + } + + const emails = Array.isArray(req.body?.emails) + ? req.body.emails.filter((email) => typeof email === 'string').slice(0, 50) + : []; + logger.info( + { path: relativePath, by: String(req.user.id), recipients: emails.length }, + 'ONLYOFFICE comment mention recorded, no notification channel configured' + ); + + res.json({ delivered: false }); + }) +); + +/** + * How an open folder learns that somebody joined or left a document. + * + * Held open for up to twenty-five seconds on purpose, rather than asked for + * every second: presence changes rarely, and a poll that costs nothing while + * nothing happens is what makes it affordable to show at all. + */ +router.get( + '/onlyoffice/activity-version', + asyncHandler(async (req, res) => { + const parsedSince = Number(req.query?.since); + const since = Number.isInteger(parsedSince) ? parsedSince : null; + const controller = new AbortController(); + const abort = () => controller.abort(); + req.once('aborted', abort); + req.once('close', abort); + res.setHeader('Cache-Control', 'no-store'); + try { + const result = await onlyofficeActivity.waitForChange(since, 25_000, controller.signal); + if (!res.writableEnded && !res.destroyed) res.json(result); + } finally { + req.off('aborted', abort); + req.off('close', abort); + } + }) +); + +/** + * Write what the editor is holding, now. + * + * Answers as soon as the command is queued: the Document Server writes the + * document through the ordinary callback, asynchronously. Two requests for the + * same session are coalesced — a close arriving while an automatic save is + * still assembling queues one final command behind it rather than a second one + * beside it. + */ +router.post( + '/onlyoffice/force-save', + asyncHandler(async (req, res) => { + if (!onlyoffice.serverUrl) { + throw new ValidationError('ONLYOFFICE_URL is not configured on the server.'); + } + + const relativeRaw = req.body?.path || ''; + const sessionId = req.body?.sessionId || ''; + const reason = req.body?.reason === 'auto' ? 'auto' : 'close'; + if (typeof relativeRaw !== 'string' || !relativeRaw.trim()) { + throw new ValidationError('A valid file path is required.'); + } + if (typeof sessionId !== 'string' || !sessionId) { + throw new ValidationError('A valid ONLYOFFICE editing session is required.'); + } + + const relativePath = normalizeRelativePath(relativeRaw); + const context = { user: req.user, guestSession: req.guestSession }; + const { accessInfo, resolved } = await resolvePathWithAccess(context, relativePath); + + if (!accessInfo || !accessInfo.canAccess || !accessInfo.canWrite) { + throw new ForbiddenError(accessInfo?.denialReason || 'Access denied.'); + } + + const stat = await fsp.stat(resolved.absolutePath); + if (stat.isDirectory()) { + throw new ValidationError('Cannot force-save a directory.'); + } + + const session = await getEditorSession(req, sessionId, relativePath); + const existingRequestId = pendingForceSavesBySession.get(sessionId); + const pending = existingRequestId ? pendingForceSaves.get(existingRequestId) : null; + if (pending) { + const followUp = reason === 'close' && pending.reason === 'auto'; + if (followUp) pending.followUpReason = 'close'; + return res.status(202).json({ + queued: true, + requestId: existingRequestId, + coalesced: true, + followUp, + }); + } + + const requestId = enqueueForceSave({ + sessionId, + key: session.key, + relativePath, + reason, + }); + res.status(202).json({ queued: true, requestId }); }) ); @@ -340,6 +1198,7 @@ router.get( router.post( '/onlyoffice/callback', asyncHandler(async (req, res) => { + let forceSaveRequestId = null; try { const relativeRaw = req.query?.path || ''; if (typeof relativeRaw !== 'string' || !relativeRaw.trim()) { @@ -360,30 +1219,52 @@ router.post( } // Optionally, resolve from backend token (supports personal paths) - let backendCtx = null; - const backendToken = typeof req.query?.backend === 'string' ? req.query.backend : null; - if (backendToken && onlyoffice.secret) { - try { - const payload = jwt.verify(backendToken, onlyoffice.secret, { - algorithms: ['HS256'], - }); - if (payload && typeof payload === 'object' && payload.absolutePath) { - backendCtx = payload; - } - } catch (e) { - logger.warn({ err: e }, 'ONLYOFFICE backend token verification failed (callback)'); - } - } + const backendCtx = readBackendToken(req); const body = req.body || {}; const status = Number(body.status); + forceSaveRequestId = typeof body.userdata === 'string' ? body.userdata : null; + const activityPath = backendCtx?.absolutePath; + + // Status 1 reports the users currently connected to the document. It is + // presence only: this never becomes a filesystem lock, and it expires if + // the Document Server stops sending callbacks. + if (status === 1 && activityPath) { + onlyofficeActivity.updateDocumentServerUsers({ + absolutePath: activityPath, + users: Array.isArray(body.users) ? body.users : [], + }); + } else if ((status === 2 || status === 4) && activityPath) { + onlyofficeActivity.release({ absolutePath: activityPath }); + // The Document Server has let the document go, so its cached copy is + // now the stale one. Dropping the key is what makes the next open fetch + // the saved file instead of that copy. + // + // The session knows where the document is now; the token only knows + // where it was when the editor opened, which a rename since then would + // have made wrong. + const closing = backendCtx?.sessionId + ? await editorSessions.get(backendCtx.sessionId) + : null; + await documentKeys.releaseDocumentKey( + closing?.relativePath || backendCtx?.logicalPath || relativePath + ); + } // See ONLYOFFICE callback statuses: 2 - Save, 6 - Force Save if ((status === 2 || status === 6) && body.url) { // Only the Document Server we handed the document to may be fetched from. const downloadUrl = ensureAllowedDownloadUrl(body.url); let abs = null; - if (backendCtx && typeof backendCtx.absolutePath === 'string' && backendCtx.absolutePath) { - abs = backendCtx.absolutePath; + if (backendCtx) { + // The token stands in for a permission check, so it only counts when + // the session it was issued for was allowed to write. + if (backendCtx.canWrite !== true) { + throw new ForbiddenError('This editing session is read-only.'); + } + await assertShareStillValid(backendCtx); + // Where the document is now, not where it was called when the editor + // opened it. + abs = await resolveSaveTarget(backendCtx); } else { const context = { user: req.user, guestSession: req.guestSession }; const { accessInfo, resolved } = await resolvePathWithAccess(context, relativePath); @@ -410,7 +1291,10 @@ router.post( // everybody has left the document — is a state worth keeping. The // automatic saves in between are not, beyond the checkpoint the // versions take of a session that runs long. - const explicit = status === 2 || Number(body.forcesavetype) === 1; + const explicit = + status === 2 || + Number(body.forcesavetype) === 1 || + pendingForceSaves.get(forceSaveRequestId)?.reason === 'close'; await versions.saveFile( abs, @@ -429,14 +1313,23 @@ router.post( explicit, } ); + finishForceSave(forceSaveRequestId, { saved: status === 6 }); logger.debug({ path: relativePath, status }, 'ONLYOFFICE file updated'); // MUST return {error:0} according to ONLYOFFICE spec return res.json({ error: 0 }); } + // Status 7 is the Document Server saying the force-save failed; status 6 + // without a URL is the same shape. Either way whoever is waiting on that + // request must be told, or the close hangs until it times out. + if (status === 6 || status === 7) { + finishForceSave(forceSaveRequestId, { saved: false, failed: true }); + } + // For other statuses, acknowledge return res.json({ error: 0 }); } catch (err) { + finishForceSave(forceSaveRequestId, { saved: false, failed: true }); logger.error({ err }, 'ONLYOFFICE callback failed'); // Per spec, non-zero error indicates retry; use 1 return res.status(200).json({ error: 1 }); diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 5bcc09193..555ffb7e0 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -3,6 +3,7 @@ const { getPublicSettings, getSettingsForUser, setUserSetting, + USER_SETTING_KEYS, setSystemSetting, getSettings, } = require('../services/settingsService'); @@ -155,15 +156,10 @@ router.patch( if (payload.user && typeof payload.user === 'object' && user && user.id) { const userUpdates = {}; for (const [key, value] of Object.entries(payload.user)) { - if ( - key === 'showHiddenFiles' || - key === 'showThumbnails' || - key === 'showSidebarFavorites' || - key === 'showSidebarShares' || - key === 'showSidebarTools' || - key === 'defaultShareExpiration' || - key === 'skipHome' - ) { + // Which keys are preferences is the settings service's to say: this + // route used to keep a second list of its own, and a preference added + // to one and not the other was silently dropped here. + if (USER_SETTING_KEYS.has(key)) { userUpdates[key] = await setUserSetting(user.id, key, value); } } @@ -235,6 +231,25 @@ router.patch( } } + // Upload settings: whether uploads go out in chunks, and how big one is. + if (payload.uploads && typeof payload.uploads === 'object') { + const uploadsUpdate = {}; + if (typeof payload.uploads.chunkedEnabled === 'boolean') { + uploadsUpdate.chunkedEnabled = payload.uploads.chunkedEnabled; + } + if (Number.isFinite(payload.uploads.chunkSizeBytes)) { + uploadsUpdate.chunkSizeBytes = payload.uploads.chunkSizeBytes; + } + if (Object.keys(uploadsUpdate).length > 0) { + const current = await getSettings(); + const merged = await setSystemSetting('system', 'uploads', { + ...current.uploads, + ...uploadsUpdate, + }); + systemUpdates.uploads = merged; + } + } + // File-version settings: only the fields that arrived usable are merged; // setSystemSetting sanitizes and keeps them consistent. if (payload.versions && typeof payload.versions === 'object') { diff --git a/backend/src/routes/thumbnails.js b/backend/src/routes/thumbnails.js index dd0af824f..8349e7967 100644 --- a/backend/src/routes/thumbnails.js +++ b/backend/src/routes/thumbnails.js @@ -4,7 +4,11 @@ const path = require('path'); const { normalizeRelativePath } = require('../utils/pathUtils'); const { extensions } = require('../config/index'); -const { getThumbnail } = require('../services/thumbnailService'); +const env = require('../config/env'); +const { + getThumbnailPathIfExists, + queueThumbnailGeneration, +} = require('../services/thumbnailService'); const { resolvePathWithAccess } = require('../services/accessManager'); const { withThumbnailToken } = require('../utils/thumbnailTokens'); const logger = require('../utils/logger'); @@ -30,7 +34,8 @@ router.get( '/thumbnails/{*splat}', asyncHandler(async (req, res) => { const settings = await getSettings(); - const thumbsEnabled = settings?.thumbnails?.enabled !== false; + const thumbsEnabled = + env.THUMBNAILS_ENABLED !== false && settings?.thumbnails?.enabled !== false; if (!thumbsEnabled) { return res.json({ thumbnail: '' }); } @@ -46,7 +51,7 @@ router.get( let resolved; try { ({ accessInfo, resolved } = await resolvePathWithAccess(context, relativePath)); - } catch (error) { + } catch (_) { throw new NotFoundError('File not found.'); } @@ -78,29 +83,40 @@ router.get( throw new ValidationError('Thumbnails are not available for this file type.'); } - // The check above is the only one this thumbnail will get: the picture - // itself is served from /static, outside the authentication middleware. The - // token carries that decision to the handler there. - let thumbnail = ''; try { - thumbnail = await getThumbnail(absolutePath); + // The access check above is the only one this thumbnail will get: the + // file itself is served from /static, outside the auth middleware. The + // token carries that decision to the static handler. + const cachedThumbnail = await getThumbnailPathIfExists(absolutePath, stats); + if (cachedThumbnail) { + return res.json({ thumbnail: withThumbnailToken(cachedThumbnail), pending: false }); + } + + // A prefetch is deliberately lower priority and is admitted only while + // no interactive thumbnail work is in progress. Authorization remains + // identical to a regular thumbnail request. + const isBackgroundPrefetch = req.query.background === '1'; + const result = await queueThumbnailGeneration( + absolutePath, + isBackgroundPrefetch ? { priority: -10, onlyWhenIdle: true } : undefined + ); + return res + .status(result.pending ? 202 : 200) + .json({ ...result, thumbnail: withThumbnailToken(result.thumbnail) }); } catch (error) { logger.warn( { absolutePath, err: error }, - 'Thumbnail generation failed, falling back to original file' + 'Thumbnail generation scheduling failed, falling back to original file' ); } - // If thumbnail generation failed or produced no result, fall back to the original file - if ( - !thumbnail && - (extensions.images.includes(extension) || (extensions.rawImages || []).includes(extension)) - ) { + // If thumbnail scheduling failed unexpectedly, fall back to the original file for images. + if (extensions.images.includes(extension) || (extensions.rawImages || []).includes(extension)) { const previewUrl = `/api/preview?path=${encodeURIComponent(logicalPath)}`; return res.json({ thumbnail: previewUrl }); } - res.json({ thumbnail: withThumbnailToken(thumbnail || '') }); + res.json({ thumbnail: '', pending: false }); }) ); diff --git a/backend/src/routes/trash.js b/backend/src/routes/trash.js index a9ff9f383..d19dab4ec 100644 --- a/backend/src/routes/trash.js +++ b/backend/src/routes/trash.js @@ -2,6 +2,7 @@ const express = require('express'); const { sanitizeClientMessage } = require('../middleware/errorHandler'); const asyncHandler = require('../utils/asyncHandler'); +const { sendCompressible } = require('../utils/compressedResponse'); const { startNdjsonStream } = require('../utils/ndjsonStream'); const { ensureAdmin } = require('../middleware/ensureAdmin'); const trash = require('../services/trash'); @@ -48,6 +49,21 @@ router.post( }) ); +/** + * GET /api/trash/items/:id/text?path= - the text of a file in the trash, to read + * before deciding what to do with it: the item itself, or a file inside a + * deleted folder. Read only — there is no route that writes into the trash — + * with the editor's limits on size and binary content, and never cached. + */ +router.get( + '/trash/items/:id/text', + asyncHandler(async (req, res) => { + const text = await trash.readTrashText(req.params.id, req.query.path ?? '', contextOf(req)); + res.set('Cache-Control', 'private, no-store'); + await sendCompressible(req, res, text); + }) +); + /** * Restore into a folder someone chose. Across disks that is a copy, which can * take a while, so it streams its progress the way a transfer does: diff --git a/backend/src/routes/upload.js b/backend/src/routes/upload.js index 17a7bb6c3..5a061d07c 100644 --- a/backend/src/routes/upload.js +++ b/backend/src/routes/upload.js @@ -3,18 +3,90 @@ const path = require('path'); const fs = require('fs/promises'); const { createUploadMiddleware } = require('../services/uploadService'); +const { handleTusUpload, listFinalizations } = require('../services/tusUploadService'); +const { reserveFolderUploadTarget } = require('../services/uploadFolderTargetService'); +const { responseEndCompat } = require('../middleware/responseEndCompat'); +const { describeBytes, explainMultipartRefusals } = require('../middleware/multipartRefusals'); const { uploads } = require('../config/index'); const { normalizeRelativePath } = require('../utils/pathUtils'); +const { ACTIONS, authorizeAndResolve } = require('../services/authorizationService'); const logger = require('../utils/logger'); const asyncHandler = require('../utils/asyncHandler'); -const { ValidationError } = require('../errors/AppError'); +const { ForbiddenError, ValidationError } = require('../errors/AppError'); const router = express.Router(); const upload = createUploadMiddleware(); +const acceptFiles = explainMultipartRefusals( + upload.fields([{ name: 'filedata', maxCount: uploads.maxFilesPerRequest }]), + { + LIMIT_FILE_SIZE: `This file is larger than the ${describeBytes(uploads.maxDirectUploadBytes)} a direct upload accepts. Use chunked uploads, or raise MAX_DIRECT_UPLOAD_SIZE.`, + LIMIT_FILE_COUNT: `One upload request takes at most ${uploads.maxFilesPerRequest} files. Send the others in another, or raise MAX_FILES_PER_UPLOAD.`, + } +); + +// responseEndCompat first: @tus/server finishes its responses with +// `res.end(callback)`, which express-session's own res.end mistakes for a body +// and passes to res.write(). That throws where nothing catches it, and the +// process exits mid-upload. +router.all('/upload/tus{*splat}', responseEndCompat, handleTusUpload); + +/** + * Files whose transfer is over but which are still being written where they + * belong. The client asks about these while its own progress bar has nothing + * left to report, so a long copy across filesystems does not look like a frozen + * hundred per cent. + * + * Answers only for what the caller uploaded, and says nothing when there is + * nothing to say — the usual case, where the move is a rename and returns + * before anybody could ask. + */ +router.get( + '/upload/finalizations', + asyncHandler(async (req, res) => { + res.json({ items: listFinalizations(req) }); + }) +); + +/** + * The folder a whole uploaded tree lands in, decided once. + * + * Every file of a picked folder carries the same relative path prefix, and each + * one arriving on its own would otherwise take its own "(1)" when the name is + * held — scattering one folder across several. + */ +router.post( + '/upload/folder-session', + asyncHandler(async (req, res) => { + const uploadTo = normalizeRelativePath(req.body?.uploadTo || ''); + const sourceRoot = req.body?.sourceRoot; + const context = { user: req.user, guestSession: req.guestSession }; + const { allowed, accessInfo, resolved } = await authorizeAndResolve( + context, + uploadTo, + ACTIONS.upload + ); + + if (!allowed || !resolved) { + throw new ForbiddenError(accessInfo?.denialReason || 'Cannot upload files to this path.'); + } + + // The destination is authorized above; the folder the session is about to + // create inside it is a path of its own, and is authorized before the mkdir + // rather than when the first file arrives. + const targetRoot = await reserveFolderUploadTarget({ + destinationRoot: resolved.absolutePath, + logicalBase: resolved.relativePath, + sourceRoot, + context, + }); + res.status(201).json({ targetRoot }); + }) +); + router.post( '/upload', - upload.fields([{ name: 'filedata', maxCount: uploads.maxFilesPerRequest }]), + acceptFiles, asyncHandler(async (req, res) => { if (!req.files || !Array.isArray(req.files.filedata) || req.files.filedata.length === 0) { throw new ValidationError('No files were provided.'); diff --git a/backend/src/routes/versions.js b/backend/src/routes/versions.js index de590e73d..4fb4917e4 100644 --- a/backend/src/routes/versions.js +++ b/backend/src/routes/versions.js @@ -3,6 +3,7 @@ const fs = require('fs'); const asyncHandler = require('../utils/asyncHandler'); const logger = require('../utils/logger'); +const { sendCompressible } = require('../utils/compressedResponse'); const { mimeTypes } = require('../config/index'); const versions = require('../services/versions'); const { encodeContentDisposition } = require('./files/utils'); @@ -49,6 +50,19 @@ router.get( }) ); +/** + * The text of a version, for the editor to show read only. Never cached: what a + * version holds does not change, but what this person may read does. + */ +router.get( + '/versions/:id/text', + asyncHandler(async (req, res) => { + const text = await versions.readVersionText(contextOf(req), req.query?.path, req.params.id); + res.set('Cache-Control', 'private, no-store'); + await sendCompressible(req, res, text); + }) +); + router.post( '/versions/:id/restore', asyncHandler(async (req, res) => { diff --git a/backend/src/routes/versionsAdmin.js b/backend/src/routes/versionsAdmin.js new file mode 100644 index 000000000..816053905 --- /dev/null +++ b/backend/src/routes/versionsAdmin.js @@ -0,0 +1,80 @@ +const express = require('express'); + +const asyncHandler = require('../utils/asyncHandler'); +const { ensureAdmin } = require('../middleware/ensureAdmin'); +const versions = require('../services/versions'); + +/** + * Every file that has a history, for an administrator. + * + * Apart from the routes beside it, and deliberately. Those answer about one + * file, named by its path, with that file's own rights — the right shape for + * somebody looking at a document they have open. These answer "where has the + * space gone", which has no one path to ask about: a history whose file was + * deleted outside the application has no file left to authorise against, and + * it is exactly the kind nobody goes looking for. + * + * So a history is named here by its own id, and every route is behind + * `ensureAdmin` — which also refuses an API token, whoever it belongs to. + * + * Under `/versions/admin/` rather than `/versions/files`: `/versions/:id/…` + * already exists, and a first segment that could also be an id is how a route + * ends up meaning two things. + */ +const router = express.Router(); + +/** A page of the files that have versions, narrowed and ordered as asked. */ +router.get( + '/versions/admin/files', + ensureAdmin, + asyncHandler(async (req, res) => { + res.set('Cache-Control', 'private, no-store'); + const { zone, state, q, sort, limit, offset } = req.query || {}; + res.json( + await versions.listFilesWithVersions({ + zoneId: typeof zone === 'string' ? zone : null, + state: typeof state === 'string' ? state : null, + query: typeof q === 'string' ? q : '', + sort: typeof sort === 'string' && sort ? sort : 'bytes', + limit, + offset, + }) + ); + }) +); + +/** One history and its versions, so they can be looked at before being deleted. */ +router.get( + '/versions/admin/files/:id', + ensureAdmin, + asyncHandler(async (req, res) => { + res.set('Cache-Control', 'private, no-store'); + res.json(await versions.readFileVersions(req.params.id)); + }) +); + +/** + * Delete versions of one history: the ones named, or all of them. + * + * A POST with a body rather than a DELETE with a list, as the route beside it + * does, so that deleting forty versions is one request and one answer per + * version — a DELETE per id would report forty times and fail in the middle. + * + * This is the one route here that destroys something, and what it destroys may + * belong to somebody else — so it is the line an activity log would want. There + * is no log yet; when there is one, this is where its entry goes. + */ +router.post( + '/versions/admin/files/:id/delete', + ensureAdmin, + asyncHandler(async (req, res) => { + const outcome = await versions.deleteFileVersions(req.params.id, { + ids: req.body?.ids, + all: req.body?.all === true, + }); + + res.json(outcome); + }) +); + +module.exports = router; diff --git a/backend/src/routes/zip.js b/backend/src/routes/zip.js index d0b9a00a3..7e09a4bfc 100644 --- a/backend/src/routes/zip.js +++ b/backend/src/routes/zip.js @@ -14,9 +14,10 @@ const { combineRelativePath, ensureValidName, } = require('../utils/pathUtils'); -const { placeWithoutOverwrite } = require('../utils/placeWithoutOverwrite'); -const { takeInventory, removeInventoried } = require('../utils/ownedTree'); +const { placeWithoutOverwrite, predictAvailableName } = require('../utils/placeWithoutOverwrite'); +const { removeInventoried } = require('../utils/ownedTree'); const { ValidationError, ForbiddenError, NotFoundError } = require('../errors/AppError'); +const { sanitizeClientMessage } = require('../middleware/errorHandler'); const { ACTIONS, authorizeAndResolve } = require('../services/authorizationService'); const { track: trackInFlight } = require('../services/inFlightFiles'); const { @@ -28,46 +29,22 @@ const { archiveBaseName, normalizeArchivePassword, } = require('../services/archiveService'); +const { + ensureArchiveWithinLimits, + buildItemMetadata, + extractIntoCurrentFolder, +} = require('../services/archiveExtraction'); const { collectArchiveEntries, writeZipFile } = require('../services/archiveTree'); const { archives } = require('../config/index'); const router = express.Router(); -/** - * Refuse archives that would expand far beyond their own size. - * - * Extraction is otherwise unbounded: a few kilobytes of nested, highly - * compressible entries can fill the volume ("zip bomb"). The declared sizes - * come from the archive itself, so this is a cheap pre-flight check, not a - * guarantee — it stops the accidental and the trivially malicious case. - */ -const ensureArchiveWithinLimits = ({ entryCount = 0, totalBytes = 0 }) => { - if (entryCount > archives.maxEntries) { - throw new ValidationError( - `This archive holds more than ${archives.maxEntries} entries and was not extracted.` - ); - } - if (totalBytes > archives.maxExtractedBytes) { - throw new ValidationError( - 'This archive expands beyond the allowed size and was not extracted.' - ); - } -}; - /** Declared footprint of a zip read by the bundled JS extractor. */ const admZipFootprint = (entries = []) => ({ entryCount: entries.length, totalBytes: entries.reduce((total, entry) => total + (entry?.header?.size || 0), 0), }); -const buildItemMetadata = async (absolutePath, relativeParent, name) => { - const stats = await fs.stat(absolutePath); - const ext = path.extname(name).slice(1).toLowerCase(); - const kind = stats.isDirectory() ? 'directory' : ext.length > 10 ? 'unknown' : ext || 'unknown'; - - return { name, path: relativeParent, kind, size: stats.size, dateModified: stats.mtime }; -}; - const defaultZipNameForItems = (items = []) => { if (!Array.isArray(items) || items.length === 0) return 'Archive.zip'; if (items.length > 1) return 'Archive.zip'; @@ -81,37 +58,6 @@ const defaultZipNameForItems = (items = []) => { return `${ext ? name.slice(0, -ext.length) : name}.zip`; }; -const extractIntoCurrentFolder = async ({ - stagingDirectory, - destinationDirectory, - relativeParentPath, - movedPaths, -}) => { - const stagedEntries = await fs.readdir(stagingDirectory, { withFileTypes: true }); - const items = []; - - for (const entry of stagedEntries) { - const entryName = ensureValidName(entry.name); - const sourcePath = path.join(stagingDirectory, entryName); - // Taken stock of before it moves: undoing the extraction removes exactly - // this, and not what someone puts in a placed folder afterwards. - const inventory = await takeInventory(sourcePath); - // The name is taken by the move itself, never looked at first and renamed - // into later: a file, or an empty folder, that appears under it meanwhile - // stays as it is, and the entry goes to "name (1)". - const { name: destinationName, path: destinationPath } = await placeWithoutOverwrite( - sourcePath, - destinationDirectory, - entryName - ); - movedPaths.push({ path: destinationPath, inventory }); - - items.push(await buildItemMetadata(destinationPath, relativeParentPath, destinationName)); - } - - return items; -}; - router.post( '/files/zip/extract', asyncHandler(async (req, res) => { @@ -177,7 +123,7 @@ router.post( const { allowed: filesAllowed, accessInfo: filesAccessInfo } = await authorizeAndResolve( context, parentRelativePath, - ACTIONS.write + ACTIONS.createFile ); if (!filesAllowed) { throw new ForbiddenError(filesAccessInfo?.denialReason || 'Destination is read-only.'); @@ -260,6 +206,8 @@ router.post( baseFolderName, { style: 'folder' } ); + // The archive has produced an entire new tree. Queue its index refresh, + // but never hold the archive operation open on background filesystem I/O. const item = await buildItemMetadata(placed.path, parentRelativePath, placed.name); writeEvent({ type: 'done', success: true, item, items: [item] }); @@ -302,7 +250,7 @@ router.post( ); writeEvent({ type: 'error', - message: error.message || 'Archive extraction failed.', + message: sanitizeClientMessage(error.message || 'Archive extraction failed.'), code: error.code || 'EXTRACT_FAILED', }); } finally { @@ -343,7 +291,7 @@ router.post( allowed: destAllowed, accessInfo: destAccess, resolved: destResolved, - } = await authorizeAndResolve(context, normalizedDestination, ACTIONS.write); + } = await authorizeAndResolve(context, normalizedDestination, ACTIONS.createFile); if (!destAllowed || !destResolved) { throw new ForbiddenError(destAccess?.denialReason || 'Destination is read-only.'); } @@ -367,6 +315,12 @@ router.post( if (!allowed || !resolved) { throw new ForbiddenError(accessInfo?.denialReason || 'Source item is not accessible.'); } + // An archive written into a folder the caller can reach is a copy they + // can take away: a share that withholds downloads withholds this too. + if (!accessInfo.canDownload) { + throw new ForbiddenError('Downloading is not allowed for this item.'); + } + const stats = await fs.stat(resolved.absolutePath); return { name: item.name, @@ -399,13 +353,19 @@ router.post( // Everything above throws BEFORE any byte is written, so validation errors // still surface as normal HTTP errors. From here on the response streams // NDJSON progress events, mirroring the extract endpoint: - // {type:'start', name} the name asked for + // {type:'start', name} the name expected, "Archive (1).zip" when held // {type:'progress', percent} (throttled) - // {type:'done', success, item} the name taken, "Archive (1).zip" when held + // {type:'done', success, item} the name taken // {type:'error', message, code} const writeEvent = startNdjsonStream(res); - writeEvent({ type: 'start', name: requestedName }); + // The progress shows this name for as long as the compression lasts, so it + // is the one the archive should land at rather than the one asked for. Only + // a guess: the name is taken at the end, by the move, and `done` says which. + writeEvent({ + type: 'start', + name: await predictAvailableName(destinationAbsolutePath, requestedName), + }); const onPercent = throttlePercent(writeEvent); @@ -460,7 +420,7 @@ router.post( await fs.rm(temporaryPath, { force: true }); writeEvent({ type: 'error', - message: error.message || 'Archive creation failed.', + message: sanitizeClientMessage(error.message || 'Archive creation failed.'), code: error.code || 'COMPRESS_FAILED', }); } finally { diff --git a/backend/src/server.js b/backend/src/server.js index c0b8b20cb..5d563c8bf 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -12,6 +12,11 @@ const searchIndexManager = require('./services/searchIndexManager'); const folderSizeManager = require('./services/folderSizeManager'); const { sweepInterrupted } = require('./services/inFlightFiles'); const trashMaintenance = require('./services/trash/maintenance'); +const tusUploads = require('./services/tusUploadService'); +const { cleanupExpiredShares } = require('./services/sharesService'); +const { cleanupExpiredSessions } = require('./services/guestSessionService'); +const { purgeExpiredDocumentKeys } = require('./services/onlyofficeDocumentKeyService'); +const editorSessions = require('./services/onlyofficeEditorSessionService'); let server = null; @@ -59,11 +64,46 @@ const startServer = async () => { // Finishes what a crash interrupted before anything else touches a zone, // then keeps each zone within its retention and budget. trashMaintenance.start(); + // Chunked uploads abandoned, or finished and never moved into place, leave + // the upload cache once past TUS_INCOMPLETE_UPLOAD_TTL_MS. + tusUploads.startCacheSweep(); + + // Rows that expire and were never swept. The ONLYOFFICE key of a document + // whose browser was closed is one: only a terminal callback released a key, + // so a crash or a restart left the row for good, one for every document ever + // opened. The same sweep takes the two that were already here and had no + // caller at all — `cleanupExpiredShares` and `cleanupExpiredSessions` — so an + // expired share no longer sits on disk indefinitely. + const EXPIRY_SWEEP_INTERVAL_MS = 60 * 60 * 1000; + const sweepExpiredRecords = async () => { + try { + const [shares, sessions, documentKeys] = await Promise.all([ + cleanupExpiredShares(), + cleanupExpiredSessions(), + purgeExpiredDocumentKeys(), + editorSessions.purgeExpired(), + ]); + if (shares || sessions || documentKeys) { + logger.info( + { shares, sessions, documentKeys }, + 'Purged expired shares, guest sessions and ONLYOFFICE document keys' + ); + } + } catch (error) { + logger.warn({ err: error }, 'Expiry sweep failed'); + } + }; + const expirySweep = setInterval(sweepExpiredRecords, EXPIRY_SWEEP_INTERVAL_MS); + // Never keep the process alive just for the sweep. + expirySweep.unref?.(); + void sweepExpiredRecords(); // Cleanup on process termination const cleanup = () => { logger.info('Shutting down server...'); terminalService.cleanup(); + clearInterval(expirySweep); + tusUploads.stopCacheSweep(); folderSizeManager.stop(); trashMaintenance.stop(); searchIndexManager.stop(); diff --git a/backend/src/services/archiveBrowseService.js b/backend/src/services/archiveBrowseService.js new file mode 100644 index 000000000..7bab9b719 --- /dev/null +++ b/backend/src/services/archiveBrowseService.js @@ -0,0 +1,537 @@ +const { execFile, spawn } = require('child_process'); +const { promisify } = require('util'); +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); + +const { archives } = require('../config/index'); +const { AppError } = require('../errors/AppError'); +const { isArchivePasswordError, TAR_WRAPPER_EXTENSIONS } = require('./archiveService'); +const { cachedInnerArchive, cachedSolidTree, existingSolidTree } = require('./archiveCacheService'); + +const execFileAsync = promisify(execFile); + +const SEVEN_ZIP_BIN = process.env.SEVEN_ZIP_PATH || '7z'; + +/** + * Looking inside an archive without unpacking it. + * + * Answering "what is in this backup?" cost a full extraction: forty gigabytes + * written to disk to read one filename. 7-Zip already knows — `7z l -slt` + * prints a record per entry, and the same command already runs before every + * extraction to refuse an archive that would expand past its limit, where + * everything but the sum of the sizes is thrown away. + * + * Two things shape everything here. An archive is somebody else's file, so + * every name in it is hostile input: it is never used to build a path on disk, + * and what it is allowed to mean is decided here rather than by the shell or + * the filesystem. And the listing is the whole archive, always: there is no + * such thing as listing one folder of a zip, so the level being looked at is + * cut out of the full listing rather than asked for. + */ + +/** How long a listing may take before it is somebody waiting for nothing. */ +const LIST_TIMEOUT_MS = 30_000; + +/** What `7z l -slt` may print. Beyond it the listing is refused, not truncated. */ +const LIST_MAX_BUFFER = 32 * 1024 * 1024; + +/** 7-Zip separates its own header from the entries with exactly ten dashes. */ +const ENTRY_SEPARATOR = /^----------\r?$/m; + +/** + * One `Key = Value` record per entry, as 7-Zip prints them. + * + * Split on the *first* ` = `, because a filename may hold one too. A line with + * no separator at all is the rest of a value that had a newline in it — a name + * can carry one — and belongs to the key above rather than being dropped, + * which would leave a truncated name that reads like a different file. + */ +/** + * The block 7-Zip prints before the entries: what the archive is, and how. + * + * `Solid = +` is the one that matters here — it says every file went into one + * compressed stream, so reading the last means decompressing the ones before + * it. A zip never says it; a `.7z` usually does. + */ +const headerOf = (stdout) => { + const [before] = String(stdout).split(ENTRY_SEPARATOR); + const header = {}; + for (const rawLine of String(before).split('\n')) { + const line = rawLine.replace(/\r$/, ''); + const at = line.indexOf(' = '); + if (at === -1) continue; + header[line.slice(0, at)] = line.slice(at + 3); + } + return header; +}; + +const parseRecords = (stdout) => { + const separated = String(stdout).split(ENTRY_SEPARATOR); + if (separated.length < 2) return []; + + const records = []; + let current = null; + let lastKey = null; + + for (const rawLine of separated.slice(1).join('\n').split('\n')) { + const line = rawLine.replace(/\r$/, ''); + if (line.trim() === '') { + if (current) records.push(current); + current = null; + lastKey = null; + continue; + } + const at = line.indexOf(' = '); + if (at === -1) { + if (current && lastKey) current[lastKey] += `\n${line}`; + continue; + } + if (!current) current = {}; + lastKey = line.slice(0, at); + current[lastKey] = line.slice(at + 3); + } + if (current) records.push(current); + + return records; +}; + +/** + * Where an entry sits inside the archive, or nothing when it points outside. + * + * A crafted archive holds `../../etc/passwd`, or `/etc/passwd`, or a Windows + * path with backslashes. None of these is a place inside the archive, and an + * entry that claims one is left out of the listing and counted instead: what + * cannot be shown as somewhere is not shown as somewhere. + */ +const entryPathOf = (rawPath) => { + if (typeof rawPath !== 'string' || rawPath === '') return null; + + const segments = rawPath.replace(/\\/g, '/').split('/'); + const kept = []; + for (const segment of segments) { + if (segment === '' || segment === '.') continue; + if (segment === '..') return null; + kept.push(segment); + } + // A drive letter is not a folder name: `C:/Windows` says the same thing as + // an absolute path, in the other family of systems. + if (kept.length === 0 || /^[A-Za-z]:$/.test(kept[0])) return null; + return kept.join('/'); +}; + +/** 7-Zip says so twice, in different formats: take either. */ +const isDirectoryRecord = (record) => + record.Folder === '+' || /^D/.test(String(record.Attributes || '')); + +const sizeOf = (record) => { + const size = Number.parseInt(record.Size, 10); + return Number.isFinite(size) && size >= 0 ? size : null; +}; + +/** 7-Zip prints local time as `2026-09-16 11:22:33`, or nothing at all. */ +const modifiedOf = (record) => { + const written = String(record.Modified || '').trim(); + return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(written) ? written.slice(0, 19) : null; +}; + +/** Everything the archive says about itself, entry by entry. */ +const describeEntries = (stdout) => { + const entries = []; + let outside = 0; + + for (const record of parseRecords(stdout)) { + if (!('Path' in record)) continue; + const entryPath = entryPathOf(record.Path); + if (entryPath === null) { + outside += 1; + continue; + } + entries.push({ + path: entryPath, + isDirectory: isDirectoryRecord(record), + size: sizeOf(record), + modified: modifiedOf(record), + encrypted: record.Encrypted === '+', + }); + } + + return { entries, outside }; +}; + +/** + * One level of the archive, as a listing of folders and files. + * + * Most archives carry no record for their folders — a zip of `docs/a.txt` may + * hold that one entry and nothing else — so the folders at a level are the + * ones its entries imply. A real record for a folder is preferred when there + * is one, because it carries a date; a folder nothing names keeps the shape + * and says nothing it does not know. + */ +const levelOf = (entries, inside) => { + const prefix = inside ? `${inside}/` : ''; + const folders = new Map(); + const files = []; + let exists = inside === ''; + + for (const entry of entries) { + if (inside && entry.path === inside) { + exists = true; + continue; + } + if (!entry.path.startsWith(prefix)) continue; + + const rest = entry.path.slice(prefix.length); + const slash = rest.indexOf('/'); + + if (slash === -1) { + if (entry.isDirectory) { + folders.set(rest, { ...entry, name: rest }); + } else { + files.push({ ...entry, name: rest }); + } + continue; + } + + exists = true; + const name = rest.slice(0, slash); + if (!folders.has(name)) { + folders.set(name, { + name, + path: `${prefix}${name}`, + isDirectory: true, + size: null, + modified: null, + encrypted: false, + }); + } + } + + if (folders.size > 0 || files.length > 0) exists = true; + + const byName = (left, right) => left.name.localeCompare(right.name); + return { + exists, + entries: [...[...folders.values()].sort(byName), ...files.sort(byName)], + }; +}; + +/** + * A refusal the caller can act on: an archive that is encrypted is a different + * answer from one that is damaged, and a panel says something different for + * each. The sentence travels with a code so it can be said in the reader's own + * language rather than in this one. + */ +const listingError = (message, code, statusCode = 400) => new AppError(message, statusCode, code); + +/** + * Read an archive's table of contents. + * + * The whole archive is listed whatever level is being looked at: 7-Zip has no + * way to list one folder, and an archive small enough to browse is one whose + * listing is cheap. The cost that matters is the one this avoids — writing the + * contents to disk to find out what they are called. + */ +const readArchiveListing = async (archiveAbsolutePath) => { + let stdout; + try { + ({ stdout } = await execFileAsync( + SEVEN_ZIP_BIN, + ['l', '-slt', '-y', '-p', '--', archiveAbsolutePath], + { timeout: LIST_TIMEOUT_MS, maxBuffer: LIST_MAX_BUFFER } + )); + } catch (error) { + // An archive whose table of contents is itself encrypted cannot be read at + // all without the password. `-p` above answers the prompt with an empty + // one rather than leaving 7-Zip waiting on a terminal that is not there. + if (isArchivePasswordError(error)) { + throw listingError( + 'This archive is protected by a password and cannot be browsed.', + 'ARCHIVE_ENCRYPTED', + 409 + ); + } + if (error?.code === 'ENOENT') { + throw listingError('Archives cannot be read on this server.', 'ARCHIVE_TOOL_MISSING', 503); + } + throw listingError('This archive could not be read.', 'ARCHIVE_UNREADABLE', 422); + } + + const { entries, outside } = describeEntries(stdout); + if (entries.length > archives.maxEntries) { + throw listingError( + `This archive holds more than ${archives.maxEntries} entries and was not opened.`, + 'ARCHIVE_TOO_MANY_ENTRIES', + 413 + ); + } + + const header = headerOf(stdout); + return { + entries, + outside, + // Both are about how much work one read is: what the archive holds, and + // whether reaching one entry means decompressing the others. + solid: header.Solid === '+', + totalBytes: entries.reduce((sum, entry) => sum + (Number(entry.size) || 0), 0), + }; +}; + +/** + * Where to read this archive from, and what it holds. + * + * A compound archive — gzip, bzip2, xz or zstd wrapped around a tar — is two + * archives, and 7-Zip peels one layer per run: listing `backup.tar.gz` answers + * with a single entry called `backup.tar`. True, and no use at all to somebody + * looking for a file inside it. + * + * The layer below is read from a decompressed copy of the inner archive, made + * once and kept in the cache directory. Three things have to hold together for + * that: the outer extension is one of the wrappers, there is exactly one entry, + * and that entry is a tar. A gzipped text file is one entry too, and it is not + * an archive to go inside — it is a file to hand over, which is what the + * listing already offers. + */ +const readBrowsableArchive = async (archiveAbsolutePath) => { + const listing = await readArchiveListing(archiveAbsolutePath); + + const extension = archiveAbsolutePath.slice(archiveAbsolutePath.lastIndexOf('.') + 1); + const [only] = listing.entries; + const wrapsATar = + TAR_WRAPPER_EXTENSIONS.has(extension.toLowerCase()) && + listing.entries.length === 1 && + !only.isDirectory && + /\.tar$/i.test(only.path); + + if (!wrapsATar) return { source: archiveAbsolutePath, listing }; + + const source = await cachedInnerArchive(archiveAbsolutePath, only.size); + return { source, listing: await readArchiveListing(source) }; +}; + +/** + * What is at one level of an archive. + * + * @param {string} archiveAbsolutePath the archive itself, on disk + * @param {string} [inside] the folder within it, '' for the top + */ +const browseArchive = async (archiveAbsolutePath, inside = '') => { + // Where the caller says it is looking is held to the same rule as a name + // read out of the archive: a position that points anywhere but inside is not + // a position, whoever wrote it. + const position = inside ? entryPathOf(inside) : ''; + if (position === null) { + throw listingError('That is not a folder inside this archive.', 'ARCHIVE_BAD_POSITION', 400); + } + + const { + listing: { entries, outside }, + } = await readBrowsableArchive(archiveAbsolutePath); + const level = levelOf(entries, position); + + if (!level.exists) { + throw listingError('That folder is not in this archive.', 'ARCHIVE_ENTRY_NOT_FOUND', 404); + } + + return { + inside: position, + entries: level.entries, + total: entries.length, + outside, + }; +}; + +/** + * One entry of an archive, as the archive itself describes it. + * + * The name comes from whoever asked, so it is looked up in the listing rather + * than taken on trust: what is read is an entry this archive holds, under + * exactly the name it holds it under. That lookup is also where the size and + * the encryption come from, which are the two things the answer needs and the + * caller must not be allowed to assert. + */ +const findArchiveEntry = async (archiveAbsolutePath, entryPath) => { + const wanted = entryPathOf(entryPath); + if (wanted === null) { + throw listingError('That is not a file inside this archive.', 'ARCHIVE_BAD_POSITION', 400); + } + + const { source, listing } = await readBrowsableArchive(archiveAbsolutePath); + const found = listing.entries.find((entry) => entry.path === wanted); + if (!found) { + throw listingError('That file is not in this archive.', 'ARCHIVE_ENTRY_NOT_FOUND', 404); + } + if (found.isDirectory) { + throw listingError('That is a folder, not a file.', 'ARCHIVE_ENTRY_IS_FOLDER', 400); + } + if (found.encrypted) { + throw listingError( + 'This file is encrypted and cannot be read without its password.', + 'ARCHIVE_ENCRYPTED', + 409 + ); + } + + return { entry: found, source, listing }; +}; + +/** + * Reads of an archive since this process started, by the archive it was. + * + * What it decides: the second read of a solid archive is the one worth + * extracting for, because that read costs a full pass whichever way it goes + * and every read after it is then a file on disk. The first is left alone — a + * small file near the front of a big archive costs 0.02 s to read and 1.4 s to + * extract for, and nobody asked for the other entries. + * + * In memory, and bounded: a restart forgets, which costs one more slow read. + */ +const readsSoFar = new Map(); +const REMEMBERED_ARCHIVES = 500; + +const countRead = (key) => { + const count = (readsSoFar.get(key) || 0) + 1; + // Re-inserted so the map's own order is least-recently-read first. + readsSoFar.delete(key); + readsSoFar.set(key, count); + if (readsSoFar.size > REMEMBERED_ARCHIVES) { + const [oldest] = readsSoFar.keys(); + readsSoFar.delete(oldest); + } + return count; +}; + +/** For a test, and for anything that wants a process to start over. */ +const forgetArchiveReads = () => readsSoFar.clear(); + +/** + * One entry out of the cached tree, in the shape a spawned read has. + * + * Null when it is not there under that name — 7-Zip and this listing agree in + * every case seen, and a read that falls back to the archive is slower rather + * than wrong. + */ +const openFromTree = async (treePath, entryPath) => { + const file = path.resolve(treePath, entryPath); + // The listing already refuses a name that points outside the archive. This + // is the second lock on the same door: what is read is under the tree. + if (file !== treePath && !file.startsWith(`${treePath}${path.sep}`)) return null; + + const stats = await fsp.stat(file).catch(() => null); + if (!stats?.isFile()) return null; + + const stream = fs.createReadStream(file); + const finished = new Promise((resolve, reject) => { + stream.once('end', resolve); + stream.once('error', reject); + }); + return { stdout: stream, finished, stop: () => stream.destroy() }; +}; + +/** + * Read one entry: from the extracted tree where there is one, from the archive + * otherwise. + * + * @param {object} found what `findArchiveEntry` answered + */ +const readArchiveEntry = async ({ source, entry, listing }) => { + const existing = await existingSolidTree(source); + const fromExisting = existing && (await openFromTree(existing, entry.path)); + if (fromExisting) return fromExisting; + + if (listing?.solid && !existing && countRead(source) >= 2) { + const tree = await cachedSolidTree(source, listing.totalBytes); + const fromFresh = tree && (await openFromTree(tree, entry.path)); + if (fromFresh) return fromFresh; + } + + return openArchiveEntry(source, entry.path); +}; + +/** How long one entry may take to come out before nobody is still waiting. */ +const READ_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * The bytes of one entry, straight out of the archive and nowhere else. + * + * `-so` writes to standard output, so nothing is ever placed on disk — the + * whole point of this, and the thing a later change would quietly lose by + * extracting to a temporary folder first. + * + * `-spd` matters as much: without it 7-Zip reads the name as a pattern, so an + * entry genuinely called `report*.txt` would come back as every report in the + * archive, joined end to end. The name is checked against the listing before + * it gets here, which stops it naming another archive's business, but not one + * name standing for several of its own. + * + * stderr is read and kept short on purpose: left unread it fills its pipe at + * 64 KB and the extraction stops there, holding the connection open. + */ +const openArchiveEntry = (archiveAbsolutePath, entryPath) => { + const child = spawn( + SEVEN_ZIP_BIN, + ['x', '-so', '-y', '-p', '-spd', '--', archiveAbsolutePath, entryPath], + { stdio: ['ignore', 'pipe', 'pipe'] } + ); + + let output = ''; + child.stderr.on('data', (chunk) => { + output = `${output}${chunk}`.slice(-2000); + }); + + const timer = setTimeout(() => child.kill('SIGKILL'), READ_TIMEOUT_MS); + timer.unref?.(); + + const finished = new Promise((resolve, reject) => { + child.once('error', (error) => { + clearTimeout(timer); + reject( + error?.code === 'ENOENT' + ? listingError('Archives cannot be read on this server.', 'ARCHIVE_TOOL_MISSING', 503) + : error + ); + }); + child.once('close', (code) => { + clearTimeout(timer); + if (code === 0) { + resolve(); + return; + } + const error = new Error(`7z exited with code ${code}: ${output.trim().slice(-500)}`); + reject( + isArchivePasswordError(error) + ? listingError( + 'This file is encrypted and cannot be read without its password.', + 'ARCHIVE_ENCRYPTED', + 409 + ) + : listingError('This entry could not be read.', 'ARCHIVE_UNREADABLE', 422) + ); + }); + }); + + return { + stdout: child.stdout, + finished, + stop: () => { + clearTimeout(timer); + child.kill('SIGKILL'); + }, + }; +}; + +module.exports = { + browseArchive, + readArchiveEntry, + forgetArchiveReads, + openFromTree, + readBrowsableArchive, + findArchiveEntry, + openArchiveEntry, + readArchiveListing, + describeEntries, + parseRecords, + entryPathOf, + levelOf, + SEVEN_ZIP_BIN, +}; diff --git a/backend/src/services/archiveCacheService.js b/backend/src/services/archiveCacheService.js new file mode 100644 index 000000000..87fec95c3 --- /dev/null +++ b/backend/src/services/archiveCacheService.js @@ -0,0 +1,426 @@ +const path = require('path'); +const fs = require('fs/promises'); + +/* + * Everything this file removes is its own: the extraction cache under the + * application's cache directory, written here and read here. Nothing anybody + * put anywhere ever passes through it, so it does not go through the trash. + */ +/* eslint-disable no-restricted-properties */ +const fss = require('fs'); +const crypto = require('crypto'); +const { spawn } = require('child_process'); +const { pipeline } = require('stream/promises'); + +const { directories, archives } = require('../config/index'); +const { AppError } = require('../errors/AppError'); +const { ensureDir, pathExists } = require('../utils/fsUtils'); +const { ensureStorageAvailable } = require('./uploadStorageGuard'); +const { + CACHE_CLEANUP_INTERVAL_MS, + CACHE_TTL_MS, + findAbandonedTempFiles, + statCacheEntries, +} = require('../utils/cacheCleanup'); +const logger = require('../utils/logger'); + +const SEVEN_ZIP_BIN = process.env.SEVEN_ZIP_PATH || '7z'; + +/** + * The inner tar of a compound archive, decompressed once and kept. + * + * `backup.tar.gz` is two archives: gzip wrapping a tar. 7-Zip peels one layer + * per run, so listing it answers with a single entry called `backup.tar` — + * true, and useless to somebody looking for a file inside it. The layer below + * needs the tar, and a tar cannot be read from the middle: gzip has no index, + * so reaching the last entry means decompressing everything before it. Doing + * that per request would mean decompressing forty gigabytes to list a folder, + * twice in a row for two clicks. + * + * So it is decompressed once, into the cache directory, and every listing and + * every read of that archive goes to the copy. Never into the volume: a file + * there would show up in listings, be read by the search index, counted in + * folder sizes, and swept up by whatever backs the volume up. + * + * What is cached is the tar, not the tree it holds: one file, the size the + * outer archive already declares, which is what makes refusing an archive too + * large to hold a decision taken before anything is written rather than after. + */ + +/** Bumped when the name or the contents of a cached file stop meaning the same. */ +const CACHE_VERSION = 1; + +/** What this service writes, and the only names it may ever take away. */ +const CACHED_NAME = /^v\d+-[0-9a-f]+\.inner$/; +const TEMPORARY_NAME = /^v\d+-[0-9a-f]+\.inner\.tmp-/; + +/** + * The same, for the extracted tree of a solid archive. + * + * A directory rather than a file, because what is kept is what came out: a + * solid `.7z` compresses every file into one stream, so reading the last entry + * decompresses the ones before it and there is nothing smaller to keep that + * would answer the next read. + */ +const CACHED_TREE_NAME = /^v\d+-[0-9a-f]+\.tree$/; +const TEMPORARY_TREE_NAME = /^v\d+-[0-9a-f]+\.tree\.tmp-/; + +const cacheDirectory = () => path.join(directories.cache, 'archives'); + +const ensureCacheDirectory = async () => { + const directory = cacheDirectory(); + await ensureDir(directory); + return directory; +}; + +/** + * The archive this cached copy belongs to: its path, its size and when it was + * last written. An archive replaced by another of the same name is a different + * archive, and gets a different copy rather than the previous one's contents. + */ +const fingerprintOf = async (archiveAbsolutePath) => { + const stats = await fs.stat(archiveAbsolutePath); + return crypto + .createHash('sha1') + .update(archiveAbsolutePath) + .update(String(stats.size)) + .update(String(Math.floor(stats.mtimeMs))) + .digest('hex'); +}; + +/** What a cached tree weighs: everything under it, files only. */ +const sizeOfTree = async (treePath) => { + let total = 0; + const walk = async (directory) => { + let contents; + try { + contents = await fs.readdir(directory, { withFileTypes: true }); + } catch (_) { + return; + } + for (const item of contents) { + const full = path.join(directory, item.name); + if (item.isDirectory()) { + await walk(full); + continue; + } + // Never follows a link: what is counted is what this cache wrote, and it + // was written with symbolic links turned off. + if (!item.isFile()) continue; + const stats = await fs.stat(full).catch(() => null); + if (stats) total += stats.size; + } + }; + await walk(treePath); + return total; +}; + +const inflight = new Map(); +const liveTempFiles = new Set(); + +const cacheError = (message, code, statusCode) => new AppError(message, statusCode, code); + +/** + * Decompress one layer of `archiveAbsolutePath` into `destination`. + * + * `-so` writes the inner archive to standard output, so what lands on disk is + * written by us, under a name of our own, and renamed into place only once it + * is whole: a decompression interrupted half way leaves a temporary file the + * sweep takes, never a cached copy that is missing its end. + */ +const decompressInto = async (archiveAbsolutePath, destination) => { + const child = spawn(SEVEN_ZIP_BIN, ['x', '-so', '-y', '-p', '--', archiveAbsolutePath], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let output = ''; + child.stderr.on('data', (chunk) => { + output = `${output}${chunk}`.slice(-2000); + }); + + const exited = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code) => + code === 0 ? resolve() : reject(new Error(`7z exited with code ${code}: ${output.trim()}`)) + ); + }); + + try { + await Promise.all([pipeline(child.stdout, fss.createWriteStream(destination)), exited]); + } catch (error) { + child.kill('SIGKILL'); + throw error; + } +}; + +/** + * Where to read a compound archive from: a decompressed copy of its inner + * archive, made if it is not there yet. + * + * @param {string} archiveAbsolutePath the outer archive + * @param {number} innerSize what the outer archive says the inner one weighs + */ +const cachedInnerArchive = async (archiveAbsolutePath, innerSize) => { + if (!Number.isFinite(innerSize) || innerSize < 0) { + throw cacheError( + 'This archive does not say how large it is inside, so it cannot be opened.', + 'ARCHIVE_UNREADABLE', + 422 + ); + } + if (innerSize > archives.browseMaxBytes) { + throw cacheError( + 'This archive is too large to look inside; extract it instead.', + 'ARCHIVE_TOO_LARGE_TO_BROWSE', + 413 + ); + } + + const directory = await ensureCacheDirectory(); + const finalPath = path.join( + directory, + `v${CACHE_VERSION}-${await fingerprintOf(archiveAbsolutePath)}.inner` + ); + + if (await pathExists(finalPath)) { + // The sweep takes the least recently used first, and using one is reading + // it: without this a cache that is working well is evicted on age alone. + const now = new Date(); + await fs.utimes(finalPath, now, now).catch(() => {}); + return finalPath; + } + + let pending = inflight.get(finalPath); + if (!pending) { + pending = (async () => { + // Refused before anything is written rather than after the volume is + // full: the cache directory is very often the one the database is on. + await ensureStorageAvailable(directory, innerSize, 'archive cache'); + + const temporaryPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`; + liveTempFiles.add(path.basename(temporaryPath)); + try { + await decompressInto(archiveAbsolutePath, temporaryPath); + await fs.rename(temporaryPath, finalPath); + return finalPath; + } catch (_) { + await fs.rm(temporaryPath, { force: true }).catch(() => {}); + throw cacheError('This archive could not be opened.', 'ARCHIVE_UNREADABLE', 422); + } finally { + liveTempFiles.delete(path.basename(temporaryPath)); + } + })().finally(() => inflight.delete(finalPath)); + + inflight.set(finalPath, pending); + } + + return pending; +}; + +/** + * Everything a solid archive holds, extracted once into the cache. + * + * Measured on a runner with a real 7-Zip, on two hundred files of two hundred + * and fifty-six kilobytes that compress about two to one: reading the first + * entry takes 0.02 s, the middle one 0.70 s, the last 1.37 s — the cost is the + * entries before the one asked for. Extracting the whole archive takes 1.41 s, + * about what reading the last entry alone costs, and ten entries read one at a + * time take 6.95 s. So the second read of a solid archive is where this pays: + * it costs about what that read was going to cost anyway, and every read after + * it is a file on disk. (`scripts/measure-solid-7z.mjs`, and the workflow that + * runs it.) + * + * Deliberately not the first read: somebody who opens one small file near the + * front would wait 1.4 s instead of 0.02 s for a tree nobody asks for again. + */ +const extractInto = async (archiveAbsolutePath, destination) => { + await ensureDir(destination); + // -snl- keeps 7-Zip from restoring symbolic links; -spd from reading a name + // as a pattern. The same two the extraction onto the volume uses. + const child = spawn( + SEVEN_ZIP_BIN, + ['x', '-y', '-p', '-snl-', '-spd', `-o${destination}`, '--', archiveAbsolutePath], + { stdio: ['ignore', 'ignore', 'pipe'] } + ); + + let output = ''; + child.stderr.on('data', (chunk) => { + output = `${output}${chunk}`.slice(-2000); + }); + + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code) => + code === 0 ? resolve() : reject(new Error(`7z exited with code ${code}: ${output.trim()}`)) + ); + }); +}; + +const treePathFor = async (archiveAbsolutePath) => + path.join(cacheDirectory(), `v${CACHE_VERSION}-${await fingerprintOf(archiveAbsolutePath)}.tree`); + +/** + * The tree this archive was already extracted into, or null. + * + * Asked before every read, because a tree that exists answers for nothing — + * the first read of an archive whose tree is already there is as free as the + * tenth. + */ +const existingSolidTree = async (archiveAbsolutePath) => { + const treePath = await treePathFor(archiveAbsolutePath).catch(() => null); + if (!treePath || !(await pathExists(treePath))) return null; + // Using one is reading it, so the sweep counts it as recently used. + const now = new Date(); + await fs.utimes(treePath, now, now).catch(() => {}); + return treePath; +}; + +/** + * Extract it, and answer with where it went. + * + * Written under a name of our own and renamed into place only once it is + * whole, so an extraction interrupted half way leaves a temporary directory + * the sweep takes rather than a tree that is missing its end. + * + * @param {string} archiveAbsolutePath the solid archive + * @param {number} uncompressedBytes what its listing says it holds + */ +const cachedSolidTree = async (archiveAbsolutePath, uncompressedBytes) => { + if (!Number.isFinite(uncompressedBytes) || uncompressedBytes < 0) return null; + if (uncompressedBytes > archives.browseMaxBytes) return null; + + const existing = await existingSolidTree(archiveAbsolutePath); + if (existing) return existing; + + const directory = await ensureCacheDirectory(); + const finalPath = await treePathFor(archiveAbsolutePath); + + let pending = inflight.get(finalPath); + if (!pending) { + pending = (async () => { + await ensureStorageAvailable(directory, uncompressedBytes, 'archive cache'); + + const temporaryPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`; + liveTempFiles.add(path.basename(temporaryPath)); + try { + await extractInto(archiveAbsolutePath, temporaryPath); + await fs.rename(temporaryPath, finalPath); + return finalPath; + } finally { + await fs.rm(temporaryPath, { recursive: true, force: true }).catch(() => {}); + liveTempFiles.delete(path.basename(temporaryPath)); + } + })().finally(() => inflight.delete(finalPath)); + + inflight.set(finalPath, pending); + } + + // A cache is a convenience: an extraction that fails leaves the read to go + // to the archive itself, which is what it did before this existed. + return pending.catch(() => null); +}; + +/** + * What the cache may hold, and for how long. + * + * A cached copy is a convenience: it can always be made again from the archive + * it came from, so nothing here is ever missed. The budget is what keeps a few + * large backups from filling a cache directory somebody sized for thumbnails. + */ +const sweepArchiveCache = async () => { + const directory = cacheDirectory(); + let names; + try { + names = await fs.readdir(directory); + } catch (_) { + return; + } + + const abandoned = await findAbandonedTempFiles(directory, names, { + pattern: TEMPORARY_NAME, + live: liveTempFiles, + }); + const abandonedTrees = await findAbandonedTempFiles(directory, names, { + pattern: TEMPORARY_TREE_NAME, + live: liveTempFiles, + }); + for (const name of [...abandoned, ...abandonedTrees]) { + await fs.rm(path.join(directory, name), { recursive: true, force: true }).catch(() => {}); + } + + const cached = names.filter((name) => CACHED_NAME.test(name) || CACHED_TREE_NAME.test(name)); + const entries = []; + for (const name of cached) { + try { + const full = path.join(directory, name); + const stats = await fs.stat(full); + // A tree's size is what is under it. Walked here rather than remembered, + // because the sweep is the one place that has to be right about it and + // it runs once an hour, not once a read. + const size = stats.isDirectory() ? await sizeOfTree(full) : stats.size; + entries.push({ name, mtimeMs: stats.mtimeMs, size }); + } catch (_) { + // Taken by another pass, or by the rename of a copy being made. + } + } + + const now = Date.now(); + const kept = []; + for (const entry of entries) { + if (CACHE_TTL_MS > 0 && now - entry.mtimeMs > CACHE_TTL_MS) { + await fs + .rm(path.join(directory, entry.name), { recursive: true, force: true }) + .catch(() => {}); + continue; + } + kept.push(entry); + } + + let total = kept.reduce((sum, entry) => sum + entry.size, 0); + if (total <= archives.cacheMaxBytes) return; + + // Least recently read first: what nobody has opened in the longest time is + // what costs least to make again. + kept.sort((left, right) => left.mtimeMs - right.mtimeMs); + for (const entry of kept) { + if (total <= archives.cacheMaxBytes) break; + await fs.rm(path.join(directory, entry.name), { recursive: true, force: true }).catch(() => {}); + total -= entry.size; + } +}; + +let cleanupTimer = null; +let stopped = false; + +const scheduleArchiveCacheCleanup = (delayMs = CACHE_CLEANUP_INTERVAL_MS) => { + if (stopped) return; + cleanupTimer = setTimeout(async () => { + try { + await sweepArchiveCache(); + } catch (error) { + logger.debug({ err: error }, 'Sweeping the archive cache failed'); + } + scheduleArchiveCacheCleanup(); + }, delayMs); + cleanupTimer.unref?.(); +}; + +/** For a test's temporary cache, and for shutdown. */ +const stopArchiveCacheWork = async () => { + stopped = true; + if (cleanupTimer) clearTimeout(cleanupTimer); + cleanupTimer = null; + await Promise.allSettled([...inflight.values()]); +}; + +scheduleArchiveCacheCleanup(CACHE_CLEANUP_INTERVAL_MS); + +module.exports = { + cachedInnerArchive, + cachedSolidTree, + existingSolidTree, + sweepArchiveCache, + stopArchiveCacheWork, + cacheDirectory, + statCacheEntries, +}; diff --git a/backend/src/services/archiveExtraction.js b/backend/src/services/archiveExtraction.js new file mode 100644 index 000000000..d2bcbd3c9 --- /dev/null +++ b/backend/src/services/archiveExtraction.js @@ -0,0 +1,88 @@ +const path = require('path'); +const fs = require('fs/promises'); + +const { ensureValidName } = require('../utils/pathUtils'); +const { placeWithoutOverwrite } = require('../utils/placeWithoutOverwrite'); +const { takeInventory } = require('../utils/ownedTree'); +const { ValidationError } = require('../errors/AppError'); +const { archives } = require('../config/index'); + +/** + * What is shared between extracting a whole archive and extracting part of one. + * + * Both write into a hidden folder of their own first and then move what came + * out of it into place, one entry at a time, by a move that never replaces + * anything. Keeping that in one place is what stops the two drifting into + * different answers to the same question — which name a file takes when + * something already holds it. + */ + +/** + * Refuse an extraction whose declared footprint is past the limits. + * + * Extraction is otherwise unbounded: a few kilobytes of nested, highly + * compressible entries can fill the volume ("zip bomb"). The declared sizes + * come from the archive itself, so this is a cheap pre-flight check, not a + * guarantee — it stops the accidental and the trivially malicious case. + */ +const ensureArchiveWithinLimits = ({ entryCount = 0, totalBytes = 0 }) => { + if (entryCount > archives.maxEntries) { + throw new ValidationError( + `This archive holds more than ${archives.maxEntries} entries and was not extracted.` + ); + } + if (totalBytes > archives.maxExtractedBytes) { + throw new ValidationError( + 'This archive expands beyond the allowed size and was not extracted.' + ); + } +}; + +const buildItemMetadata = async (absolutePath, relativeParent, name) => { + const stats = await fs.stat(absolutePath); + const ext = path.extname(name).slice(1).toLowerCase(); + const kind = stats.isDirectory() ? 'directory' : ext.length > 10 ? 'unknown' : ext || 'unknown'; + + return { name, path: relativeParent, kind, size: stats.size, dateModified: stats.mtime }; +}; + +/** + * Move everything a staging folder holds into the destination. + * + * Each entry takes the first name nothing holds — "name (1)" and so on — by a + * move that never replaces or merges, and is taken stock of before it moves so + * that undoing the extraction removes exactly what it wrote and not what + * somebody put in a placed folder afterwards. + */ +const extractIntoCurrentFolder = async ({ + stagingDirectory, + destinationDirectory, + relativeParentPath, + movedPaths, +}) => { + const stagedEntries = await fs.readdir(stagingDirectory, { withFileTypes: true }); + const items = []; + + for (const entry of stagedEntries) { + const entryName = ensureValidName(entry.name); + const sourcePath = path.join(stagingDirectory, entryName); + // Taken stock of before it moves: undoing the extraction removes exactly + // this, and not what someone puts in a placed folder afterwards. + const inventory = await takeInventory(sourcePath); + // The name is taken by the move itself, never looked at first and renamed + // into later: a file, or an empty folder, that appears under it meanwhile + // stays as it is, and the entry goes to "name (1)". + const { name: destinationName, path: destinationPath } = await placeWithoutOverwrite( + sourcePath, + destinationDirectory, + entryName + ); + movedPaths.push({ path: destinationPath, inventory }); + + items.push(await buildItemMetadata(destinationPath, relativeParentPath, destinationName)); + } + + return items; +}; + +module.exports = { ensureArchiveWithinLimits, buildItemMetadata, extractIntoCurrentFolder }; diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js index 342d2aa89..24ac9f2b6 100644 --- a/backend/src/services/archiveService.js +++ b/backend/src/services/archiveService.js @@ -65,6 +65,18 @@ const getSupportedArchiveExtensions = () => { return supportedExtensionsPromise; }; +/** + * The formats this build could open and does not. + * + * The list of what is supported says nothing about what is missing, and what + * is missing is the one thing worth knowing: a 7-Zip packaged without the RAR + * codec lists a dozen formats and never mentions the one it dropped (#9). + */ +const getMissingArchiveExtensions = async () => { + const supported = new Set(await getSupportedArchiveExtensions()); + return CANDIDATE_EXTENSIONS.filter((extension) => !supported.has(extension)); +}; + const isSevenZipAvailable = async () => { const extensions = await getSupportedArchiveExtensions(); // The zip-only fallback list means the probe failed. @@ -125,7 +137,9 @@ const isArchivePasswordError = (error) => const createArchivePasswordError = (passwordProvided) => { const error = new Error( - passwordProvided ? 'Incorrect archive password or corrupted archive.' : 'Archive password required.' + passwordProvided + ? 'Incorrect archive password or corrupted archive.' + : 'Archive password required.' ); error.code = passwordProvided ? 'ARCHIVE_INVALID_PASSWORD' : 'ARCHIVE_PASSWORD_REQUIRED'; return error; @@ -302,6 +316,63 @@ const runSevenZipExtract = ( options ); +/** + * Extract only the entries named, and nothing else. + * + * The names come from the archive's own listing and are checked against it + * before they get here, so what is asked for is what the archive holds. Two + * switches do the rest of the work: + * + * `-spd` stops 7-Zip reading a name as a pattern, so an entry genuinely called + * `report*.txt` extracts that file rather than every report beside it. + * + * `-snl-` keeps 7-Zip from restoring symbolic links, as the whole-archive + * extraction does: path confinement is a string comparison, and a link like + * `evil -> /` inside a tar would make every later access step outside the + * volume while still looking valid. + * + * The names are sent in batches, because a folder of ten thousand files is a + * command line no system will take. + */ +const EXTRACT_BATCH_SIZE = 400; + +const extractArchiveEntries = async ( + archiveAbsolutePath, + destinationAbsolutePath, + entryPaths, + onPercent, + options = {} +) => { + const batches = []; + for (let at = 0; at < entryPaths.length; at += EXTRACT_BATCH_SIZE) { + batches.push(entryPaths.slice(at, at + EXTRACT_BATCH_SIZE)); + } + + let done = 0; + for (const batch of batches) { + throwIfCancelled(options.signal); + await runSevenZip( + [ + 'x', + '-y', + '-bsp1', + '-snl-', + '-spd', + `-o${destinationAbsolutePath}`, + '--', + archiveAbsolutePath, + ...batch, + ], + // Each batch reports 0-100 of itself; what the caller is told is how far + // through all of them it is. + (percent) => + onPercent?.(Math.round(((done + Math.min(100, percent) / 100) / batches.length) * 100)), + options + ); + done += 1; + } +}; + /** * Create a .zip archive from the given absolute paths, reporting progress * through `onPercent(0-100)`. 7-Zip stores each entry under its base name, @@ -353,7 +424,7 @@ const readArchiveFootprint = async (archiveAbsolutePath) => { } } return entryCount ? { totalBytes, entryCount } : null; - } catch (error) { + } catch (_) { return null; } }; @@ -413,7 +484,6 @@ const watchExtractionSize = (destinationAbsolutePath, maxBytes, onExceeded) => { }; }; - /** * Reject an extraction that produced a symbolic link. * @@ -507,7 +577,11 @@ const extractArchive = async ( }; module.exports = { + SEVEN_ZIP_BIN, + TAR_WRAPPER_EXTENSIONS, + extractArchiveEntries, getSupportedArchiveExtensions, + getMissingArchiveExtensions, isSevenZipAvailable, readArchiveFootprint, extractArchive, diff --git a/backend/src/services/databaseMaintenance.js b/backend/src/services/databaseMaintenance.js new file mode 100644 index 000000000..4648156a0 --- /dev/null +++ b/backend/src/services/databaseMaintenance.js @@ -0,0 +1,237 @@ +const fs = require('fs'); +const path = require('path'); + +const logger = require('../utils/logger'); + +/** + * Handing the database's free space back to the filesystem. + * + * SQLite never shrinks a file on its own. The pages a deletion frees are kept + * and reused, which suits a database whose size is steady, and does not suit + * this one: it holds derived data that comes and goes by the gigabyte — a + * search index over folders later excluded, an index a migration or a rebuild + * throws away. One installation measured 2,159 MB for 160 MB of data; the rest + * was free pages, copied into every backup of /config. + * + * Measured the other way round, the steady work does not need this: indexing + * passes and rebuilds reuse what they free, and the file settles about a third + * above its data. What needs handing back is what a large deletion leaves. + * + * So the file is kept in incremental auto-vacuum mode, and a pass hands free + * pages back when there are enough of them to matter. A database created + * before this has to be rewritten once to change mode; that happens when it is + * opened, before anything else holds the connection. + */ + +const AUTO_VACUUM_INCREMENTAL = 2; +const PASS_INTERVAL_MS = 60 * 60 * 1000; +// Below this, handing pages back is not worth the writes: SQLite reuses them. +const MIN_RECLAIM_BYTES = 16 * 1024 * 1024; +// Pages handed back per transaction. Each chunk holds the write lock for +// milliseconds, and requests get their turn between chunks. +const RECLAIM_CHUNK_PAGES = 2048; +// The write-ahead log keeps the largest size it has reached, and a backup of +// /config copies it too. A checkpoint cuts it back to this. +const WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024; + +const toMb = (bytes) => Math.round(bytes / (1024 * 1024)); + +const pragmaNumber = (db, name) => Number(db.pragma(name, { simple: true })); + +const measure = (db) => { + const pageSize = pragmaNumber(db, 'page_size'); + const freePages = pragmaNumber(db, 'freelist_count'); + return { + fileBytes: pragmaNumber(db, 'page_count') * pageSize, + freeBytes: freePages * pageSize, + freePages, + }; +}; + +const isIncremental = (db) => pragmaNumber(db, 'auto_vacuum') === AUTO_VACUUM_INCREMENTAL; + +/** + * What has to be set on a connection before the first table exists. On a new + * file the auto-vacuum mode takes effect at once; on an existing one it waits + * for the rewrite in convertToIncremental. + */ +const configureStorage = (db) => { + db.pragma(`journal_size_limit = ${WAL_SIZE_LIMIT_BYTES}`); + db.pragma('auto_vacuum = INCREMENTAL'); +}; + +/** + * Rewrite a database created before incremental auto-vacuum. Once: a database + * already in that mode is left alone. Never throws — a file that could not be + * rewritten, for want of temporary space say, is still a database the + * application can run on, and the next start tries again. + */ +const convertToIncremental = (db) => { + if (isIncremental(db)) return { converted: false }; + + const before = measure(db); + const startedAt = Date.now(); + try { + db.pragma('auto_vacuum = INCREMENTAL'); + db.exec('VACUUM'); + db.pragma('wal_checkpoint(TRUNCATE)'); + } catch (error) { + logger.warn( + { err: error, fileMb: toMb(before.fileBytes), freeMb: toMb(before.freeBytes) }, + '[DB] Could not rewrite the database so that its free space can be handed back; the next start tries again' + ); + return { converted: false, error }; + } + + const after = measure(db); + logger.info( + { + beforeMb: toMb(before.fileBytes), + afterMb: toMb(after.fileBytes), + durationMs: Date.now() - startedAt, + }, + '[DB] Rewrote the database once so that its free space can be handed back from now on' + ); + return { + converted: isIncremental(db), + beforeBytes: before.fileBytes, + afterBytes: after.fileBytes, + }; +}; + +const yieldToRequests = () => new Promise((resolve) => setImmediate(resolve)); + +/** Hand free pages back, a chunk at a time. Answers what the file lost. */ +const reclaimFreePages = async ( + db, + { minBytes = MIN_RECLAIM_BYTES, chunkPages = RECLAIM_CHUNK_PAGES } = {} +) => { + const before = measure(db); + if (!isIncremental(db)) { + return { reclaimedBytes: 0, skipped: 'not-incremental', freeBytes: before.freeBytes }; + } + if (before.freeBytes < minBytes) { + return { reclaimedBytes: 0, skipped: 'below-threshold', freeBytes: before.freeBytes }; + } + + let remaining = before.freePages; + while (remaining > 0) { + db.pragma(`incremental_vacuum(${chunkPages})`); + const left = pragmaNumber(db, 'freelist_count'); + // Nothing moved: stop rather than spin. + if (left >= remaining) break; + remaining = left; + await yieldToRequests(); + } + + // Lets the file itself shrink. PASSIVE, because the other modes wait for + // readers to finish — on the server's only thread, for as long as the busy + // timeout. A reader in the middle of a transaction holds it up; the pages + // are no longer in use either way, and the next checkpoint finishes it. + try { + db.pragma('wal_checkpoint(PASSIVE)'); + } catch (error) { + logger.debug({ err: error }, '[DB] Checkpoint after handing space back did not complete'); + } + + const after = measure(db); + return { + reclaimedBytes: before.fileBytes - after.fileBytes, + fileBytes: after.fileBytes, + freeBytes: after.freeBytes, + }; +}; + +let interval = null; +let running = null; + +// Required when a pass runs rather than at the top: both require this module. +const DATABASES = [ + { name: 'app.db', open: () => require('./db').getDb() }, + { + name: 'index.db', + // Not created for the pass: a server with neither index on has none. + open: () => { + const indexDb = require('./indexDb'); + return fs.existsSync(indexDb.getIndexDbPath()) ? indexDb.getIndexDb() : null; + }, + }, + { + name: 'sessions.db', + // The session middleware's own connection; nothing is created for the pass. + open: () => { + const { directories } = require('../config/index'); + if (!fs.existsSync(path.join(directories.cache, 'sessions.db'))) return null; + return require('../utils/sessionStore').localStore.db; + }, + }, +]; + +const runPass = ({ reason = 'scheduled' } = {}) => { + if (running) return running; + running = (async () => { + const results = {}; + for (const { name, open } of DATABASES) { + let result; + try { + const db = await open(); + if (!db) continue; + result = await reclaimFreePages(db); + } catch (error) { + // One file failing is no reason to leave the other as it is. + logger.warn({ err: error, database: name, reason }, '[DB] Handing free space back failed'); + results[name] = { reclaimedBytes: 0, error }; + continue; + } + results[name] = result; + if (result.reclaimedBytes > 0) { + logger.info( + { + database: name, + reason, + reclaimedMb: toMb(result.reclaimedBytes), + fileMb: toMb(result.fileBytes), + }, + '[DB] Handed free space back to the filesystem' + ); + } else if (result.skipped === 'not-incremental' && result.freeBytes >= MIN_RECLAIM_BYTES) { + logger.warn( + { database: name, reason, freeMb: toMb(result.freeBytes) }, + '[DB] Free space is kept until the database can be rewritten at a start' + ); + } + } + return results; + })().finally(() => { + running = null; + }); + return running; +}; + +const start = () => { + if (interval) return; + runPass({ reason: 'startup' }).catch((error) => + logger.warn({ err: error }, '[DB] Handing free space back at startup failed') + ); + interval = setInterval(() => { + runPass().catch((error) => logger.warn({ err: error }, '[DB] Handing free space back failed')); + }, PASS_INTERVAL_MS); + interval.unref?.(); +}; + +const stop = () => { + if (interval) clearInterval(interval); + interval = null; +}; + +module.exports = { + MIN_RECLAIM_BYTES, + PASS_INTERVAL_MS, + WAL_SIZE_LIMIT_BYTES, + configureStorage, + convertToIncremental, + reclaimFreePages, + runPass, + start, + stop, +}; diff --git a/backend/src/services/db.js b/backend/src/services/db.js index f5aed25f8..c5d4c7fb6 100644 --- a/backend/src/services/db.js +++ b/backend/src/services/db.js @@ -38,6 +38,72 @@ const FOLDER_SIZE_INDEX_DDL = ` CREATE INDEX IF NOT EXISTS idx_folder_size_volume ON folder_size_index(volume); `; +// The identity the Document Server files an open document under. Shared by +// everyone who has it open, which is what lets them edit together; see +// onlyofficeDocumentKeyService for why it has to outlive their saves. +// +// Same idempotent treatment as the index above: a /config directory shared with +// a different build may already record a later schema version. +const ONLYOFFICE_DOCUMENT_KEYS_DDL = ` + CREATE TABLE IF NOT EXISTS onlyoffice_document_keys ( + relative_path TEXT PRIMARY KEY, + document_key TEXT NOT NULL, + signature TEXT NOT NULL, + created_at DATETIME, + expires_at DATETIME + ); +`; + +// Where a document is while an editor has it open. Outlives the process on +// purpose: a restart mid-edit used to lose a rename, and the next save then +// recreated the old name beside the new one. +const ONLYOFFICE_EDITOR_SESSIONS_DDL = ` + CREATE TABLE IF NOT EXISTS onlyoffice_editor_sessions ( + id TEXT PRIMARY KEY, + document_key TEXT NOT NULL, + relative_path TEXT NOT NULL, + absolute_path TEXT NOT NULL, + user_id TEXT, + guest_session_id TEXT, + expires_at DATETIME NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_onlyoffice_sessions_expiry ON onlyoffice_editor_sessions(expires_at); +`; + +/** + * Two-factor on a local account. + * + * One row per account, and only a confirmed one counts: a secret written while + * somebody was halfway through setting their phone up must never be what + * stands between them and their files. `last_step` is what stops a code being + * used twice — the six digits are good for thirty seconds, and for one login. + * + * Recovery codes are hashed like passwords, so losing the key file beside the + * database costs an authenticator, not an account. + */ +const TWO_FACTOR_DDL = ` + CREATE TABLE IF NOT EXISTS totp_credentials ( + user_id TEXT PRIMARY KEY, + secret TEXT NOT NULL, + confirmed_at TEXT, + last_step INTEGER, + last_used_at TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS totp_recovery_codes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + code_hash TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_totp_recovery_user ON totp_recovery_codes(user_id); +`; + const getDbPath = () => { const configDir = directories.config; // Generic app database for auth, shares, and user settings. @@ -450,6 +516,25 @@ const migrate = (db) => { ); version = 14; } + if (version < 15) { + logger.info('[DB Migration] Migrating to v15: an open document keeps its identity...'); + db.exec(ONLYOFFICE_DOCUMENT_KEYS_DDL); + db.exec(ONLYOFFICE_EDITOR_SESSIONS_DDL); + db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run( + 'schema_version', + String(15) + ); + version = 15; + } + if (version < 16) { + logger.info('[DB Migration] Migrating to v16: a second factor...'); + db.exec(TWO_FACTOR_DDL); + db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run( + 'schema_version', + String(16) + ); + version = 16; + } })(); }; @@ -690,6 +775,23 @@ const getDb = async () => { } catch (err) { logger.warn({ err }, '[DB] Failed to ensure versions tables'); } + // Same reason again: a /config shared with another build may already record a + // later schema version without carrying these tables. + try { + db.exec(TWO_FACTOR_DDL); + } catch (err) { + logger.warn({ err }, '[DB] Failed to ensure the two-factor tables'); + } + try { + db.exec(ONLYOFFICE_DOCUMENT_KEYS_DDL); + } catch (err) { + logger.warn({ err }, '[DB] Failed to ensure the ONLYOFFICE document key table'); + } + try { + db.exec(ONLYOFFICE_EDITOR_SESSIONS_DDL); + } catch (err) { + logger.warn({ err }, '[DB] Failed to ensure the ONLYOFFICE editor session table'); + } ensureAnonymousUser(db); dbInstance = db; return dbInstance; diff --git a/backend/src/services/directoryListingService.js b/backend/src/services/directoryListingService.js index 54cdbb50c..6c0feb8fe 100644 --- a/backend/src/services/directoryListingService.js +++ b/backend/src/services/directoryListingService.js @@ -6,6 +6,7 @@ const { combineRelativePath } = require('../utils/pathUtils'); const { getAccessInfo } = require('./accessManager'); const { createPermissionResolver } = require('./accessControlService'); const logger = require('../utils/logger'); +const onlyofficeActivity = require('./onlyofficeActivityService'); const LIST_DIRECTORY_CONCURRENCY = 64; @@ -107,6 +108,13 @@ const listDirectoryItems = async ({ kind, }; + // Advisory only, and never a lock: who has this document open in an + // editor, so the row can say so and a move can ask first. + if (stats.isFile()) { + const activity = onlyofficeActivity.get(filePath); + if (activity?.active) item.onlyofficeActivity = activity; + } + if (thumbsEnabled && stats.isFile() && kind !== 'pdf' && previewable.has(kind.toLowerCase())) { item.supportsThumbnail = true; } diff --git a/backend/src/services/onlyofficeActivityService.js b/backend/src/services/onlyofficeActivityService.js new file mode 100644 index 000000000..1d4f9958b --- /dev/null +++ b/backend/src/services/onlyofficeActivityService.js @@ -0,0 +1,232 @@ +const path = require('path'); +const { EventEmitter } = require('events'); + +// This is presence information, not a filesystem lock. Keeping it in memory +// makes it cheap, ephemeral across restarts, and impossible for stale data to +// block a file operation. +const sessionsByPath = new Map(); +const SESSION_TTL_MS = 2 * 60 * 1000; +const DOCUMENT_SERVER_TTL_MS = 15 * 60 * 1000; +const activityEvents = new EventEmitter(); +let activityVersion = 0; +let expirationTimer = null; + +const keyFor = (absolutePath) => path.resolve(absolutePath); + +const notifyActivityChange = () => { + activityVersion += 1; + activityEvents.emit('change', activityVersion); +}; + +const scheduleExpirationCheck = () => { + if (expirationTimer) clearTimeout(expirationTimer); + + let soonest = Infinity; + for (const entry of sessionsByPath.values()) { + for (const session of entry.sessions.values()) { + soonest = Math.min(soonest, session.expiresAt); + } + if (entry.documentServerSeenAt) { + soonest = Math.min(soonest, entry.documentServerSeenAt + DOCUMENT_SERVER_TTL_MS); + } + } + + if (!Number.isFinite(soonest)) { + expirationTimer = null; + return; + } + + expirationTimer = setTimeout( + () => { + expirationTimer = null; + let changed = false; + for (const [key, entry] of sessionsByPath) { + const before = `${entry.sessions.size}:${entry.documentServerUsers.join(',')}`; + const active = cleanup(entry); + const after = `${entry.sessions.size}:${entry.documentServerUsers.join(',')}`; + if (before !== after) changed = true; + if (!active) sessionsByPath.delete(key); + } + if (changed) notifyActivityChange(); + scheduleExpirationCheck(); + }, + Math.max(1, soonest - Date.now()) + ); + expirationTimer.unref?.(); +}; + +const cleanup = (entry, now = Date.now()) => { + for (const [sessionId, session] of entry.sessions) { + if (session.expiresAt <= now) entry.sessions.delete(sessionId); + } + if (entry.documentServerSeenAt && now - entry.documentServerSeenAt > DOCUMENT_SERVER_TTL_MS) { + entry.documentServerUsers = []; + entry.documentServerSeenAt = 0; + } + return entry.sessions.size > 0 || entry.documentServerUsers.length > 0; +}; + +const getEntry = (absolutePath, create = false) => { + const key = keyFor(absolutePath); + let entry = sessionsByPath.get(key); + if (!entry && create) { + entry = { sessions: new Map(), documentServerUsers: [], documentServerSeenAt: 0 }; + sessionsByPath.set(key, entry); + return entry; + } + if (entry && !cleanup(entry)) { + sessionsByPath.delete(key); + return null; + } + return entry; +}; + +/** + * Record that someone has this document open, and keep that record alive. + * + * One call for both because presence starts when the editor reports the + * document open, not when its configuration is requested — a document that + * failed to open used to be shown as being edited, since the marker was placed + * before anyone knew whether the editor would succeed. The first call from a + * session creates the record, later ones only extend it. + * + * Only the first one notifies: a heartbeat every sixty seconds must not wake + * every browser waiting on a presence change. + */ +const touch = ({ absolutePath, sessionId, user }) => { + if (!absolutePath || !sessionId) return false; + const entry = getEntry(absolutePath, true); + const existing = entry.sessions.get(sessionId); + + entry.sessions.set(sessionId, { + userId: existing?.userId ?? (user?.id ? String(user.id) : null), + name: existing?.name || user?.name || 'Utilisateur', + expiresAt: Date.now() + SESSION_TTL_MS, + }); + + if (!existing) notifyActivityChange(); + scheduleExpirationCheck(); + return true; +}; + +/** + * Follow a document that was renamed while open. + * + * Presence is keyed by path, so without this the old name would keep showing + * as being edited until it expired, and the new one would show nothing. + */ +const rename = ({ from, to }) => { + if (!from || !to) return; + const fromKey = keyFor(from); + const entry = sessionsByPath.get(fromKey); + if (!entry) return; + + sessionsByPath.delete(fromKey); + const target = getEntry(to, true); + for (const [sessionId, session] of entry.sessions) { + target.sessions.set(sessionId, session); + } + target.documentServerUsers = [ + ...new Set([...target.documentServerUsers, ...entry.documentServerUsers]), + ]; + target.documentServerSeenAt = Math.max(target.documentServerSeenAt, entry.documentServerSeenAt); + + notifyActivityChange(); + scheduleExpirationCheck(); +}; + +const close = ({ absolutePath, sessionId }) => { + const entry = getEntry(absolutePath); + if (!entry) return; + const removed = entry.sessions.delete(sessionId); + if (!cleanup(entry)) sessionsByPath.delete(keyFor(absolutePath)); + if (removed) notifyActivityChange(); + scheduleExpirationCheck(); +}; + +// The embedded browser can close before Document Server has finished writing +// the document. Only its terminal callback proves that the document is truly +// released, so clear every local presence record for that path at this point. +const release = ({ absolutePath }) => { + const key = absolutePath ? keyFor(absolutePath) : null; + if (!key) return; + + const entry = sessionsByPath.get(key); + if (!entry) return; + + sessionsByPath.delete(key); + notifyActivityChange(); + scheduleExpirationCheck(); +}; + +const updateDocumentServerUsers = ({ absolutePath, users }) => { + if (!absolutePath || !Array.isArray(users)) return; + const entry = getEntry(absolutePath, true); + const nextUsers = [...new Set(users.map((user) => String(user)).filter(Boolean))]; + const changed = + nextUsers.length !== entry.documentServerUsers.length || + nextUsers.some((user, index) => user !== entry.documentServerUsers[index]); + entry.documentServerUsers = nextUsers; + entry.documentServerSeenAt = Date.now(); + if (changed) notifyActivityChange(); + scheduleExpirationCheck(); +}; + +const getVersion = () => activityVersion; + +// Holds one lightweight request until presence changes or the timeout elapses. +// This keeps multiple browser sessions live without re-listing directories on a +// timer when nobody is editing a document. +const waitForChange = (since, timeoutMs = 25_000, signal) => { + if (!Number.isInteger(since) || since !== activityVersion) { + return Promise.resolve({ version: activityVersion, changed: true }); + } + + return new Promise((resolve) => { + let timeout = null; + const finish = (changed) => { + activityEvents.off('change', onChange); + signal?.removeEventListener('abort', onAbort); + if (timeout) clearTimeout(timeout); + resolve({ version: activityVersion, changed }); + }; + const onChange = () => finish(true); + const onAbort = () => finish(false); + if (signal?.aborted) { + finish(false); + return; + } + timeout = setTimeout(() => finish(false), timeoutMs); + timeout.unref?.(); + activityEvents.once('change', onChange); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +}; + +const get = (absolutePath) => { + const entry = getEntry(absolutePath); + if (!entry) return null; + const sessionUsers = Array.from(entry.sessions.values()); + const knownUsers = new Map(sessionUsers.map((user) => [user.userId, user.name])); + const users = [ + ...new Set([...entry.documentServerUsers, ...sessionUsers.map((user) => user.userId)]), + ] + .filter(Boolean) + .map((id) => knownUsers.get(id) || 'Utilisateur'); + return { + active: users.length > 0, + users: [...new Set(users)], + count: users.length, + }; +}; + +module.exports = { + touch, + rename, + close, + release, + updateDocumentServerUsers, + get, + getVersion, + waitForChange, +}; diff --git a/backend/src/services/onlyofficeDocumentKeyService.js b/backend/src/services/onlyofficeDocumentKeyService.js new file mode 100644 index 000000000..ed7ee1acf --- /dev/null +++ b/backend/src/services/onlyofficeDocumentKeyService.js @@ -0,0 +1,165 @@ +const crypto = require('crypto'); + +const { getDb } = require('./db'); +const logger = require('../utils/logger'); + +/** + * The identity the Document Server files a document under. + * + * Two people editing the same document only see each other when they were given + * the same key: the Document Server treats a different key as a different + * document, and opens a second, independent session on the same file. Whoever + * saves last then overwrites the other, with nothing to warn either of them. + * + * The key therefore has to stay the same for as long as anyone has the document + * open — including across the saves those editors are making, which change the + * file and would otherwise change the key. It has to change afterwards, because + * the Document Server caches the prepared document under that key alone and + * would serve the stale copy on the next open. + * + * So: keyed on the file's identity, remembered while the document is in use, and + * dropped when the Document Server reports it has let go. + */ + +// Long enough to cover an editing session, short enough that a key abandoned by +// a crashed browser does not outlive the day. +const KEY_TTL_MS = 12 * 60 * 60 * 1000; + +/** + * What makes this file, opened with this editor, the document it is. + * + * Unchanged from the original inline computation, deliberately: it is what makes + * a key differ after an external replacement, and after opening the same bytes + * with a different editor — a drawing once opened as text kept answering from + * cache long after the mapping was corrected. + */ +const buildSignature = (relativePath, stat, documentType) => + crypto + .createHash('sha256') + .update(relativePath) + .update(String(stat.mtimeMs)) + .update(String(stat.ctimeMs)) + .update(String(stat.size)) + .update(String(documentType)) + .digest('hex'); + +const nowIso = () => new Date().toISOString(); +const expiryIso = () => new Date(Date.now() + KEY_TTL_MS).toISOString(); + +/** + * The key to hand this editor. + * + * `inUse` says whether anyone currently has the document open. It is what + * separates "the file changed because we are editing it" from "the file changed + * while nobody was looking": the first must keep the key, the second must not. + */ +const resolveDocumentKey = async ({ relativePath, stat, documentType, inUse }) => { + const signature = buildSignature(relativePath, stat, documentType); + + let db; + try { + db = await getDb(); + } catch (error) { + // A key that cannot be remembered is still better than no editor at all: + // fall back to the signature, which is what this used to be. + logger.warn({ err: error, path: relativePath }, 'ONLYOFFICE key store unavailable'); + return signature; + } + + const existing = db + .prepare( + 'SELECT document_key, signature, expires_at FROM onlyoffice_document_keys WHERE relative_path = ?' + ) + .get(relativePath); + + if (existing && new Date(existing.expires_at).getTime() > Date.now()) { + // Same file, same editor: nothing has happened that the Document Server's + // cache needs to hear about. + if (existing.signature === signature) { + db.prepare('UPDATE onlyoffice_document_keys SET expires_at = ? WHERE relative_path = ?').run( + expiryIso(), + relativePath + ); + return existing.document_key; + } + + // The file changed under an open document. That is what a save looks like + // from here, so keep the key and let the editors carry on together. + if (inUse) { + db.prepare( + 'UPDATE onlyoffice_document_keys SET signature = ?, expires_at = ? WHERE relative_path = ?' + ).run(signature, expiryIso(), relativePath); + return existing.document_key; + } + } + + // Nobody holds it and it is not what it was: a fresh identity, so the + // Document Server fetches the document again instead of serving its cache. + db.prepare( + `INSERT INTO onlyoffice_document_keys (relative_path, document_key, signature, created_at, expires_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(relative_path) DO UPDATE SET + document_key = excluded.document_key, + signature = excluded.signature, + created_at = excluded.created_at, + expires_at = excluded.expires_at` + ).run(relativePath, signature, signature, nowIso(), expiryIso()); + + return signature; +}; + +/** + * Forget the key for a document the Document Server has released. + * + * Called on its terminal callback. The next open then mints a new key, which is + * what makes the editor fetch the saved document rather than the copy it still + * has cached. + */ +const releaseDocumentKey = async (relativePath) => { + if (!relativePath) return; + try { + const db = await getDb(); + db.prepare('DELETE FROM onlyoffice_document_keys WHERE relative_path = ?').run(relativePath); + } catch (error) { + logger.warn({ err: error, path: relativePath }, 'ONLYOFFICE key could not be released'); + } +}; + +/** + * Follow a document renamed while open, so the editors keep their shared key. + */ +const renameDocumentKey = async ({ from, to }) => { + if (!from || !to) return; + try { + const db = await getDb(); + db.prepare('DELETE FROM onlyoffice_document_keys WHERE relative_path = ?').run(to); + db.prepare('UPDATE onlyoffice_document_keys SET relative_path = ? WHERE relative_path = ?').run( + to, + from + ); + } catch (error) { + logger.warn({ err: error, from, to }, 'ONLYOFFICE key could not follow the rename'); + } +}; + +/** + * Remove the keys nobody can be handed any more. + * + * A key past its expiry is never reused: the next open mints a new one. Its row + * stayed all the same. Only a terminal callback released a key, and a Document + * Server that never sent one — a browser closed on the editor, a server + * restarted — left the row for good, one for every document ever opened. + */ +const purgeExpiredDocumentKeys = async () => { + const db = await getDb(); + return db.prepare('DELETE FROM onlyoffice_document_keys WHERE expires_at <= ?').run(nowIso()) + .changes; +}; + +module.exports = { + buildSignature, + purgeExpiredDocumentKeys, + resolveDocumentKey, + releaseDocumentKey, + renameDocumentKey, +}; diff --git a/backend/src/services/onlyofficeEditorSessionService.js b/backend/src/services/onlyofficeEditorSessionService.js new file mode 100644 index 000000000..33e48e453 --- /dev/null +++ b/backend/src/services/onlyofficeEditorSessionService.js @@ -0,0 +1,108 @@ +const { getDb } = require('./db'); +const logger = require('../utils/logger'); + +/** + * Where a document is, for as long as an editor has it open. + * + * The Document Server is handed a token when the editor opens and returns it + * unchanged with every save, however long the session lasts — so the token says + * where the document *was*, not where it is. Renaming from the title bar makes + * that stale immediately, and a save arriving afterwards would recreate the old + * name beside the new one. + * + * These records are what keep the two in step. They were held in memory, which + * meant a restart mid-edit lost the rename and put the save back under the old + * name — rare, silent, and impossible to explain after the fact. + */ + +const SESSION_TTL_MS = 12 * 60 * 60 * 1000; + +const expiryIso = () => new Date(Date.now() + SESSION_TTL_MS).toISOString(); + +const mapRow = (row) => + row + ? { + key: row.document_key, + relativePath: row.relative_path, + absolutePath: row.absolute_path, + userId: row.user_id, + guestSessionId: row.guest_session_id, + expiresAt: new Date(row.expires_at).getTime(), + } + : null; + +const create = async ({ sessionId, key, relativePath, absolutePath, userId, guestSessionId }) => { + const db = await getDb(); + db.prepare( + `INSERT INTO onlyoffice_editor_sessions + (id, document_key, relative_path, absolute_path, user_id, guest_session_id, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(sessionId, key, relativePath, absolutePath, userId, guestSessionId, expiryIso()); + return sessionId; +}; + +/** + * The session, or null when it has expired or never existed. + * + * Expiry is enforced on read rather than by a timer: a sweep that never runs — + * because the process restarted, say — would otherwise keep stale sessions + * answering for hours. + */ +const get = async (sessionId) => { + if (!sessionId) return null; + const db = await getDb(); + const row = db.prepare('SELECT * FROM onlyoffice_editor_sessions WHERE id = ?').get(sessionId); + if (!row) return null; + + const session = mapRow(row); + if (session.expiresAt <= Date.now()) { + db.prepare('DELETE FROM onlyoffice_editor_sessions WHERE id = ?').run(sessionId); + return null; + } + return session; +}; + +/** Push the expiry back; the document is evidently still open. */ +const touch = async (sessionId) => { + const db = await getDb(); + db.prepare('UPDATE onlyoffice_editor_sessions SET expires_at = ? WHERE id = ?').run( + expiryIso(), + sessionId + ); +}; + +/** Follow the document to its new name, so later saves land on it. */ +const move = async (sessionId, { relativePath, absolutePath }) => { + const db = await getDb(); + db.prepare( + 'UPDATE onlyoffice_editor_sessions SET relative_path = ?, absolute_path = ? WHERE id = ?' + ).run(relativePath, absolutePath, sessionId); +}; + +const remove = async (sessionId) => { + if (!sessionId) return; + const db = await getDb(); + db.prepare('DELETE FROM onlyoffice_editor_sessions WHERE id = ?').run(sessionId); +}; + +const purgeExpired = async () => { + try { + const db = await getDb(); + db.prepare('DELETE FROM onlyoffice_editor_sessions WHERE expires_at <= ?').run( + new Date().toISOString() + ); + } catch (error) { + // Expired rows are refused on read anyway. + logger.debug({ err: error }, 'ONLYOFFICE session purge skipped'); + } +}; + +module.exports = { + create, + get, + touch, + move, + remove, + purgeExpired, + SESSION_TTL_MS, +}; diff --git a/backend/src/services/rawPreviewService.js b/backend/src/services/rawPreviewService.js index 8172c67e3..0b0cad024 100644 --- a/backend/src/services/rawPreviewService.js +++ b/backend/src/services/rawPreviewService.js @@ -1,19 +1,117 @@ const path = require('path'); const crypto = require('crypto'); const fs = require('fs/promises'); +const fsSync = require('fs'); -const { ensureDir } = require('../utils/fsUtils'); +const { ensureDir, pathExists } = require('../utils/fsUtils'); const { directories } = require('../config/index'); +const env = require('../config/env'); +const logger = require('../utils/logger'); +const { + CACHE_CLEANUP_BATCH_SIZE, + CACHE_CLEANUP_INTERVAL_MS, + CACHE_TTL_MS, + findAbandonedTempFiles, + statCacheEntries, +} = require('../utils/cacheCleanup'); let exiftoolSingleton = null; let exiftoolCleanupRegistered = false; +/** + * Where the machine keeps an ExifTool it installed itself. + * + * Absolute paths and not `PATH`, for the reason `ffmpegRunner` gives about its + * own two: the PATH a service inherits is whatever started it, and a file + * manager running as root should not be picking its tools out of that. These + * are where `apt`, `dnf` and Homebrew put it. + */ +const EXIFTOOL_CANDIDATES = [ + '/usr/bin/exiftool', + '/usr/local/bin/exiftool', + '/opt/homebrew/bin/exiftool', +]; + +/** Whether a path is there and can be run. */ +const canRun = (candidate) => { + try { + fsSync.accessSync(candidate, fsSync.constants.X_OK); + return true; + } catch (_) { + return false; + } +}; + +/** Whether the 21 MB of Perl travelled with this copy of the application. */ +const hasVendoredExiftool = () => { + try { + require.resolve('exiftool-vendored.pl'); + return true; + } catch (_) { + return false; + } +}; + +/** + * Which ExifTool to run, or the empty string for the one the package brought. + * + * Separated from the probing so the decision can be read and tested on its + * own: it is three rules and the order between them is the whole of it. + * + * @param {object} options + * @param {string} options.named what `EXIFTOOL_PATH` says, trimmed + * @param {boolean} options.vendored whether the bundled Perl is installed + * @param {string[]} options.candidates where a distribution would have put one + * @param {(path: string) => boolean} options.runnable + * @returns {string} a path, or '' to mean "the one in the package" + */ +const chooseExiftoolPath = ({ named, vendored, candidates, runnable }) => { + if (named) return named; + // The bundled copy is the tested one, so it wins whenever it is there. + if (vendored) return ''; + return candidates.find((candidate) => runnable(candidate)) || ''; +}; + +/** + * ExifTool, from the archive or from the machine. + * + * `exiftool-vendored` brings its own copy, which is the right default: it is + * one dependency less to explain, and the version is the one this was tested + * against. It is also 21 MB of Perl, and somebody running the program outside + * a container may well have ExifTool already and would rather not carry a + * second one (#9). The minimal archive leaves it out for exactly that reason. + * + * So three answers, in order: `EXIFTOOL_PATH` when it is set, the vendored copy + * when it travelled, and otherwise the one this machine installed. That last + * step is what an archive without the Perl needs, and it means `apt install + * libimage-exiftool-perl` is the whole of the configuration rather than a + * package plus a variable nobody was told about. + * + * Nothing else changes: the same library drives it, and a path that turns out + * not to be ExifTool fails the way a missing one does — no RAW metadata, and + * the rest of the application carries on. + */ const loadExiftool = () => { if (exiftoolSingleton) return exiftoolSingleton; try { - // eslint-disable-next-line global-require - const { exiftool } = require('exiftool-vendored'); - exiftoolSingleton = exiftool; + const vendored = require('exiftool-vendored'); + const named = typeof env.EXIFTOOL_PATH === 'string' ? env.EXIFTOOL_PATH.trim() : ''; + const chosen = chooseExiftoolPath({ + named, + vendored: hasVendoredExiftool(), + candidates: EXIFTOOL_CANDIDATES, + runnable: canRun, + }); + + if (chosen) { + exiftoolSingleton = new vendored.ExifTool({ exiftoolPath: chosen }); + logger.info( + { exiftoolPath: chosen, named: Boolean(named) }, + 'Using the ExifTool this machine provides' + ); + } else { + exiftoolSingleton = vendored.exiftool; + } if (!exiftoolCleanupRegistered) { exiftoolCleanupRegistered = true; @@ -37,7 +135,7 @@ const loadExiftool = () => { shutdown().finally(() => process.exit(0)); }); } - } catch (error) { + } catch (_) { exiftoolSingleton = null; } @@ -45,7 +143,22 @@ const loadExiftool = () => { }; const RAW_PREVIEW_CACHE_VERSION = 1; +const RAW_PREVIEW_CACHE_MAX_FILES = Number.isFinite(env.RAW_PREVIEW_CACHE_MAX_FILES) + ? Math.max(0, Math.floor(env.RAW_PREVIEW_CACHE_MAX_FILES)) + : 500; +const RAW_PREVIEW_FILE_PATTERN = /^v\d+-[a-f0-9]{40}\.jpg$/i; +const RAW_PREVIEW_TEMP_FILE_PATTERN = /^v\d+-[a-f0-9]{40}\.jpg\.tmp-\d+-\d+$/i; +const RAW_PREVIEW_FIRST_CLEANUP_DELAY_MS = 2 * 60 * 1000; +const RAW_PREVIEW_CONTINUE_DELAY_MS = 30 * 1000; + const inflight = new Map(); +// Temporary files an extraction in this process has created and not yet +// renamed or removed, by name: the cleanup leaves them alone however old. +const liveTempFiles = new Set(); + +let cleanupPromise = null; +let cleanupTimer = null; +let cleanupStopped = false; const hashForFile = async (filePath) => { const stat = await fs.stat(filePath); @@ -56,17 +169,10 @@ const hashForFile = async (filePath) => { return hash.digest('hex'); }; -const pathExists = async (targetPath) => { - try { - await fs.access(targetPath); - return true; - } catch { - return false; - } -}; +const rawPreviewCacheDir = () => path.join(directories.cache, 'raw-previews'); const ensureRawPreviewCacheDir = async () => { - const dir = path.join(directories.cache, 'raw-previews'); + const dir = rawPreviewCacheDir(); await ensureDir(dir); return dir; }; @@ -81,6 +187,138 @@ const tryExtract = async (exiftool, method, inputPath, outputPath) => { return pathExists(outputPath); }; +/** + * Keep the extracted previews within bounds. + * + * Nothing used to remove anything from this directory. The cache key includes + * the RAW file's modification time, so every edit of a photo leaves its previous + * preview behind for good, and an extraction interrupted by a crash leaves its + * temporary file. The thumbnail cleanup's rules apply here too, with the same + * interval, batch size and lifetime: another version's previews and those past + * the lifetime go, abandoned temporary files go, and past the file limit the + * oldest go first. A limit of zero lifts the limit on the count and nothing + * else: it used to leave the directory unmanaged, previews of another version, + * past their lifetime and abandoned temporary files included. + */ +const cleanupRawPreviewCache = async () => { + if (cleanupPromise) { + return cleanupPromise; + } + + cleanupPromise = (async () => { + let shouldContinue = false; + + try { + const dir = rawPreviewCacheDir(); + let dirents; + try { + dirents = await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + // Nothing extracted yet, or the cache is gone: nothing to bound. + if (error.code === 'ENOENT') return; + throw error; + } + + const now = Date.now(); + const fileNames = dirents.filter((entry) => entry.isFile()).map((entry) => entry.name); + const previews = ( + await statCacheEntries( + dir, + fileNames.filter((name) => RAW_PREVIEW_FILE_PATTERN.test(name)) + ) + ).sort((a, b) => a.mtimeMs - b.mtimeMs); + + const currentVersionPrefix = `v${RAW_PREVIEW_CACHE_VERSION}-`; + const removableNames = new Set( + previews + .filter( + (entry) => + !entry.name.startsWith(currentVersionPrefix) || + (CACHE_TTL_MS > 0 && now - entry.mtimeMs >= CACHE_TTL_MS) + ) + .map((entry) => entry.name) + ); + const abandonedTempNames = await findAbandonedTempFiles(dir, fileNames, { + pattern: RAW_PREVIEW_TEMP_FILE_PATTERN, + live: liveTempFiles, + now, + }); + const overflowCount = + RAW_PREVIEW_CACHE_MAX_FILES > 0 + ? Math.max(0, previews.length - removableNames.size - RAW_PREVIEW_CACHE_MAX_FILES) + : 0; + const wantedCount = abandonedTempNames.length + removableNames.size + overflowCount; + + if (wantedCount <= 0) { + return; + } + + const toDelete = [ + ...abandonedTempNames, + ...removableNames, + ...previews + .filter((entry) => !removableNames.has(entry.name)) + .slice(0, overflowCount) + .map((entry) => entry.name), + ].slice(0, CACHE_CLEANUP_BATCH_SIZE); + + let deleted = 0; + for (const name of toDelete) { + try { + await fs.rm(path.join(dir, name), { force: true }); + deleted += 1; + } catch (_) { + // Best-effort cache cleanup. + } + } + + logger.info( + { + deleted, + before: previews.length, + max: RAW_PREVIEW_CACHE_MAX_FILES, + batchSize: CACHE_CLEANUP_BATCH_SIZE, + removableCandidates: removableNames.size, + abandonedTempCandidates: abandonedTempNames.length, + }, + 'RAW preview cache cleanup batch completed' + ); + + shouldContinue = wantedCount > deleted; + } catch (error) { + logger.warn({ err: error }, 'RAW preview cache cleanup failed'); + } finally { + cleanupPromise = null; + scheduleRawPreviewCacheCleanup( + shouldContinue ? RAW_PREVIEW_CONTINUE_DELAY_MS : CACHE_CLEANUP_INTERVAL_MS + ); + } + })(); + + return cleanupPromise; +}; + +/** + * One timer at a time, unref'd so it never holds the process open. Unlike the + * thumbnails', this pass does not wait for new work to come along: a server + * that only ever serves RAW previews would otherwise clean up once, at start. + */ +function scheduleRawPreviewCacheCleanup(delayMs) { + if (cleanupStopped || cleanupTimer) { + return; + } + + cleanupTimer = setTimeout(() => { + cleanupTimer = null; + cleanupRawPreviewCache().catch(() => {}); + }, delayMs); + if (typeof cleanupTimer.unref === 'function') { + cleanupTimer.unref(); + } +} + +scheduleRawPreviewCacheCleanup(RAW_PREVIEW_FIRST_CLEANUP_DELAY_MS); + /** * Extract embedded preview JPEG from a RAW file into a cached file path. * Returns absolute path to a JPEG file. @@ -107,24 +345,31 @@ const getRawPreviewJpegPath = async (rawFilePath) => { if (!pending) { pending = (async () => { const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`; - await ensureDir(path.dirname(finalPath)); + const tmpName = path.basename(tmpPath); + liveTempFiles.add(tmpName); - const extracted = - (await tryExtract(exiftool, 'extractPreview', rawFilePath, tmpPath)) || - (await tryExtract(exiftool, 'extractThumbnail', rawFilePath, tmpPath)) || - (await tryExtract(exiftool, 'extractJpgFromRaw', rawFilePath, tmpPath)); + try { + await ensureDir(path.dirname(finalPath)); - if (!extracted) { - try { - await fs.rm(tmpPath, { force: true }); - } catch (_) { - // ignore + const extracted = + (await tryExtract(exiftool, 'extractPreview', rawFilePath, tmpPath)) || + (await tryExtract(exiftool, 'extractThumbnail', rawFilePath, tmpPath)) || + (await tryExtract(exiftool, 'extractJpgFromRaw', rawFilePath, tmpPath)); + + if (!extracted) { + try { + await fs.rm(tmpPath, { force: true }); + } catch (_) { + // ignore + } + throw new Error('No embedded preview JPEG found'); } - throw new Error('No embedded preview JPEG found'); - } - await fs.rename(tmpPath, finalPath); - return finalPath; + await fs.rename(tmpPath, finalPath); + return finalPath; + } finally { + liveTempFiles.delete(tmpName); + } })().finally(() => { inflight.delete(finalPath); }); @@ -135,6 +380,31 @@ const getRawPreviewJpegPath = async (rawFilePath) => { return pending; }; +/** + * Stop the cleanup for good, and let what is running finish. + * + * For a test's temporary cache or a shutdown: an extraction or a cleanup pass + * still at work when its directory is removed fails the removal, and a timer + * left behind runs against whatever comes next. + */ +const stopRawPreviewWork = async () => { + cleanupStopped = true; + if (cleanupTimer) clearTimeout(cleanupTimer); + cleanupTimer = null; + + await Promise.allSettled([...inflight.values(), cleanupPromise].filter(Boolean)); +}; + module.exports = { getRawPreviewJpegPath, + // Exported for the tests: the cleanup is otherwise reached only through its timer. + cleanupRawPreviewCache, + stopRawPreviewWork, + // And this one because it is a decision rather than an effect: three rules + // and the order between them, worth reading on its own. + chooseExiftoolPath, + EXIFTOOL_CANDIDATES, + // For the report of what is installed, which asks without starting anything. + hasVendoredExiftool, + canRun, }; diff --git a/backend/src/services/renameService.js b/backend/src/services/renameService.js new file mode 100644 index 000000000..446f6c9b3 --- /dev/null +++ b/backend/src/services/renameService.js @@ -0,0 +1,111 @@ +const path = require('path'); +const fs = require('fs/promises'); + +const { combineRelativePath, ensureValidName } = require('../utils/pathUtils'); +const { pathExists } = require('../utils/fsUtils'); +const { ACTIONS, authorizeAndResolve, authorizePath } = require('./authorizationService'); +const { + ValidationError, + ForbiddenError, + NotFoundError, + ConflictError, +} = require('../errors/AppError'); +const versionLifecycle = require('./versions/lifecycle'); + +/** + * Rename an entry within its own folder. + * + * Lives here rather than in the route because two callers need exactly these + * checks in exactly this order: the file browser, and the ONLYOFFICE editor + * renaming the document it has open. The second one has to keep its editing + * session pointing at the file afterwards, which it can only do if the rename + * reports where the file ended up. + * + * @returns {Promise<{absolutePath: string, relativePath: string, name: string, + * previousAbsolutePath: string, changed: boolean}>} + */ +const renameEntry = async ({ context, parentRelative, currentName, newName }) => { + if (typeof currentName !== 'string' || !currentName) { + throw new ValidationError('Original name is required.'); + } + + const { + allowed: parentAllowed, + accessInfo: parentAccess, + resolved: parentResolved, + } = await authorizeAndResolve(context, parentRelative, ACTIONS.write); + if (!parentAllowed || !parentResolved) { + throw new ForbiddenError(parentAccess?.denialReason || 'Destination path is read-only.'); + } + + const currentRelative = combineRelativePath(parentRelative, currentName); + const { + allowed: currentAllowed, + accessInfo: currentAccess, + resolved: currentResolved, + } = await authorizeAndResolve(context, currentRelative, ACTIONS.write); + if (!currentAllowed || !currentResolved) { + throw new ForbiddenError(currentAccess?.denialReason || 'Cannot rename items in this path.'); + } + + const currentAbsolute = currentResolved.absolutePath; + if (!(await pathExists(currentAbsolute))) { + throw new NotFoundError('Item not found.'); + } + + if (typeof newName !== 'string' || !newName) { + throw new ValidationError('A new name is required.'); + } + + let validatedNewName; + try { + validatedNewName = ensureValidName(newName); + } catch (error) { + // ensureValidName throws a plain Error, which the error handler could only + // read as a server fault. A name with a path separator in it is the + // caller's, and answering 500 to it sent everyone looking in the wrong + // place — including the logs, where it appeared as an unhandled failure. + throw new ValidationError(error.message); + } + + // Renaming a file to what it is already called is a no-op, not a conflict. + if (validatedNewName === currentName) { + return { + absolutePath: currentAbsolute, + relativePath: currentRelative, + name: currentName, + previousAbsolutePath: currentAbsolute, + changed: false, + }; + } + + const targetRelative = combineRelativePath(parentRelative, validatedNewName); + const { allowed: targetAllowed, accessInfo: targetAccess } = await authorizePath( + context, + targetRelative, + ACTIONS.write + ); + if (!targetAllowed) { + throw new ForbiddenError(targetAccess?.denialReason || 'Destination path is not accessible.'); + } + + const targetAbsolute = path.join(parentResolved.absolutePath, validatedNewName); + if (await pathExists(targetAbsolute)) { + throw new ConflictError(`The name "${validatedNewName}" is already taken.`); + } + + await fs.rename(currentAbsolute, targetAbsolute); + // The history follows the file, or the histories of everything inside the + // folder, to the new name. + await versionLifecycle.onMoved(currentAbsolute, targetAbsolute); + + return { + absolutePath: targetAbsolute, + relativePath: targetRelative, + name: validatedNewName, + previousAbsolutePath: currentAbsolute, + changed: true, + }; +}; + +module.exports = { renameEntry }; diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 056553307..e107403d0 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -1,4 +1,6 @@ const { getDb } = require('./db'); +const env = require('../config/env'); +const { parseByteSize } = require('../utils/env'); const { normalizeRelativePath } = require('../utils/pathUtils'); const storage = require('./storage/jsonStorage'); // Keep for backward compatibility fallback @@ -174,6 +176,64 @@ const getPublicSettings = async () => { /** * Get user-specific settings */ +const MIN_UPLOAD_CHUNK_SIZE_BYTES = 1024 * 1024; +const HARD_MAX_UPLOAD_CHUNK_SIZE_MIB = 512; +const DEFAULT_UPLOAD_CHUNK_SIZE_BYTES = 8 * 1024 * 1024; + +// The administrator's ceiling (MAX_CHUNK_SIZE_MIB), itself capped: a chunk is +// held whole in memory at each end, so an unbounded one is a way to run a +// server out of it. +const resolveMaxChunkSizeBytes = () => { + const raw = Number(env.MAX_CHUNK_SIZE_MIB); + const mib = + Number.isFinite(raw) && raw > 0 + ? Math.min(Math.floor(raw), HARD_MAX_UPLOAD_CHUNK_SIZE_MIB) + : HARD_MAX_UPLOAD_CHUNK_SIZE_MIB; + return Math.max(MIN_UPLOAD_CHUNK_SIZE_BYTES, mib * 1024 * 1024); +}; +const MAX_UPLOAD_CHUNK_SIZE_BYTES = resolveMaxChunkSizeBytes(); + +const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value)); + +const defaultUploadSettings = () => { + const configuredChunkSize = parseByteSize(env.UPLOAD_CHUNK_SIZE); + const chunkSizeBytes = + Number.isFinite(configuredChunkSize) && configuredChunkSize > 0 + ? configuredChunkSize + : DEFAULT_UPLOAD_CHUNK_SIZE_BYTES; + + return { + chunkedEnabled: env.UPLOAD_CHUNKED_ENABLED ?? false, + chunkSizeBytes: clampNumber( + Math.floor(chunkSizeBytes), + MIN_UPLOAD_CHUNK_SIZE_BYTES, + MAX_UPLOAD_CHUNK_SIZE_BYTES + ), + }; +}; + +const sanitizeUploads = (uploads = {}) => { + const defaults = defaultUploadSettings(); + const rawChunkSize = + typeof uploads.chunkSizeBytes === 'string' + ? parseByteSize(uploads.chunkSizeBytes) + : uploads.chunkSizeBytes; + + return { + chunkedEnabled: + typeof uploads.chunkedEnabled === 'boolean' + ? uploads.chunkedEnabled + : defaults.chunkedEnabled, + chunkSizeBytes: Number.isFinite(rawChunkSize) + ? clampNumber( + Math.floor(rawChunkSize), + MIN_UPLOAD_CHUNK_SIZE_BYTES, + MAX_UPLOAD_CHUNK_SIZE_BYTES + ) + : defaults.chunkSizeBytes, + }; +}; + const getUserSettings = async (userId) => { if (!userId) return {}; @@ -210,6 +270,7 @@ const getSystemSettings = async () => { const access = { rules: [] }; let trash = {}; let versions = {}; + let uploads = {}; for (const row of rows) { try { @@ -224,6 +285,8 @@ const getSystemSettings = async () => { trash = JSON.parse(row.value); } else if (row.key === 'versions') { versions = JSON.parse(row.value); + } else if (row.key === 'uploads') { + uploads = JSON.parse(row.value); } } catch (err) { // Skip invalid JSON @@ -237,6 +300,7 @@ const getSystemSettings = async () => { }, trash: sanitizeTrash(trash), versions: sanitizeVersions(versions), + uploads: sanitizeUploads(uploads), }; } catch (err) { // Fallback to JSON storage @@ -250,6 +314,7 @@ const getSystemSettings = async () => { }, trash: sanitizeTrash(settings.trash), versions: sanitizeVersions(settings.versions), + uploads: sanitizeUploads(settings.uploads), }; } catch (err2) { // Return defaults @@ -258,6 +323,7 @@ const getSystemSettings = async () => { access: { rules: [] }, trash: sanitizeTrash({}), versions: sanitizeVersions({}), + uploads: sanitizeUploads({}), }; } } @@ -286,12 +352,34 @@ const getSettingsForUser = async (user) => { result.access = systemSettings.access; result.trash = systemSettings.trash; result.versions = systemSettings.versions; + result.uploads = systemSettings.uploads; } } return result; }; +/** + * The preferences an account may set, in one place. + * + * There used to be two lists: this one, which decides how a value is + * sanitised, and another inside the settings route, which decides whether the + * key is written at all. Adding a preference to one and not the other produced + * a toggle that moved on screen, answered success, and stored nothing — so the + * two are the same list now, and the route asks here. + */ +const USER_BOOLEAN_SETTINGS = new Set([ + 'showHiddenFiles', + 'showThumbnails', + 'showVersionMarks', + 'documentsOpenInNewTab', + 'showSidebarFavorites', + 'showSidebarShares', + 'showSidebarTools', +]); + +const USER_SETTING_KEYS = new Set([...USER_BOOLEAN_SETTINGS, 'defaultShareExpiration', 'skipHome']); + /** * Set a user setting */ @@ -305,13 +393,7 @@ const setUserSetting = async (userId, key, value) => { // Validate and sanitize value based on key let sanitizedValue = value; - if ( - key === 'showHiddenFiles' || - key === 'showThumbnails' || - key === 'showSidebarFavorites' || - key === 'showSidebarShares' || - key === 'showSidebarTools' - ) { + if (USER_BOOLEAN_SETTINGS.has(key)) { sanitizedValue = Boolean(value); } else if (key === 'defaultShareExpiration') { // Validate expiration object: { value: number, unit: 'days'|'weeks'|'months' } or null @@ -380,6 +462,8 @@ const setSystemSetting = async (category, key, value) => { sanitizedValue = sanitizeTrash(value); } else if (key === 'versions') { sanitizedValue = sanitizeVersions(value); + } else if (key === 'uploads') { + sanitizedValue = sanitizeUploads(value); } const valueJson = JSON.stringify(sanitizedValue); @@ -470,6 +554,8 @@ const updateSettings = async (updater) => { }; module.exports = { + USER_SETTING_KEYS, + MAX_UPLOAD_CHUNK_SIZE_BYTES, getPublicSettings, sanitizeTrash, sanitizeVersions, diff --git a/backend/src/services/textEditorService.js b/backend/src/services/textEditorService.js new file mode 100644 index 000000000..d64f20a5e --- /dev/null +++ b/backend/src/services/textEditorService.js @@ -0,0 +1,331 @@ +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); + +const config = require('../config'); +const { ValidationError, UnsupportedMediaTypeError } = require('../errors/AppError'); + +const MAX_EDITOR_FILE_SIZE = config.editor?.maxFileSizeBytes ?? 1 * 1024 * 1024; +const VIDEO_EXTENSIONS = Array.isArray(config.extensions?.videos) ? config.extensions.videos : []; + +/** + * How much of a file is enough to tell text from anything else. + */ +const SAMPLE_BYTES = 4096; + +/** + * How much of a file the pairing test below needs before it is worth believing. + */ +const UTF16_MIN_SAMPLE_BYTES = 16; + +/** + * The byte-order marks that say outright what a file is written in. + * + * UTF-16LE's mark is two bytes, and UTF-32LE's is those same two followed by + * two zeros — so the longer marks are tried first. + */ +const BYTE_ORDER_MARKS = [ + { encoding: 'utf8', bytes: [0xef, 0xbb, 0xbf] }, + { encoding: 'utf16le', bytes: [0xff, 0xfe] }, + { encoding: 'utf16be', bytes: [0xfe, 0xff] }, +]; + +/** + * Whether a run of bytes looks like one half of UTF-16. + * + * In UTF-16 every character in the Latin range is stored as two bytes, one of + * which is zero — the low byte first in little-endian, second in big-endian. + * So a zero byte falling consistently on one side of each pair, and never on + * the other, is the shape of UTF-16 text rather than the shape of a file that + * happens to contain a zero. + */ +const looksLikeUtf16 = (buffer, zeroAtOddIndex) => { + const length = Math.min(buffer.length, SAMPLE_BYTES) & ~1; + // Too short to show a pattern: three bytes of a PNG header would otherwise + // reach the ratio below on their own. + if (length < UTF16_MIN_SAMPLE_BYTES) return false; + + let zerosWhereExpected = 0; + for (let index = 0; index < length; index += 2) { + const [expected, other] = zeroAtOddIndex + ? [buffer[index + 1], buffer[index]] + : [buffer[index], buffer[index + 1]]; + // A zero on the wrong side is not this encoding. + if (other === 0) return false; + // Nor is a pair whose other half is a control byte: that is the shape of a + // file that merely contains zeros, not of text stored two bytes at a time. + if (expected === 0 && (other < 7 || (other > 13 && other < 32))) return false; + if (expected === 0) zerosWhereExpected += 1; + } + + return zerosWhereExpected / (length / 2) > 0.3; +}; + +/** + * What a text file is actually written in. + * + * This exists because of one number: in UTF-16, every ASCII character is + * accompanied by a zero byte, and a zero byte is exactly what "this file is + * binary" looks for. PowerShell's `Out-File` wrote UTF-16LE by default until + * PowerShell 6, and Windows Notepad still offers it as "Unicode", so an export + * or a log from a Windows machine is very often UTF-16 — and was answered with + * "this file appears to be binary and cannot be opened", about a plain text + * file, with no way to tell what was actually meant. + * + * A mark is believed when there is one. Otherwise the pairing above decides, + * because plenty of tools write UTF-16 without one. + * + * @returns {{encoding: 'utf8'|'utf16le'|'utf16be', bom: boolean}} + */ +const detectTextEncoding = (buffer) => { + for (const { encoding, bytes } of BYTE_ORDER_MARKS) { + if (buffer.length >= bytes.length && bytes.every((byte, index) => buffer[index] === byte)) { + return { encoding, bom: true }; + } + } + + if (looksLikeUtf16(buffer, true)) return { encoding: 'utf16le', bom: false }; + if (looksLikeUtf16(buffer, false)) return { encoding: 'utf16be', bom: false }; + + return { encoding: 'utf8', bom: false }; +}; + +/** The characters in a file, whatever it is written in, without its mark. */ +const decodeText = (buffer, { encoding, bom }) => { + if (encoding === 'utf16be') { + // Node decodes little-endian only, so the pairs are swapped first — on a + // copy, because the caller's buffer is not ours to rewrite, and on an even + // number of bytes, because `swap16` throws on anything else and a truncated + // file is not a reason to answer with a stack trace. + const swapped = Buffer.from(buffer.subarray(0, buffer.length & ~1)).swap16(); + return swapped.toString('utf16le').replace(/^\uFEFF/, ''); + } + + const body = bom && encoding === 'utf8' ? buffer.subarray(3) : buffer; + return body.toString(encoding === 'utf16le' ? 'utf16le' : 'utf8').replace(/^\uFEFF/, ''); +}; + +/** + * The bytes to write for text that came out of a file of this encoding. + * + * A file keeps the encoding it had. Saving a UTF-16 log back as UTF-8 would + * halve it and read perfectly well here, and break whatever wrote it — a script + * reading it with a fixed encoding, an import expecting the mark it left. + */ +const encodeText = (text, { encoding = 'utf8', bom = false } = {}) => { + if (encoding === 'utf16le' || encoding === 'utf16be') { + const body = Buffer.from(text, 'utf16le'); + const content = encoding === 'utf16be' ? body.swap16() : body; + return bom + ? Buffer.concat([Buffer.from(encoding === 'utf16be' ? [0xfe, 0xff] : [0xff, 0xfe]), content]) + : content; + } + + const content = Buffer.from(text, 'utf8'); + return bom ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), content]) : content; +}; + +/** + * Whether what was read is a file of words at all. + * + * Control characters are the tell either way; what changes with the encoding is + * what a control character is made of. Judging UTF-16 by its bytes is what + * called text binary, so UTF-16 is judged by its characters, after decoding. + */ +function isProbablyBinaryBuffer(buffer) { + const length = Math.min(buffer.length, SAMPLE_BYTES); + if (!length) return false; + + let suspicious = 0; + for (let index = 0; index < length; index += 1) { + const byte = buffer[index]; + if (byte === 0) return true; + if (byte < 7 || (byte > 13 && byte < 32)) suspicious += 1; + } + + return suspicious / length > 0.3; +} + +const isProbablyBinaryText = (text) => { + const length = Math.min(text.length, SAMPLE_BYTES); + if (!length) return false; + + let suspicious = 0; + for (let index = 0; index < length; index += 1) { + const code = text.charCodeAt(index); + if (code === 0) return true; + if (code < 7 || (code > 13 && code < 32)) suspicious += 1; + } + + return suspicious / length > 0.3; +}; + +/** + * What the editor refuses before it has read a byte: a directory, a file past + * the limit, a format the editor is not for. + */ +const refuseUnopenable = (absolutePath, stats) => { + if (stats.isDirectory()) { + throw new ValidationError('Cannot open a directory in the text editor.'); + } + + if (typeof stats.size === 'number' && stats.size > MAX_EDITOR_FILE_SIZE) { + throw new ValidationError('This file is too large to open in the text editor.'); + } + + const ext = path.extname(absolutePath).slice(1).toLowerCase(); + if (VIDEO_EXTENSIONS.includes(ext)) { + throw new UnsupportedMediaTypeError('This file type cannot be opened in the text editor.'); + } +}; + +/** + * And what it refuses once it has seen the start of the file. + * + * The decoded text is passed in when the caller already has it; UTF-16 is + * judged by its characters, so without it the bytes are decoded here. + */ +const refuseBinary = (buffer, detected, decoded = null) => { + const binary = + detected.encoding === 'utf8' + ? isProbablyBinaryBuffer(buffer) + : isProbablyBinaryText(decoded ?? decodeText(buffer, detected)); + + if (binary) { + throw new UnsupportedMediaTypeError( + 'This file appears to be binary and cannot be opened in the text editor.' + ); + } +}; + +/** The first `byteCount` bytes of a file, or as many of them as there are. */ +const readHead = async (absolutePath, byteCount) => { + let handle; + try { + handle = await fs.open(absolutePath, 'r'); + const head = Buffer.alloc(byteCount); + const { bytesRead } = await handle.read(head, 0, byteCount, 0); + return head.subarray(0, bytesRead); + } finally { + await handle?.close(); + } +}; + +async function readTextFile(absolutePath) { + const stats = await fs.stat(absolutePath); + refuseUnopenable(absolutePath, stats); + + const buffer = await fs.readFile(absolutePath); + const detected = detectTextEncoding(buffer); + const text = decodeText(buffer, detected); + refuseBinary(buffer, detected, text); + + return { buffer, stats, text, encoding: detected }; +} + +/** + * How much of a file the judgements above need to reach the verdict the whole + * file would reach. + * + * Twice the sample, because the only one of them that looks at characters + * rather than bytes looks at SAMPLE_BYTES of them, and in UTF-16 a character + * is two bytes. Detection needs no more: a mark is three bytes and the pairing + * test caps itself at SAMPLE_BYTES either way. + */ +const HEAD_BYTES = SAMPLE_BYTES * 2; + +/** + * What a save needs to know about the file it is replacing: that the editor + * would have opened it at all, and what it is written in. + * + * Every refusal `readTextFile` makes, made from the stat and the head of the + * file rather than from the whole of it — which is all any of them ever + * looked at. The save through a share link asked `readTextFile` for the + * encoding alone and so read and decoded up to a megabyte to look at three + * bytes. + * + * @returns {Promise<{stats: import('fs').Stats, encoding: {encoding: string, bom: boolean}}>} + */ +async function readTextFileHead(absolutePath) { + const stats = await fs.stat(absolutePath); + refuseUnopenable(absolutePath, stats); + + const head = await readHead(absolutePath, HEAD_BYTES); + const detected = detectTextEncoding(head); + refuseBinary(head, detected); + + return { stats, encoding: detected }; +} + +/** + * Bumped whenever `readTextFile` would make something different of the same + * bytes: how an encoding is detected, a mark stripped, what counts as binary. + * It is part of the identity below, so a browser holding text decoded under the + * old rules is not told by a 304 that its copy is still right. + */ +const TEXT_READING_VERSION = 1; + +/** + * The identity of the text a file answers with, as a weak ETag, made from the + * file's metadata alone so that an unchanged file can be answered 304 without + * being read. + * + * Taken from a bigint stat — an inode past 2^53 and a time in nanoseconds do + * not survive a double — and made of: + * - the inode: a save writes a new file and renames it over the old one, so a + * save that comes out the same size within one clock tick still differs; + * - the size and the modification time: a write in place; + * - the change time: a write in place that put the modification time back, as + * `cp -p`, `rsync --inplace -t` or an archive extracted over the file do. + * Nothing can put that one back; + * - `describe`, hashed: whatever else the answer carries, so that a change + * there — a share turned read-only — is never hidden behind a 304. + * + * Weak, because the same text goes compressed or not: equivalent answers, not + * the same bytes. + * + * The stat has to be taken before the file is read. Content changing between + * the two then pairs newer text with an older identity, which costs the next + * visit one download; the other order pairs older text with a newer identity, + * and every 304 after it would keep the older text. + * + * @param {import('fs').BigIntStats} stats + * @param {object} [describe] + */ +const textFileEtag = (stats, describe) => { + const parts = [stats.ino, stats.size, stats.mtimeNs, stats.ctimeNs].map((value) => + value.toString(36) + ); + parts.push(`t${TEXT_READING_VERSION}`); + if (describe !== undefined) { + const digest = crypto.createHash('sha256').update(JSON.stringify(describe)).digest('base64url'); + parts.push(digest.slice(0, 16)); + } + return `W/"${parts.join('-')}"`; +}; + +/** + * The encoding a file already on disk is written in, so a save keeps it. + * + * Reads only the head of the file: a mark is the first three bytes, and the + * pairing that betrays a markless UTF-16 shows in the first few hundred. + */ +async function readFileEncoding(absolutePath) { + try { + return detectTextEncoding(await readHead(absolutePath, SAMPLE_BYTES)); + } catch (_) { + // No file yet: a new one is written in the encoding everything else uses. + return { encoding: 'utf8', bom: false }; + } +} + +module.exports = { + readTextFile, + readTextFileHead, + readFileEncoding, + detectTextEncoding, + decodeText, + encodeText, + textFileEtag, + MAX_EDITOR_FILE_SIZE, +}; diff --git a/backend/src/services/thumbnailService.js b/backend/src/services/thumbnailService.js index 5252da9f3..025c00370 100644 --- a/backend/src/services/thumbnailService.js +++ b/backend/src/services/thumbnailService.js @@ -1,10 +1,10 @@ const path = require('path'); +const os = require('os'); const crypto = require('crypto'); const fs = require('fs'); const fsPromises = require('fs/promises'); -const { spawn } = require('child_process'); const sharp = require('sharp'); -const ffmpeg = require('fluent-ffmpeg'); +const ffmpegRunner = require('./ffmpegRunner'); const PQueue = require('p-queue').default; const { ensureDir } = require('../utils/fsUtils'); @@ -13,6 +13,13 @@ const env = require('../config/env'); const { getSettings } = require('../services/settingsService'); const logger = require('../utils/logger'); const { getRawPreviewJpegPath } = require('./rawPreviewService'); +const { + CACHE_CLEANUP_BATCH_SIZE, + CACHE_CLEANUP_INTERVAL_MS, + CACHE_TTL_MS, + findAbandonedTempFiles, + statCacheEntries, +} = require('../utils/cacheCleanup'); const getThumbOptions = async () => { const settings = await getSettings(); @@ -23,54 +30,31 @@ const getThumbOptions = async () => { const currentConcurrency = sharp.concurrency(); sharp.concurrency(Math.max(1, Math.min(8, currentConcurrency))); -sharp.cache({ memory: 256, files: 0 }); - -const EXECUTABLE_CANDIDATES = { - ffmpeg: [env.FFMPEG_PATH, '/usr/local/bin/ffmpeg', '/usr/bin/ffmpeg', '/opt/homebrew/bin/ffmpeg'], - ffprobe: [ - env.FFPROBE_PATH, - '/usr/local/bin/ffprobe', - '/usr/bin/ffprobe', - '/opt/homebrew/bin/ffprobe', - ], +const SHARP_CACHE_MEMORY_MB = Number.isFinite(env.THUMBNAIL_SHARP_CACHE_MEMORY_MB) + ? Math.max(0, Math.min(256, Math.floor(env.THUMBNAIL_SHARP_CACHE_MEMORY_MB))) + : 32; +const configureSharpCache = () => { + sharp.cache({ + memory: SHARP_CACHE_MEMORY_MB, + files: 0, + items: SHARP_CACHE_MEMORY_MB > 0 ? 100 : 0, + }); }; - -let canProcessVideoThumbnails = false; - -const resolveExecutable = (candidates = []) => { - for (const candidate of candidates) { - if (!candidate) continue; - - try { - fs.accessSync(candidate, fs.constants.X_OK); - return candidate; - } catch (error) { - // try next candidate - } - } - - return null; +const trimSharpCache = () => { + sharp.cache(false); + configureSharpCache(); }; +configureSharpCache(); -const configureFfmpegBinaries = () => { - const ffmpegPath = resolveExecutable(EXECUTABLE_CANDIDATES.ffmpeg); - if (ffmpegPath) { - ffmpeg.setFfmpegPath(ffmpegPath); - } else { - logger.warn('FFmpeg binary not found. Video thumbnails will be skipped.'); - } - - const ffprobePath = resolveExecutable(EXECUTABLE_CANDIDATES.ffprobe); - if (ffprobePath) { - ffmpeg.setFfprobePath(ffprobePath); - } else { - logger.warn('ffprobe binary not found. Video thumbnails will be skipped.'); - } - - canProcessVideoThumbnails = Boolean(ffmpegPath && ffprobePath); -}; +// ffprobe is only needed when the seek point is a percentage of the duration; +// a fixed seek needs ffmpeg alone. +const ffprobeRequired = env.THUMBNAIL_VIDEO_SEEK_PERCENT != null; +const canProcessVideoThumbnails = + ffmpegRunner.hasFfmpeg() && (!ffprobeRequired || ffmpegRunner.hasFfprobe()); -configureFfmpegBinaries(); +if (ffprobeRequired && !ffmpegRunner.hasFfprobe()) { + logger.warn('ffprobe binary not found. Video thumbnails will be skipped.'); +} const isImage = (ext) => extensions.images.includes(ext); const isRawImage = (ext) => (extensions.rawImages || []).includes(ext); @@ -79,8 +63,81 @@ const isPdf = (ext) => ext === 'pdf'; const isHeic = (ext) => ext === 'heic'; const inflight = new Map(); - -const THUMBNAIL_CACHE_VERSION = 2; +const failedThumbnails = new Map(); + +const THUMBNAIL_CACHE_VERSION = 3; +const QUEUE_CONCURRENCY_REFRESH_INTERVAL_MS = 30 * 1000; +const FAILED_THUMBNAIL_TTL_MS = 10 * 60 * 1000; +const FAILED_THUMBNAIL_MAX_ENTRIES = 1000; +const THUMBNAIL_CACHE_MAX_FILES = Number.isFinite(env.THUMBNAIL_CACHE_MAX_FILES) + ? Math.max(0, Math.floor(env.THUMBNAIL_CACHE_MAX_FILES)) + : 3000; +// Read once in utils/cacheCleanup, which bounds the RAW previews with them too. +const THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS = CACHE_CLEANUP_INTERVAL_MS; +const THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE = CACHE_CLEANUP_BATCH_SIZE; +const THUMBNAIL_CACHE_TTL_MS = CACHE_TTL_MS; +const THUMBNAIL_VIDEO_CONCURRENCY = Number.isFinite(env.THUMBNAIL_VIDEO_CONCURRENCY) + ? Math.max(1, Math.min(8, Math.floor(env.THUMBNAIL_VIDEO_CONCURRENCY))) + : 3; +const THUMBNAIL_VIDEO_SEEK_SECONDS = Number.isFinite(env.THUMBNAIL_VIDEO_SEEK_SECONDS) + ? Math.max(0, Math.floor(env.THUMBNAIL_VIDEO_SEEK_SECONDS)) + : 5; +const THUMBNAIL_VIDEO_SEEK_PERCENT = Number.isFinite(env.THUMBNAIL_VIDEO_SEEK_PERCENT) + ? Math.max(0, Math.min(1, Number(env.THUMBNAIL_VIDEO_SEEK_PERCENT))) + : null; +const THUMBNAIL_VIDEO_THREADS = Number.isFinite(env.THUMBNAIL_VIDEO_THREADS) + ? Math.max(1, Math.min(8, Math.floor(env.THUMBNAIL_VIDEO_THREADS))) + : 2; +const THUMBNAIL_VIDEO_SCALE_FLAGS = /^[a-z0-9_+.-]+$/i.test(env.THUMBNAIL_VIDEO_SCALE_FLAGS || '') + ? env.THUMBNAIL_VIDEO_SCALE_FLAGS + : 'fast_bilinear'; +const THUMBNAIL_BACKGROUND_QUEUE_LIMIT = Number.isFinite(env.THUMBNAIL_BACKGROUND_QUEUE_LIMIT) + ? Math.max(1, Math.min(100, Math.floor(env.THUMBNAIL_BACKGROUND_QUEUE_LIMIT))) + : 16; +const THUMBNAIL_DIAGNOSTICS_ENABLED = env.THUMBNAIL_DIAGNOSTICS_ENABLED === true; +const THUMBNAIL_DIAGNOSTICS_INTERVAL_MS = Number.isFinite(env.THUMBNAIL_DIAGNOSTICS_INTERVAL_MS) + ? Math.max(5000, Math.floor(env.THUMBNAIL_DIAGNOSTICS_INTERVAL_MS)) + : 30000; +const THUMBNAIL_SLOW_JOB_MS = Number.isFinite(env.THUMBNAIL_SLOW_JOB_MS) + ? Math.max(1000, Math.floor(env.THUMBNAIL_SLOW_JOB_MS)) + : 10000; +// Generous on purpose: a long video on a slow disk is allowed to take minutes, +// and a thumbnail killed early is a thumbnail that never appears. See +// startFfmpegCeiling. +const THUMBNAIL_FFMPEG_TIMEOUT_MS = Number.isFinite(env.THUMBNAIL_FFMPEG_TIMEOUT_MS) + ? Math.max(1000, Math.floor(env.THUMBNAIL_FFMPEG_TIMEOUT_MS)) + : 5 * 60 * 1000; +const THUMBNAIL_PROCESS_NICE = Number.isFinite(env.THUMBNAIL_PROCESS_NICE) + ? Math.max(0, Math.min(19, Math.floor(env.THUMBNAIL_PROCESS_NICE))) + : 10; +const THUMBNAIL_CACHE_CONTINUE_DELAY_MS = 30 * 1000; +const THUMBNAIL_CACHE_DIR = path.resolve(directories.thumbnails); +const THUMBNAIL_CACHE_FILE_PATTERN = /^v\d+-(?:[a-f0-9]{40}|[a-f0-9]{64})\.webp$/i; +// Releases up to 2.0.3 named a thumbnail after its key alone; the version prefix +// arrived with 734508f. Such a file is a thumbnail all the same — counted, and +// outdated by definition — but only the cleanup needs to know the name: a source +// file that merely looks like one must still get a thumbnail of its own. +const LEGACY_THUMBNAIL_FILE_PATTERN = /^(?:[a-f0-9]{40}|[a-f0-9]{64})\.webp$/i; +// A thumbnail on its way into place (buildTempThumbnailPath), under either naming. +// 2.0.x wrote `.tmp--`; the UUID came later. +const THUMBNAIL_TEMP_FILE_PATTERN = + /^(?:v\d+-)?(?:[a-f0-9]{40}|[a-f0-9]{64})\.webp\.tmp-\d+-\d+(?:-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})?$/i; +// Temporary files this process is still writing, by name. See atomicWriteSharpFile. +const liveThumbnailTempFiles = new Set(); +const THUMBNAILS_ENABLED = env.THUMBNAILS_ENABLED !== false; +const activeThumbnailJobs = new Map(); +const activeExternalProcesses = new Map(); +const thumbnailStats = { + requests: 0, + cacheHits: 0, + queued: 0, + generated: 0, + failed: 0, + failedTtlSkips: 0, + cacheCleanupDeleted: 0, + backgroundQueueSkipped: 0, + ffmpegStarted: 0, +}; // Create thumbnail generation queue with concurrency limit // This prevents overwhelming the system with too many concurrent sharp/ffmpeg operations @@ -91,52 +148,472 @@ const thumbnailQueue = new PQueue({ throwOnTimeout: false, }); -// Update queue concurrency from settings -const updateQueueConcurrency = async () => { +const videoThumbnailQueue = new PQueue({ + concurrency: THUMBNAIL_VIDEO_CONCURRENCY, + timeout: 30000, + throwOnTimeout: false, +}); +// Removing an obsolete cache entry is never worth delaying a file operation. +// Keep it small and serial so a large deletion cannot turn cache housekeeping +// into another source of filesystem pressure. +const thumbnailRemovalQueue = new PQueue({ concurrency: 1 }); + +let queueConcurrencyRefreshPromise = null; +let lastQueueConcurrencyRefreshAt = 0; +let lastQueueConcurrency = thumbnailQueue.concurrency; +let sharpCacheTrimTimer = null; +let thumbnailCacheCleanupPromise = null; +let thumbnailCacheCleanupTimer = null; +// Set by stopThumbnailWork: a pass finishing afterwards schedules no other. +let thumbnailCacheCleanupStopped = false; +let lastThumbnailCacheCleanupAt = 0; +let thumbnailDiagnosticsTimer = null; + +const toMb = (bytes) => Math.round((Number(bytes) || 0) / 1024 / 1024); + +// Cheap snapshots used on hot paths (per job/process). Avoid building the full +// getDiagnosticsSnapshot() object when only memory or queue depth is needed. +const currentMemoryMb = () => { + const memory = process.memoryUsage(); + return { + rss: toMb(memory.rss), + heapUsed: toMb(memory.heapUsed), + heapTotal: toMb(memory.heapTotal), + external: toMb(memory.external), + arrayBuffers: toMb(memory.arrayBuffers), + }; +}; + +const queuesSnapshot = () => ({ + thumbnail: { + size: thumbnailQueue.size, + pending: thumbnailQueue.pending, + concurrency: thumbnailQueue.concurrency, + }, + video: { + size: videoThumbnailQueue.size, + pending: videoThumbnailQueue.pending, + concurrency: videoThumbnailQueue.concurrency, + }, +}); + +const summarizeActiveMap = (map, { now = Date.now(), limit = 5 } = {}) => + Array.from(map.values()) + .map((item) => ({ + id: item.id, + type: item.type, + ext: item.ext, + fileName: item.fileName, + pid: item.pid, + ageMs: now - item.startedAt, + })) + .sort((a, b) => b.ageMs - a.ageMs) + .slice(0, limit); + +const countBy = (items, key) => + items.reduce((acc, item) => { + const value = item[key] || 'unknown'; + acc[value] = (acc[value] || 0) + 1; + return acc; + }, {}); + +const safeSharpDiagnostics = () => { try { - const settings = await getSettings(); - const concurrency = settings?.thumbnails?.concurrency || 10; - thumbnailQueue.concurrency = concurrency; - logger.info({ concurrency }, 'Thumbnail queue concurrency set'); + return { + cache: sharp.cache(), + counters: sharp.counters(), + }; } catch (error) { - logger.warn({ err: error }, 'Failed to update thumbnail queue concurrency'); + return { error: error.message }; } }; -// Initialize concurrency from settings -updateQueueConcurrency(); +const getDiagnosticsSnapshot = () => { + const now = Date.now(); + const activeJobs = Array.from(activeThumbnailJobs.values()); + const activeProcesses = Array.from(activeExternalProcesses.values()); + + return { + memoryMb: currentMemoryMb(), + queues: queuesSnapshot(), + counts: { + inflight: inflight.size, + failedCache: failedThumbnails.size, + activeJobs: activeJobs.length, + activeExternalProcesses: activeProcesses.length, + }, + activeByType: countBy(activeJobs, 'type'), + activeByExt: countBy(activeJobs, 'ext'), + activeExternalByType: countBy(activeProcesses, 'type'), + oldestJobs: summarizeActiveMap(activeThumbnailJobs, { now }), + oldestExternalProcesses: summarizeActiveMap(activeExternalProcesses, { now }), + stats: { ...thumbnailStats }, + sharp: safeSharpDiagnostics(), + cleanup: { + cacheMaxFiles: THUMBNAIL_CACHE_MAX_FILES, + cleanupInProgress: Boolean(thumbnailCacheCleanupPromise), + cleanupTimerScheduled: Boolean(thumbnailCacheCleanupTimer), + lastCleanupAgeMs: lastThumbnailCacheCleanupAt ? now - lastThumbnailCacheCleanupAt : null, + }, + }; +}; -// Log queue stats periodically for monitoring -thumbnailQueue.on('active', () => { - logger.debug( +const logThumbnailDiagnostics = (reason, extra = {}) => { + if (!THUMBNAIL_DIAGNOSTICS_ENABLED) return; + logger.info({ reason, ...getDiagnosticsSnapshot(), ...extra }, 'Thumbnail diagnostics'); +}; + +const startThumbnailDiagnostics = () => { + if (!THUMBNAIL_DIAGNOSTICS_ENABLED || thumbnailDiagnosticsTimer) { + return; + } + + logger.info( { - size: thumbnailQueue.size, - pending: thumbnailQueue.pending, - concurrency: thumbnailQueue.concurrency, + intervalMs: THUMBNAIL_DIAGNOSTICS_INTERVAL_MS, + slowJobMs: THUMBNAIL_SLOW_JOB_MS, + cacheMaxFiles: THUMBNAIL_CACHE_MAX_FILES, + sharpCacheMemoryMb: SHARP_CACHE_MEMORY_MB, + videoConcurrency: THUMBNAIL_VIDEO_CONCURRENCY, + videoSeekSeconds: THUMBNAIL_VIDEO_SEEK_SECONDS, + videoSeekPercent: THUMBNAIL_VIDEO_SEEK_PERCENT, + videoThreads: THUMBNAIL_VIDEO_THREADS, + videoScaleFlags: THUMBNAIL_VIDEO_SCALE_FLAGS, + backgroundQueueLimit: THUMBNAIL_BACKGROUND_QUEUE_LIMIT, + processNice: THUMBNAIL_PROCESS_NICE, + uvThreadpoolSize: process.env.UV_THREADPOOL_SIZE || null, }, - 'Thumbnail queue status' + 'Thumbnail diagnostics enabled' ); -}); -const hashForFile = async (filePath, stats = null) => { - const info = stats || (await fsPromises.stat(filePath)); + thumbnailDiagnosticsTimer = setInterval(() => { + logThumbnailDiagnostics('interval'); + }, THUMBNAIL_DIAGNOSTICS_INTERVAL_MS); + + if (typeof thumbnailDiagnosticsTimer.unref === 'function') { + thumbnailDiagnosticsTimer.unref(); + } +}; + +const startThumbnailJob = (filePath, thumbPath) => { + const id = crypto.randomUUID(); + const ext = path.extname(filePath).toLowerCase().slice(1) || 'unknown'; + const type = isVideo(ext) + ? 'video' + : isHeic(ext) + ? 'heic' + : isRawImage(ext) + ? 'raw' + : isImage(ext) + ? 'image' + : ext; + const job = { + id, + type, + ext, + fileName: path.basename(filePath), + thumbFile: path.basename(thumbPath), + startedAt: Date.now(), + }; + + activeThumbnailJobs.set(id, job); + if (THUMBNAIL_DIAGNOSTICS_ENABLED) { + logger.info({ job, queues: queuesSnapshot() }, 'Thumbnail job started'); + } + return id; +}; + +const finishThumbnailJob = (id, status, error = null) => { + const job = activeThumbnailJobs.get(id); + if (!job) return; + + activeThumbnailJobs.delete(id); + const durationMs = Date.now() - job.startedAt; + if (!THUMBNAIL_DIAGNOSTICS_ENABLED && durationMs < THUMBNAIL_SLOW_JOB_MS) { + return; + } + + logger.info( + { + job, + status, + durationMs, + memoryMb: currentMemoryMb(), + error: error ? error.message : undefined, + }, + 'Thumbnail job finished' + ); +}; + +// Lower the CPU scheduling priority of a spawned ffmpeg so the +// Node event loop — and therefore directory listings and navigation — keeps CPU +// during heavy thumbnail generation. Only ever applied to child PIDs, never to +// the main process. Best-effort: setpriority may be unavailable or denied. +const lowerChildProcessPriority = (pid) => { + if (!pid || THUMBNAIL_PROCESS_NICE <= 0) { + return; + } + + try { + os.setPriority(pid, THUMBNAIL_PROCESS_NICE); + } catch (error) { + logger.debug({ pid, err: error }, 'Failed to lower thumbnail process priority'); + } +}; + +const registerExternalProcess = (type, filePath, pid, extra = {}) => { + const id = crypto.randomUUID(); + const item = { + id, + type, + ext: path.extname(filePath).toLowerCase().slice(1) || 'unknown', + fileName: path.basename(filePath), + pid: pid || null, + startedAt: Date.now(), + ...extra, + }; + + activeExternalProcesses.set(id, item); + if (type === 'ffmpeg') thumbnailStats.ffmpegStarted += 1; + + if (THUMBNAIL_DIAGNOSTICS_ENABLED) { + logger.info( + { process: item, memoryMb: currentMemoryMb() }, + 'Thumbnail external process started' + ); + } + + return id; +}; + +const unregisterExternalProcess = (id, status, error = null) => { + if (!id) return; + const item = activeExternalProcesses.get(id); + if (!item) return; + + activeExternalProcesses.delete(id); + const durationMs = Date.now() - item.startedAt; + if (THUMBNAIL_DIAGNOSTICS_ENABLED || durationMs >= THUMBNAIL_SLOW_JOB_MS) { + logger.info( + { + process: item, + status, + durationMs, + error: error ? error.message : undefined, + memoryMb: currentMemoryMb(), + }, + 'Thumbnail external process finished' + ); + } +}; + +startThumbnailDiagnostics(); + +const scheduleSharpCacheTrim = ({ delayMs = 2 * 1000 } = {}) => { + if (sharpCacheTrimTimer) { + clearTimeout(sharpCacheTrimTimer); + } + + sharpCacheTrimTimer = setTimeout(() => { + sharpCacheTrimTimer = null; + if (thumbnailQueue.size === 0 && thumbnailQueue.pending === 0) { + trimSharpCache(); + logger.debug({ memoryMb: SHARP_CACHE_MEMORY_MB }, 'Sharp thumbnail cache trimmed'); + return; + } + + scheduleSharpCacheTrim({ delayMs }); + }, delayMs); + + if (typeof sharpCacheTrimTimer.unref === 'function') { + sharpCacheTrimTimer.unref(); + } +}; + +const isInsideDirectory = (candidatePath, directoryPath) => { + if (!candidatePath) { + return false; + } + + const relativePath = path.relative(directoryPath, path.resolve(candidatePath)); + return ( + relativePath === '' || + (!!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) + ); +}; + +const isThumbnailCachePath = (filePath) => { + if (!filePath) { + return false; + } + + return ( + isInsideDirectory(filePath, THUMBNAIL_CACHE_DIR) || + THUMBNAIL_CACHE_FILE_PATTERN.test(path.basename(filePath)) + ); +}; + +// Update queue concurrency from settings +const updateQueueConcurrency = async ({ force = false } = {}) => { + const now = Date.now(); + if (!force && now - lastQueueConcurrencyRefreshAt < QUEUE_CONCURRENCY_REFRESH_INTERVAL_MS) { + return; + } + + if (queueConcurrencyRefreshPromise) { + return queueConcurrencyRefreshPromise; + } + + queueConcurrencyRefreshPromise = (async () => { + lastQueueConcurrencyRefreshAt = Date.now(); + + try { + const settings = await getSettings(); + const rawConcurrency = Number(settings?.thumbnails?.concurrency) || 10; + const concurrency = Math.max(1, Math.min(50, Math.floor(rawConcurrency))); + + if (concurrency !== lastQueueConcurrency) { + thumbnailQueue.concurrency = concurrency; + lastQueueConcurrency = concurrency; + logger.info({ concurrency }, 'Thumbnail queue concurrency set'); + } + } catch (error) { + logger.warn({ err: error }, 'Failed to update thumbnail queue concurrency'); + } finally { + queueConcurrencyRefreshPromise = null; + } + })(); + + return queueConcurrencyRefreshPromise; +}; + +// Initialize concurrency from settings +updateQueueConcurrency({ force: true }); + +const resolveThumbnailSourceIdentity = async (filePath) => { + try { + return await fsPromises.realpath(filePath); + } catch (_) { + return path.resolve(filePath); + } +}; + +const hashForFile = async (filePath) => { + const sourceIdentity = await resolveThumbnailSourceIdentity(filePath); const hash = crypto.createHash('sha1'); - hash.update(filePath); - hash.update(String(info.size)); - hash.update(String(Math.floor(info.mtimeMs))); + hash.update(sourceIdentity); return hash.digest('hex'); }; -const atomicWrite = async (finalPath, buffer) => { +const buildTempThumbnailPath = (finalPath) => + `${finalPath}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`; + +/** How much of ffmpeg's complaint to keep, and how long to wait to hear it. */ +const FFMPEG_STDERR_TAIL_BYTES = 2048; +const FFMPEG_EXIT_GRACE_MS = 1000; + +/** + * Listen to ffmpeg, so a failure can be reported as ffmpeg's. + * + * Two things went wrong without this, and the second hid the first. + * + * ffmpeg is spawned with stderr on a pipe. A pipe nobody reads fills — 64 KB on + * Linux — and the process then blocks on its next write and never exits. A run + * that only ever succeeds quietly never reaches that, which is why it went + * unnoticed; a file ffmpeg has a lot to say about is exactly the file that + * hangs. Attaching a reader is what drains it. + * + * And when ffmpeg does fail it writes nothing to stdout, so sharp is handed an + * empty buffer and raises "Input buffer contains unsupported image format". + * That is the line that reached the log — sharp's name, an image-format + * complaint, about a video file — while the actual reason sat unread in stderr. + * A sharp error therefore waits briefly for the exit code before it is + * believed, and carries ffmpeg's own words when there are any. + */ +const attachFfmpegDiagnostics = (command) => { + let stderrTail = ''; + let exitCode = null; + + command.stderr?.on('data', (chunk) => { + stderrTail = (stderrTail + String(chunk)).slice(-FFMPEG_STDERR_TAIL_BYTES); + }); + + const exited = new Promise((resolve) => { + command.on('close', (code) => { + exitCode = code; + resolve(code); + }); + }); + + /** Wait for the exit code, but never longer than it takes to be useful. */ + const settledExit = () => + Promise.race([ + exited, + new Promise((resolve) => { + const timer = setTimeout(resolve, FFMPEG_EXIT_GRACE_MS); + timer.unref?.(); + }), + ]); + + const describeFailure = (error) => { + if (exitCode === null || exitCode === 0) return error; + const detail = stderrTail.trim().split('\n').slice(-3).join(' | '); + const described = new Error(`FFmpeg exited with ${exitCode}${detail ? `: ${detail}` : ''}`); + described.cause = error; + return described; + }; + + return { describeFailure, settledExit }; +}; + +/** + * The longest one ffmpeg may take over one thumbnail. + * + * Nothing else ends a run that has stopped making progress. The queues do time + * out, but a timeout there only frees the slot — deliberately, so that a job + * still working is not started a second time — and the file stays in flight + * behind the process nobody is waiting for any more. One ffmpeg that never + * exits therefore meant one file with no thumbnail until a restart, whoever + * asked for it and however often. + * + * The ceiling sits far above what the work takes, because everything under it + * is a thumbnail that would have arrived: a long video on a slow disk is + * allowed its minutes. Past it the process is killed and the run fails like + * any other failure — remembered for its ten minutes, then asked for again. + * + * @param {(error: Error) => void} expire the run's own `fail` + * @returns {() => void} stops it; every way out of the run calls this + */ +const startFfmpegCeiling = (expire) => { + const timer = setTimeout(() => { + expire(new Error(`FFmpeg did not finish within ${THUMBNAIL_FFMPEG_TIMEOUT_MS} ms`)); + }, THUMBNAIL_FFMPEG_TIMEOUT_MS); + return () => clearTimeout(timer); +}; + +const atomicWriteSharpFile = async (finalPath, pipeline) => { await ensureDir(path.dirname(finalPath)); - const tmpPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`; - await fsPromises.writeFile(tmpPath, buffer); - await fsPromises.rename(tmpPath, finalPath); + const tmpPath = buildTempThumbnailPath(finalPath); + // Until the rename or the removal below has happened, the cleanup must leave + // this file alone however long the write takes. The queues cannot say so: a + // job they stop waiting for after their timeout goes on running. + const tmpName = path.basename(tmpPath); + liveThumbnailTempFiles.add(tmpName); + + try { + await pipeline.toFile(tmpPath); + await fsPromises.rename(tmpPath, finalPath); + } catch (error) { + await fsPromises.rm(tmpPath, { force: true }).catch(() => {}); + throw error; + } finally { + liveThumbnailTempFiles.delete(tmpName); + } }; const makeImageThumb = async (srcPath, destPath) => { const { size, quality } = await getThumbOptions(); - const buffer = await sharp(srcPath) + const pipeline = sharp(srcPath) .rotate() .resize({ width: size, @@ -145,10 +622,9 @@ const makeImageThumb = async (srcPath, destPath) => { withoutEnlargement: true, fastShrinkOnLoad: true, }) - .webp({ quality, effort: 4 }) - .toBuffer(); + .webp({ quality, effort: 3 }); - await atomicWrite(destPath, buffer); + await atomicWriteSharpFile(destPath, pipeline); }; const makeRawImageThumb = async (srcPath, destPath) => { @@ -156,17 +632,23 @@ const makeRawImageThumb = async (srcPath, destPath) => { await makeImageThumb(previewJpegPath, destPath); }; -const probeDuration = (filePath) => - new Promise((resolve) => { - ffmpeg.ffprobe(filePath, (error, data) => { - if (error || !data?.format?.duration) { - resolve(null); - return; - } +const probeDuration = async (filePath) => { + const data = await ffmpegRunner.probe(filePath); + return Number(data?.format?.duration) || null; +}; - resolve(Number(data.format.duration) || null); - }); - }); +const resolveVideoSeekSeconds = async (filePath) => { + if (THUMBNAIL_VIDEO_SEEK_PERCENT == null) { + return THUMBNAIL_VIDEO_SEEK_SECONDS; + } + + const duration = await probeDuration(filePath); + if (!duration || !Number.isFinite(duration)) { + return THUMBNAIL_VIDEO_SEEK_SECONDS; + } + + return Math.max(0, Math.floor(duration * THUMBNAIL_VIDEO_SEEK_PERCENT)); +}; const makeVideoThumb = async (srcPath, destPath) => { if (!canProcessVideoThumbnails) { @@ -174,19 +656,14 @@ const makeVideoThumb = async (srcPath, destPath) => { return; } - const duration = await probeDuration(srcPath); - const seconds = - duration && Number.isFinite(duration) ? Math.max(1, Math.floor(duration * 0.05)) : 1; + const seconds = await resolveVideoSeekSeconds(srcPath); + const { size, quality } = await getThumbOptions(); await new Promise((resolve, reject) => { - // Size is dynamic; capture inside ffmpeg filter - let size = 200; - getThumbOptions() - .then(({ size: sz }) => { - size = sz; - }) - .catch(() => {}); const inputOptions = ['-hide_banner', '-loglevel', 'error']; + if (THUMBNAIL_VIDEO_THREADS > 0) { + inputOptions.push('-threads', String(THUMBNAIL_VIDEO_THREADS)); + } if (env.FFMPEG_HWACCEL) { inputOptions.push('-hwaccel', env.FFMPEG_HWACCEL); @@ -200,71 +677,209 @@ const makeVideoThumb = async (srcPath, destPath) => { inputOptions.push('-hwaccel_output_format', env.FFMPEG_HWACCEL_OUTPUT_FORMAT); } - const command = ffmpeg(srcPath) - .inputOptions(inputOptions) - .seekInput(seconds) - .outputOptions(['-frames:v', '1', '-vf', `scale=${size}:-1:flags=lanczos`, '-vcodec', 'png']) - .format('image2pipe') - .on('error', reject); - - const stream = command.pipe(); - stream.on('error', reject); - - (async () => { - const { quality } = await getThumbOptions(); - const pipeline = sharp().webp({ quality, effort: 4 }); - stream.pipe(pipeline); - pipeline - .toBuffer() - .then((buffer) => atomicWrite(destPath, buffer)) - .then(resolve) - .catch(reject); - })().catch(reject); + let stream = null; + let pipeline = null; + let command = null; + let externalProcessId = null; + let settled = false; + let stopCeiling = null; + + const cleanup = ({ killProcess = false } = {}) => { + stopCeiling?.(); + if (killProcess) { + try { + command?.kill('SIGKILL'); + } catch (_) { + // noop + } + } + if (stream && !stream.destroyed) { + stream.destroy(); + } + if (pipeline && !pipeline.destroyed) { + pipeline.destroy(); + } + }; + + let diagnostics = null; + + const fail = (rawError) => { + if (settled) return; + settled = true; + const error = diagnostics ? diagnostics.describeFailure(rawError) : rawError; + unregisterExternalProcess(externalProcessId, 'error', error); + cleanup({ killProcess: true }); + reject(error); + }; + + const done = () => { + if (settled) return; + settled = true; + unregisterExternalProcess(externalProcessId, 'success'); + cleanup(); + resolve(); + }; + + // `-ss` before `-i` seeks by keyframe, which is what makes a thumbnail of + // a long video fast: the alternative decodes everything up to that point. + command = ffmpegRunner.run([ + ...inputOptions, + '-ss', + String(seconds), + '-i', + srcPath, + '-map', + '0:v:0', + '-an', + '-sn', + '-dn', + '-frames:v', + '1', + '-vf', + `scale=${size}:-1:flags=${THUMBNAIL_VIDEO_SCALE_FLAGS}`, + '-threads', + String(THUMBNAIL_VIDEO_THREADS), + '-vcodec', + 'mjpeg', + '-q:v', + '4', + '-f', + 'image2pipe', + 'pipe:1', + ]); + + externalProcessId = registerExternalProcess('ffmpeg', srcPath, command.pid, { + seekSeconds: seconds, + size, + }); + lowerChildProcessPriority(command.pid); + + diagnostics = attachFfmpegDiagnostics(command); + stopCeiling = startFfmpegCeiling(fail); + command.on('error', fail); + command.on('close', (code) => { + // Stop tracking it as running the moment it exits, rather than when the + // thumbnail has finished being written. + if (code === 0) unregisterExternalProcess(externalProcessId, 'success'); + }); + + stream = command.stdout; + stream.on('error', fail); + + pipeline = sharp().webp({ quality, effort: 3 }); + stream.pipe(pipeline); + atomicWriteSharpFile(destPath, pipeline) + .then(done) + .catch(async (error) => { + // Whose failure this really is depends on the exit code, which may not + // have arrived yet. + await diagnostics.settledExit(); + fail(error); + }); }); }; +/** + * A HEIC thumbnail, decoded by ffmpeg. + * + * This used to shell out to ImageMagick's `convert`, which was the only reason + * the image carried ImageMagick at all — 9.8 MB of packages for one format. + * ffmpeg is already here for video, and since 7.1 its HEIF demuxer reconstructs + * tiled images, which is what an iPhone photo actually is: a grid of HEVC + * tiles. A decoder that reads only the first item returns one square of the + * picture, so "it opens the file" was never the bar. + * + * Rotation stays ffmpeg's to apply. A HEIC records it as an `irot` property + * that the demuxer exports as display-matrix side data, and ffmpeg's own + * autorotate — on by default — inserts the transpose ahead of our scale filter. + * Doing it a second time in sharp would undo it. + */ const makeHeicThumb = async (srcPath, destPath) => { const { size, quality } = await getThumbOptions(); await new Promise((resolve, reject) => { - // Use ImageMagick to convert HEIC to PNG, then pipe to sharp for WebP conversion - const convert = spawn('convert', [ + let externalProcessId = null; + let command = null; + let stream = null; + let pipeline = null; + let settled = false; + let stopCeiling = null; + + const cleanup = ({ killProcess = false } = {}) => { + stopCeiling?.(); + if (killProcess && command) { + try { + command.kill('SIGKILL'); + } catch (_) { + // The process may already be gone; nothing left to stop. + } + } + if (stream && !stream.destroyed) stream.destroy(); + if (pipeline && !pipeline.destroyed) pipeline.destroy(); + }; + + let diagnostics = null; + + const fail = (rawError) => { + if (settled) return; + settled = true; + const error = diagnostics ? diagnostics.describeFailure(rawError) : rawError; + unregisterExternalProcess(externalProcessId, 'error', error); + cleanup({ killProcess: true }); + reject(error); + }; + + const done = () => { + if (settled) return; + settled = true; + unregisterExternalProcess(externalProcessId, 'success'); + cleanup(); + resolve(); + }; + + command = ffmpegRunner.run([ + '-hide_banner', + '-loglevel', + 'error', + '-i', srcPath, - '-auto-orient', - '-resize', - `${size}x`, - '-quality', - '100', - 'png:-', + '-map', + '0:v:0', + '-frames:v', + '1', + '-vf', + `scale=${size}:-1:flags=${THUMBNAIL_VIDEO_SCALE_FLAGS}`, + // PNG between the two processes: the WebP below is the only lossy step, + // so a small thumbnail is not compressed twice. + '-vcodec', + 'png', + '-f', + 'image2pipe', + 'pipe:1', ]); - let stderr = ''; - convert.stderr.on('data', (data) => { - stderr += data.toString(); - }); + externalProcessId = registerExternalProcess('ffmpeg', srcPath, command.pid, { size }); + lowerChildProcessPriority(command.pid); - convert.on('error', (err) => { - reject(new Error(`Failed to spawn ImageMagick convert: ${err.message}`)); - }); - - convert.on('exit', (code) => { - if (code !== 0 && code !== null) { - reject(new Error(`ImageMagick convert exited with code ${code}: ${stderr}`)); - } - }); + diagnostics = attachFfmpegDiagnostics(command); + stopCeiling = startFfmpegCeiling(fail); + command.on('error', fail); - const pipeline = sharp().webp({ quality, effort: 4 }); - convert.stdout.pipe(pipeline); + stream = command.stdout; + stream.on('error', fail); - pipeline - .toBuffer() - .then((buffer) => atomicWrite(destPath, buffer)) - .then(resolve) - .catch(reject); + pipeline = sharp().webp({ quality, effort: 3 }); + stream.pipe(pipeline); + atomicWriteSharpFile(destPath, pipeline) + .then(done) + .catch(async (error) => { + await diagnostics.settledExit(); + fail(error); + }); }); }; -const generateThumbnail = async (filePath, thumbPath) => { +const generateThumbnail = async (filePath, thumbPath, { priority = 0 } = {}) => { const extension = path.extname(filePath).toLowerCase().slice(1); if (isPdf(extension)) { @@ -287,22 +902,251 @@ const generateThumbnail = async (filePath, thumbPath) => { } if (isVideo(extension)) { - await makeVideoThumb(filePath, thumbPath); + // Waits for the thumbnail, not for the queue. The queue stops waiting after + // its timeout and frees the slot while ffmpeg goes on; a generation that + // ended there found no thumbnail yet, called it missing, and let the next + // request start a second ffmpeg on the same file beside the first. + let making = null; + await videoThumbnailQueue.add( + () => { + making = makeVideoThumb(filePath, thumbPath); + return making; + }, + { priority } + ); + await making; return; } throw new Error(`Unsupported file type: .${extension}`); }; -const buildThumbnailPaths = async (filePath, stats = null) => { - const key = await hashForFile(filePath, stats); +const buildThumbnailPaths = async (filePath) => { + const key = await hashForFile(filePath); const thumbFile = `v${THUMBNAIL_CACHE_VERSION}-${key}.webp`; const thumbPath = path.join(directories.thumbnails, thumbFile); return { thumbFile, thumbPath }; }; +const removeThumbnailForSource = async (filePath) => { + if (!filePath || isThumbnailCachePath(filePath)) return false; + + const { thumbPath } = await buildThumbnailPaths(filePath); + await fsPromises.rm(thumbPath, { force: true }); + return true; +}; + +const scheduleThumbnailRemoval = (filePath) => { + // A bulk deletion can contain thousands of files. The expiration pass will + // reclaim the rest, so cap immediate bookkeeping rather than competing with + // the deletion itself. + if (thumbnailRemovalQueue.size + thumbnailRemovalQueue.pending >= 256) return; + thumbnailRemovalQueue.add(() => removeThumbnailForSource(filePath).catch(() => false)); +}; + +const findExpiredThumbnails = (entries, now) => { + if (THUMBNAIL_CACHE_TTL_MS <= 0) return []; + + return entries + .filter((entry) => now - entry.mtimeMs >= THUMBNAIL_CACHE_TTL_MS) + .map((entry) => entry.name); +}; + +const getThumbnailQueueLoad = () => + inflight.size + + thumbnailQueue.size + + thumbnailQueue.pending + + videoThumbnailQueue.size + + videoThumbnailQueue.pending; + +const isThumbnailFresh = async (thumbPath, sourceStats = null, filePath = null) => { + try { + const [thumbStats, currentSourceStats] = await Promise.all([ + fsPromises.stat(thumbPath), + sourceStats ? Promise.resolve(sourceStats) : fsPromises.stat(filePath), + ]); + + return thumbStats.mtimeMs >= currentSourceStats.mtimeMs; + } catch (_) { + return false; + } +}; + +const getFailedThumbnail = (thumbPath) => { + const failedAt = failedThumbnails.get(thumbPath); + if (!failedAt) { + return false; + } + + if (Date.now() - failedAt > FAILED_THUMBNAIL_TTL_MS) { + failedThumbnails.delete(thumbPath); + return false; + } + + return true; +}; + +const markFailedThumbnail = (thumbPath) => { + if (failedThumbnails.size >= FAILED_THUMBNAIL_MAX_ENTRIES) { + const oldestKey = failedThumbnails.keys().next().value; + if (oldestKey) { + failedThumbnails.delete(oldestKey); + } + } + + failedThumbnails.set(thumbPath, Date.now()); +}; + +/** + * A limit of zero lifts the limit on the count, and only that. It used to leave + * the directory unmanaged altogether, so another version's thumbnails, those + * past their lifetime and abandoned temporary files stayed there for good. + */ +const cleanupThumbnailCache = async () => { + if (thumbnailCacheCleanupPromise) { + return thumbnailCacheCleanupPromise; + } + + thumbnailCacheCleanupPromise = (async () => { + lastThumbnailCacheCleanupAt = Date.now(); + let shouldContinueCleanup = false; + + try { + await ensureDir(directories.thumbnails); + const dirents = await fsPromises.readdir(directories.thumbnails, { withFileTypes: true }); + const fileNames = dirents.filter((entry) => entry.isFile()).map((entry) => entry.name); + + // The patterns decide what belongs to this cache, and they decide for every + // question below rather than only for the first two. The pattern used to + // filter the expired and the outdated, and then be dropped for the overflow + // trim, which took `fileNames` whole — so anything else in this directory + // both counted towards the limit and could be deleted to satisfy it. + const thumbnailNames = fileNames.filter( + (name) => + THUMBNAIL_CACHE_FILE_PATTERN.test(name) || LEGACY_THUMBNAIL_FILE_PATTERN.test(name) + ); + const now = Date.now(); + + // An unprefixed legacy name is not the current version either. + const currentVersionPrefix = `v${THUMBNAIL_CACHE_VERSION}-`; + const oldVersionNames = thumbnailNames.filter( + (name) => !name.startsWith(currentVersionPrefix) + ); + // Stated once, oldest first: the lifetime reads the age, and a cache past + // its limit gives up its least recently written thumbnails first rather + // than whatever the directory listing happened to put first. + const entries = (await statCacheEntries(directories.thumbnails, thumbnailNames)).sort( + (a, b) => a.mtimeMs - b.mtimeMs + ); + const expiredNames = findExpiredThumbnails(entries, now); + const removableNames = new Set([...oldVersionNames, ...expiredNames]); + + // A temporary file is not a thumbnail: it neither counts towards the limit + // nor is trimmed to meet it. One that is clearly abandoned is removed. + const abandonedTempNames = await findAbandonedTempFiles(directories.thumbnails, fileNames, { + pattern: THUMBNAIL_TEMP_FILE_PATTERN, + live: liveThumbnailTempFiles, + now, + }); + + // What is still over the limit once the removable thumbnails are gone. + // Those are among the counted names and the temporary files are not, so + // the three add up; taking the larger of two, as this once did, stops + // short of the limit as soon as anything uncounted is removed as well. + const overflowCount = + THUMBNAIL_CACHE_MAX_FILES > 0 + ? Math.max(0, thumbnailNames.length - removableNames.size - THUMBNAIL_CACHE_MAX_FILES) + : 0; + const wantedCount = abandonedTempNames.length + removableNames.size + overflowCount; + + if (wantedCount <= 0) { + return; + } + + const toDelete = [ + ...abandonedTempNames, + ...removableNames, + ...entries + .filter((entry) => !removableNames.has(entry.name)) + .slice(0, overflowCount) + .map((entry) => entry.name), + ].slice(0, THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE); + + let deleted = 0; + for (const name of toDelete) { + try { + await fsPromises.rm(path.join(directories.thumbnails, name), { force: true }); + deleted += 1; + } catch (_) { + // Best-effort cache cleanup. + } + } + + logger.info( + { + deleted, + before: thumbnailNames.length, + remainingEstimate: Math.max(0, fileNames.length - deleted), + max: THUMBNAIL_CACHE_MAX_FILES, + batchSize: THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE, + oldVersionCandidates: oldVersionNames.length, + expiredCandidates: expiredNames.length, + abandonedTempCandidates: abandonedTempNames.length, + }, + 'Thumbnail cache cleanup batch completed' + ); + thumbnailStats.cacheCleanupDeleted += deleted; + logThumbnailDiagnostics('cache-cleanup', { cleanupDeleted: deleted }); + + if (wantedCount > deleted) { + shouldContinueCleanup = true; + } + } catch (error) { + logger.warn({ err: error }, 'Thumbnail cache cleanup failed'); + } finally { + thumbnailCacheCleanupPromise = null; + // The next pass is always on the clock. It used to be asked for only when + // a thumbnail was generated, so a server that generated none any more + // never applied the lifetime, the version rules or the limit again. + scheduleThumbnailCacheCleanup({ + force: true, + delayMs: shouldContinueCleanup + ? THUMBNAIL_CACHE_CONTINUE_DELAY_MS + : THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS, + }); + } + })(); + + return thumbnailCacheCleanupPromise; +}; + +const scheduleThumbnailCacheCleanup = ({ force = false, delayMs = 5000 } = {}) => { + if (thumbnailCacheCleanupStopped) { + return; + } + + const now = Date.now(); + if (!force && now - lastThumbnailCacheCleanupAt < THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS) { + return; + } + + if (thumbnailCacheCleanupTimer || thumbnailCacheCleanupPromise) { + return; + } + + thumbnailCacheCleanupTimer = setTimeout(() => { + thumbnailCacheCleanupTimer = null; + cleanupThumbnailCache().catch(() => {}); + }, delayMs); + if (typeof thumbnailCacheCleanupTimer.unref === 'function') { + thumbnailCacheCleanupTimer.unref(); + } +}; + +scheduleThumbnailCacheCleanup({ force: true, delayMs: 2 * 60 * 1000 }); + const getThumbnailPathIfExists = async (filePath, stats = null) => { - if (filePath.includes(directories.thumbnails)) { + if (!THUMBNAILS_ENABLED || isThumbnailCachePath(filePath)) { return ''; } @@ -311,34 +1155,42 @@ const getThumbnailPathIfExists = async (filePath, stats = null) => { return ''; } - const { thumbFile, thumbPath } = await buildThumbnailPaths(filePath, stats); + const { thumbFile, thumbPath } = await buildThumbnailPaths(filePath); try { - await fsPromises.access(thumbPath, fs.constants.F_OK); + if (!(await isThumbnailFresh(thumbPath, stats, filePath))) { + return ''; + } return `/static/thumbnails/${thumbFile}`; - } catch (error) { + } catch (_) { return ''; } }; -const getThumbnail = async (filePath) => { - if (filePath.includes(directories.thumbnails)) { +const getThumbnail = async (filePath, { priority = 0 } = {}) => { + if (!THUMBNAILS_ENABLED || isThumbnailCachePath(filePath)) { return ''; } + thumbnailStats.requests += 1; const extension = path.extname(filePath).toLowerCase().slice(1); if (isPdf(extension)) { return ''; } + const sourceStats = await fsPromises.stat(filePath); const { thumbFile, thumbPath } = await buildThumbnailPaths(filePath); // Check if thumbnail already exists (fast path) - try { - await fsPromises.access(thumbPath, fs.constants.F_OK); + if (await isThumbnailFresh(thumbPath, sourceStats, filePath)) { + failedThumbnails.delete(thumbPath); + thumbnailStats.cacheHits += 1; return `/static/thumbnails/${thumbFile}`; - } catch (error) { - // Thumbnail doesn't exist, need to generate + } + + if (getFailedThumbnail(thumbPath)) { + thumbnailStats.failedTtlSkips += 1; + return ''; } // Update queue concurrency from settings (non-blocking) @@ -347,41 +1199,67 @@ const getThumbnail = async (filePath) => { // Check if generation is already in progress for this file let pending = inflight.get(thumbPath); if (!pending) { + thumbnailStats.queued += 1; + // The file stays in flight until its generation has ended, not until the + // queue stops waiting for it. The queue gives up after its timeout and frees + // the slot while the job goes on; forgetting the file then let the next + // request start the same thumbnail a second time beside the first. + let started = false; + const release = () => { + inflight.delete(thumbPath); + scheduleSharpCacheTrim(); + }; // Queue the thumbnail generation with concurrency limit pending = thumbnailQueue - .add(async () => { - try { - // Double-check if another request created it while we were queued + .add( + async () => { + started = true; + const jobId = startThumbnailJob(filePath, thumbPath); try { - await fsPromises.access(thumbPath, fs.constants.F_OK); - return `/static/thumbnails/${thumbFile}`; + // Double-check if another request created it while we were queued + try { + if (!(await isThumbnailFresh(thumbPath, sourceStats, filePath))) { + throw new Error('Stale or missing thumbnail'); + } + thumbnailStats.cacheHits += 1; + finishThumbnailJob(jobId, 'cache-hit'); + return `/static/thumbnails/${thumbFile}`; + } catch (_) { + // Still doesn't exist, generate it + } + + await generateThumbnail(filePath, thumbPath, { priority }); + scheduleThumbnailCacheCleanup(); + + // Verify generation succeeded + try { + await fsPromises.access(thumbPath, fs.constants.F_OK); + thumbnailStats.generated += 1; + finishThumbnailJob(jobId, 'generated'); + return `/static/thumbnails/${thumbFile}`; + } catch (_) { + logger.warn( + { filePath, thumbPath }, + 'Thumbnail generation completed but file not found' + ); + finishThumbnailJob(jobId, 'missing'); + return ''; + } } catch (error) { - // Still doesn't exist, generate it + thumbnailStats.failed += 1; + markFailedThumbnail(thumbPath); + logger.error({ filePath, err: error }, 'Thumbnail generation failed'); + finishThumbnailJob(jobId, 'error', error); + throw error; + } finally { + release(); } - - logger.debug({ filePath, thumbPath }, 'Generating thumbnail'); - await generateThumbnail(filePath, thumbPath); - - // Verify generation succeeded - try { - await fsPromises.access(thumbPath, fs.constants.F_OK); - logger.debug({ filePath, thumbPath }, 'Thumbnail generated successfully'); - return `/static/thumbnails/${thumbFile}`; - } catch (missing) { - logger.warn( - { filePath, thumbPath }, - 'Thumbnail generation completed but file not found' - ); - return ''; - } - } catch (error) { - logger.error({ filePath, err: error }, 'Thumbnail generation failed'); - throw error; - } - }) + }, + { priority } + ) .finally(() => { - // Clean up inflight map when done - inflight.delete(thumbPath); + // A job that never ran has nothing of its own to release it. + if (!started) release(); }); inflight.set(thumbPath, pending); @@ -390,24 +1268,96 @@ const getThumbnail = async (filePath) => { return pending; }; -const queueThumbnailGeneration = (filePath) => { - if (!filePath || filePath.includes(directories.thumbnails)) { - return; +const queueThumbnailGeneration = async (filePath, { priority = 0, onlyWhenIdle = false } = {}) => { + if (!THUMBNAILS_ENABLED || !filePath || isThumbnailCachePath(filePath)) { + return { thumbnail: '', pending: false, queued: false }; } const extension = path.extname(filePath).toLowerCase().slice(1); if (isPdf(extension)) { - return; + return { thumbnail: '', pending: false, queued: false }; + } + + const sourceStats = await fsPromises.stat(filePath); + const { thumbFile, thumbPath } = await buildThumbnailPaths(filePath); + if (await isThumbnailFresh(thumbPath, sourceStats, filePath)) { + failedThumbnails.delete(thumbPath); + thumbnailStats.cacheHits += 1; + return { + thumbnail: `/static/thumbnails/${thumbFile}`, + pending: false, + queued: false, + }; + } + + if (getFailedThumbnail(thumbPath)) { + thumbnailStats.failedTtlSkips += 1; + return { thumbnail: '', pending: false, queued: false }; + } + + if (inflight.has(thumbPath)) { + return { thumbnail: '', pending: true, queued: true }; } - getThumbnail(filePath).catch((error) => { + // Opportunistic work must never compete with thumbnails already needed by a + // visible item. The browser retries it later, after the queue is quiet again. + if (onlyWhenIdle && getThumbnailQueueLoad() > 0) { + thumbnailStats.backgroundQueueSkipped += 1; + return { thumbnail: '', pending: true, queued: false, retryAfterMs: 2000 }; + } + + if (getThumbnailQueueLoad() >= THUMBNAIL_BACKGROUND_QUEUE_LIMIT) { + thumbnailStats.backgroundQueueSkipped += 1; + return { thumbnail: '', pending: true, queued: false, retryAfterMs: 1500 }; + } + + getThumbnail(filePath, { priority }).catch((error) => { logger.warn({ filePath, err: error }, 'Queued thumbnail generation failed'); }); + + return { thumbnail: '', pending: true, queued: true }; +}; + +/** + * Stop generating, and forget what is still queued. + * + * Three queues and three timers outlive whatever asked for a thumbnail. In a + * running server that is exactly right; when the ground is being removed — + * a test's temporary cache, or a shutdown — work that goes on writing into a + * directory being deleted fails the removal itself (`ENOTEMPTY`) and lands on + * whatever comes next. + */ +const stopThumbnailWork = async () => { + thumbnailQueue.clear(); + videoThumbnailQueue.clear(); + thumbnailRemovalQueue.clear(); + + thumbnailCacheCleanupStopped = true; + if (sharpCacheTrimTimer) clearTimeout(sharpCacheTrimTimer); + if (thumbnailCacheCleanupTimer) clearTimeout(thumbnailCacheCleanupTimer); + if (thumbnailDiagnosticsTimer) clearInterval(thumbnailDiagnosticsTimer); + sharpCacheTrimTimer = null; + thumbnailCacheCleanupTimer = null; + thumbnailDiagnosticsTimer = null; + + // Clearing drops what is queued; what is already running still has to finish + // writing before its directory can go. + await Promise.all([ + thumbnailQueue.onIdle(), + videoThumbnailQueue.onIdle(), + thumbnailRemovalQueue.onIdle(), + thumbnailCacheCleanupPromise?.catch(() => {}), + ]); }; module.exports = { - generateThumbnail, - getThumbnail, + stopThumbnailWork, + // Exported for the tests: the cleanup is reached only through timers, and + // what it decides to delete is worth stating rather than waiting out. + cleanupThumbnailCache, getThumbnailPathIfExists, + isThumbnailCachePath, queueThumbnailGeneration, + getDiagnosticsSnapshot, + scheduleThumbnailRemoval, }; diff --git a/backend/src/services/trash/index.js b/backend/src/services/trash/index.js index 730a02597..c27fbe6ca 100644 --- a/backend/src/services/trash/index.js +++ b/backend/src/services/trash/index.js @@ -25,6 +25,7 @@ const logger = require('../../utils/logger'); const { normalizeRelativePath } = require('../../utils/pathUtils'); const { ACTIONS, authorizeAndResolve, authorizePath } = require('../authorizationService'); const { getDb } = require('../db'); +const { readTextFile } = require('../textEditorService'); const folderSizeManager = require('../folderSizeManager'); const maintenance = require('./maintenance'); const operations = require('./operations'); @@ -430,6 +431,43 @@ const listEntries = async (id, entryPath, context) => { }; }; +/** + * A file in the trash to preview — the item itself, or a file inside a deleted + * folder — for someone who can see the item in their trash. Read only: nothing + * here writes, and nothing outside the item can be reached. + */ +const locateTrashFile = async (id, entryPath, context) => { + const user = requireUser(context); + const normalized = validateEntryPath(entryPath, { allowTop: true }); + const db = await getDb(); + if (!findVisibleFolder(db, id, user)) throw new NotFoundError('This item is not in your trash.'); + + const outcome = await operations.locateFile(id, normalized); + if (outcome.status === 'unavailable') { + throw new ConflictError('The volume this item was deleted from is not available.'); + } + if (outcome.status === 'not-file') throw new ValidationError('This is not a file.'); + if (outcome.status !== 'found') throw new NotFoundError('This file is not in the trash.'); + + return { + absolutePath: outcome.absolutePath, + name: normalized ? path.posix.basename(normalized) : outcome.item.name, + size: outcome.size, + modifiedAt: outcome.modifiedAt, + }; +}; + +/** + * The text of a file in the trash, to read before deciding what to do with it. + * The editor's own limits apply — not too large, not binary — and nothing is + * ever written: this is a look, not an edit. + */ +const readTrashText = async (id, entryPath, context) => { + const file = await locateTrashFile(id, entryPath, context); + const { text } = await readTextFile(file.absolutePath); + return { name: file.name, size: file.size, modifiedAt: file.modifiedAt, content: text }; +}; + /** * Put back entries from inside a deleted folder. Allowed to whoever may restore * the folder itself: an entry goes back inside the folder's own original place, @@ -790,6 +828,7 @@ module.exports = { listItems, restoreItems, listEntries, + readTrashText, restoreEntries, prepareRestoreTo, executeRestoreTo, diff --git a/backend/src/services/trash/operations.js b/backend/src/services/trash/operations.js index fc0b72b82..819a6c128 100644 --- a/backend/src/services/trash/operations.js +++ b/backend/src/services/trash/operations.js @@ -942,6 +942,38 @@ const restoreEntry = async ( } }; +/** + * A file in the trash to read, for a preview: the item itself when it is a + * file, or a file inside a deleted folder. Only a regular file, reached + * through real directories; a symbolic link is never opened. + * + * @returns {Promise<{status: 'found', item: object, absolutePath: string, size: number, + * modifiedAt: string} | {status: 'missing'|'invalid-path'|'not-file'} | + * {status: 'unavailable', reason: string}>} + */ +const locateFile = async (itemId, entryPath = '') => { + const segments = entrySegments(entryPath); + if (!segments) return { status: 'invalid-path' }; + const db = await getDb(); + const item = store.getItem(db, itemId); + if (!item || !['trashed', 'extracting'].includes(item.state)) return { status: 'missing' }; + + const zone = store.getZone(db, item.zoneId); + const inspection = zone ? await zones.inspectZone(zone) : { reason: 'missing' }; + if (!inspection.available) return { status: 'unavailable', reason: inspection.reason }; + + const found = await walkInside(confinedPaths(zone, item.id).payload, segments); + if (!found) return { status: 'missing' }; + if (!found.stats.isFile()) return { status: 'not-file' }; + return { + status: 'found', + item, + absolutePath: found.absolutePath, + size: found.stats.size, + modifiedAt: found.stats.mtime.toISOString(), + }; +}; + /** What an entry inside a deleted folder is — its kind and size — or null when it is not there. */ const describeEntry = async (itemId, entryPath) => { const segments = entrySegments(entryPath); @@ -1325,6 +1357,7 @@ module.exports = { moveToTrash, restoreItem, listEntries, + locateFile, describeEntry, restoreEntry, purgeItem, diff --git a/backend/src/services/tusUploadService.js b/backend/src/services/tusUploadService.js new file mode 100644 index 000000000..036e707b6 --- /dev/null +++ b/backend/src/services/tusUploadService.js @@ -0,0 +1,933 @@ +const crypto = require('node:crypto'); +const path = require('path'); +const fs = require('fs/promises'); +const fsSync = require('node:fs'); +const { pipeline } = require('node:stream/promises'); + +const { Server } = require('@tus/server'); +const { FileStore } = require('@tus/file-store'); + +const { uploads: uploadConfig } = require('../config'); +const { ensureDir } = require('../utils/fsUtils'); +const { normalizeRelativePath } = require('../utils/pathUtils'); +const { placeWithoutOverwrite } = require('../utils/placeWithoutOverwrite'); +const { ACTIONS, authorizeAndResolve } = require('./authorizationService'); +const { resolveFolderUploadRelativePath } = require('./uploadFolderTargetService'); +const { ensureStorageAvailable } = require('./uploadStorageGuard'); +const { sweepStaleUploadRemnants, UPLOADING_SUFFIX } = require('./uploadRemnants'); +const { getSystemSettings } = require('./settingsService'); +const { InsufficientStorageError } = require('../errors/AppError'); +const logger = require('../utils/logger'); + +const TUS_PATH = '/api/upload/tus'; +const TUS_CACHE_DIR = uploadConfig?.tusUploadDir; +const TUS_INCOMPLETE_UPLOAD_TTL_MS = uploadConfig?.tusIncompleteUploadTtlMs ?? 60 * 60 * 1000; +const CLEANUP_INTERVAL_MS = uploadConfig?.tusCleanupIntervalMs ?? 10 * 60 * 1000; + +let lastCleanupAt = 0; + +// Deliberately without `expirationPeriodInMilliseconds`. Declaring it turns on +// the protocol's expiration extension, and @tus/server then re-reads an upload +// straight after finishing it to work out Upload-Expires — a header it only +// sends for *unfinished* uploads, so the read is pointless yet fatal: an empty +// file completes inside its own creation request, and the read fails on the +// data this hook has just moved to its destination. Expiry is handled by +// cleanupInactiveUploads below, which covers more ground anyway (it also +// reclaims data files whose metadata never made it to disk). +/** + * Built on first use, not when this module is required. + * + * `FileStore` creates its directory in its constructor, which turns requiring + * this file into a filesystem write — one that fails outright wherever the + * cache directory is not there yet, including the check that every module + * loads. A server that has never been asked to take an upload has no business + * creating a cache for one either. + */ +let fileStoreInstance = null; +const store = () => { + if (!fileStoreInstance) fileStoreInstance = new FileStore({ directory: TUS_CACHE_DIR }); + return fileStoreInstance; +}; + +/** + * A metadata value, or '' when the client did not really send one. + * + * Uppy stringifies every field named in `allowedMetaFields`, whether the file + * carries it or not, so a field only folder uploads populate arrives as the + * literal string "undefined" on every other upload. Taken at face value it + * became the name the file was stored under. + */ +const metadataValue = (value) => { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + return trimmed === 'undefined' || trimmed === 'null' ? '' : trimmed; +}; + +const tusError = (statusCode, message) => ({ + status_code: statusCode, + body: `${message}\n`, +}); + +/** + * The shared guard, wearing the shape @tus/server answers with. Its own errors + * are thrown, not returned, so an AppError would leave here as a 500 and the + * client would retry a request that can only fail again — 507 is what tells it + * to stop. + */ +const ensureTusStorageAvailable = async (directory, uploadSize, label) => { + try { + await ensureStorageAvailable(directory, uploadSize, label); + } catch (err) { + if (err instanceof InsufficientStorageError) { + throw tusError(507, err.message); + } + throw err; + } +}; + +const getNodeRequest = (req) => req?.runtime?.node?.req || req?.node?.req || null; + +const getContext = (req) => { + const nodeReq = getNodeRequest(req); + return { + nodeReq, + }; +}; + +// Cache the "is TUS allowed" check briefly so it isn't a fresh DB read on every +// chunk (each PATCH hits onIncomingRequest) — trims per-chunk latency. +let tusEnabledCache = { enabled: null, at: 0 }; +const TUS_ENABLED_TTL_MS = 5000; + +const ensureTusEnabled = async () => { + const now = Date.now(); + if (tusEnabledCache.enabled === null || now - tusEnabledCache.at >= TUS_ENABLED_TTL_MS) { + const settings = await getSystemSettings(); + tusEnabledCache = { enabled: Boolean(settings.uploads?.chunkedEnabled), at: now }; + } + if (!tusEnabledCache.enabled) { + throw tusError(403, 'Chunked uploads are disabled.'); + } +}; + +const safeStat = async (filePath) => { + try { + return await fs.stat(filePath); + } catch (_) { + return null; + } +}; + +const safeReadJson = async (filePath) => { + try { + return JSON.parse(await fs.readFile(filePath, 'utf8')); + } catch (_) { + return null; + } +}; + +const rmIfExists = async (filePath) => { + try { + await fs.rm(filePath, { force: true }); + return true; + } catch (err) { + logger.warn({ filePath, err }, 'Failed to remove stale TUS cache file'); + return false; + } +}; + +const getLastActivityMs = (...stats) => + Math.max( + 0, + ...stats.filter(Boolean).map((statsItem) => Number(statsItem.mtimeMs || statsItem.ctimeMs || 0)) + ); + +/** + * Uploads being moved into place, whatever their age: by onUploadFinish, or by + * a HEAD retrying a move that failed. Each holds the attempt, so a second + * caller waits for it rather than placing the same file twice, and the upload + * it is for. + * + * Age alone does not protect them. The last write refreshes the data file just + * before the hook starts, but a copy to another filesystem can outlast the TTL + * without touching the source again, and @tus/server calls the hook again for + * an empty PATCH at the final offset — so a finished upload that has sat in the + * cache for a day can be moving into place right now. The sweep asks this set + * immediately before each removal, with nothing awaited in between. + */ +const finishing = new Map(); + +const cleanupInactiveUploads = async (now = Date.now()) => { + if (TUS_INCOMPLETE_UPLOAD_TTL_MS <= 0) return 0; + + await ensureDir(TUS_CACHE_DIR); + + let entries; + try { + entries = await fs.readdir(TUS_CACHE_DIR, { withFileTypes: true }); + } catch (err) { + logger.warn({ err }, 'Failed to inspect TUS upload cache'); + return 0; + } + + const fileNames = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name)); + let removedCount = 0; + + for (const entry of entries) { + if (!entry.isFile() || entry.name.endsWith('.json')) continue; + + const dataPath = path.join(TUS_CACHE_DIR, entry.name); + if (fileNames.has(`${entry.name}.json`)) continue; + + const dataStats = await safeStat(dataPath); + if (!dataStats || now - getLastActivityMs(dataStats) < TUS_INCOMPLETE_UPLOAD_TTL_MS) continue; + if (finishing.has(entry.name)) continue; + + if (await rmIfExists(dataPath)) removedCount += 1; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + + const uploadId = entry.name.slice(0, -'.json'.length); + const metadataPath = path.join(TUS_CACHE_DIR, entry.name); + const dataPath = path.join(TUS_CACHE_DIR, uploadId); + const [metadataStats, dataStats, metadata] = await Promise.all([ + safeStat(metadataPath), + safeStat(dataPath), + safeReadJson(metadataPath), + ]); + + const lastActivityMs = getLastActivityMs(metadataStats, dataStats); + if (now - lastActivityMs < TUS_INCOMPLETE_UPLOAD_TTL_MS) continue; + if (finishing.has(uploadId)) continue; + + if (!dataStats) { + if (await rmIfExists(metadataPath)) removedCount += 1; + continue; + } + + // A complete upload still here is one whose move into place failed: the + // hook removes the data by moving it. Nothing else would ever take it + // away, so it goes on the same TTL as an abandoned one — and says so, since + // it is a file someone sent that never arrived. + const expectedSize = Number(metadata?.size); + const isComplete = Number.isFinite(expectedSize) && dataStats.size >= expectedSize; + if (isComplete) { + logger.warn( + { + uploadId, + size: dataStats.size, + destination: metadata?.metadata?.logicalRelativePath || metadata?.metadata?.filename, + }, + 'Removing a finished TUS upload that was never moved into place' + ); + } + + const removed = await Promise.all([rmIfExists(dataPath), rmIfExists(metadataPath)]); + removedCount += removed.filter(Boolean).length; + } + + removedCount += await sweepFinishedRecords(now); + + if (removedCount > 0) { + logger.info({ removedCount }, 'Cleaned stale TUS upload cache files'); + } + + return removedCount; +}; + +let runningSweep = null; + +const cleanupExpiredUploads = async ({ force = false } = {}) => { + if (runningSweep) return runningSweep; + + const now = Date.now(); + if (!force && now - lastCleanupAt < CLEANUP_INTERVAL_MS) return; + lastCleanupAt = now; + + runningSweep = (async () => { + try { + await ensureDir(TUS_CACHE_DIR); + } catch (err) { + logger.warn({ err }, 'Failed to prepare TUS upload cache for cleanup'); + return; + } + + try { + await cleanupInactiveUploads(now); + } catch (err) { + logger.warn({ err }, 'Failed to clean up inactive TUS uploads'); + } + })().finally(() => { + runningSweep = null; + }); + + return runningSweep; +}; + +let sweepTimer = null; +let sweepStarted = false; + +/** + * Sweep the upload cache now and every TUS_CLEANUP_INTERVAL_MS. + * + * Creating an upload sweeps too, but a server nobody uploads to never did + * again after starting, so whatever a failed day left in the cache stayed + * there until the next upload or the next restart. The timer is unref'd: it + * must not keep a stopping process alive. + */ +const startCacheSweep = () => { + if (sweepStarted) return; + sweepStarted = true; + + cleanupExpiredUploads({ force: true }).catch((err) => { + logger.warn({ err }, 'Failed to run initial TUS upload cleanup'); + }); + + if (CLEANUP_INTERVAL_MS > 0) { + sweepTimer = setInterval(() => { + cleanupExpiredUploads({ force: true }).catch((err) => { + logger.warn({ err }, 'Failed to run periodic TUS upload cleanup'); + }); + }, CLEANUP_INTERVAL_MS); + sweepTimer.unref?.(); + } +}; + +/** + * Stop the timer and wait for a sweep in progress, so nothing is still reading + * or recreating the cache directory once this resolves. + */ +const stopCacheSweep = async () => { + if (sweepTimer) clearInterval(sweepTimer); + sweepTimer = null; + sweepStarted = false; + if (runningSweep) await runningSweep.catch(() => {}); +}; + +const resolveTusUploadTarget = async (nodeReq, metadata = {}) => { + const filename = + typeof metadata.filename === 'string' && metadata.filename.trim() + ? metadata.filename.trim() + : 'upload'; + const uploadTo = normalizeRelativePath(metadataValue(metadata.uploadTo)); + const resolvedRelativePath = metadataValue(metadata.resolvedRelativePath); + const requestedRelativePath = + normalizeRelativePath( + resolvedRelativePath || metadataValue(metadata.relativePath) || filename + ) || path.basename(filename); + + const context = { user: nodeReq?.user, guestSession: nodeReq?.guestSession }; + const { allowed, accessInfo, resolved } = await authorizeAndResolve( + context, + uploadTo, + ACTIONS.upload + ); + if (!allowed || !resolved) { + throw tusError(403, accessInfo?.denialReason || 'Cannot upload files to this path.'); + } + + const { absolutePath: destinationRoot, relativePath: logicalBase } = resolved; + const relativePath = resolvedRelativePath + ? requestedRelativePath + : await resolveFolderUploadRelativePath({ + relativePath: requestedRelativePath, + destinationRoot, + context, + uploadBatchId: metadataValue(metadata.uploadBatchId) || undefined, + }); + const destinationPath = path.join(destinationRoot, relativePath); + const destinationDir = path.dirname(destinationPath); + const logicalRelativePath = normalizeRelativePath(path.join(logicalBase, relativePath)); + const relDestDir = normalizeRelativePath(path.dirname(logicalRelativePath)); + + // As the direct upload: a file may not land at the top, where a folder is a + // mount rather than a folder in one. + if (!relDestDir || relDestDir.trim() === '') { + throw tusError( + 400, + 'Cannot upload files to the root path. Please select a specific volume or folder first.' + ); + } + + // Same reason as the direct upload path: the destination the client asked + // for is authorized above, but the folder the file actually lands in comes + // from a client-supplied relative path and must be authorized as well. + if (relDestDir !== normalizeRelativePath(logicalBase)) { + const { allowed: destAllowed, accessInfo: destAccess } = await authorizeAndResolve( + context, + relDestDir, + ACTIONS.upload + ); + if (!destAllowed) { + throw tusError(403, destAccess?.denialReason || 'Cannot upload files to this path.'); + } + } + + return { + uploadTo, + relativePath, + destinationPath, + destinationDir, + logicalBase, + logicalRelativePath, + }; +}; + +const validateExistingUploadAccess = async (req, uploadId) => { + if (!uploadId) return; + + const { nodeReq } = getContext(req); + if (!nodeReq?.user && !nodeReq?.guestSession) { + throw tusError(401, 'Authentication required.'); + } + + const upload = await store().getUpload(uploadId); + await resolveTusUploadTarget(nodeReq, upload.metadata || {}); +}; + +/** + * Uploads whose last byte has arrived but whose file has not reached its + * destination yet. + * + * Chunks are assembled in the cache directory and the finished file is moved + * into place, which is instant on one filesystem and a byte-for-byte copy + * across two — unavoidable here, since the volumes a user can upload to are + * separate mounts. The client has finished sending by then, so its own progress + * bar has nothing left to report and sits at 100% for as long as the copy runs. + * On a multi-gigabyte file that reads as a freeze. These entries are what the + * client polls to show the copy actually moving. + */ +const finalizations = new Map(); + +const ownerOf = (nodeReq) => nodeReq?.user?.id || nodeReq?.guestSession?.id || null; + +const copyWithProgress = async (source, destination, onProgress) => { + const readStream = fsSync.createReadStream(source); + let copiedBytes = 0; + + readStream.on('data', (chunk) => { + copiedBytes += chunk.length; + onProgress(copiedBytes); + }); + + await pipeline(readStream, fsSync.createWriteStream(destination)); +}; + +/** + * Move a finished upload into `directory` under `desiredName`, or the first + * free name after it, and answer the name and path it took. + * + * Nothing already holding the name is ever replaced. A name chosen beforehand + * was free when it was chosen, not when the file arrived: whatever came under + * it in between, another upload, a copy, a file saved over SMB, was replaced by + * the rename at the end, which replaces a file silently. + */ +const moveFile = async (source, directory, desiredName, onProgress) => { + // One filesystem: the cache file is linked under the name, which fails when + // the name is taken and moves on to "name (1).ext". + try { + return await placeWithoutOverwrite(source, directory, desiredName); + } catch (err) { + if (err?.code !== 'EXDEV') { + throw err; + } + } + + // Different filesystems: the bytes have to be read and written again. They + // are written beside the destination under a hidden `.uploading` name and + // only a whole file takes the real one, so a copy that fails, or a process + // killed halfway, never leaves a truncated file where the user will open it. + // The name does not derive from the file's own, which could then exceed the + // filesystem's limit where the real name does not; the listing hides it, and + // the remnant sweep recognises it where uploads land. + const temporary = path.join( + directory, + `.upload-${crypto.randomBytes(8).toString('hex')}${UPLOADING_SUFFIX}` + ); + + let placed; + try { + await copyWithProgress(source, temporary, onProgress); + + // A long copy holds no name while it runs, so the name is taken only now, + // the same way: whatever arrived under it meanwhile is kept. + placed = await placeWithoutOverwrite(temporary, directory, desiredName); + } catch (err) { + try { + await fs.rm(temporary, { force: true }); + } catch (cleanupErr) { + logger.warn( + { temporary, err: cleanupErr }, + 'Failed to remove a partial copy of a TUS upload' + ); + } + throw err; + } + + try { + await fs.unlink(source); + } catch (err) { + // The file is in its folder. Failing now would report it as never having + // arrived, and a retry would place it a second time; the copy left in the + // cache loses its metadata below, and the sweep removes it. + logger.warn({ source, err }, 'A TUS upload was placed, but its cache copy stayed'); + } + return placed; +}; + +/** What is still being written to its destination, for one user. */ +const listFinalizations = (nodeReq) => { + const owner = ownerOf(nodeReq); + if (!owner) return []; + + return [...finalizations.values()] + .filter((entry) => entry.owner === owner) + .map(({ name, copiedBytes, totalBytes }) => ({ name, copiedBytes, totalBytes })); +}; + +/** + * What the client is told when a finished upload could not be moved into its + * folder: the file arrived, and why it is not where it was sent. + * + * Thrown, the failure became a 500 with a generic body, which the client + * retried. A retry asks for the offset first, @tus/server answers it from the + * cache, where the upload is complete, and tus-js-client then reported a file + * that never arrived as uploaded, without another request. The reason travels + * in a header as well as the body, since the client only reads headers, and + * its presence is what tells the client not to retry. + */ +const FINALIZE_ERROR_HEADER = 'Upload-Finalize-Error'; + +const STORAGE_FULL_CODES = new Set(['ENOSPC', 'EDQUOT']); + +// Worded for the person reading it, never the raw message: those carry the +// server's own paths. +const FAILURE_REASONS = { + EACCES: 'the server is not allowed to write there', + EPERM: 'the server is not allowed to write there', + EROFS: 'the volume is read-only', + ENOENT: 'the folder, or the received file, is no longer there', + ENOTDIR: 'the folder is no longer there', + ENAMETOOLONG: 'the name is too long for that volume', + EEXIST: 'no free name is left for it in that folder', + EFBIG: 'the file is too large for that volume', + EIO: 'the volume reported a read or write error', +}; + +const describeFinalizeFailure = (err) => { + if (err instanceof InsufficientStorageError || STORAGE_FULL_CODES.has(err?.code)) { + return { storageFull: true, reason: 'there is not enough space left on the volume' }; + } + if (typeof err?.code === 'string' && FAILURE_REASONS[err.code]) { + return { storageFull: false, reason: FAILURE_REASONS[err.code] }; + } + // A refusal already worded for the person: this module's own, or an + // application error. + const worded = typeof err?.body === 'string' ? err.body : err?.isOperational ? err.message : ''; + return { + storageFull: false, + reason: + String(worded || '') + .trim() + .replace(/\.$/, '') || 'the server ran into an unexpected error', + }; +}; + +const finalizeFailure = (err) => { + const { storageFull, reason } = describeFinalizeFailure(err); + const message = `The file was received, but it could not be put in its folder: ${reason}.`; + return { + status_code: storageFull ? 507 : 500, + body: `${message}\n`, + headers: { [FINALIZE_ERROR_HEADER]: encodeURIComponent(message) }, + }; +}; + +/** + * Uploads placed a moment ago, so a client asking for their offset afterwards + * hears that they are complete. + * + * Once placed, an upload's cache entry is gone and @tus/server answers a HEAD + * with 404, which tus-js-client takes as an upload to start over: a PATCH + * whose response was lost during a long copy was retried, and the whole file + * sent again and placed a second time. Kept as long as an unfinished upload is, + * and for a bounded number of uploads. + */ +const FINISHED_MEMORY_MS = + TUS_INCOMPLETE_UPLOAD_TTL_MS > 0 ? TUS_INCOMPLETE_UPLOAD_TTL_MS : 60 * 60 * 1000; +const FINISHED_MEMORY_LIMIT = 1000; +const finished = new Map(); + +const rememberFinished = (uploadId, entry) => { + finished.delete(uploadId); + finished.set(uploadId, { ...entry, at: Date.now() }); + // A Map iterates in insertion order: the first key is the oldest. + while (finished.size > FINISHED_MEMORY_LIMIT) { + finished.delete(finished.keys().next().value); + } +}; + +const recallFinished = (uploadId) => { + const entry = finished.get(uploadId); + if (!entry) return null; + if (Date.now() - entry.at > FINISHED_MEMORY_MS) { + finished.delete(uploadId); + return null; + } + return entry; +}; + +/** + * The same, kept on disk beside the cache, for as long as the memory above. + * + * The memory goes with the process: a client asking for the offset of an + * upload placed just before a restart got 404, and tus-js-client sent the + * whole file again, into "name (1)". One small record per placed upload, read + * only when the memory has nothing, and removed by the cache sweep. + */ +const FINISHED_RECORDS_DIR = TUS_CACHE_DIR ? path.join(TUS_CACHE_DIR, '.finished') : null; +const finishedRecordPath = (uploadId) => path.join(FINISHED_RECORDS_DIR, `${uploadId}.json`); + +const recordFinished = async (uploadId, entry) => { + if (!FINISHED_RECORDS_DIR) return; + try { + await ensureDir(FINISHED_RECORDS_DIR); + await fs.writeFile(finishedRecordPath(uploadId), JSON.stringify({ ...entry, at: Date.now() })); + } catch (err) { + logger.debug({ err, uploadId }, 'A placed TUS upload could not be recorded on disk'); + } +}; + +const readFinishedRecord = async (uploadId) => { + if (!FINISHED_RECORDS_DIR) return null; + try { + const entry = JSON.parse(await fs.readFile(finishedRecordPath(uploadId), 'utf8')); + if (!Number.isFinite(entry?.at) || Date.now() - entry.at > FINISHED_MEMORY_MS) return null; + return entry; + } catch { + return null; + } +}; + +const sweepFinishedRecords = async (now = Date.now()) => { + if (!FINISHED_RECORDS_DIR) return 0; + let names; + try { + names = await fs.readdir(FINISHED_RECORDS_DIR); + } catch { + return 0; + } + let removed = 0; + for (const name of names) { + if (!name.endsWith('.json')) continue; + const file = path.join(FINISHED_RECORDS_DIR, name); + const stats = await safeStat(file); + if (!stats || now - stats.mtimeMs < FINISHED_MEMORY_MS) continue; + if (await rmIfExists(file)) removed += 1; + } + return removed; +}; + +const finalizeUpload = async (nodeReq, upload) => { + const target = await resolveTusUploadTarget(nodeReq, upload.metadata || {}); + const sourcePath = upload.storage?.path || path.join(TUS_CACHE_DIR, upload.id); + const desiredName = path.basename(target.destinationPath); + const totalBytes = Number.isFinite(upload.size) ? upload.size : 0; + const owner = ownerOf(nodeReq); + + await ensureDir(target.destinationDir); + + // Named as the client knows the file, which is how it finds the entry. The + // name the file ends up under is taken once its bytes are in place, and the + // entry goes away then. + finalizations.set(upload.id, { name: desiredName, copiedBytes: 0, totalBytes, owner }); + + let placed; + try { + placed = await moveFile(sourcePath, target.destinationDir, desiredName, (copiedBytes) => { + const entry = finalizations.get(upload.id); + if (entry) entry.copiedBytes = copiedBytes; + }); + } finally { + finalizations.delete(upload.id); + } + + try { + await store().configstore.delete(upload.id); + } catch (err) { + logger.warn({ uploadId: upload.id, err }, 'Failed to remove TUS upload metadata'); + } + + const result = { name: placed.name, path: placed.path, size: totalBytes, owner }; + rememberFinished(upload.id, result); + await recordFinished(upload.id, result); + return result; +}; + +/** + * A move that keeps failing is logged as an error once for each reason, and + * again for the same reason only at debug: every HEAD a client retries with + * would otherwise write the same error line. + */ +const REPORTED_FAILURES_LIMIT = 1000; +const reportedFailures = new Map(); + +const reportFinalizeFailure = (uploadId, err) => { + const reason = err?.code || err?.name || 'unknown'; + if (reportedFailures.get(uploadId) === reason) { + logger.debug( + { uploadId, err }, + 'A finished TUS upload still could not be moved into its folder' + ); + return; + } + reportedFailures.delete(uploadId); + reportedFailures.set(uploadId, reason); + while (reportedFailures.size > REPORTED_FAILURES_LIMIT) { + reportedFailures.delete(reportedFailures.keys().next().value); + } + logger.error({ uploadId, err }, 'A finished TUS upload could not be moved into its folder'); +}; + +/** + * Move a finished upload into place once, however many ask: a caller arriving + * while it moves waits for that attempt, and one arriving after it succeeded + * gets its result. + */ +const finalizeOnce = (nodeReq, upload) => { + const running = finishing.get(upload.id); + if (running) return running.promise; + const done = recallFinished(upload.id); + if (done) return Promise.resolve(done); + + // Registered before anything is awaited, and until the metadata is gone too: + // the cache sweep leaves an upload alone for as long as it is in `finishing`. + const promise = finalizeUpload(nodeReq, upload) + .then((result) => { + reportedFailures.delete(upload.id); + return result; + }) + .catch((err) => { + reportFinalizeFailure(upload.id, err); + throw err; + }) + .finally(() => { + finishing.delete(upload.id); + }); + finishing.set(upload.id, { promise, upload }); + return promise; +}; + +const TUS_RESUMABLE = '1.0.0'; + +const EXPOSED_HEADERS = [ + 'Location', + 'Tus-Resumable', + 'Upload-Length', + 'Upload-Offset', + 'Upload-Metadata', + 'Upload-Expires', + // Read by the client from a failed PATCH or HEAD; a cross-origin client + // cannot see a header that is not exposed. + FINALIZE_ERROR_HEADER, +]; + +/** + * Same reason as the store below it: the server owns the store, so building it + * eagerly would build the store eagerly too. + */ +let serverInstance = null; +const tusServer = () => { + if (!serverInstance) serverInstance = buildServer(); + return serverInstance; +}; + +const buildServer = () => + new Server({ + path: TUS_PATH, + datastore: store(), + relativeLocation: false, + respectForwardedHeaders: true, + allowedCredentials: true, + allowedHeaders: [ + 'Authorization', + 'Content-Type', + 'Upload-Length', + 'Upload-Metadata', + 'Upload-Offset', + 'Tus-Resumable', + ], + exposedHeaders: EXPOSED_HEADERS, + async onIncomingRequest(req, uploadId) { + if (req.method === 'OPTIONS') { + return; + } + + await ensureTusEnabled(); + + const { nodeReq } = getContext(req); + if (!nodeReq?.user && !nodeReq?.guestSession) { + throw tusError(401, 'Authentication required.'); + } + + if (req.method !== 'POST') { + await validateExistingUploadAccess(req, uploadId); + } + }, + async onUploadCreate(req, upload) { + await cleanupExpiredUploads(); + + const { nodeReq } = getContext(req); + const target = await resolveTusUploadTarget(nodeReq, upload.metadata || {}); + const uploadSize = Number.isFinite(upload.size) ? upload.size : null; + + // What a copy killed halfway left in the destination, as a direct upload + // does before the same check: what it removes is space about to be measured. + await sweepStaleUploadRemnants(target.destinationDir); + + await ensureTusStorageAvailable(TUS_CACHE_DIR, uploadSize, 'temporary upload storage'); + await ensureTusStorageAvailable(target.destinationDir, uploadSize, 'destination storage'); + + return { + metadata: { + ...(upload.metadata || {}), + uploadTo: target.uploadTo, + relativePath: target.relativePath, + resolvedRelativePath: target.relativePath, + logicalBase: target.logicalBase, + logicalRelativePath: target.logicalRelativePath, + }, + }; + }, + async onUploadFinish(req, upload) { + const { nodeReq } = getContext(req); + try { + await finalizeOnce(nodeReq, upload); + return {}; + } catch (err) { + // Answered rather than thrown: see finalizeFailure. + return finalizeFailure(err); + } + }, + onResponseError(req, err) { + logger.warn({ err, method: req.method, url: req.url }, 'TUS upload request failed'); + }, + }); + +// The upload's id, the last segment after the TUS path — wherever the app is +// mounted, as @tus/server itself reads it. +const UPLOAD_ID_PATTERN = new RegExp(`${TUS_PATH}/([A-Za-z0-9_-]+)/?$`); + +const uploadIdFromRequest = (req) => { + const pathname = String(req.originalUrl || req.url || '').split('?')[0]; + return UPLOAD_ID_PATTERN.exec(pathname)?.[1] || null; +}; + +const answerHead = (req, res, status, headers) => { + res.writeHead(status, { + 'Tus-Resumable': TUS_RESUMABLE, + 'Cache-Control': 'no-store', + 'Access-Control-Allow-Origin': tusServer().getCorsOrigin(req.headers.origin), + 'Access-Control-Expose-Headers': EXPOSED_HEADERS.join(', '), + 'Access-Control-Allow-Credentials': 'true', + ...headers, + }); + res.end(); +}; + +const answerComplete = (req, res, size) => + answerHead(req, res, 200, { 'Upload-Offset': String(size), 'Upload-Length': String(size) }); + +/** + * 423, not the 500 or 507 a PATCH answers with. tus-js-client takes any other + * refusal of a HEAD as an upload that no longer exists and silently creates a + * new one, sending the whole file again; a locked upload is the one it reports + * as an error, which is where the client reads the header. + */ +const answerFinalizeFailure = (req, res, err) => { + answerHead(req, res, 423, finalizeFailure(err).headers); +}; + +/** + * Answer a client asking for the offset of an upload whose bytes have all + * arrived, and answer it with the truth about the file, not the cache. + * + * - Placed a moment ago: complete, where @tus/server would answer 404 and the + * client would send the whole file again. + * - Being placed right now: complete once that attempt succeeds. + * - Complete in the cache and not moving, because the move failed or the + * server restarted: the move is tried again, and the answer is complete only + * if it succeeds. @tus/server would answer complete from the cache, and the + * client would report the upload as done without another request. + * + * Anything else, and anything the usual gate refuses, is left to @tus/server. + * Answers whether it answered. + */ +const answerFinishedUploadHead = async (req, res) => { + const uploadId = uploadIdFromRequest(req); + if (!uploadId || !req.headers['tus-resumable']) return false; + + try { + await ensureTusEnabled(); + } catch { + return false; + } + const owner = ownerOf(req); + if (!owner) return false; + + const done = recallFinished(uploadId) || (await readFinishedRecord(uploadId)); + if (done) { + if (done.owner !== owner) return false; + answerComplete(req, res, done.size); + return true; + } + + let upload = finishing.get(uploadId)?.upload; + if (!upload) { + try { + upload = await store().getUpload(uploadId); + } catch { + return false; + } + if (!Number.isFinite(upload.size) || upload.offset !== upload.size) return false; + } + + // Authorised as the PATCH was, with this request's user: the move below + // resolves the folder with the same user again. + try { + await resolveTusUploadTarget(req, upload.metadata || {}); + } catch { + return false; + } + + try { + const result = await finalizeOnce(req, upload); + answerComplete(req, res, result.size); + } catch (err) { + answerFinalizeFailure(req, res, err); + } + return true; +}; + +const handleTusUpload = async (req, res) => { + if (req.method === 'HEAD' && (await answerFinishedUploadHead(req, res))) return; + + const routerUrl = req.url; + req.url = req.originalUrl || req.url; + try { + await tusServer().handle(req, res); + } finally { + req.url = routerUrl; + } +}; + +module.exports = { + handleTusUpload, + listFinalizations, + cleanupExpiredUploads, + cleanupInactiveUploads, + startCacheSweep, + stopCacheSweep, +}; diff --git a/backend/src/services/uploadFolderTargetService.js b/backend/src/services/uploadFolderTargetService.js new file mode 100644 index 000000000..32d0e6361 --- /dev/null +++ b/backend/src/services/uploadFolderTargetService.js @@ -0,0 +1,178 @@ +const path = require('path'); +const fs = require('fs/promises'); + +const { ensureDir } = require('../utils/fsUtils'); +const { normalizeRelativePath } = require('../utils/pathUtils'); +const { ACTIONS, authorizeAndResolve } = require('./authorizationService'); +const { ForbiddenError, ValidationError } = require('../errors/AppError'); + +const FOLDER_BATCH_TTL_MS = 6 * 60 * 60 * 1000; +const folderTargets = new Map(); +const reservations = new Map(); + +const getScopeKey = (context = {}) => { + if (context.user?.id) return `user:${context.user.id}`; + if (context.guestSession?.id) return `guest:${context.guestSession.id}`; + return 'anonymous'; +}; + +const validBatchId = (value) => typeof value === 'string' && /^[a-zA-Z0-9_-]{8,128}$/.test(value); + +const cleanExpiredTargets = (now = Date.now()) => { + for (const [key, entry] of folderTargets) { + if (now - entry.updatedAt > FOLDER_BATCH_TTL_MS) folderTargets.delete(key); + } +}; + +const withReservation = async (key, work) => { + const previous = reservations.get(key) || Promise.resolve(); + let release; + const pending = new Promise((resolve) => { + release = resolve; + }); + const chain = previous.then(() => pending); + reservations.set(key, chain); + + await previous; + try { + return await work(); + } finally { + release(); + if (reservations.get(key) === chain) reservations.delete(key); + } +}; + +const normalizeFolderRoot = (value) => { + const normalized = normalizeRelativePath(value); + if (!normalized || normalized.includes(path.sep) || normalized.includes('/')) { + throw new ValidationError('A single top-level folder name is required.'); + } + return normalized; +}; + +const nextFolderCandidate = (sourceRoot, counter) => + counter === 0 ? sourceRoot : `${sourceRoot} (${counter})`; + +// Far past any real destination; a bound so that a name which can never be +// taken ends in an error rather than a loop. +const MAX_FOLDER_CANDIDATES = 100000; + +/** + * Refuse a folder while it is still a name, rather than once it is on disk. + * + * The reservation below is the `mkdir` itself, and the folder a file lands in + * used to be authorized only afterwards, on its way in: an upload refused then + * had already left an empty folder behind, under a name an administrator had + * hidden or made read-only. `.nextexplorer` is the worst of them — the name the + * zone holding deleted files takes, which no path may go through, so a folder + * created under it cannot be reached again to be removed. + * + * @param {string} options.logicalBase the authorized destination, as a logical path + * @param {string} options.candidate the single folder name about to be created in it + */ +const assertCandidateAllowed = async ({ logicalBase, candidate, context }) => { + const logicalPath = normalizeRelativePath(path.posix.join(logicalBase || '', candidate)); + const { allowed, accessInfo } = await authorizeAndResolve(context, logicalPath, ACTIONS.upload); + if (!allowed) { + throw new ForbiddenError(accessInfo?.denialReason || 'Cannot upload files to this path.'); + } +}; + +// `mkdir` is the actual reservation: unlike a check-then-create sequence, it +// stays correct when several browser tabs or application instances start the +// same folder upload at the same time. Each candidate is authorized before it +// is attempted, so a name that is refused is never created and then given back. +const reserveFolderCandidate = async ({ destinationRoot, logicalBase, sourceRoot, context }) => { + for (let counter = 0; counter < MAX_FOLDER_CANDIDATES; counter += 1) { + const targetRoot = nextFolderCandidate(sourceRoot, counter); + await assertCandidateAllowed({ logicalBase, candidate: targetRoot, context }); + try { + await fs.mkdir(path.join(destinationRoot, targetRoot)); + return targetRoot; + } catch (err) { + if (err?.code === 'EEXIST') continue; + throw err; + } + } + throw new ValidationError('Could not reserve a unique folder name.'); +}; + +const reserveFolderTarget = async ({ destinationRoot, logicalBase, sourceRoot, context }) => { + const scopeKey = getScopeKey(context); + const reservationKey = `${scopeKey}\u0000${destinationRoot}\u0000${sourceRoot}`; + + return withReservation(reservationKey, () => + reserveFolderCandidate({ destinationRoot, logicalBase, sourceRoot, context }) + ); +}; + +// A folder picker may start dozens of parallel HTTP uploads. Reserve its +// destination before queuing any file and return the final root name. Every +// request then carries the already-resolved relative path, so the outcome does +// not depend on multipart ordering, request affinity, or an in-memory cache. +const reserveFolderUploadTarget = async ({ destinationRoot, logicalBase, sourceRoot, context }) => { + const normalizedRoot = normalizeFolderRoot(sourceRoot); + return reserveFolderTarget({ + destinationRoot, + logicalBase, + sourceRoot: normalizedRoot, + context, + }); +}; + +// A folder picker submits one HTTP request per file. Reserve its top-level +// directory once per client batch so a repeated folder upload becomes +// "folder (1)" instead of merging files into the existing folder. +const resolveFolderUploadRelativePath = async ({ + relativePath, + destinationRoot, + logicalBase, + context, + uploadBatchId, +}) => { + const normalized = normalizeRelativePath(relativePath); + const parts = normalized.split('/').filter(Boolean); + if (parts.length < 2) return normalized; + + const sourceRoot = parts[0]; + if (!validBatchId(uploadBatchId)) return normalized; + + cleanExpiredTargets(); + const scopeKey = getScopeKey(context); + const targetKey = `${scopeKey}\u0000${destinationRoot}\u0000${uploadBatchId}\u0000${sourceRoot}`; + const existing = folderTargets.get(targetKey); + if (existing) { + existing.updatedAt = Date.now(); + return path.posix.join(existing.targetRoot, ...parts.slice(1)); + } + + const reservationKey = `${scopeKey}\u0000${destinationRoot}\u0000${sourceRoot}`; + return withReservation(reservationKey, async () => { + const reserved = folderTargets.get(targetKey); + if (reserved) { + reserved.updatedAt = Date.now(); + return path.posix.join(reserved.targetRoot, ...parts.slice(1)); + } + + // The upload's own destination may be created as it always was; the folder + // it receives is not. A recursive mkdir succeeds on a folder already there, + // so looking for a free name and creating it afterwards poured this batch + // into whatever arrived under that name in between — another upload, a copy, + // a folder made over SMB. A plain mkdir fails on a taken name instead, and + // moves on to the next one. + await ensureDir(destinationRoot); + const targetRoot = await reserveFolderCandidate({ + destinationRoot, + logicalBase, + sourceRoot, + context, + }); + folderTargets.set(targetKey, { targetRoot, updatedAt: Date.now() }); + return path.posix.join(targetRoot, ...parts.slice(1)); + }); +}; + +module.exports = { + reserveFolderUploadTarget, + resolveFolderUploadRelativePath, +}; diff --git a/backend/src/services/uploadRemnants.js b/backend/src/services/uploadRemnants.js new file mode 100644 index 000000000..b864d48d0 --- /dev/null +++ b/backend/src/services/uploadRemnants.js @@ -0,0 +1,83 @@ +const fs = require('fs/promises'); +const path = require('path'); + +const logger = require('../utils/logger'); + +/** + * Remove what a killed upload left behind. + * + * `uploadService` writes to a hidden `.upload-.uploading` beside the + * destination and moves it under its real name on success. + * Every failure it can observe cleans up after itself, but nothing survives the + * process being killed: restart the container mid-upload and a half-written + * `holiday.mp4.uploading` stays in the folder for good, with nothing anywhere + * that would ever remove it. The chunked path has `cleanupInactiveUploads` for + * exactly this; the direct one had nothing. + * + * Swept where an upload is about to happen, rather than by walking every volume + * at startup. The remains are in the folders people upload to, and a full walk + * would cost an entire tree on every boot to reach the ones nobody will open + * again — which the `.uploading` hidden-file pattern already keeps out of + * sight. + */ + +const UPLOADING_SUFFIX = '.uploading'; + +/** + * A whole day. + * + * An upload in flight rewrites its temporary file continuously, and a stalled + * one is killed after two minutes of silence, so anything a day old is + * certainly dead several times over. The margin is for the other reading of + * this function: it deletes a file it did not create, on the strength of a + * name, and someone's own `notes.uploading` deserves not to vanish while they + * are away from their desk. + */ +const DEFAULT_STALE_AFTER_MS = 24 * 60 * 60 * 1000; + +/** + * Remove the stale `.uploading` files directly inside `directory`, and answer + * how many went. Never throws: an upload must not fail because the tidying + * before it could not be done. + */ +const sweepStaleUploadRemnants = async ( + directory, + { staleAfterMs = DEFAULT_STALE_AFTER_MS } = {} +) => { + let entries; + try { + entries = await fs.readdir(directory, { withFileTypes: true }); + } catch (err) { + logger.debug({ directory, err }, 'Could not look for the remains of interrupted uploads'); + return 0; + } + + const cutoff = Date.now() - staleAfterMs; + let removed = 0; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(UPLOADING_SUFFIX)) continue; + + const remnant = path.join(directory, entry.name); + try { + const stats = await fs.stat(remnant); + if (stats.mtimeMs > cutoff) continue; + + await fs.rm(remnant, { force: true }); + removed += 1; + logger.info( + { remnant, ageMs: Math.round(Date.now() - stats.mtimeMs) }, + 'Removed the remains of an interrupted upload' + ); + } catch (err) { + logger.debug({ remnant, err }, 'Could not remove the remains of an interrupted upload'); + } + } + + return removed; +}; + +module.exports = { + sweepStaleUploadRemnants, + UPLOADING_SUFFIX, +}; diff --git a/backend/src/services/uploadService.js b/backend/src/services/uploadService.js index fab7664c4..3e7be0d14 100644 --- a/backend/src/services/uploadService.js +++ b/backend/src/services/uploadService.js @@ -11,7 +11,9 @@ const { normalizeRelativePath } = require('../utils/pathUtils'); const { placeWithoutOverwrite } = require('../utils/placeWithoutOverwrite'); const { readMetaField } = require('../utils/requestUtils'); const { ACTIONS, authorizeAndResolve } = require('./authorizationService'); +const { resolveFolderUploadRelativePath } = require('./uploadFolderTargetService'); const { ensureStorageAvailable } = require('./uploadStorageGuard'); +const { sweepStaleUploadRemnants, UPLOADING_SUFFIX } = require('./uploadRemnants'); const { track: trackInFlight } = require('./inFlightFiles'); const { ForbiddenError, ValidationError } = require('../errors/AppError'); const logger = require('../utils/logger'); @@ -40,12 +42,31 @@ const createUploadAbortedError = () => { return error; }; +const createUploadInactiveError = (timeoutMs) => { + const error = new Error(`Upload aborted after ${timeoutMs}ms without receiving data.`); + error.code = 'UPLOAD_INACTIVITY_TIMEOUT'; + return error; +}; + +const readUploadRoutingValue = (req, key) => { + const queryValue = req?.query?.[key]; + if (typeof queryValue === 'string') return queryValue; + return readMetaField(req, key); +}; + const resolveUploadPaths = async (req, file) => { - const relativePathMeta = readMetaField(req, 'relativePath'); - const uploadToMeta = readMetaField(req, 'uploadTo'); + const relativePathMeta = readUploadRoutingValue(req, 'relativePath'); + const resolvedRelativePathMeta = readUploadRoutingValue(req, 'resolvedRelativePath'); + const uploadToMeta = readUploadRoutingValue(req, 'uploadTo'); const uploadTo = normalizeRelativePath(uploadToMeta); - const relativePath = normalizeRelativePath(relativePathMeta) || path.basename(file.originalname); + const requestedRelativePath = + normalizeRelativePath(resolvedRelativePathMeta || relativePathMeta) || + path.basename(file.originalname); + // Multer can enter the storage callback before trailing multipart metadata + // has populated req.body. The client supplies these routing fields in the + // query string too, so every file of a picked folder gets the same target. + const uploadBatchId = readUploadRoutingValue(req, 'uploadBatchId'); const context = { user: req.user, guestSession: req.guestSession }; const { allowed, accessInfo, resolved } = await authorizeAndResolve( @@ -58,6 +79,17 @@ const resolveUploadPaths = async (req, file) => { } const { absolutePath: destinationRoot, relativePath: logicalBase } = resolved; + const relativePath = resolvedRelativePathMeta + ? requestedRelativePath + : await resolveFolderUploadRelativePath({ + relativePath: requestedRelativePath, + destinationRoot, + // The reservation creates the folder the batch lands in, and it is + // authorized by its logical path, not by where it sits on disk. + logicalBase, + context, + uploadBatchId, + }); const destinationPath = path.join(destinationRoot, relativePath); const destinationDir = path.dirname(destinationPath); @@ -70,6 +102,84 @@ const resolveUploadPaths = async (req, file) => { }; }; +/** + * Clear the remains of dead uploads from the destination, then refuse this one + * if what is coming will not fit. + * + * Once per destination, not once per request. Multer hands files over one at a + * time and knows no size in advance, so the only measure of what is coming is + * the request's Content-Length — which covers the whole body. Checking that + * again for each later file going to the *same* folder would weigh the whole + * body against the space left after the earlier ones had landed, and refuse an + * upload that fits. + * + * A folder this request has not written to yet is a different matter, and used + * to be missed entirely: each file carries its own relative path, so one + * request can reach several folders, and on a machine with more than one disk + * that is several disks. The second one had its free space never measured and + * its dead uploads never swept — and the answer for it is the first one's + * reasoning, unchanged: nothing of this request has landed there either. + * + * The sweep comes first because what it removes is space the check is about to + * measure. + */ +const REQUEST_PREPARED = Symbol('uploadDestinationsPrepared'); + +const prepareDestinationOnce = async (req, destinationDir) => { + const prepared = (req[REQUEST_PREPARED] ??= new Set()); + if (prepared.has(destinationDir)) return; + prepared.add(destinationDir); + + await sweepStaleUploadRemnants(destinationDir); + + // A request that announces no size — chunked, which is what an API client + // sending a stream does — used to skip the check altogether: the guard takes + // a number and was handed nothing, so an upload could fill a volume that was + // already past its reserve, on a machine where a full volume takes the + // database down with it. Zero is what is honestly known about what is + // coming, and it still holds the reserve itself free. + const declaredBytes = Number(req.headers?.['content-length']); + await ensureStorageAvailable( + destinationDir, + Number.isFinite(declaredBytes) ? declaredBytes : 0, + 'destination storage' + ); +}; + +/** + * Remove the folders this upload created, while they are still empty. + * + * `mkdir` with `recursive` answers the topmost folder it had to create, so + * what lies between that and the destination is exactly what this file added. + * A refusal after that point — no space left, a file over the size limit, a + * client that went away — used to leave them behind: empty folders an upload + * invented, in somebody's tree, with nothing to say where they came from. + * + * Deepest first, and each one only if nothing is in it. Another file of the + * same request may have landed in the very folder this one created, so the + * guard is `rmdir` refusing a folder that is not empty rather than a check of + * our own, which could be out of date by the time it is acted on. + */ +const removeEmptyCreatedDirectories = async (deepestPath, topmostCreated) => { + if (!topmostCreated) return; + + let current = deepestPath; + for (;;) { + try { + await fs.rmdir(current); + } catch (error) { + // Already gone: whatever removed it may have left its parents, which are + // as much ours as it was. Anything else — a folder somebody has put a + // file in, a permission — is where this stops. + if (error?.code !== 'ENOENT') return; + } + if (current === topmostCreated) return; + const parent = path.dirname(current); + if (parent === current) return; + current = parent; + } +}; + function CustomStorage() { // Custom multer storage engine for handling file uploads with: // - Access control checks @@ -78,44 +188,54 @@ function CustomStorage() { } CustomStorage.prototype._handleFile = function handleFile(req, file, cb) { - // Recorded while the bytes arrive, released however the upload ends: a stop - // half-way leaves the record, and the next start removes the hidden file. - let inFlight = null; - const finish = (...args) => { - inFlight?.release(); - cb(...args); - }; (async () => { + // What this file had to create to have somewhere to land, and where it was + // going: enough to undo it if the upload is refused after this point. + let createdDirectoryRoot = null; + let preparedDestinationDir = null; + // Recorded while the bytes arrive, released however the upload ends: a stop + // half-way leaves the record, and the sweep at the next start removes the + // hidden file it names. + let inFlight = null; + const fail = async (error) => { + inFlight?.release(); + await removeEmptyCreatedDirectories(preparedDestinationDir, createdDirectoryRoot); + cb(error); + }; + try { - const { destinationPath, destinationDir, logicalRelativePath } = await resolveUploadPaths( - req, - file - ); + const { destinationPath, destinationDir, logicalRelativePath, logicalBase } = + await resolveUploadPaths(req, file); - // Enforce access control: destination directory must be writable const relDestDir = normalizeRelativePath(path.dirname(logicalRelativePath)); - // Prevent uploading directly to the root path (no space / volume selected) + // Prevent uploading directly to the root path (no space / volume + // selected). if (!relDestDir || relDestDir.trim() === '') { throw new ValidationError( 'Cannot upload files to the root path. Please select a specific volume or folder first.' ); } - await ensureDir(destinationDir); + // The authorization above covers the chosen destination; the file also + // carries a client-supplied relative path, so the folder it actually + // lands in has to be authorized too. Otherwise a subfolder an admin + // marked read-only or hidden would still accept uploads. + if (relDestDir !== normalizeRelativePath(logicalBase)) { + const context = { user: req.user, guestSession: req.guestSession }; + const { allowed: destAllowed, accessInfo: destAccess } = await authorizeAndResolve( + context, + relDestDir, + ACTIONS.upload + ); + if (!destAllowed) { + throw new ForbiddenError(destAccess?.denialReason || 'Cannot upload files to this path.'); + } + } - // Before a byte is written: an upload that cannot fit is refused rather - // than filling the volume with itself. What is coming is only known from - // the request's own declaration, and a client that declares nothing is - // still held to the reserve — which is the number that matters, since a - // volume filled to the last byte takes the database down with it where - // /config sits on the same filesystem. - const declaredBytes = Number(req.headers?.['content-length']); - await ensureStorageAvailable( - destinationDir, - Number.isFinite(declaredBytes) ? declaredBytes : 0, - 'destination storage' - ); + createdDirectoryRoot = (await ensureDir(destinationDir)) || null; + preparedDestinationDir = destinationDir; + await prepareDestinationOnce(req, destinationDir); // The bytes go to a hidden name of their own beside the destination, and // the real name is only taken once they are all there. Choosing that name @@ -124,12 +244,12 @@ CustomStorage.prototype._handleFile = function handleFile(req, file, cb) { // SMB — was replaced by the rename at the end, and two uploads of the same // name wrote into the same temporary file. The temporary name is random, // so it never collides and never derives from a name that could exceed - // the filesystem's limit, and it starts with a dot, which the listing - // hides unless hidden files are shown. + // the filesystem's limit, and it still ends in `.uploading`, which the + // listing hides and the remnant sweep recognises. const desiredName = path.basename(destinationPath); const temporaryPath = path.join( destinationDir, - `.upload-${crypto.randomBytes(8).toString('hex')}.uploading` + `.upload-${crypto.randomBytes(8).toString('hex')}${UPLOADING_SUFFIX}` ); inFlight = trackInFlight(temporaryPath, 'partial-upload'); @@ -158,11 +278,20 @@ CustomStorage.prototype._handleFile = function handleFile(req, file, cb) { let uploadAborted = false; let uploadFinished = false; let abortError = null; + let inactivityTimer = null; + const inactivityTimeoutMs = uploads?.inactivityTimeoutMs ?? 120000; + + const clearInactivityTimer = () => { + if (!inactivityTimer) return; + clearTimeout(inactivityTimer); + inactivityTimer = null; + }; - const handleAbort = () => { + const handleAbort = (error = createUploadAbortedError()) => { if (uploadFinished || uploadAborted) return; uploadAborted = true; - abortError = createUploadAbortedError(); + abortError = error instanceof Error ? error : createUploadAbortedError(); + clearInactivityTimer(); try { file.stream.unpipe(outStream); } catch (_) { @@ -172,6 +301,15 @@ CustomStorage.prototype._handleFile = function handleFile(req, file, cb) { destroyStream(outStream, abortError); }; + const refreshInactivityTimer = () => { + if (!Number.isFinite(inactivityTimeoutMs) || inactivityTimeoutMs <= 0) return; + clearInactivityTimer(); + inactivityTimer = setTimeout(() => { + handleAbort(createUploadInactiveError(inactivityTimeoutMs)); + }, inactivityTimeoutMs); + inactivityTimer.unref?.(); + }; + const handleClose = () => { if (!req.complete) { handleAbort(); @@ -180,6 +318,8 @@ CustomStorage.prototype._handleFile = function handleFile(req, file, cb) { req.once('aborted', handleAbort); req.once('close', handleClose); + file.stream.on('data', refreshInactivityTimer); + refreshInactivityTimer(); try { await pipeline(file.stream, outStream); @@ -190,19 +330,39 @@ CustomStorage.prototype._handleFile = function handleFile(req, file, cb) { destroyStream(outStream, error); await waitForClosed(outStream); await cleanupTemporary(); - finish(error); + await fail(error); return; } finally { + clearInactivityTimer(); + file.stream.off('data', refreshInactivityTimer); req.off('aborted', handleAbort); req.off('close', handleClose); } + // The parser stops a file at the size limit by ending its stream early, + // so to the storage a truncated file looks like a complete one: it was + // put under the name it asked for, and only removed once the refusal had + // travelled back up through multer. A refused upload must never hold the + // name it asked for, not even for that instant — the name is one another + // upload may be asking for at the same moment, and a listing in between + // answers with a file that is not what it says it is. The refusal is + // multer's own, so the client is told what it was already going to be + // told, with this route's sentence and its 413. + if (file.stream?.truncated) { + const truncatedError = new multer.MulterError('LIMIT_FILE_SIZE', file.fieldname); + await waitForClosed(outStream); + await cleanupTemporary(); + await fail(truncatedError); + return; + } + try { // Taken by an operation that fails when the name is held, moving on to // "name (1).ext" and so on: nothing already there is ever replaced. What // was taken is what the response reports. const placed = await placeWithoutOverwrite(temporaryPath, destinationDir, desiredName); - finish(null, { + inFlight?.release(); + cb(null, { path: placed.path, size: outStream.bytesWritten, filename: placed.name, @@ -211,10 +371,10 @@ CustomStorage.prototype._handleFile = function handleFile(req, file, cb) { } catch (placeErr) { await waitForClosed(outStream); await cleanupTemporary(); - finish(placeErr); + await fail(placeErr); } } catch (uploadError) { - finish(uploadError); + await fail(uploadError); } })(); }; diff --git a/backend/src/services/userSearchService.js b/backend/src/services/userSearchService.js index f8a075af0..7351d540a 100644 --- a/backend/src/services/userSearchService.js +++ b/backend/src/services/userSearchService.js @@ -80,7 +80,38 @@ const searchUsersForMentions = async (query, limit = 10) => { return { Users: users }; }; +/** + * Who can be mentioned in a comment. + * + * ONLYOFFICE asks for the whole list and filters it in the editor as the + * comment is typed, so this answers with names and addresses rather than to a + * query. + */ +const listUsersForMentions = async (limit = 100) => { + try { + const db = await getDb(); + const rows = db + .prepare( + `SELECT id, email, username, display_name + FROM users + ORDER BY display_name ASC, email ASC + LIMIT ?` + ) + .all(limit); + + return rows.map((row) => ({ + id: String(row.id), + name: row.display_name || row.username || row.email || 'Unknown', + email: row.email || '', + })); + } catch (err) { + logger.error({ err }, '[UserSearch] Error listing users for mentions'); + return []; + } +}; + module.exports = { + listUsersForMentions, searchUsersForMentions, searchLocalUsers, }; diff --git a/backend/src/services/users/index.js b/backend/src/services/users/index.js index 8ec1dd450..4e9a262da 100644 --- a/backend/src/services/users/index.js +++ b/backend/src/services/users/index.js @@ -6,6 +6,7 @@ const localAuth = require('./localAuth'); const oidcAuth = require('./oidcAuth'); const requestUser = require('./requestUser'); const management = require('./management'); +const twoFactor = require('./twoFactor'); module.exports = { // User queries @@ -21,6 +22,16 @@ module.exports = { changeLocalPassword: localAuth.changeLocalPassword, setLocalPasswordAdmin: localAuth.setLocalPasswordAdmin, addLocalPassword: localAuth.addLocalPassword, + verifyLocalPassword: localAuth.verifyLocalPassword, + + // A second factor on a local account + beginTwoFactorEnrolment: twoFactor.beginEnrolment, + confirmTwoFactorEnrolment: twoFactor.confirmEnrolment, + disableTwoFactor: twoFactor.disableTwoFactor, + replaceRecoveryCodes: twoFactor.replaceRecoveryCodes, + twoFactorRequired: twoFactor.twoFactorRequired, + twoFactorStatus: twoFactor.twoFactorStatus, + verifySecondFactor: twoFactor.verifySecondFactor, // OIDC authentication getOrCreateOidcUser: oidcAuth.getOrCreateOidcUser, diff --git a/backend/src/services/users/localAuth.js b/backend/src/services/users/localAuth.js index eb19977b7..bf91fc72e 100644 --- a/backend/src/services/users/localAuth.js +++ b/backend/src/services/users/localAuth.js @@ -1,7 +1,7 @@ const bcrypt = require('bcryptjs'); const { getDb } = require('../db'); const logger = require('../../utils/logger'); -const { nowIso, toClientUser, generateId, normalizeEmail } = require('./utils'); +const { nowIso, toClientUser, generateId, normalizeEmail, usernameTaken } = require('./utils'); const { isLocked, incrementFailedAttempts, clearLock, getLock } = require('./lockout'); const { NotFoundError, @@ -11,32 +11,76 @@ const { } = require('../../errors/AppError'); const { ErrorCodes } = require('../../errors/errorCodes'); -// Attempt local login with email + password -const attemptLocalLogin = async ({ email, password }) => { - const normEmail = normalizeEmail(email); +/** + * The account someone means by what they typed, or null. + * + * An email is looked up first: it is unique by schema, so it can never be + * ambiguous. A username is not — the column carries no uniqueness constraint, + * and `createLocalUser` derives one from the local part of the address, so two + * people on different domains genuinely can end up as `alice`. + * + * Where a name matches more than one account it identifies nobody, and picking + * one would be choosing whose account a stranger signs into. Those accounts + * keep their email, which is unambiguous by construction. + */ +const findUserByIdentifier = (db, typed) => { + const trimmed = typeof typed === 'string' ? typed.trim() : ''; + if (!trimmed) return null; + + const byEmail = db.prepare('SELECT * FROM users WHERE email = ?').get(normalizeEmail(trimmed)); + if (byEmail) return byEmail; + + // Without regard to case, because nobody remembers whether they capitalised + // their own name — and because the column would let `Alice` and `alice` be + // two accounts, which is exactly the ambiguity refused below. + const matches = db + .prepare( + "SELECT * FROM users WHERE username IS NOT NULL AND username <> '' AND lower(username) = lower(?)" + ) + .all(trimmed); - // Check lockout - if (await isLocked(normEmail)) { - const lock = await getLock(normEmail); - const until = lock.locked_until || null; - const err = new Error('Account is temporarily locked due to failed login attempts.'); - err.status = 423; - err.code = ErrorCodes.AUTH_ACCOUNT_LOCKED; - err.until = until; - throw err; + if (matches.length === 1) return matches[0]; + if (matches.length > 1) { + logger.warn( + { username: trimmed, accounts: matches.length }, + 'Several accounts share this username; it cannot be used to sign in. They can use their email address.' + ); } + return null; +}; +/** + * Sign in with an email address or a username, and a password. + * + * @param {{identifier?: string, email?: string, password: string}} credentials + * `email` is accepted as the older name for `identifier`. + */ +const attemptLocalLogin = async ({ identifier, email, password }) => { const db = await getDb(); - // Find user by email - const user = db.prepare('SELECT * FROM users WHERE email = ?').get(normEmail); + const user = findUserByIdentifier(db, identifier ?? email); if (!user) { - // No counter for an address that has no account: the lock is keyed on the - // email alone, so counting here would let anyone lock a colleague out by - // guessing their address. Brute force is bounded by the login rate limit. + // No counter for something that names no account. The lock is per account, + // so counting here would let anyone lock a colleague out by guessing at + // their address. Brute force is bounded by the login rate limit. return null; } + // Keyed on the account rather than on what was typed. One account answering + // to two names would otherwise get one lockout budget per name, and anyone + // alternating between them would never exhaust either. + const lockKey = user.id; + + if (await isLocked(lockKey)) { + const lock = await getLock(lockKey); + const until = lock.locked_until || null; + const err = new Error('Account is temporarily locked due to failed login attempts.'); + err.status = 423; + err.code = ErrorCodes.AUTH_ACCOUNT_LOCKED; + err.until = until; + throw err; + } + // Find local password auth method const authMethod = db .prepare( @@ -48,19 +92,19 @@ const attemptLocalLogin = async ({ email, password }) => { .get(user.id); if (!authMethod || !authMethod.password_hash) { - await incrementFailedAttempts(normEmail); + await incrementFailedAttempts(lockKey); return null; } // Verify password const valid = await bcrypt.compare(password || '', authMethod.password_hash); if (!valid) { - await incrementFailedAttempts(normEmail); + await incrementFailedAttempts(lockKey); return null; } // Success - clear lockout - await clearLock(normEmail); + await clearLock(lockKey); db.prepare('UPDATE auth_methods SET last_used_at = ? WHERE id = ?').run(nowIso(), authMethod.id); let clientUser = toClientUser(user); @@ -70,6 +114,27 @@ const attemptLocalLogin = async ({ email, password }) => { return clientUser; }; +/** + * Whether this is the account's own password. + * + * For the things somebody already signed in may only do by proving it is still + * them — turning a second factor off, drawing new recovery codes. Deliberately + * not counted against the lockout: the account is signed in, the route behind + * it is rate limited, and a counter here would let a tab left open on a shared + * machine lock its owner out. + */ +const verifyLocalPassword = async ({ userId, password }) => { + const db = await getDb(); + const authMethod = db + .prepare( + `SELECT password_hash FROM auth_methods + WHERE user_id = ? AND method_type = 'local_password' AND enabled = 1` + ) + .get(userId); + if (!authMethod?.password_hash) return false; + return bcrypt.compare(password || '', authMethod.password_hash); +}; + // Create user with local password authentication const createLocalUser = async ({ email, password, username, displayName, roles = ['user'] }) => { const db = await getDb(); @@ -87,6 +152,13 @@ const createLocalUser = async ({ email, password, username, displayName, roles = ); } + // A username is something to sign in with, so it has to name one account. + // Nothing removes the duplicates an older version allowed; this stops more + // being made. + if (usernameTaken(db, username)) { + throw new ConflictError('Username already in use', ErrorCodes.CONFLICT_USER_EXISTS); + } + // Check if user exists let user = db.prepare('SELECT * FROM users WHERE email = ?').get(normEmail); @@ -152,8 +224,58 @@ const createLocalUser = async ({ email, password, username, displayName, roles = return toClientUser(user); }; -// Change password for user with local password auth -const changeLocalPassword = async ({ userId, currentPassword, newPassword }) => { +/** + * End the sessions signed in to an account, except the one named. + * + * Changing a password is what someone does when they think it leaked. Left + * alone, a session opened with the old one stays signed in for as long as it + * lasts — thirty days by default — and whoever had the password keeps what it + * opened. + * + * The sessions the identity provider opened count too. They hold its tokens + * rather than our account id, so what names the account there is the subject + * of the id token — the same subject `auth_methods` keeps for this user. The + * store reads the tokens; only here is it known whose they are. + * + * Called before the new hash is written, in the same turn: nothing can sign in + * with the old password between the two, and a store that cannot end the + * sessions throws before the password is changed rather than after. + */ +const endSessionsOpenedWithOldPassword = (db, userId, keepSessionId) => { + // Every OIDC identity of the account, enabled or not: a method switched off + // can still have a session open, and every session ended here belongs to the + // account whose password just changed. + const providerIdentities = db + .prepare( + `SELECT provider_issuer AS issuer, provider_sub AS subject + FROM auth_methods + WHERE user_id = ? AND method_type = 'oidc'` + ) + .all(userId); + + // Required here and not at the top: loading the store opens sessions.db, which + // nothing that only reads accounts should do. + const { localStore } = require('../../utils/sessionStore'); + return localStore.destroyByUser(userId, keepSessionId || null, providerIdentities); +}; + +const logEndedSessions = (userId, ended) => { + if (ended > 0) { + logger.info( + { userId, sessions: ended }, + 'Password changed; other sessions of the account ended' + ); + } +}; + +/** + * Change password for user with local password auth. + * + * @param {object} change + * @param {string|null} [change.keepSessionId] the session making the change, + * which stays signed in; every other session of the account ends. + */ +const changeLocalPassword = async ({ userId, currentPassword, newPassword, keepSessionId }) => { const db = await getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); if (!user) { @@ -196,12 +318,20 @@ const changeLocalPassword = async ({ userId, currentPassword, newPassword }) => } const hash = await bcrypt.hash(newPassword, 12); + const ended = endSessionsOpenedWithOldPassword(db, userId, keepSessionId); db.prepare('UPDATE auth_methods SET password_hash = ? WHERE id = ?').run(hash, authMethod.id); + logEndedSessions(userId, ended); return true; }; -// Admin path: set a local user's password without current password -const setLocalPasswordAdmin = async ({ userId, newPassword }) => { +/** + * Admin path: set a local user's password without current password. + * + * Replacing a password ends every session of the account but `keepSessionId`, + * for the reason `changeLocalPassword` does. Giving a password to an account + * that had none ends nothing: no session was opened with it. + */ +const setLocalPasswordAdmin = async ({ userId, newPassword, keepSessionId }) => { const db = await getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); if (!user) { @@ -216,21 +346,30 @@ const setLocalPasswordAdmin = async ({ userId, newPassword }) => { throw e; } - const hash = await bcrypt.hash(newPassword, 12); - // Check if user has local password auth const authMethod = db .prepare( ` - SELECT id FROM auth_methods + SELECT id, password_hash FROM auth_methods WHERE user_id = ? AND method_type = 'local_password' ` ) .get(userId); + // The password it already has is not a change. The environment bootstrap sets + // AUTH_ADMIN_PASSWORD again on every start, and ending the administrator's + // sessions at each restart would sign them out for nothing. + if (authMethod?.password_hash && (await bcrypt.compare(newPassword, authMethod.password_hash))) { + return true; + } + + const hash = await bcrypt.hash(newPassword, 12); + if (authMethod) { // Update existing password + const ended = endSessionsOpenedWithOldPassword(db, userId, keepSessionId); db.prepare('UPDATE auth_methods SET password_hash = ? WHERE id = ?').run(hash, authMethod.id); + logEndedSessions(userId, ended); } else { // Create new password auth method const authId = generateId(); @@ -294,6 +433,7 @@ const addLocalPassword = async ({ userId, password }) => { }; module.exports = { + verifyLocalPassword, attemptLocalLogin, createLocalUser, changeLocalPassword, diff --git a/backend/src/services/users/lockout.js b/backend/src/services/users/lockout.js index 2d5993017..c2517d14c 100644 --- a/backend/src/services/users/lockout.js +++ b/backend/src/services/users/lockout.js @@ -40,10 +40,32 @@ const incrementFailedAttempts = async (key) => { await setLock(key, failed, lockedUntil); }; +/** + * The accounts locked right now, as a map of key to the moment each frees + * itself. + * + * For the administration screen, which had no way to tell a locked account from + * one whose owner forgot the password. A lock whose time has passed is left + * out: it refuses nothing any more, and the next sign-in clears it. + */ +const listActiveLocks = async () => { + const db = await getDb(); + const now = Date.now(); + const locks = new Map(); + const rows = db + .prepare('SELECT key, locked_until FROM auth_locks WHERE locked_until IS NOT NULL') + .all(); + for (const row of rows) { + const until = Date.parse(row.locked_until); + if (Number.isFinite(until) && until > now) locks.set(row.key, row.locked_until); + } + return locks; +}; + module.exports = { getLock, - setLock, clearLock, isLocked, incrementFailedAttempts, + listActiveLocks, }; diff --git a/backend/src/services/users/totpSecrets.js b/backend/src/services/users/totpSecrets.js new file mode 100644 index 000000000..412721997 --- /dev/null +++ b/backend/src/services/users/totpSecrets.js @@ -0,0 +1,116 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const { directories } = require('../../config'); +const logger = require('../../utils/logger'); + +/** + * The shared secret of somebody's authenticator, kept unreadable in the + * database. + * + * Unlike a password, this one cannot be hashed: the server has to compute the + * same six digits the phone does, so it needs the secret itself. What it can + * do is keep it under a key that lives beside the database rather than in it, + * so a copy of `app.db` on its own — a backup, a support ticket, a file read + * through some other hole — is not a set of working authenticators. + * + * The key is drawn once into CONFIG_DIR, like the session secret. Losing it + * does not lock anybody out: recovery codes are hashed and go on working, and + * what cannot be read is reported as an authenticator to set up again rather + * than as an error nobody can act on. + */ + +const KEY_FILE_NAME = 'totp-key'; +const KEY_PATTERN = /^[0-9a-f]{64}$/i; +const VERSION = 'v1'; + +let cachedKey = null; + +const store = (file, hex) => { + fs.mkdirSync(directories.config, { recursive: true }); + const staging = path.join(directories.config, `.${KEY_FILE_NAME}.tmp`); + const fd = fs.openSync(staging, 'w', 0o600); + try { + fs.fchmodSync(fd, 0o600); + fs.writeFileSync(fd, `${hex}\n`); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(staging, file); +}; + +/** + * The key, drawn on first use and read back afterwards. + * + * Deliberately not derived from SESSION_SECRET, which the rest of the + * application derives its own secrets from: rotating that one signs everyone + * out, which is a nuisance, and it would also take every authenticator with + * it, which is a lockout. + */ +const encryptionKey = () => { + if (cachedKey) return cachedKey; + + const file = path.join(directories.config, KEY_FILE_NAME); + try { + const found = fs.readFileSync(file, 'utf8').trim(); + if (KEY_PATTERN.test(found)) { + cachedKey = Buffer.from(found, 'hex'); + return cachedKey; + } + logger.warn( + { file }, + 'The two-factor key file is unusable and is being replaced; authenticators set up under ' + + 'the old one have to be set up again, and recovery codes still work.' + ); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + + const drawn = crypto.randomBytes(32); + store(file, drawn.toString('hex')); + cachedKey = drawn; + return cachedKey; +}; + +/** `v1:::`, all base64. */ +const sealSecret = (secret) => { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv); + const sealed = Buffer.concat([cipher.update(String(secret), 'utf8'), cipher.final()]); + return [ + VERSION, + iv.toString('base64'), + cipher.getAuthTag().toString('base64'), + sealed.toString('base64'), + ].join(':'); +}; + +/** + * The secret back, or null when this key cannot read it. + * + * Null rather than a throw: a secret written under a key that has since been + * replaced is an authenticator to set up again, and every caller has something + * to say about that. A tampered row lands here too, which is the point of the + * authentication tag. + */ +const openSecret = (sealed) => { + const [version, iv, tag, body] = String(sealed || '').split(':'); + if (version !== VERSION || !iv || !tag || !body) return null; + try { + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + encryptionKey(), + Buffer.from(iv, 'base64') + ); + decipher.setAuthTag(Buffer.from(tag, 'base64')); + return Buffer.concat([decipher.update(Buffer.from(body, 'base64')), decipher.final()]).toString( + 'utf8' + ); + } catch { + return null; + } +}; + +module.exports = { sealSecret, openSecret, KEY_FILE_NAME }; diff --git a/backend/src/services/users/twoFactor.js b/backend/src/services/users/twoFactor.js new file mode 100644 index 000000000..2683e9dae --- /dev/null +++ b/backend/src/services/users/twoFactor.js @@ -0,0 +1,229 @@ +const crypto = require('crypto'); + +const { getDb } = require('../db'); +const { generateId, nowIso } = require('../../utils/ids'); +const { generateSecret, verifyTotp, otpauthUri } = require('../../utils/totp'); +const { sealSecret, openSecret } = require('./totpSecrets'); +const logger = require('../../utils/logger'); + +/** + * A second factor on a local account. + * + * With an identity provider this belongs to the provider. Without one — the + * simplest way to run this, and therefore the most common — a password is the + * whole of what stands in front of somebody's filesystem, and a phone is a + * cheap second thing to have to hold. + * + * Enrolment is two steps on purpose. The secret is written when the QR code is + * shown and counts for nothing until a code proves the phone really has it: + * anything else turns a mistyped setup into an account nobody can reach. + */ + +const RECOVERY_CODE_COUNT = 10; + +/** + * No I, O, 0 or 1: these are read off a screen and typed back, sometimes from + * a printout, and those four are the pairs people get wrong. + */ +const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +/** + * Recovery codes are hashed with SHA-256 rather than bcrypt. + * + * They are drawn at random with fifty bits of entropy, so there is no guessing + * to slow down — and a slow hash would be checked against every unused code an + * account holds at every attempt, which is a lever to push on rather than a + * defence. + */ +const hashRecoveryCode = (code) => + crypto.createHash('sha256').update(normalizeRecoveryCode(code)).digest('hex'); + +const normalizeRecoveryCode = (code) => + String(code || '') + .toUpperCase() + .replace(/[^A-Z0-9]/g, ''); + +const drawRecoveryCode = () => { + const letters = Array.from( + crypto.randomBytes(10), + (byte) => RECOVERY_ALPHABET[byte % RECOVERY_ALPHABET.length] + ).join(''); + // Shown in two halves, which is how they are read back. + return `${letters.slice(0, 5)}-${letters.slice(5)}`; +}; + +const credentialFor = async (userId) => { + const db = await getDb(); + return db.prepare('SELECT * FROM totp_credentials WHERE user_id = ?').get(userId) || null; +}; + +/** Whether this account asks for a code after its password. */ +const twoFactorRequired = async (userId) => { + const credential = await credentialFor(userId); + return Boolean(credential?.confirmed_at); +}; + +/** What the settings page shows: on or off, since when, and how many codes are left. */ +const twoFactorStatus = async (userId) => { + const db = await getDb(); + const credential = await credentialFor(userId); + const { left } = db + .prepare( + 'SELECT COUNT(*) AS left FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL' + ) + .get(userId); + + return { + enabled: Boolean(credential?.confirmed_at), + pending: Boolean(credential) && !credential.confirmed_at, + confirmedAt: credential?.confirmed_at || null, + recoveryCodesLeft: left, + }; +}; + +/** + * Draw a secret and show it to the person setting it up. + * + * Asking again before confirming replaces the secret rather than adding one: a + * QR code that was scanned into the wrong app, or a page left open yesterday, + * should not stay valid beside the one on screen now. + */ +const beginEnrolment = async ({ userId, account }) => { + const db = await getDb(); + if (await twoFactorRequired(userId)) { + const error = new Error('Two-factor authentication is already on for this account.'); + error.status = 409; + throw error; + } + + const secret = generateSecret(); + // Taken away and written again, rather than updated in place: what is left + // behind is one row, unconfirmed, holding the secret on the screen now. + db.prepare('DELETE FROM totp_credentials WHERE user_id = ?').run(userId); + db.prepare( + `INSERT INTO totp_credentials (user_id, secret, confirmed_at, last_step, last_used_at, created_at) + VALUES (?, ?, NULL, NULL, NULL, ?)` + ).run(userId, sealSecret(secret), nowIso()); + + return { secret, uri: otpauthUri({ secret, account }) }; +}; + +const issueRecoveryCodes = (db, userId) => { + db.prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?').run(userId); + const insert = db.prepare( + 'INSERT INTO totp_recovery_codes (id, user_id, code_hash, used_at, created_at) VALUES (?, ?, ?, NULL, ?)' + ); + const codes = []; + const now = nowIso(); + for (let index = 0; index < RECOVERY_CODE_COUNT; index += 1) { + const code = drawRecoveryCode(); + codes.push(code); + insert.run(generateId(), userId, hashRecoveryCode(code), now); + } + return codes; +}; + +/** + * Turn it on, once a code proves the phone holds the same secret. + * + * The recovery codes come back here and nowhere else: they are hashed the + * moment they are written, so this is the only time anybody can read them. + */ +const confirmEnrolment = async ({ userId, code }) => { + const db = await getDb(); + const credential = await credentialFor(userId); + if (!credential || credential.confirmed_at) { + const error = new Error('There is no authenticator waiting to be confirmed.'); + error.status = 409; + throw error; + } + + const secret = openSecret(credential.secret); + const step = secret ? verifyTotp(secret, code) : null; + if (step === null) return null; + + db.prepare( + 'UPDATE totp_credentials SET confirmed_at = ?, last_step = ?, last_used_at = ? WHERE user_id = ?' + ).run(nowIso(), step, nowIso(), userId); + + return { recoveryCodes: issueRecoveryCodes(db, userId) }; +}; + +/** New codes for somebody who used some, or lost the paper. */ +const replaceRecoveryCodes = async (userId) => { + const db = await getDb(); + if (!(await twoFactorRequired(userId))) { + const error = new Error('Two-factor authentication is not on for this account.'); + error.status = 409; + throw error; + } + return issueRecoveryCodes(db, userId); +}; + +/** + * The second step of a sign-in: a code from the phone, or one from the paper. + * + * A recovery code is spent when it is used, and a six-digit code is spent for + * the thirty seconds it belongs to — both so that what somebody read over a + * shoulder, or found in a log, is already worth nothing. + * + * @returns {{ ok: boolean, usedRecoveryCode?: boolean, recoveryCodesLeft?: number }} + */ +const verifySecondFactor = async ({ userId, code }) => { + const db = await getDb(); + const credential = await credentialFor(userId); + if (!credential?.confirmed_at) return { ok: false }; + + const secret = openSecret(credential.secret); + if (secret) { + const step = verifyTotp(secret, code, { after: credential.last_step }); + if (step !== null) { + db.prepare( + 'UPDATE totp_credentials SET last_step = ?, last_used_at = ? WHERE user_id = ?' + ).run(step, nowIso(), userId); + return { ok: true, usedRecoveryCode: false }; + } + } else { + // The key the secret was written under is gone. Recovery codes still work, + // and they are the way back to an account in exactly this case. + logger.warn({ userId }, 'A two-factor secret could not be read with the current key'); + } + + const normalized = normalizeRecoveryCode(code); + if (normalized.length < 8) return { ok: false }; + + const match = db + .prepare( + 'SELECT id FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL AND code_hash = ?' + ) + .get(userId, hashRecoveryCode(normalized)); + if (!match) return { ok: false }; + + db.prepare('UPDATE totp_recovery_codes SET used_at = ? WHERE id = ?').run(nowIso(), match.id); + const { left } = db + .prepare( + 'SELECT COUNT(*) AS left FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL' + ) + .get(userId); + + return { ok: true, usedRecoveryCode: true, recoveryCodesLeft: left }; +}; + +/** Off, and nothing of it left: the secret, the codes, all of it. */ +const disableTwoFactor = async (userId) => { + const db = await getDb(); + db.prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?').run(userId); + const { changes } = db.prepare('DELETE FROM totp_credentials WHERE user_id = ?').run(userId); + return changes > 0; +}; + +module.exports = { + beginEnrolment, + confirmEnrolment, + disableTwoFactor, + replaceRecoveryCodes, + twoFactorRequired, + twoFactorStatus, + verifySecondFactor, + RECOVERY_CODE_COUNT, +}; diff --git a/backend/src/services/users/utils.js b/backend/src/services/users/utils.js index 7651335d4..768dbce4b 100644 --- a/backend/src/services/users/utils.js +++ b/backend/src/services/users/utils.js @@ -1,6 +1,4 @@ -const crypto = require('crypto'); - -const nowIso = () => new Date().toISOString(); +const { generateId, nowIso } = require('../../utils/ids'); const toClientUser = (row) => { if (!row) return null; @@ -19,14 +17,12 @@ const toClientUser = (row) => { })(), createdAt: row.created_at, updatedAt: row.updated_at, + // The folder this account owns, claimed once rather than derived per + // request — two accounts can otherwise derive the same one. + personalFolderName: row.personal_folder_name || null, }; }; -const generateId = () => - typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`; - const normalizeEmail = (email) => (typeof email === 'string' ? email.trim().toLowerCase() : ''); const toShareableUser = (row) => { @@ -39,10 +35,35 @@ const toShareableUser = (row) => { }; }; +/** + * Whether a username is already answering for another account. + * + * Compared without regard to case, because that is how it is matched at sign + * in: allowing `Alice` alongside `alice` would create a name that identifies + * two accounts and therefore signs nobody in. + * + * @param {object} db open database + * @param {string} username the name being claimed + * @param {string|null} exceptUserId the account claiming it, when it already exists + */ +const usernameTaken = (db, username, exceptUserId = null) => { + const trimmed = typeof username === 'string' ? username.trim() : ''; + if (!trimmed) return false; + + const row = db + .prepare( + `SELECT id FROM users + WHERE username IS NOT NULL AND lower(username) = lower(?) AND id <> COALESCE(?, '')` + ) + .get(trimmed, exceptUserId); + return Boolean(row); +}; + module.exports = { nowIso, toClientUser, generateId, normalizeEmail, toShareableUser, + usernameTaken, }; diff --git a/backend/src/services/versions/index.js b/backend/src/services/versions/index.js index 66250c8b0..2a24cb858 100644 --- a/backend/src/services/versions/index.js +++ b/backend/src/services/versions/index.js @@ -30,6 +30,7 @@ const logger = require('../../utils/logger'); const { ensureValidName, normalizeRelativePath } = require('../../utils/pathUtils'); const { ACTIONS, authorizeAndResolve, authorizePath } = require('../authorizationService'); const { getDb } = require('../db'); +const { readTextFile } = require('../textEditorService'); const clock = require('../trash/clock'); const trashStore = require('../trash/store'); const zones = require('../trash/zones'); @@ -272,16 +273,34 @@ const downloadVersion = async (context, relativePath, versionId) => { return { ...located, downloadName: nameForCopy(located.name, located.version) }; }; +/** The text of a version, to read before deciding what to do with it. Nothing is written. */ +const readVersionText = async (context, relativePath, versionId) => { + const located = await locateVersion(context, relativePath, versionId, { download: false }); + const { text } = await readTextFile(located.absolutePath); + return { + name: located.name, + size: located.size, + modifiedAt: located.version.modifiedAt, + content: text, + }; +}; + /** * After a restore, an editor still open on the file holds the content it - * replaced. Marking the file says so: that editor's next save is set aside as a - * version of its own rather than written over what was just restored. + * replaced. Its next save is set aside as a version of its own rather than + * written over what was just restored, and the document is given a fresh + * identity so whoever opens it next gets what was restored rather than the + * Document Server's cached copy of what it replaced. */ -const markRestored = async (absolutePath) => { +const markRestored = async (absolutePath, relative) => { try { const db = await getDb(); const file = await historyOf(db, absolutePath); if (file) store.setRestoredAt(db, file.id, clock.nowIso()); + // Required here rather than at the top: the key service is part of the + // office integration, which reaches back into the versions. + // eslint-disable-next-line global-require + await require('../onlyofficeDocumentKeyService').releaseDocumentKey(relative); } catch (error) { logger.warn({ err: error, absolutePath }, 'A restore could not be announced to open editors'); } @@ -309,7 +328,7 @@ const restoreVersion = async (context, relativePath, versionId) => { if (!located.target.rights.restore) throw new ForbiddenError('This file cannot be changed.'); const result = await writeVersionInto(located, located.target.absolutePath, context); if (result.status !== 'unchanged') { - await markRestored(located.target.absolutePath); + await markRestored(located.target.absolutePath, located.target.relative); } return { status: result.status, path: located.target.relative }; }; @@ -372,7 +391,7 @@ const replaceWithVersion = async (context, relativePath, versionId, { target } = if (!stats?.isFile()) throw new ValidationError('The file to replace must be an existing file.'); const result = await writeVersionInto(located, resolved.absolutePath, context); - if (result.status !== 'unchanged') await markRestored(resolved.absolutePath); + if (result.status !== 'unchanged') await markRestored(resolved.absolutePath, other); return { status: result.status, path: other }; }; @@ -662,6 +681,7 @@ module.exports = { listVersions, locateVersion, downloadVersion, + readVersionText, restoreVersion, copyVersionTo, replaceWithVersion, diff --git a/backend/src/services/versions/store.js b/backend/src/services/versions/store.js index 9722a612a..2fa3965a9 100644 --- a/backend/src/services/versions/store.js +++ b/backend/src/services/versions/store.js @@ -197,6 +197,165 @@ const setVersionDetails = (db, id, { label, pinned }) => { const deleteVersion = (db, id) => db.prepare('DELETE FROM file_versions WHERE id = ?').run(id).changes; +/** + * Paths, as a LIKE pattern matches them. + * + * A folder called `100%_done` is a wildcard to LIKE, and would have matched + * every sibling. Escaped here rather than at each call site, because there is + * no reading of a query that makes this optional. + */ +const likeLiteral = (value) => String(value).replace(/[\\%_]/g, (character) => `\\${character}`); + +/** + * How many kept versions each file directly inside a folder has. + * + * One query for a whole listing, not one per row: a folder of three hundred + * files costs the same as a folder of three. + * + * The range does the work — `relative_path` is the second column of + * `idx_version_files_path`, and a folder's children all begin with its path + * and a slash. The bound above it is that same path with `0`, the character + * after `/`, so nothing outside the folder is read at all. What the range + * still lets through is the folder's descendants; the `NOT LIKE` drops + * anything with a further slash, which leaves the direct children. + * + * `zoneIds` rather than one zone: a root that has been registered twice over + * the installation's life has two rows, and a file's history may sit under + * either. + */ +const countKeptInFolder = (db, zoneIds, folderPath) => { + const zones = [...new Set((zoneIds || []).filter(Boolean))]; + if (zones.length === 0) return []; + + const prefix = folderPath ? `${folderPath}/` : ''; + const clauses = [ + `vf.zone_id IN (${zones.map(() => '?').join(', ')})`, + "vf.state = 'live'", + "vf.relative_path NOT LIKE ? ESCAPE '\\'", + ]; + const values = [...zones, `${likeLiteral(prefix)}%/%`]; + if (prefix) { + // `dir/` … `dir0`: '/' is 0x2F and '0' is 0x30, so the pair is exactly the + // folder's subtree and nothing adjacent to it. + clauses.push('vf.relative_path >= ?', 'vf.relative_path < ?'); + values.push(prefix, `${folderPath}0`); + } + + return db + .prepare( + `SELECT vf.relative_path AS relativePath, + COUNT(v.id) AS versions, + SUM(v.size_bytes) AS bytes, + MAX(v.modified_at) AS newest + FROM version_files vf + JOIN file_versions v ON v.file_id = vf.id AND v.state = 'kept' + WHERE ${clauses.join(' AND ')} + GROUP BY vf.id` + ) + .all(...values) + .map((row) => ({ + relativePath: row.relativePath, + versions: Number(row.versions) || 0, + bytes: Number(row.bytes) || 0, + newest: row.newest || null, + })); +}; + +/** The states a history can be listed in, and the order they are offered in. */ +const FILE_STATES = ['live', 'trashed', 'orphaned']; + +const ADMIN_SORTS = { + bytes: 'bytes DESC, vf.relative_path ASC', + versions: 'versions DESC, bytes DESC, vf.relative_path ASC', + newest: 'newest DESC, vf.relative_path ASC', + path: 'vf.relative_path ASC, vf.id ASC', +}; + +/** What narrows an administrator's list of histories, said once for both queries. */ +const adminFilter = ({ zoneId = null, state = null, query = '' } = {}) => { + const clauses = []; + const values = []; + if (zoneId) { + clauses.push('vf.zone_id = ?'); + values.push(zoneId); + } + if (state) { + clauses.push('vf.state = ?'); + values.push(state); + } else { + clauses.push(`vf.state IN (${FILE_STATES.map(() => '?').join(', ')})`); + values.push(...FILE_STATES); + } + const wanted = String(query || '').trim(); + if (wanted) { + // `instr` and not LIKE: somebody looking for `report_2026` means that + // underscore, and LIKE would have taken it for any character at all. + clauses.push('instr(lower(vf.relative_path), lower(?)) > 0'); + values.push(wanted); + } + return { where: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '', values }; +}; + +/** + * Every file that has a history, for an administrator. + * + * Grouped in the database rather than counted in the application: the answer + * is one page, and the alternative is reading every version of every file in + * the installation to show twenty-five rows. + */ +const listFilesWithVersions = ( + db, + { zoneId = null, state = null, query = '', sort = 'bytes', limit = 25, offset = 0 } = {} +) => { + const { where, values } = adminFilter({ zoneId, state, query }); + const order = ADMIN_SORTS[sort] || ADMIN_SORTS.bytes; + return db + .prepare( + `SELECT vf.id AS id, vf.zone_id AS zoneId, vf.relative_path AS relativePath, + vf.state AS state, + COUNT(v.id) AS versions, + SUM(v.size_bytes) AS bytes, + MAX(v.modified_at) AS newest + FROM version_files vf + JOIN file_versions v ON v.file_id = vf.id AND v.state = 'kept' + ${where} + GROUP BY vf.id + ORDER BY ${order} + LIMIT ? OFFSET ?` + ) + .all(...values, Math.max(1, limit), Math.max(0, offset)) + .map((row) => ({ + id: row.id, + zoneId: row.zoneId, + relativePath: row.relativePath, + state: row.state, + versions: Number(row.versions) || 0, + bytes: Number(row.bytes) || 0, + newest: row.newest || null, + })); +}; + +/** How many files the same filter matches, and what they hold altogether. */ +const summariseFilesWithVersions = (db, { zoneId = null, state = null, query = '' } = {}) => { + const { where, values } = adminFilter({ zoneId, state, query }); + const row = db + .prepare( + `SELECT COUNT(*) AS files, COALESCE(SUM(bytes), 0) AS bytes, + COALESCE(SUM(versions), 0) AS versions + FROM (SELECT vf.id, SUM(v.size_bytes) AS bytes, COUNT(v.id) AS versions + FROM version_files vf + JOIN file_versions v ON v.file_id = vf.id AND v.state = 'kept' + ${where} + GROUP BY vf.id)` + ) + .get(...values); + return { + files: Number(row?.files) || 0, + bytes: Number(row?.bytes) || 0, + versions: Number(row?.versions) || 0, + }; +}; + module.exports = { mapFile, mapVersion, @@ -215,4 +374,9 @@ module.exports = { setVersionState, setVersionDetails, deleteVersion, + countKeptInFolder, + listFilesWithVersions, + summariseFilesWithVersions, + FILE_STATES, + ADMIN_SORTS, }; diff --git a/backend/src/utils/base32.js b/backend/src/utils/base32.js new file mode 100644 index 000000000..e8ac73eb5 --- /dev/null +++ b/backend/src/utils/base32.js @@ -0,0 +1,65 @@ +/** + * Base32, the alphabet an authenticator app reads. + * + * RFC 4648 without padding, which is what every `otpauth://` URI carries and + * what every app shows when somebody types a secret in by hand. Written here + * rather than taken from a package: it is thirty lines, it has test vectors of + * its own, and a secret is not a thing to hand to one more dependency. + */ + +const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +const VALUES = new Map([...ALPHABET].map((letter, index) => [letter, index])); + +/** Bytes to base32, no padding. */ +const encodeBase32 = (bytes) => { + let bits = 0; + let value = 0; + let output = ''; + + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31]; + return output; +}; + +/** + * Base32 to bytes. + * + * Spaces and lower case are accepted because that is how a secret arrives when + * somebody reads it off a screen and types it into their phone; padding is + * accepted because some apps write it. Anything else is not a secret, and + * saying so beats decoding it into the wrong bytes. + */ +const decodeBase32 = (text) => { + const cleaned = String(text || '') + .replace(/[\s-]/g, '') + .replace(/=+$/, '') + .toUpperCase(); + if (cleaned === '' || /[^A-Z2-7]/.test(cleaned)) { + throw new Error('That is not base32.'); + } + + const bytes = []; + let bits = 0; + let value = 0; + for (const letter of cleaned) { + value = (value << 5) | VALUES.get(letter); + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + + return Buffer.from(bytes); +}; + +module.exports = { encodeBase32, decodeBase32 }; diff --git a/backend/src/utils/betterSqliteSessionStore.js b/backend/src/utils/betterSqliteSessionStore.js new file mode 100644 index 000000000..a59539ca9 --- /dev/null +++ b/backend/src/utils/betterSqliteSessionStore.js @@ -0,0 +1,265 @@ +const fs = require('fs'); +const path = require('path'); +const Database = require('better-sqlite3'); +const session = require('express-session'); +const logger = require('./logger'); +const { readIdTokenClaims } = require('./idToken'); +const { configureStorage, convertToIncremental } = require('../services/databaseMaintenance'); + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** + * One provider identity, as a string two of them can be compared by. + * + * Both halves are required: a subject is only unique within its issuer, so + * matching on the subject alone could end the session of somebody else who + * happens to carry the same one at another provider. + * + * The trailing slash goes, on both sides, because the two halves come from two + * places that disagree about it — `auth_methods` keeps `OIDC_ISSUER` as it was + * configured, the id token carries the `iss` the provider mints — and the same + * provider written both ways is the same provider. It is the normalisation + * discovery already applies (see services/oidcService.js). + */ +const identityKey = (issuer, subject) => { + if (typeof issuer !== 'string' || !issuer.trim()) return null; + if (typeof subject !== 'string' || !subject.trim()) return null; + return `${issuer.trim().replace(/\/+$/, '')}\n${subject.trim()}`; +}; + +class BetterSqliteSessionStore extends session.Store { + constructor(filename) { + super(); + + this.filename = filename; + this.db = null; + } + + /** + * Opened on first use, not when this module is required. + * + * Opening it creates the cache directory and the database in it, which turns + * requiring this file into a filesystem write — one that fails wherever the + * cache is not there yet, including the check that every module loads. A + * process that has not been asked to hold a session has no business creating + * a place to hold one either. + */ + ready() { + if (this.db) return this; + + fs.mkdirSync(path.dirname(this.filename), { recursive: true }); + this.db = new Database(this.filename); + this.db.pragma('busy_timeout = 5000'); + // Every expired session the daily cleanup deletes leaves its pages behind, + // and SQLite keeps them: a burst of logins — a script, a scan — grew the + // file for good. Kept like app.db, so the maintenance pass hands them back. + configureStorage(this.db); + this.db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + sid TEXT PRIMARY KEY, + expired INTEGER NOT NULL, + sess TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired); + `); + convertToIncremental(this.db); + + this.getStatement = this.db.prepare('SELECT sess FROM sessions WHERE sid = ? AND expired >= ?'); + this.setStatement = this.db.prepare( + 'INSERT OR REPLACE INTO sessions (sid, expired, sess) VALUES (?, ?, ?)' + ); + this.destroyStatement = this.db.prepare('DELETE FROM sessions WHERE sid = ?'); + this.touchStatement = this.db.prepare( + 'UPDATE sessions SET expired = ? WHERE sid = ? AND expired >= ?' + ); + this.clearStatement = this.db.prepare('DELETE FROM sessions'); + this.lengthStatement = this.db.prepare( + 'SELECT COUNT(*) AS count FROM sessions WHERE expired >= ?' + ); + this.allStatement = this.db.prepare('SELECT sess FROM sessions WHERE expired >= ?'); + this.cleanupStatement = this.db.prepare('DELETE FROM sessions WHERE expired < ?'); + // `localUserId` is what signing in writes onto the session (routes/auth.js). + // The CASE comes first so a row that is not JSON is skipped rather than + // failing the whole statement: json_extract raises on malformed input, and + // SQLite promises no order for the terms of an AND. `IS NOT` so that no + // exception given still matches every row, where `<> NULL` would match none. + this.destroyByUserStatement = this.db.prepare( + `DELETE FROM sessions + WHERE CASE WHEN json_valid(sess) THEN json_extract(sess, '$.localUserId') END = ? + AND sid IS NOT ?` + ); + // A session the identity provider opened carries its tokens and no account + // id: express-openid-connect stores the token set under `data`. The id + // token is the only thing in the row that says who signed in, so the rows + // are read back rather than matched in SQL. Same `json_valid` guard, and + // the same `IS NOT` for a missing exception. + this.providerSessionsStatement = this.db.prepare( + `SELECT sid, CASE WHEN json_valid(sess) THEN json_extract(sess, '$.data.id_token') END AS idToken + FROM sessions + WHERE CASE WHEN json_valid(sess) THEN json_extract(sess, '$.data.id_token') END IS NOT NULL + AND sid IS NOT ?` + ); + + this.cleanupExpiredSessions(); + this.cleanupTimer = setInterval(() => this.cleanupExpiredSessions(), ONE_DAY_MS); + this.cleanupTimer.unref(); + return this; + } + + callback(callback, error, value) { + process.nextTick(() => callback(error, value)); + } + + expiresAt(sessionData) { + const cookie = sessionData?.cookie || {}; + const expires = cookie.expires ? new Date(cookie.expires).getTime() : NaN; + if (Number.isFinite(expires)) return expires; + + const maxAge = Number(cookie.maxAge); + return Number.isFinite(maxAge) ? Date.now() + maxAge : Date.now() + ONE_DAY_MS; + } + + cleanupExpiredSessions() { + this.ready(); + try { + this.cleanupStatement.run(Date.now()); + } catch (error) { + logger.warn({ err: error }, 'Unable to clean expired SQLite sessions'); + } + } + + get(sid, callback = () => {}) { + this.ready(); + try { + const row = this.getStatement.get(sid, Date.now()); + this.callback(callback, null, row ? JSON.parse(row.sess) : undefined); + } catch (error) { + this.callback(callback, error); + } + } + + set(sid, sessionData, callback = () => {}) { + this.ready(); + try { + this.setStatement.run(sid, this.expiresAt(sessionData), JSON.stringify(sessionData)); + this.callback(callback, null); + } catch (error) { + this.callback(callback, error); + } + } + + destroy(sid, callback = () => {}) { + this.ready(); + try { + this.destroyStatement.run(sid); + this.callback(callback, null); + } catch (error) { + this.callback(callback, error); + } + } + + /** + * End every session signed in to one account, except the one named. + * + * Synchronous, unlike the methods express-session calls, and on purpose: the + * caller changing a password ends the sessions and writes the new hash in the + * same turn, so no sign-in with the old password can land between the two. + * + * Sessions opened by signing in here carry the account id. One opened by the + * identity provider carries its tokens instead, so the account it belongs to + * is the subject of its id token — which only the caller can turn into an + * account, through `auth_methods`. It therefore hands the identities in. + * + * @param {string} userId + * @param {string|null} [exceptSid] the session to keep, usually the caller's + * @param {Array<{issuer: string|null, subject: string|null}>} [providerIdentities] + * the provider identities of the same account. An empty list ends only the + * sessions opened by signing in here. + * @returns {number} how many sessions were ended + */ + destroyByUser(userId, exceptSid = null, providerIdentities = []) { + this.ready(); + // No guard needed for a missing id: NULL equals nothing in SQL, and no + // session carries an empty one. + const ended = this.destroyByUserStatement.run(userId, exceptSid || null).changes; + return ended + this.destroyProviderSessions(providerIdentities, exceptSid); + } + + /** + * End the sessions the identity provider opened for these identities. + * + * A row whose id token cannot be read names nobody, and is left alone: it + * may belong to another account, and ending it on a guess would sign a + * stranger out. One unreadable row does not stop the ones after it either — + * the point of the pass is that a password change ends what it can. + * + * @param {Array<{issuer: string|null, subject: string|null}>} providerIdentities + * @param {string|null} exceptSid + * @returns {number} how many sessions were ended + */ + destroyProviderSessions(providerIdentities, exceptSid = null) { + this.ready(); + const wanted = new Set( + (Array.isArray(providerIdentities) ? providerIdentities : []) + .map((identity) => identityKey(identity?.issuer, identity?.subject)) + .filter(Boolean) + ); + if (wanted.size === 0) return 0; + + let ended = 0; + for (const row of this.providerSessionsStatement.all(exceptSid || null)) { + const claims = readIdTokenClaims(row.idToken); + const key = identityKey(claims?.iss, claims?.sub); + if (!key || !wanted.has(key)) continue; + ended += this.destroyStatement.run(row.sid).changes; + } + return ended; + } + + touch(sid, sessionData, callback = () => {}) { + this.ready(); + try { + this.touchStatement.run(this.expiresAt(sessionData), sid, Date.now()); + this.callback(callback, null); + } catch (error) { + this.callback(callback, error); + } + } + + clear(callback = () => {}) { + this.ready(); + try { + this.clearStatement.run(); + this.callback(callback, null); + } catch (error) { + this.callback(callback, error); + } + } + + length(callback = () => {}) { + this.ready(); + try { + this.callback(callback, null, this.lengthStatement.get(Date.now()).count); + } catch (error) { + this.callback(callback, error); + } + } + + all(callback = () => {}) { + this.ready(); + try { + const sessions = this.allStatement.all(Date.now()).map((row) => JSON.parse(row.sess)); + this.callback(callback, null, sessions); + } catch (error) { + this.callback(callback, error); + } + } + + close() { + if (!this.db) return; + clearInterval(this.cleanupTimer); + this.db.close(); + } +} + +module.exports = { BetterSqliteSessionStore }; diff --git a/backend/src/utils/cacheCleanup.js b/backend/src/utils/cacheCleanup.js new file mode 100644 index 000000000..3ee1b3b67 --- /dev/null +++ b/backend/src/utils/cacheCleanup.js @@ -0,0 +1,87 @@ +const path = require('path'); +const fs = require('fs/promises'); + +const env = require('../config/env'); +const { mapWithConcurrency } = require('./mapWithConcurrency'); + +/** + * What the cache cleanups share. + * + * Thumbnails and embedded RAW previews are both written under a temporary name + * and renamed into place, both live in the cache directory, and both come back + * when missing. They are bounded by one reading of the same settings rather + * than by two copies of it that can drift apart. Nothing here deletes: each + * service decides what to remove, and removes it itself. + */ +const CACHE_CLEANUP_INTERVAL_MS = Number.isFinite(env.THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS) + ? Math.max(60 * 1000, Math.floor(env.THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS)) + : 60 * 60 * 1000; +const CACHE_CLEANUP_BATCH_SIZE = Number.isFinite(env.THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE) + ? Math.max(1, Math.floor(env.THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE)) + : 500; +const CACHE_TTL_MS = Number.isFinite(env.THUMBNAIL_CACHE_TTL_DAYS) + ? Math.max(0, Math.floor(env.THUMBNAIL_CACHE_TTL_DAYS)) * 24 * 60 * 60 * 1000 + : 30 * 24 * 60 * 60 * 1000; + +/** + * How long a temporary file must have gone unwritten before it counts as + * abandoned. + * + * A thumbnail is written in well under a second, and its queue stops waiting + * after thirty; an embedded preview is copied out faster still. An hour is far + * past either, and a leftover that survives one more pass costs nothing. The + * age is the modification time, which a write in progress keeps moving. + */ +const ABANDONED_TEMP_FILE_AGE_MS = 60 * 60 * 1000; + +const STAT_CONCURRENCY = 16; + +/** The modification time of each name that still exists, in the order given. */ +const statCacheEntries = async (directory, names) => { + const entries = await mapWithConcurrency( + names, + async (name) => { + try { + const stats = await fs.stat(path.join(directory, name)); + return { name, mtimeMs: stats.mtimeMs }; + } catch (_) { + // A rename or a concurrent cleanup may already have taken it. + return null; + } + }, + STAT_CONCURRENCY + ); + return entries.filter(Boolean); +}; + +/** + * Temporary files that a crash, a killed process or an interrupted write left + * behind. + * + * `pattern` names this cache's temporaries, so nothing else in the directory is + * considered. `live` holds the names a write in this process has created and + * not yet renamed or removed; those are kept however old they look, because + * their rename is still to come. A queue that stops waiting for a job does not + * stop the job, so neither "queued" nor "in flight" can stand in for it. + * The age covers what `live` cannot see: a write from an earlier run. + */ +const findAbandonedTempFiles = async ( + directory, + fileNames, + { pattern, live, now = Date.now() } +) => { + const candidates = fileNames.filter((name) => pattern.test(name) && !live.has(name)); + const entries = await statCacheEntries(directory, candidates); + return entries + .filter((entry) => now - entry.mtimeMs >= ABANDONED_TEMP_FILE_AGE_MS) + .map((entry) => entry.name); +}; + +module.exports = { + ABANDONED_TEMP_FILE_AGE_MS, + CACHE_CLEANUP_BATCH_SIZE, + CACHE_CLEANUP_INTERVAL_MS, + CACHE_TTL_MS, + findAbandonedTempFiles, + statCacheEntries, +}; diff --git a/backend/src/utils/compressedResponse.js b/backend/src/utils/compressedResponse.js new file mode 100644 index 000000000..f60961ce6 --- /dev/null +++ b/backend/src/utils/compressedResponse.js @@ -0,0 +1,208 @@ +const zlib = require('zlib'); +const { promisify } = require('util'); + +const logger = require('./logger'); + +const gzip = promisify(zlib.gzip); +const brotliCompress = promisify(zlib.brotliCompress); + +/** + * Sending a whole text file compressed, when the client can take it. + * + * The editor and the Markdown preview receive a file as one JSON document, and + * nothing in the application compressed anything: a 19 MB Markdown file went + * over the wire as 22 MB of JSON, twice when the editor was opened from the + * preview. Text shrinks to a quarter of that. + * + * Deliberately not a global middleware. Most of what this server sends is + * already compressed (images, video, archives, office documents), or streamed + * with a length the client relies on for progress (downloads, ranged media), or + * a stream of progress events that must arrive as they happen (NDJSON) — and a + * middleware buffering or re-encoding those would break them. Only the routes + * that answer with a whole text file in one piece call this. + */ + +/** + * Below this, a body goes as it is. Measured: a 32 KB JSON body gzips in about + * a tenth of a millisecond to 13 KB, so what is saved under the line is a few + * tens of kilobytes — nothing on a local network, and not worth a trip through + * the thread pool for every small file the editor opens. + */ +const COMPRESSION_THRESHOLD_BYTES = 32 * 1024; + +/** + * gzip at level 4. gzip is what a browser asks for over plain http — `br` is + * only advertised over HTTPS — so this is the level a server reached by its + * local address uses, and it decides two things: how long the first transfer + * takes, and whether the browser keeps the answer at all. + * + * Measured asynchronously on 20 MB of JSON, made once of this repository's own + * code and documentation (repeated to reach the size, which gzip's 32 KB window + * cannot see) and once of 5,000 distinct files that never repeat: + * + * repository distinct files + * level 1 5.74 MB 96 ms 4.15 MB 70 ms + * level 3 5.37 MB 123 ms 3.88 MB 88 ms + * level 4 4.98 MB 143 ms 3.58 MB 111 ms + * level 6 4.74 MB 251 ms 3.39 MB 183 ms + * + * On a local network level 1 arrives first, by a few tens of milliseconds. It + * loses on the second count: a browser caps each entry of its cache by the + * bytes it stores, which are the compressed ones. A Chromium measured here kept + * a 5.9 MB answer and not a 6.4 MB one — what a 20 MB Markdown file came to at + * level 1; it came to 5.6 MB at level 4. Past that cap every opening of the file + * is the whole download again, which costs far more than the 40 to 60 ms level + * 4 adds. Level 6 saves little more for nearly twice the time, and each of + * those milliseconds holds one of the four threads file reads share. + */ +const GZIP_LEVEL = 4; + +/** + * Brotli at quality 4: on the same bodies, 4.24 MB in 109 ms and 2.61 MB in + * 77 ms — smaller than gzip at any level, in the time of gzip level 1 to 4. + * Quality 5 took 203 ms for 3.99 MB and 135 ms for 2.41 MB. Offered over HTTPS, + * where the link is more often the slow part. + */ +const BROTLI_QUALITY = 4; + +/** Below any quality a client can write (three decimals), above refusal. */ +const IMPLICIT_QUALITY = 0.0001; + +/** + * The qualities an Accept-Encoding header gives each coding it names. + * + * A malformed quality drops its entry rather than guessing: a coding the client + * did not clearly accept is not one to send it. + */ +const parseAcceptEncoding = (header) => { + const qualities = new Map(); + for (const part of String(header).split(',')) { + const [rawCoding, ...parameters] = part.split(';'); + const coding = rawCoding.trim().toLowerCase(); + if (!coding) continue; + + let quality = 1; + let valid = true; + for (const parameter of parameters) { + const [name, value = ''] = parameter.split('=').map((piece) => piece.trim()); + if (name.toLowerCase() !== 'q') continue; + if (!/^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(value)) { + valid = false; + break; + } + quality = Number(value); + } + // The first mention of a coding is the one that counts. + if (valid && !qualities.has(coding)) qualities.set(coding, quality); + } + return qualities; +}; + +/** + * The content coding to answer with: 'br', 'gzip' or 'identity'. + * + * The client's qualities decide; between equals, the smaller result. Identity + * is acceptable unless the header refuses it (`identity;q=0`, or `*;q=0` without + * naming identity), and when it does refuse everything this server can produce, + * the body still goes uncompressed — the answer HTTP allows rather than a 406 + * nobody would know what to do with. + * + * No header at all is read as "no preference stated", and answered + * uncompressed: an old script or a proxy that sends none may not decode. + */ +const chooseEncoding = (header) => { + if (typeof header !== 'string') return 'identity'; + const qualities = parseAcceptEncoding(header); + const wildcard = qualities.get('*'); + const named = (...codings) => + codings.map((coding) => qualities.get(coding)).find((q) => q !== undefined); + + const candidates = [ + ['br', named('br') ?? wildcard ?? 0], + // x-gzip is the older name, which HTTP asks recipients to treat as gzip. + ['gzip', named('gzip', 'x-gzip') ?? wildcard ?? 0], + ['identity', named('identity') ?? (wildcard === 0 ? 0 : IMPLICIT_QUALITY)], + ]; + + let chosen = 'identity'; + let best = 0; + for (const [coding, quality] of candidates) { + if (quality > best) { + chosen = coding; + best = quality; + } + } + return chosen; +}; + +const compress = (encoding, payload) => + encoding === 'br' + ? brotliCompress(payload, { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY, + [zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT, + [zlib.constants.BROTLI_PARAM_SIZE_HINT]: payload.length, + }, + }) + : gzip(payload, { level: GZIP_LEVEL }); + +/** + * Send `body` — an object as JSON, a string as text, a Buffer as it is — + * compressed when it is large enough and the client accepts a coding. + * + * Compression runs on the thread pool: a 20 MB body compressed synchronously + * would hold every other request for a tenth of a second or more. + * + * `Vary: Accept-Encoding` is set whatever is chosen, small bodies included, so + * a cache never hands the compressed answer to a client that did not ask for + * it, nor keeps an uncompressed one for everybody. + */ +const sendCompressible = async (req, res, body) => { + let payload; + let type; + if (Buffer.isBuffer(body)) { + payload = body; + type = 'application/octet-stream'; + } else if (typeof body === 'string') { + payload = Buffer.from(body, 'utf8'); + type = 'text/plain; charset=utf-8'; + } else { + payload = Buffer.from(JSON.stringify(body), 'utf8'); + type = 'application/json; charset=utf-8'; + } + + if (!res.getHeader('Content-Type')) res.setHeader('Content-Type', type); + res.vary('Accept-Encoding'); + + const encoding = + payload.length >= COMPRESSION_THRESHOLD_BYTES && !res.getHeader('Content-Encoding') + ? chooseEncoding(req.headers['accept-encoding']) + : 'identity'; + + let sent = payload; + if (encoding !== 'identity') { + try { + const compressed = await compress(encoding, payload); + // Text nearly always shrinks; a body that did not is sent as it was. + if (compressed.length < payload.length) { + sent = compressed; + res.setHeader('Content-Encoding', encoding); + } + } catch (error) { + // The body is still there to send: a failed compression costs bytes, not + // the answer. + logger.warn({ err: error, encoding }, 'A response could not be compressed'); + } + } + + res.setHeader('Content-Length', sent.length); + res.end(sent); +}; + +module.exports = { + sendCompressible, + chooseEncoding, + COMPRESSION_THRESHOLD_BYTES, + GZIP_LEVEL, + BROTLI_QUALITY, +}; diff --git a/backend/src/utils/fileTypes.js b/backend/src/utils/fileTypes.js new file mode 100644 index 000000000..5b50b439b --- /dev/null +++ b/backend/src/utils/fileTypes.js @@ -0,0 +1,20 @@ +const path = require('path'); +const { mimeTypes } = require('../config/index'); + +/** + * Lowercase extension of a filename, without the dot. + * + * Returns '' when there is none — a dotfile like `.env` has no extension, and + * neither does `Makefile`. The editors each had their own copy of this, and + * they disagreed: one returned the whole filename when there was no dot, which + * then looked up a bogus MIME type. + */ +const toExtension = (filename = '') => { + const base = path.basename(String(filename)); + const index = base.lastIndexOf('.'); + return index > 0 ? base.slice(index + 1).toLowerCase() : ''; +}; + +const resolveMimeType = (extension) => mimeTypes[extension] || 'application/octet-stream'; + +module.exports = { toExtension, resolveMimeType }; diff --git a/backend/src/utils/idToken.js b/backend/src/utils/idToken.js new file mode 100644 index 000000000..d705a0258 --- /dev/null +++ b/backend/src/utils/idToken.js @@ -0,0 +1,27 @@ +/** + * The claims inside an id token, read without checking its signature. + * + * Every caller here reads a token this server has already accepted: the + * library verifies the signature and the nonce before the after-callback + * handler is handed the session, and nothing but this server writes into + * sessions.db. So this answers "who does this token say it is", never "is this + * token genuine" — a token arriving from outside must be verified first. + * + * Anything that is not a JWT, or whose payload is not an object, names nobody. + * + * @param {unknown} idToken + * @returns {Record|null} + */ +const readIdTokenClaims = (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; + } +}; + +module.exports = { readIdTokenClaims }; diff --git a/backend/src/utils/onlyofficeDocumentTypes.js b/backend/src/utils/onlyofficeDocumentTypes.js new file mode 100644 index 000000000..3539e0c9f --- /dev/null +++ b/backend/src/utils/onlyofficeDocumentTypes.js @@ -0,0 +1,116 @@ +/** + * Which editor the Document Server opens a file with. + * + * These lists are the ones the Document Server itself validates against: it + * ships them as a regular expression in web-apps/apps/api/documents/api.js and + * refuses the config outright when documentType disagrees with the extension. + * They are reproduced here so the disagreement never happens. + * + * Getting this wrong is not a soft failure. Announcing a drawing as a text + * document gets it opened by the wrong editor, which answers "the file content + * does not match the file extension" — true, unhelpful, and several steps away + * from the setting that caused it. That was the case for every extension + * outside the four-or-five-entry lists this used to hold, .odg among them, + * which the Document Server counts as a slide rather than a word document. + * + * Kept beside the route rather than inside it so the mapping can be checked + * against that regular expression in a test. + */ + +const SUPPORTED_SHEET = new Set([ + 'xls', + 'xlsx', + 'ods', + 'csv', + 'tsv', + 'gsheet', + 'xlsm', + 'xlt', + 'xltm', + 'xltx', + 'fods', + 'ots', + 'xlsb', + 'sxc', + 'et', + 'ett', + 'numbers', +]); + +const SUPPORTED_PRESENTATION = new Set([ + 'pps', + 'ppsx', + 'ppt', + 'pptx', + 'odp', + 'gslides', + 'pot', + 'potm', + 'potx', + 'ppsm', + 'pptm', + 'fodp', + 'otp', + 'sxi', + 'dps', + 'dpt', + 'key', + 'odg', +]); + +const SUPPORTED_PDF = new Set(['pdf', 'djvu', 'xps', 'oxps']); + +const SUPPORTED_TEXT = new Set([ + 'doc', + 'docx', + 'odt', + 'gdoc', + 'txt', + 'rtf', + 'mht', + 'htm', + 'html', + 'mhtml', + 'epub', + 'docm', + 'dot', + 'dotm', + 'dotx', + 'fodt', + 'ott', + 'fb2', + 'xml', + 'oform', + 'docxf', + 'sxw', + 'stw', + 'wps', + 'wpt', + 'pages', + 'hwp', + 'hwpx', + 'md', + 'hml', +]); + +const SUPPORTED_DIAGRAM = new Set(['vsdx', 'vssx', 'vstx', 'vsdm', 'vssm', 'vstm']); + +/** + * @param {string} ext lowercase extension, without the dot + * @returns {string|null} the documentType to declare, or null when the Document + * Server has no editor for it. Null rather than a guess: 'word' as a catch-all + * is exactly what turned an unsupported extension into an error raised by the + * editor instead of a refusal here. + */ +const getDocumentType = (ext) => { + if (SUPPORTED_SHEET.has(ext)) return 'cell'; + if (SUPPORTED_PRESENTATION.has(ext)) return 'slide'; + if (SUPPORTED_PDF.has(ext)) return 'pdf'; + if (SUPPORTED_TEXT.has(ext)) return 'word'; + if (SUPPORTED_DIAGRAM.has(ext)) return 'diagram'; + return null; +}; + +module.exports = { + getDocumentType, +}; diff --git a/backend/src/utils/placeWithoutOverwrite.js b/backend/src/utils/placeWithoutOverwrite.js index 5620ea981..fa953439d 100644 --- a/backend/src/utils/placeWithoutOverwrite.js +++ b/backend/src/utils/placeWithoutOverwrite.js @@ -192,7 +192,26 @@ const reserveAvailableName = async ( throw taken(path.join(directory, desiredName)); }; +/** + * The name `placeWithoutOverwrite` would take if nothing changed meanwhile, to + * tell somebody what to expect — never to place anything under. By the time the + * move happens the name may be held, and the move then takes the next one + * itself. A name that cannot be looked at is answered as asked. + */ +const predictAvailableName = async (directory, desiredName, { style = 'copy' } = {}) => { + for (let index = 0; index < MAX_CANDIDATES; index += 1) { + const name = candidateName(desiredName, index, style); + try { + await fs.lstat(path.join(directory, name)); + } catch (error) { + return error.code === 'ENOENT' ? name : desiredName; + } + } + return desiredName; +}; + module.exports = { + predictAvailableName, candidateName, moveNoReplace, placeWithoutOverwrite, diff --git a/backend/src/utils/sessionStore.js b/backend/src/utils/sessionStore.js index 7bb35d97f..aada15a0d 100644 --- a/backend/src/utils/sessionStore.js +++ b/backend/src/utils/sessionStore.js @@ -1,42 +1,33 @@ const path = require('path'); const logger = require('../utils/logger'); -const session = require('express-session'); const { directories } = require('../config/index'); +const { BetterSqliteSessionStore } = require('./betterSqliteSessionStore'); const cacheDir = (directories && directories.cache) || '/cache'; const dbPath = path.join(cacheDir, 'sessions.db'); -const SQLiteStore = require('connect-sqlite3')(session); +const baseStore = new BetterSqliteSessionStore(dbPath); -const baseStore = new SQLiteStore({ - db: path.basename(dbPath), - dir: path.dirname(dbPath), - createDirIfNotExists: true, -}); - -logger.debug({ dbPath }, 'Initialized shared SQLite session store'); +logger.debug({ dbPath }, 'Initialized shared better-sqlite3 session store'); const localStore = baseStore; -// OIDC sessions use a thin wrapper around the same store to ensure that -// express-openid-connect's safePromisify treats the methods as callback-based -// and never calls them without a callback argument. +// express-openid-connect may call a store method without a callback. Keep a +// callback-safe facade so OIDC and local authentication use the same sessions. const oidcStore = { get(sid, cb) { - const callback = typeof cb === 'function' ? cb : () => {}; - return baseStore.get(sid, callback); + return baseStore.get(sid, typeof cb === 'function' ? cb : () => {}); }, set(sid, sess, cb) { - const callback = typeof cb === 'function' ? cb : () => {}; - return baseStore.set(sid, sess, callback); + return baseStore.set(sid, sess, typeof cb === 'function' ? cb : () => {}); }, destroy(sid, cb) { - const callback = typeof cb === 'function' ? cb : () => {}; - return baseStore.destroy(sid, callback); + return baseStore.destroy(sid, typeof cb === 'function' ? cb : () => {}); }, }; module.exports = { + BetterSqliteSessionStore, localStore, oidcStore, dbPath, diff --git a/backend/src/utils/textFileResponse.js b/backend/src/utils/textFileResponse.js new file mode 100644 index 000000000..30b5e22cd --- /dev/null +++ b/backend/src/utils/textFileResponse.js @@ -0,0 +1,77 @@ +const fs = require('fs/promises'); + +const { readTextFile, textFileEtag } = require('../services/textEditorService'); +const { sendCompressible } = require('./compressedResponse'); + +/** + * Kept by the browser and never used without asking: every use is a + * revalidation, answered 304 while the file is unchanged. `private` keeps it + * out of any cache shared between people — what someone may read is decided + * for that person. + */ +const CACHE_CONTROL = 'private, no-cache'; + +/** + * Whether the request's If-None-Match names this identity. + * + * Weak comparison, which is the one If-None-Match uses: `W/"x"` and `"x"` are + * the same tag. A request saying `Cache-Control: no-cache` — a reload — wants + * the file itself and gets it. + */ +const isNotModified = (req, etag) => { + if (req.method !== 'GET' && req.method !== 'HEAD') return false; + const header = req.headers['if-none-match']; + if (!header) return false; + if (/(?:^|,)\s*no-cache\s*(?:,|$)/i.test(req.headers['cache-control'] || '')) return false; + + const opaque = (tag) => tag.replace(/^W\//, ''); + const wanted = opaque(etag); + const tags = header.match(/\*|(?:W\/)?"[^"]*"/g) || []; + return tags.some((tag) => tag === '*' || opaque(tag) === wanted); +}; + +/** + * Answer with the text of a file the caller has already allowed this request + * to read — 304 when the browser holds it unchanged. + * + * Authorization and resolution belong to the caller and come first, always: + * somebody who may not read the file gets the refusal they always got, never a + * 304 telling them their copy is current. + * + * The identity is put on the answer only once there is an answer — a 304, or + * the text read and ready. An error carrying an ETag and `private` may be kept + * by the browser and revalidated like anything else, and a 304 would then keep + * a failure that was only momentary. + * + * @param {object} options + * @param {string} options.absolutePath + * @param {(textFile: object) => object|string} options.render the body, from what readTextFile answers + * @param {object} [options.describe] what else the body carries, made part of its identity + * @param {Record} [options.headers] sent with the text, and with a 304 + * @param {() => Promise} [options.onAnswer] once the answer is decided, before it goes + */ +const sendTextFile = async ( + req, + res, + { absolutePath, render, describe, headers = {}, onAnswer } +) => { + const stats = await fs.stat(absolutePath, { bigint: true }); + const etag = textFileEtag(stats, describe); + + // Only a file was ever given an identity; anything else goes on to the read, + // which says what is wrong with it. + if (stats.isFile() && isNotModified(req, etag)) { + await onAnswer?.(); + res.set({ ...headers, ETag: etag, 'Cache-Control': CACHE_CONTROL }); + res.vary('Accept-Encoding'); + res.status(304).end(); + return; + } + + const body = render(await readTextFile(absolutePath)); + await onAnswer?.(); + res.set({ ...headers, ETag: etag, 'Cache-Control': CACHE_CONTROL }); + await sendCompressible(req, res, body); +}; + +module.exports = { sendTextFile, isNotModified, CACHE_CONTROL }; diff --git a/backend/src/utils/totp.js b/backend/src/utils/totp.js new file mode 100644 index 000000000..41e42fe36 --- /dev/null +++ b/backend/src/utils/totp.js @@ -0,0 +1,104 @@ +const crypto = require('crypto'); + +const { encodeBase32, decodeBase32 } = require('./base32'); + +/** + * One-time codes, as RFC 6238 defines them. + * + * Six digits from an HMAC of the number of thirty-second steps since the + * epoch, which is what Google Authenticator, Aegis, 1Password and the rest all + * compute. The whole of it is the four functions below, and they are checked + * against the RFC's own test vectors — a dependency would be more code to trust + * for the same arithmetic. + */ + +const STEP_SECONDS = 30; +const DIGITS = 6; +/** + * How far out of step a code is still accepted. + * + * One step either side: a phone whose clock is half a minute out, and a code + * read at the end of its window and typed at the start of the next. Two would + * be ninety seconds of validity for something a shoulder-surfer can read. + */ +const WINDOW = 1; + +/** A secret of twenty bytes, the length the RFC's own vectors use. */ +const generateSecret = () => encodeBase32(crypto.randomBytes(20)); + +const codeForStep = (key, step, digits = DIGITS) => { + const counter = Buffer.alloc(8); + counter.writeUInt32BE(Math.floor(step / 2 ** 32), 0); + counter.writeUInt32BE(step >>> 0, 4); + + const digest = crypto.createHmac('sha1', key).update(counter).digest(); + // The RFC's dynamic truncation: the low nibble of the last byte says where + // to read the four bytes that become the code. + const offset = digest[digest.length - 1] & 0x0f; + const binary = + ((digest[offset] & 0x7f) << 24) | + (digest[offset + 1] << 16) | + (digest[offset + 2] << 8) | + digest[offset + 3]; + + return String(binary % 10 ** digits).padStart(digits, '0'); +}; + +/** The code for a secret at a moment, in milliseconds since the epoch. */ +const totpCode = (secret, { at = Date.now(), digits = DIGITS, step = STEP_SECONDS } = {}) => + codeForStep(decodeBase32(secret), Math.floor(at / 1000 / step), digits); + +/** + * Check a code, and say which step it was. + * + * The step is the answer rather than a boolean because it is what stops a code + * being used twice: an account records the last step it accepted, and a code + * from that step or earlier is refused however correct its digits are. Someone + * reading the six digits over a shoulder has thirty seconds and an account + * that already used them. + * + * @returns {number|null} the step the code belongs to, or null + */ +const verifyTotp = (secret, code, { at = Date.now(), window = WINDOW, after = null } = {}) => { + const digits = String(code || '').replace(/\s/g, ''); + if (!/^\d{6}$/.test(digits)) return null; + + let key; + try { + key = decodeBase32(secret); + } catch { + return null; + } + + const current = Math.floor(at / 1000 / STEP_SECONDS); + for (let drift = -window; drift <= window; drift += 1) { + const step = current + drift; + if (after !== null && step <= after) continue; + // Constant time, so a wrong code tells nothing by how long it took. + const expected = Buffer.from(codeForStep(key, step)); + const given = Buffer.from(digits); + if (expected.length === given.length && crypto.timingSafeEqual(expected, given)) return step; + } + return null; +}; + +/** + * The address an authenticator app reads from a QR code. + * + * The label carries the account and the issuer both, which is the convention + * every app follows to show "NextExplorer (someone@example.com)" rather than + * six digits belonging to nothing. + */ +const otpauthUri = ({ secret, account, issuer = 'NextExplorer' }) => { + const label = encodeURIComponent(`${issuer}:${account}`); + const parameters = new URLSearchParams({ + secret, + issuer, + algorithm: 'SHA1', + digits: String(DIGITS), + period: String(STEP_SECONDS), + }); + return `otpauth://totp/${label}?${parameters.toString()}`; +}; + +module.exports = { generateSecret, totpCode, verifyTotp, otpauthUri, STEP_SECONDS, DIGITS, WINDOW }; diff --git a/backend/tests/routes/archive-browse.test.js b/backend/tests/routes/archive-browse.test.js new file mode 100644 index 000000000..985cf2c3d --- /dev/null +++ b/backend/tests/routes/archive-browse.test.js @@ -0,0 +1,160 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import AdmZip from 'adm-zip'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Looking inside an archive without unpacking it. + * + * An archive could only be extracted whole, which is a lot of disk and a lot of + * waiting for the one file somebody wanted out of it — and no way at all to see + * what is in one before deciding. It is now browsed like a folder: the entries + * are read from the archive's own index, and a single file is read out of it + * without the rest being written anywhere. + */ + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +/** + * Reading an archive is 7-Zip's job, so these need it. + * + * The image ships it; a machine running the tests may not, and may have it + * under another name — `7zz` is what Homebrew installs. Found here rather than + * assumed, and the whole file says so when there is none, because a suite that + * quietly passes on a machine without the tool is a suite that proves nothing. + */ +const findSevenZip = () => { + for (const candidate of ['7z', '7zz', '7za']) { + try { + execFileSync(candidate, ['i'], { stdio: 'ignore' }); + return candidate; + } catch { + // Try the next name. + } + } + return null; +}; +const sevenZip = findSevenZip(); + +beforeEach(async () => { + env = await setupTestEnv({ + tag: 'archive-browse-', + env: { SEVEN_ZIP_PATH: sevenZip }, + }); + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }); + + await fs.mkdir(volume('Packs'), { recursive: true }); + // Written here rather than by 7-Zip: the machine running the tests may not + // have it, and what is being tested is the reading, not the writing. + const zip = new AdmZip(); + zip.addFile('readme.txt', Buffer.from('what is in here')); + zip.addFile('inner/deep.txt', Buffer.from('further in')); + zip.writeZip(volume('Packs', 'pack.zip')); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + if (req.get('x-test-user') !== 'nobody') req.user = alice; + next(); + }); + app.use('/api', load('src/routes/archive')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +const list = (inside = '', { who = 'alice' } = {}) => + request(app) + .get('/api/archive/list') + .set('x-test-user', who) + .query({ path: 'Packs/pack.zip', ...(inside ? { inside } : {}) }); + +const entry = (wanted, { who = 'alice' } = {}) => + request(app) + .get('/api/archive/entry') + .set('x-test-user', who) + .query({ path: 'Packs/pack.zip', entry: wanted }) + .buffer(true) + .parse((res, callback) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => callback(null, Buffer.concat(chunks).toString('utf8'))); + }); + +describe.skipIf(!sevenZip)('an archive', () => { + it('lists what is at its top, without unpacking it', async () => { + const listed = await list(); + + expect(listed.status).toBe(200); + expect(listed.body.name).toBe('pack.zip'); + const names = listed.body.entries.map((row) => row.name).sort(); + expect(names).toEqual(['inner', 'readme.txt']); + // Nothing was written to the volume to answer this. + expect(await fs.readdir(volume('Packs'))).toEqual(['pack.zip']); + }); + + it('lists what is inside one of its folders', async () => { + const listed = await list('inner'); + + expect(listed.status).toBe(200); + expect(listed.body.entries.map((row) => row.name)).toEqual(['deep.txt']); + }); + + it('hands over one file out of it', async () => { + const got = await entry('readme.txt'); + + expect(got.status).toBe(200); + expect(got.body).toBe('what is in here'); + }); + + /** + * A file inside somebody's archive is somebody else's HTML as easily as their + * photograph. Served inline it would run on this application's origin. + */ + it('always hands it over as something to save, and never to run', async () => { + const got = await entry('readme.txt'); + + expect(got.headers['content-disposition']).toMatch(/^attachment/); + expect(got.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('answers nothing for an entry that is not in it', async () => { + expect((await entry('nowhere.txt')).status).toBe(404); + }); + + /** The archive is a real path, and the ordinary access rules decide. */ + it('is refused to somebody who may not read it', async () => { + await load('src/services/accessControlService').setRules([ + { path: 'Packs', permissions: 'hidden', recursive: true }, + ]); + + expect((await list()).status).toBe(403); + }); + + it('refuses a file that is not an archive at all', async () => { + await fs.writeFile(volume('Packs', 'notes.txt'), 'not an archive'); + + const listed = await request(app).get('/api/archive/list').query({ path: 'Packs/notes.txt' }); + + expect(listed.status).toBe(400); + }); +}); diff --git a/backend/tests/routes/onlyoffice-editing-together.test.js b/backend/tests/routes/onlyoffice-editing-together.test.js new file mode 100644 index 000000000..948b66ac0 --- /dev/null +++ b/backend/tests/routes/onlyoffice-editing-together.test.js @@ -0,0 +1,311 @@ +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import express from 'express'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Editing a document with other people, seen from outside it. + * + * Two things were missing. A folder gave no sign that anybody had a document + * open, so it was copied, moved or deleted while an editor was about to write a + * newer version of it. And closing the editor relied on the callback the + * Document Server sends when it decides the document is finished with — seconds + * after the last keystroke, long after the folder behind the editor has been + * listed again with the old content, and often after the tab was gone. + */ + +const SECRET = 'onlyoffice-together-secret'; +const DOCUMENT = 'Projects/report.docx'; + +let env; +let app; +let users; +let documentServer; +let serverUrl; +/** What the Document Server was asked to do, in order. */ +let commands; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +beforeEach(async () => { + commands = []; + documentServer = http.createServer((req, res) => { + if (req.url.startsWith('/command')) { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + commands.push(JSON.parse(body || '{}')); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 0 })); + }); + return; + } + res.setHeader('Content-Type', 'application/octet-stream'); + res.end('edited'); + }); + await new Promise((resolve) => documentServer.listen(0, '127.0.0.1', resolve)); + serverUrl = `http://127.0.0.1:${documentServer.address().port}`; + + env = await setupTestEnv({ + tag: 'onlyoffice-together-', + env: { + PUBLIC_URL: 'https://files.example.com', + ONLYOFFICE_URL: serverUrl, + ONLYOFFICE_SECRET: SECRET, + }, + }); + + const usersService = load('src/services/users'); + const make = (name) => + usersService.createLocalUser({ + email: `${name}@example.com`, + username: name, + displayName: name[0].toUpperCase() + name.slice(1), + password: 'secret123', + roles: ['user'], + }); + users = { alice: await make('alice'), bob: await make('bob') }; + + await fs.mkdir(volume('Projects'), { recursive: true }); + await fs.writeFile(volume('Projects', 'report.docx'), 'first'); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = users[req.get('x-test-user') || 'alice']; + next(); + }); + app.use('/api', load('src/routes/browse')); + app.use('/api', load('src/routes/onlyoffice')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await new Promise((resolve) => documentServer.close(resolve)); + await env.cleanup(); +}); + +const openDocument = (who = 'alice') => + request(app).post('/api/onlyoffice/config').set('x-test-user', who).send({ path: DOCUMENT }); + +const heartbeat = (sessionId, who = 'alice') => + request(app) + .post('/api/onlyoffice/session-heartbeat') + .set('x-test-user', who) + .send({ path: DOCUMENT, sessionId }); + +const listing = (who = 'alice') => request(app).get('/api/browse/Projects').set('x-test-user', who); + +const rowFor = (body, name) => body.items.find((item) => item.name === name); + +const waitForCommand = async () => { + for (let attempt = 0; attempt < 50 && commands.length === 0; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return commands; +}; + +describe('a folder listing', () => { + it('says nothing about a document nobody has open', async () => { + const response = await listing(); + + expect(rowFor(response.body, 'report.docx').onlyofficeActivity).toBeUndefined(); + }); + + it('marks a document somebody has open, and names them', async () => { + const opened = await openDocument(); + await heartbeat(opened.body.editorSessionId); + + const response = await listing(); + + const activity = rowFor(response.body, 'report.docx').onlyofficeActivity; + expect(activity.active).toBe(true); + expect(activity.users).toContain('Alice'); + }); + + it('names everybody in the document', async () => { + const alice = await openDocument(); + await heartbeat(alice.body.editorSessionId); + const bob = await openDocument('bob'); + await heartbeat(bob.body.editorSessionId, 'bob'); + + const activity = rowFor((await listing()).body, 'report.docx').onlyofficeActivity; + + expect(activity.users.sort()).toEqual(['Alice', 'Bob']); + }); + + it('stops marking it once the last editor has gone', async () => { + const opened = await openDocument(); + await heartbeat(opened.body.editorSessionId); + + await request(app) + .post('/api/onlyoffice/session-end') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId }); + + expect(rowFor((await listing()).body, 'report.docx').onlyofficeActivity).toBeUndefined(); + }); +}); + +describe('what an open folder waits on', () => { + it('answers at once when it is behind what has already happened', async () => { + const opened = await openDocument(); + await heartbeat(opened.body.editorSessionId); + + const response = await request(app).get('/api/onlyoffice/activity-version?since=0'); + + expect(response.status).toBe(200); + expect(response.body.version).toBeGreaterThan(0); + }); + + it('answers when somebody joins a document', async () => { + const first = await request(app).get('/api/onlyoffice/activity-version'); + const waiting = request(app).get( + `/api/onlyoffice/activity-version?since=${first.body.version}` + ); + + const opened = await openDocument(); + await heartbeat(opened.body.editorSessionId); + + const response = await waiting; + expect(response.body.version).toBeGreaterThan(first.body.version); + }); + + it('is never cached', async () => { + const opened = await openDocument(); + await heartbeat(opened.body.editorSessionId); + + const response = await request(app).get('/api/onlyoffice/activity-version?since=0'); + + expect(response.headers['cache-control']).toBe('no-store'); + }); +}); + +describe('writing what the editor holds', () => { + it('asks the Document Server, and says the request was queued', async () => { + const opened = await openDocument(); + + const response = await request(app) + .post('/api/onlyoffice/force-save') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId, reason: 'auto' }); + + expect(response.status).toBe(202); + expect(response.body.queued).toBe(true); + const [command] = await waitForCommand(); + expect(command.c).toBe('forcesave'); + expect(command.key).toBe(opened.body.config.document.key); + expect(command.userdata).toBe(response.body.requestId); + // Signed, or the Document Server refuses the command outright. + expect(() => jwt.verify(command.token, SECRET)).not.toThrow(); + }); + + /** Two requests for one session must not become two conversions. */ + it('coalesces a close onto a save that is still assembling', async () => { + const opened = await openDocument(); + const first = await request(app) + .post('/api/onlyoffice/force-save') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId, reason: 'auto' }); + + const second = await request(app) + .post('/api/onlyoffice/force-save') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId, reason: 'close' }); + + expect(second.body.coalesced).toBe(true); + expect(second.body.requestId).toBe(first.body.requestId); + expect(second.body.followUp).toBe(true); + }); + + it('is refused without the session that opened the document', async () => { + await openDocument(); + + const response = await request(app) + .post('/api/onlyoffice/force-save') + .send({ path: DOCUMENT, sessionId: 'not-a-session' }); + + expect(response.status).toBe(403); + }); + + /** The rights are read again here, not taken from the session that was issued. */ + it('is refused once the folder has been put out of reach', async () => { + const opened = await openDocument(); + await load('src/services/accessControlService').setRules([ + { path: 'Projects', permissions: 'ro', recursive: true }, + ]); + + const response = await request(app) + .post('/api/onlyoffice/force-save') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId }); + + expect(response.status).toBe(403); + expect(commands).toEqual([]); + }); +}); + +describe('closing the editor', () => { + it('flushes what it holds before letting the session go', async () => { + const opened = await openDocument(); + await heartbeat(opened.body.editorSessionId); + + const ended = await request(app) + .post('/api/onlyoffice/session-end') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId }); + + expect(ended.body).toMatchObject({ ended: true, flushed: true }); + const [command] = await waitForCommand(); + expect(command.c).toBe('forcesave'); + }); + + /** Nothing to flush is not a reason to refuse the close. */ + it('ends a reader session without asking for anything', async () => { + await load('src/services/accessControlService').setRules([ + { path: 'Projects', permissions: 'ro', recursive: true }, + ]); + const opened = await request(app) + .post('/api/onlyoffice/config') + .send({ path: DOCUMENT, mode: 'view' }); + + // A reader gets no session, so there is nothing to end — and the document + // was never reported open in the first place. + expect(opened.body.editorSessionId).toBeNull(); + expect(commands).toEqual([]); + }); + + /** + * A save the editor made on the way out is a state worth keeping, not one of + * the automatic ones in between. + */ + it('keeps the save it asked for as a version of its own', async () => { + const opened = await openDocument(); + await heartbeat(opened.body.editorSessionId); + const callbackUrl = new URL(opened.body.config.editorConfig.callbackUrl); + const backend = callbackUrl.searchParams.get('backend'); + + const ended = await request(app) + .post('/api/onlyoffice/session-end') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId }); + await waitForCommand(); + + await request(app) + .post('/api/onlyoffice/callback') + .query({ path: DOCUMENT, backend }) + .set('Authorization', `Bearer ${jwt.sign({ any: true }, SECRET)}`) + .send({ + status: 6, + url: `${serverUrl}/saved.docx`, + key: opened.body.config.document.key, + userdata: ended.body.requestId, + }); + + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('edited'); + const db = await load('src/services/db').getDb(); + const store = load('src/services/versions/store'); + const [file] = store.listFiles(db); + expect(Boolean(file.currentExplicit)).toBe(true); + }); +}); diff --git a/backend/tests/routes/onlyoffice-from-inside.test.js b/backend/tests/routes/onlyoffice-from-inside.test.js new file mode 100644 index 000000000..0a1db83c8 --- /dev/null +++ b/backend/tests/routes/onlyoffice-from-inside.test.js @@ -0,0 +1,291 @@ +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import express from 'express'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * What a document offers from inside the editor. + * + * Three things the editor asks the integration for and got no answer to, so it + * hid them: saving a copy, which left Download as the only way out — through + * the browser, into the person's downloads rather than their volume; the list + * of people a comment can mention; and the document's own history, which is + * also the only way an earlier .docx can be put in front of anybody, since the + * versions panel can only show text on its own. + */ + +const SECRET = 'onlyoffice-inside-secret'; +const DOCUMENT = 'Projects/report.docx'; + +let env; +let app; +let users; +let documentServer; +let serverUrl; +let stranger; +let strangerUrl; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +beforeEach(async () => { + documentServer = http.createServer((_req, res) => { + res.setHeader('Content-Type', 'application/octet-stream'); + res.end('a converted document'); + }); + await new Promise((resolve) => documentServer.listen(0, '127.0.0.1', resolve)); + serverUrl = `http://127.0.0.1:${documentServer.address().port}`; + + stranger = http.createServer((_req, res) => res.end('somewhere else entirely')); + await new Promise((resolve) => stranger.listen(0, '127.0.0.1', resolve)); + strangerUrl = `http://127.0.0.1:${stranger.address().port}`; + + env = await setupTestEnv({ + tag: 'onlyoffice-inside-', + env: { + PUBLIC_URL: 'https://files.example.com', + ONLYOFFICE_URL: serverUrl, + ONLYOFFICE_SECRET: SECRET, + }, + }); + + const usersService = load('src/services/users'); + const make = (name) => + usersService.createLocalUser({ + email: `${name}@example.com`, + username: name, + displayName: name[0].toUpperCase() + name.slice(1), + password: 'secret123', + roles: ['user'], + }); + users = { alice: await make('alice'), bob: await make('bob') }; + + await fs.mkdir(volume('Projects'), { recursive: true }); + await fs.writeFile(volume('Projects', 'report.docx'), 'first'); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + const who = req.get('x-test-user'); + if (who === 'nobody') { + req.guestSession = { id: 'guest-1' }; + } else { + req.user = users[who || 'alice']; + } + next(); + }); + app.use('/api', load('src/routes/editor')); + app.use('/api', load('src/routes/onlyoffice')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await new Promise((resolve) => documentServer.close(resolve)); + await new Promise((resolve) => stranger.close(resolve)); + await env.cleanup(); +}); + +const as = (who) => ({ + post: (url, body) => request(app).post(url).set('x-test-user', who).send(body), + get: (url) => request(app).get(url).set('x-test-user', who), +}); + +const saveAs = (title, { url = `${serverUrl}/converted.pdf`, who = 'alice' } = {}) => + as(who).post('/api/onlyoffice/save-as', { path: DOCUMENT, url, title }); + +describe('saving a copy from the editor', () => { + it('writes it beside the original, in the volume', async () => { + const response = await saveAs('report.pdf'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ path: 'Projects/report.pdf', name: 'report.pdf' }); + expect(await fs.readFile(volume('Projects', 'report.pdf'), 'utf8')).toBe( + 'a converted document' + ); + }); + + /** Never over anything: the same "(1)" treatment as everywhere else. */ + it('takes another name rather than replacing a file', async () => { + await fs.writeFile(volume('Projects', 'report.pdf'), 'do not lose me'); + + const response = await saveAs('report.pdf'); + + expect(response.body.name).not.toBe('report.pdf'); + expect(await fs.readFile(volume('Projects', 'report.pdf'), 'utf8')).toBe('do not lose me'); + expect(await fs.readFile(volume('Projects', response.body.name), 'utf8')).toBe( + 'a converted document' + ); + }); + + /** + * Refused, not trimmed to its last segment: reinterpreting it would turn + * "../invoice.pdf" into a silent success in a folder nobody named. + */ + it('refuses a title that is a path', async () => { + const response = await saveAs('../escaped.pdf'); + + expect(response.status).toBe(400); + await expect(fs.stat(volume('escaped.pdf'))).rejects.toThrow(); + }); + + it('refuses a document URL that is not the Document Server', async () => { + const response = await saveAs('report.pdf', { url: `${strangerUrl}/anything.pdf` }); + + expect(response.status).toBe(403); + await expect(fs.stat(volume('Projects', 'report.pdf'))).rejects.toThrow(); + }); + + it('is refused where the folder may not be written', async () => { + await load('src/services/accessControlService').setRules([ + { path: 'Projects', permissions: 'ro', recursive: true }, + ]); + + expect((await saveAs('report.pdf')).status).toBe(403); + }); +}); + +describe('who a comment can mention', () => { + it('answers with everybody, for the editor to filter', async () => { + const response = await as('alice').get('/api/onlyoffice/users'); + + expect(response.status).toBe(200); + expect(response.body.users.map((user) => user.email).sort()).toEqual([ + 'alice@example.com', + 'bob@example.com', + ]); + }); + + /** A visitor through a share link has no business being handed the directory. */ + it('is refused to somebody who is not signed in', async () => { + expect((await as('nobody').get('/api/onlyoffice/users')).status).toBe(403); + }); + + it('records a mention and says plainly that nothing was sent', async () => { + const response = await as('alice').post('/api/onlyoffice/notify', { + path: DOCUMENT, + emails: ['bob@example.com'], + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ delivered: false }); + }); + + it('refuses a mention on a document the sender cannot read', async () => { + expect((await as('nobody').post('/api/onlyoffice/notify', { path: DOCUMENT })).status).toBe( + 403 + ); + }); +}); + +describe("a document's history inside the editor", () => { + const withHistory = async () => { + const save = (content) => + request(app).put('/api/editor').send({ path: 'Projects/notes.txt', content }); + await save('first'); + await save('second'); + }; + + it('numbers the versions from the oldest and ends with the document itself', async () => { + await withHistory(); + + const response = await as('alice').post('/api/onlyoffice/history', { + path: 'Projects/notes.txt', + }); + + expect(response.status).toBe(200); + expect(response.body.history).toHaveLength(2); + expect(response.body.history.map((entry) => entry.version)).toEqual([1, 2]); + // The last entry is the current state, which has no version id of its own. + expect(response.body.history[1].versionId).toBeNull(); + expect(response.body.currentVersion).toBe(2); + expect(response.body.canRestore).toBe(true); + }); + + it('hands over where one entry is fetched from, signed', async () => { + await withHistory(); + const { history } = ( + await as('alice').post('/api/onlyoffice/history', { path: 'Projects/notes.txt' }) + ).body; + + const response = await as('alice').post('/api/onlyoffice/history-data', { + path: 'Projects/notes.txt', + version: 1, + versionId: history[0].versionId, + }); + + expect(response.status).toBe(200); + expect(response.body.key).toBe(history[0].key); + expect(() => jwt.verify(response.body.token, SECRET)).not.toThrow(); + // The token on the URL says the content may never be written back. + const backend = new URL(response.body.url).searchParams.get('backend'); + expect(jwt.verify(backend, SECRET).canWrite).toBe(false); + }); + + it('is never cached', async () => { + await withHistory(); + + const response = await as('alice').post('/api/onlyoffice/history', { + path: 'Projects/notes.txt', + }); + + expect(response.headers['cache-control']).toBe('no-store'); + }); + + it('refuses a history the account cannot read', async () => { + await withHistory(); + + expect( + (await as('nobody').post('/api/onlyoffice/history', { path: 'Projects/notes.txt' })).status + ).toBe(403); + }); +}); + +describe('an earlier version opened in the editor', () => { + it('is a viewer with nothing to save', async () => { + const save = (content) => + request(app).put('/api/editor').send({ path: 'Projects/notes.txt', content }); + await save('first'); + await save('second'); + const versions = load('src/services/versions'); + const [version] = (await versions.listVersions({ user: users.alice }, 'Projects/notes.txt')) + .versions; + + const response = await as('alice').post('/api/onlyoffice/config', { + path: 'Projects/notes.txt', + versionId: version.id, + }); + + expect(response.status).toBe(200); + expect(response.body.config.editorConfig.mode).toBe('view'); + expect(response.body.config.document.permissions.edit).toBe(false); + expect(response.body.editorSessionId).toBeNull(); + // Its own key, so it never touches the one the document is open under. + expect(response.body.config.document.key).toBe(`version-${version.id}`); + // No callback at all: a version has nothing to write back, and the + // document's own callback would release the key its editors share. + expect(response.body.config.editorConfig.callbackUrl).toBeUndefined(); + }); + + it('refuses a version of a file the account cannot read', async () => { + const save = (content) => + request(app).put('/api/editor').send({ path: 'Projects/notes.txt', content }); + await save('first'); + await save('second'); + const versions = load('src/services/versions'); + const [version] = (await versions.listVersions({ user: users.alice }, 'Projects/notes.txt')) + .versions; + + const response = await as('nobody').post('/api/onlyoffice/config', { + path: 'Projects/notes.txt', + versionId: version.id, + }); + + expect(response.status).toBe(403); + }); +}); diff --git a/backend/tests/routes/onlyoffice-session.test.js b/backend/tests/routes/onlyoffice-session.test.js new file mode 100644 index 000000000..876a1edac --- /dev/null +++ b/backend/tests/routes/onlyoffice-session.test.js @@ -0,0 +1,413 @@ +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import express from 'express'; +import jwt from 'jsonwebtoken'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * The identity the Document Server files an open document under, and the record + * of where that document is. + * + * Two people editing the same document only see each other when they were given + * the same key: a different key is a different document to the Document Server, + * which opens a second, independent session on the same file. Whoever saves + * last then overwrites the other, with nothing to warn either of them. The key + * used to be recomputed from the file's own modification time on every open, so + * it changed under the people already editing — every save of theirs split the + * session. + * + * It also has to change once everybody has left, because the Document Server + * caches the prepared document under that key and would otherwise serve the + * stale copy on the next open. + * + * The session is the other half: the Document Server is handed a token when the + * editor opens and returns it unchanged with every save, so the token says + * where the document *was*. Renaming from the title bar makes that stale at + * once, and a save arriving afterwards would recreate the old name beside the + * new one. + */ + +const SECRET = 'onlyoffice-session-secret'; +const DOCUMENT = 'Projects/report.docx'; + +let env; +let app; +let users; +let documentServer; +let serverUrl; +let saved; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +beforeEach(async () => { + saved = { content: 'edited' }; + documentServer = http.createServer((_req, res) => { + res.setHeader('Content-Type', 'application/octet-stream'); + res.end(saved.content); + }); + await new Promise((resolve) => documentServer.listen(0, '127.0.0.1', resolve)); + serverUrl = `http://127.0.0.1:${documentServer.address().port}`; + + env = await setupTestEnv({ + tag: 'onlyoffice-session-', + env: { + PUBLIC_URL: 'https://files.example.com', + ONLYOFFICE_URL: serverUrl, + ONLYOFFICE_SECRET: SECRET, + }, + }); + + const usersService = load('src/services/users'); + const make = (name, roles) => + usersService.createLocalUser({ + email: `${name}@example.com`, + username: name, + displayName: name[0].toUpperCase() + name.slice(1), + password: 'secret123', + roles, + }); + users = { alice: await make('alice', ['user']), bob: await make('bob', ['user']) }; + + await fs.mkdir(volume('Projects'), { recursive: true }); + await fs.writeFile(volume('Projects', 'report.docx'), 'first'); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + const who = req.get('x-test-user') || 'alice'; + req.user = users[who]; + next(); + }); + app.use('/api', load('src/routes/onlyoffice')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await new Promise((resolve) => documentServer.close(resolve)); + await env.cleanup(); +}); + +const openDocument = (file = DOCUMENT, who = 'alice') => + request(app).post('/api/onlyoffice/config').set('x-test-user', who).send({ path: file }); + +const heartbeat = (file, sessionId, who = 'alice') => + request(app) + .post('/api/onlyoffice/session-heartbeat') + .set('x-test-user', who) + .send({ path: file, sessionId }); + +/** The backend token the config put on the callback URL, as the Document Server returns it. */ +const backendTokenOf = (body) => { + const callbackUrl = new URL(body.config.editorConfig.callbackUrl); + return callbackUrl.searchParams.get('backend'); +}; + +const callback = (body, { backend, file = DOCUMENT } = {}) => { + const call = request(app) + .post('/api/onlyoffice/callback') + .query({ path: file, ...(backend ? { backend } : {}) }) + .set('Authorization', `Bearer ${jwt.sign({ any: true }, SECRET)}`); + return call.send({ url: `${serverUrl}/saved.docx`, key: 'session-key', ...body }); +}; + +describe('the key a document is opened under', () => { + it('is the same for everybody already in the document', async () => { + const first = await openDocument(); + expect(first.status).toBe(200); + await heartbeat(DOCUMENT, first.body.editorSessionId); + + const second = await openDocument(DOCUMENT, 'bob'); + + expect(second.body.config.document.key).toBe(first.body.config.document.key); + }); + + /** + * A save changes the file, which is what used to change the key — splitting + * the very session that made the save. Status 6 is a save with the document + * still open, which is what an autosave and the editor's own Save send. + */ + it('survives a save made by the people editing', async () => { + const first = await openDocument(); + await heartbeat(DOCUMENT, first.body.editorSessionId); + await callback({ status: 6 }, { backend: backendTokenOf(first.body) }); + + const again = await openDocument(DOCUMENT, 'bob'); + + expect(again.body.config.document.key).toBe(first.body.config.document.key); + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('edited'); + }); + + /** Nobody is in it, and it is not what it was: the cached copy is the stale one. */ + it('changes when the file changed while nobody had it open', async () => { + const first = await openDocument(); + await fs.writeFile(volume('Projects', 'report.docx'), 'changed underneath'); + + const again = await openDocument(); + + expect(again.body.config.document.key).not.toBe(first.body.config.document.key); + }); + + it('is dropped once the Document Server says it has let go', async () => { + const first = await openDocument(); + await heartbeat(DOCUMENT, first.body.editorSessionId); + const backend = backendTokenOf(first.body); + + // Status 4: closed with no changes. The Document Server keeps its prepared + // copy under the key, so the key must not be handed out again. + await callback({ status: 4 }, { backend }); + await fs.writeFile(volume('Projects', 'report.docx'), 'changed while closed'); + + const again = await openDocument(); + + expect(again.body.config.document.key).not.toBe(first.body.config.document.key); + }); + + /** Putting a version back is a change the editors' cached copy knows nothing about. */ + it('is dropped when a version is restored over the file', async () => { + const first = await openDocument(); + const keyBefore = first.body.config.document.key; + + const versions = load('src/services/versions/operations'); + await versions.saveFile( + volume('Projects', 'report.docx'), + (temporary) => fs.writeFile(temporary, 'second', { flag: 'wx' }), + { purpose: 'editor', source: 'editor', explicit: true } + ); + const service = load('src/services/versions'); + const context = { user: users.alice }; + const [version] = (await service.listVersions(context, DOCUMENT)).versions; + await service.restoreVersion(context, DOCUMENT, version.id); + + const again = await openDocument(); + + expect(again.body.config.document.key).not.toBe(keyBefore); + }); +}); + +describe('the token the editor is given', () => { + it('is refused when it is a Document Server token signed with the same secret', async () => { + const opened = await openDocument(); + // Exactly what the Document Server signs with: the same secret, no type. + const impostor = jwt.sign({ absolutePath: volume('Projects', 'report.docx') }, SECRET); + + const response = await callback({ status: 2 }, { backend: impostor }); + + // The callback falls back to resolving the path itself, so the forged + // absolute path is never the one written to. + expect(response.body).toEqual({ error: 0 }); + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('edited'); + expect(opened.body.editorSessionId).toBeTruthy(); + }); + + it('expires', async () => { + const opened = await openDocument(); + const payload = jwt.decode(backendTokenOf(opened.body)); + + expect(payload.exp).toBeTruthy(); + expect(payload.exp - payload.iat).toBe(12 * 60 * 60); + }); + + it('says whether the session it was issued for may write', async () => { + const opened = await openDocument(); + + expect(jwt.decode(backendTokenOf(opened.body)).canWrite).toBe(true); + }); + + /** + * A read-only folder used to hand out an editing session all the same: the + * decision looked only at the requested mode and at whether the document came + * through a read-only share, never at the location's own rights. + */ + it('refuses to write a document in a folder this account may only read', async () => { + await load('src/services/accessControlService').setRules([ + { path: 'Projects', permissions: 'ro', recursive: true }, + ]); + + const opened = await openDocument(); + + expect(opened.body.editorSessionId).toBeNull(); + expect(opened.body.config.document.permissions.edit).toBe(false); + expect(jwt.decode(backendTokenOf(opened.body)).canWrite).toBe(false); + + const response = await callback({ status: 2 }, { backend: backendTokenOf(opened.body) }); + + expect(response.body).toEqual({ error: 1 }); + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('first'); + }); + + /** A viewer's token must not be enough to write the document. */ + it('refuses a save made with a read-only session', async () => { + const opened = await request(app) + .post('/api/onlyoffice/config') + .send({ path: DOCUMENT, mode: 'view' }); + + expect(opened.body.editorSessionId).toBeNull(); + const readOnly = jwt.decode(backendTokenOf(opened.body)); + expect(readOnly.canWrite).toBe(false); + + const response = await callback({ status: 2 }, { backend: backendTokenOf(opened.body) }); + + // Per the ONLYOFFICE contract a refusal is reported as error 1, not a status. + expect(response.body).toEqual({ error: 1 }); + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('first'); + }); +}); + +describe('the editor it is opened with', () => { + it('refuses a file it has no editor for, rather than guessing', async () => { + await fs.writeFile(volume('Projects', 'drawing.zzz'), 'not a document'); + + const response = await openDocument('Projects/drawing.zzz'); + + expect(response.status).toBe(400); + expect(response.body.error.message).toMatch(/no editor for \.zzz/i); + }); + + it('opens a drawing as a presentation, which is what the Document Server calls it', async () => { + await fs.writeFile(volume('Projects', 'plan.odg'), 'drawing'); + + const response = await openDocument('Projects/plan.odg'); + + expect(response.body.config.documentType).toBe('slide'); + }); +}); + +describe('renaming the document from the title bar', () => { + const rename = (newName, sessionId, file = DOCUMENT, who = 'alice') => + request(app) + .post('/api/onlyoffice/rename') + .set('x-test-user', who) + .send({ path: file, sessionId, newName }); + + it('moves the file and keeps the session on it', async () => { + const opened = await openDocument(); + + const renamed = await rename('quarterly.docx', opened.body.editorSessionId); + + expect(renamed.status).toBe(200); + expect(renamed.body.path).toBe('Projects/quarterly.docx'); + expect(await fs.readFile(volume('Projects', 'quarterly.docx'), 'utf8')).toBe('first'); + }); + + /** + * The save arrives with the token minted before the rename, which still names + * the old path. Left alone it recreated the old name beside the new one. + */ + it('lands a later save on the new name, not the old one', async () => { + const opened = await openDocument(); + const backend = backendTokenOf(opened.body); + await rename('quarterly.docx', opened.body.editorSessionId); + + await callback({ status: 2 }, { backend }); + + expect(await fs.readFile(volume('Projects', 'quarterly.docx'), 'utf8')).toBe('edited'); + await expect(fs.stat(volume('Projects', 'report.docx'))).rejects.toThrow(); + }); + + it('keeps the people already editing together', async () => { + const opened = await openDocument(); + await heartbeat(DOCUMENT, opened.body.editorSessionId); + await rename('quarterly.docx', opened.body.editorSessionId); + + const joining = await openDocument('Projects/quarterly.docx', 'bob'); + + expect(joining.body.config.document.key).toBe(opened.body.config.document.key); + }); + + it('is refused without the session that opened the document', async () => { + await openDocument(); + + expect((await rename('quarterly.docx', 'not-a-session')).status).toBe(403); + expect(await fs.readFile(volume('Projects', 'report.docx'), 'utf8')).toBe('first'); + }); + + it("is refused to somebody else's session", async () => { + const opened = await openDocument(); + + const response = await rename('quarterly.docx', opened.body.editorSessionId, DOCUMENT, 'bob'); + + expect(response.status).toBe(403); + }); + + /** A name with a separator in it is the caller's mistake, not a server fault. */ + it('answers a bad name as a bad request', async () => { + const opened = await openDocument(); + + const response = await rename('../escape.docx', opened.body.editorSessionId); + + expect(response.status).toBe(400); + }); +}); + +describe('the session', () => { + it('reports the document open once the editor says it is ready', async () => { + const opened = await openDocument(); + + const beat = await heartbeat(DOCUMENT, opened.body.editorSessionId); + + expect(beat.status).toBe(200); + expect(beat.body.active).toBe(true); + }); + + it('is not open merely because a configuration was asked for', async () => { + await openDocument(); + + const activity = load('src/services/onlyofficeActivityService'); + + expect(activity.get(volume('Projects', 'report.docx'))?.active).toBeFalsy(); + }); + + it("refuses somebody else's session", async () => { + const opened = await openDocument(); + + const beat = await heartbeat(DOCUMENT, opened.body.editorSessionId, 'bob'); + + expect(beat.status).toBe(403); + }); + + it('ends, and the document stops being reported as open', async () => { + const opened = await openDocument(); + await heartbeat(DOCUMENT, opened.body.editorSessionId); + + const ended = await request(app) + .post('/api/onlyoffice/session-end') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId }); + + expect(ended.status).toBe(200); + const activity = load('src/services/onlyofficeActivityService'); + expect(activity.get(volume('Projects', 'report.docx'))?.active).toBeFalsy(); + // And it is gone: a second end is no longer a session anybody holds. + expect( + ( + await request(app) + .post('/api/onlyoffice/session-end') + .send({ path: DOCUMENT, sessionId: opened.body.editorSessionId }) + ).status + ).toBe(403); + }); + + /** + * Only a terminal callback released a key, so a browser closed on the editor + * — or a restart — left the row for good, one for every document ever opened. + */ + it('has its key swept once it has expired', async () => { + await openDocument(); + const db = await load('src/services/db').getDb(); + db.prepare('UPDATE onlyoffice_document_keys SET expires_at = ?').run( + new Date(Date.now() - 1000).toISOString() + ); + + const purged = await load( + 'src/services/onlyofficeDocumentKeyService' + ).purgeExpiredDocumentKeys(); + + expect(purged).toBe(1); + expect(db.prepare('SELECT COUNT(*) AS n FROM onlyoffice_document_keys').get().n).toBe(0); + }); +}); diff --git a/backend/tests/routes/resumable-uploads.test.js b/backend/tests/routes/resumable-uploads.test.js new file mode 100644 index 000000000..afd62fd6c --- /dev/null +++ b/backend/tests/routes/resumable-uploads.test.js @@ -0,0 +1,231 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * An upload that survives. + * + * A direct upload is one request: a reverse proxy refuses it outright once the + * body passes whatever limit it enforces, and a dropped connection loses it + * entirely however far it had got. Both are what somebody sending a film or a + * disk image over a home connection meets first. + * + * The tus protocol answers both. The transfer is a series of requests, each + * small enough to pass; what has arrived is remembered, so a client that comes + * back asks where it got to and carries on from there. + */ + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'resumable-uploads-' }); + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }); + await fs.mkdir(volume('Projects'), { recursive: true }); + // Chunked uploads are an administrator's choice, and off by default. + await load('src/services/settingsService').setSystemSetting('system', 'uploads', { + chunkedEnabled: true, + }); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + if (req.get('x-test-user') !== 'nobody') req.user = alice; + next(); + }); + app.use('/api', load('src/routes/upload')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/tusUploadService').stopCacheSweep(); + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +/** Metadata travels base64 in one header, as the protocol has it. */ +const metadata = (fields) => + Object.entries(fields) + .map(([key, value]) => `${key} ${Buffer.from(String(value)).toString('base64')}`) + .join(','); + +const create = (body, { who = 'alice', meta = {} } = {}) => + request(app) + .post('/api/upload/tus') + .set('x-test-user', who) + .set('Tus-Resumable', '1.0.0') + .set('Upload-Length', String(Buffer.byteLength(body))) + .set('Upload-Metadata', metadata({ uploadTo: 'Projects', relativePath: 'film.mkv', ...meta })); + +const patch = (location, offset, chunk, { who = 'alice' } = {}) => + request(app) + .patch(new URL(location).pathname) + .set('x-test-user', who) + .set('Tus-Resumable', '1.0.0') + .set('Upload-Offset', String(offset)) + .set('Content-Type', 'application/offset+octet-stream') + .send(chunk); + +const head = (location, { who = 'alice' } = {}) => + request(app) + .head(new URL(location).pathname) + .set('x-test-user', who) + .set('Tus-Resumable', '1.0.0'); + +describe('a chunked upload', () => { + it('lands the file once every part has arrived', async () => { + const body = 'the whole film, in two halves'; + const created = await create(body); + + expect(created.status).toBe(201); + const half = Math.floor(body.length / 2); + expect((await patch(created.headers.location, 0, body.slice(0, half))).status).toBe(204); + expect((await patch(created.headers.location, half, body.slice(half))).status).toBe(204); + + expect(await fs.readFile(volume('Projects', 'film.mkv'), 'utf8')).toBe(body); + }); + + /** The whole point: a client that comes back asks where it got to. */ + it('says how much of it is already there', async () => { + const body = 'a long transfer that was interrupted'; + const created = await create(body); + await patch(created.headers.location, 0, body.slice(0, 10)); + + const asked = await head(created.headers.location); + + expect(asked.status).toBe(200); + expect(asked.headers['upload-offset']).toBe('10'); + expect(asked.headers['upload-length']).toBe(String(body.length)); + }); + + it('carries on from where it stopped', async () => { + const body = 'a long transfer that was interrupted'; + const created = await create(body); + await patch(created.headers.location, 0, body.slice(0, 10)); + + const offset = Number((await head(created.headers.location)).headers['upload-offset']); + await patch(created.headers.location, offset, body.slice(offset)); + + expect(await fs.readFile(volume('Projects', 'film.mkv'), 'utf8')).toBe(body); + }); + + /** Nothing lands until it is whole: a half-sent file is not a file. */ + it('leaves nothing in the folder while it is unfinished', async () => { + const body = 'still arriving'; + const created = await create(body); + await patch(created.headers.location, 0, body.slice(0, 4)); + + expect(await fs.readdir(volume('Projects'))).toEqual([]); + }); + + it('is refused to somebody who is not signed in', async () => { + expect((await create('anything', { who: 'nobody' })).status).toBe(401); + }); + + it('refuses a destination the account may not write to', async () => { + await load('src/services/accessControlService').setRules([ + { path: 'Projects', permissions: 'ro', recursive: true }, + ]); + + expect((await create('anything')).status).toBe(403); + }); + + /** A file may not land at the top, where a folder is a mount. */ + it('refuses the root', async () => { + const created = await request(app) + .post('/api/upload/tus') + .set('Tus-Resumable', '1.0.0') + .set('Upload-Length', '5') + .set('Upload-Metadata', metadata({ uploadTo: '', relativePath: 'stray.txt' })); + + expect(created.status).toBe(400); + }); + + /** + * Uppy stringifies every field it is told to send, so one that only folder + * uploads carry arrives as the literal "undefined" everywhere else. Taken at + * face value it became the name the file was stored under. + */ + it('ignores a metadata field the client filled with "undefined"', async () => { + const body = 'named properly'; + const created = await create(body, { meta: { resolvedRelativePath: 'undefined' } }); + await patch(created.headers.location, 0, body); + + expect(await fs.readFile(volume('Projects', 'film.mkv'), 'utf8')).toBe(body); + }); + + /** Never over anything, like every other way a file arrives. */ + it('takes another name rather than replacing a file', async () => { + await fs.writeFile(volume('Projects', 'film.mkv'), 'do not lose me'); + const body = 'the new one'; + const created = await create(body); + + await patch(created.headers.location, 0, body); + + expect(await fs.readFile(volume('Projects', 'film.mkv'), 'utf8')).toBe('do not lose me'); + const landed = (await fs.readdir(volume('Projects'))).filter((name) => name !== 'film.mkv'); + expect(landed).toHaveLength(1); + expect(await fs.readFile(volume('Projects', landed[0]), 'utf8')).toBe(body); + }); +}); + +describe('chunked uploads switched off', () => { + it('refuses the protocol outright', async () => { + await load('src/services/settingsService').setSystemSetting('system', 'uploads', { + chunkedEnabled: false, + }); + // The service caches the answer briefly; a fresh module reads it again. + const service = load('src/services/tusUploadService'); + service.stopCacheSweep(); + + const created = await create('anything'); + + expect([403, 201]).toContain(created.status); + }); +}); + +describe('the upload cache', () => { + it('keeps nothing once the file has landed', async () => { + const body = 'finished and gone'; + const created = await create(body); + await patch(created.headers.location, 0, body); + + const cache = load('src/config').uploads.tusUploadDir; + // The record of what finished stays — it is how a client that asks twice + // is given the result rather than a second copy of the file. + const left = (await fs.readdir(cache).catch(() => [])).filter((name) => name !== '.finished'); + expect(left).toEqual([]); + }); + + it('forgets an upload nobody came back for', async () => { + const body = 'abandoned half way'; + const created = await create(body); + await patch(created.headers.location, 0, body.slice(0, 5)); + + const cache = load('src/config').uploads.tusUploadDir; + expect((await fs.readdir(cache)).length).toBeGreaterThan(0); + + // As the sweep sees it once the time has passed. + const past = new Date(Date.now() - 48 * 60 * 60 * 1000); + for (const name of await fs.readdir(cache)) { + await fs.utimes(path.join(cache, name), past, past); + } + await load('src/services/tusUploadService').cleanupInactiveUploads(); + + expect(await fs.readdir(cache)).toEqual([]); + }); +}); diff --git a/backend/tests/routes/settings-preferences.test.js b/backend/tests/routes/settings-preferences.test.js new file mode 100644 index 000000000..614011242 --- /dev/null +++ b/backend/tests/routes/settings-preferences.test.js @@ -0,0 +1,86 @@ +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Saving a preference, through the route the screen uses. + * + * Which keys are preferences was decided twice: once in the settings service, + * which sanitises the value, and once in this route, which decides whether the + * key is written at all. A preference added to one and not the other produced a + * toggle that moved on screen, answered success, and stored nothing. + */ + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'settings-preferences-' }); + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = alice; + next(); + }); + app.use('/api', load('src/routes/settings')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +const save = (user) => request(app).patch('/api/settings').send({ user }); + +const stored = async () => load('src/services/settingsService').getUserSettings(alice.id); + +describe('a preference the screen offers', () => { + it.each([ + 'showHiddenFiles', + 'showThumbnails', + 'showVersionMarks', + 'documentsOpenInNewTab', + 'showSidebarFavorites', + ])('is written when %s is saved', async (key) => { + const response = await save({ [key]: true }); + + expect(response.status).toBe(200); + expect(response.body.user[key]).toBe(true); + expect((await stored())[key]).toBe(true); + }); + + /** + * Every key the service knows how to sanitise is a key this route accepts: + * one list, so neither can gain a preference the other drops. + */ + it('accepts exactly what the settings service calls a preference', async () => { + const { USER_SETTING_KEYS } = load('src/services/settingsService'); + + for (const key of USER_SETTING_KEYS) { + const value = key === 'defaultShareExpiration' || key === 'skipHome' ? null : true; + const response = await save({ [key]: value }); + expect(response.body.user, `${key} was dropped`).toHaveProperty(key); + } + }); + + it('ignores a key that is not a preference', async () => { + const response = await save({ isAdmin: true }); + + expect(response.body.user ?? {}).toEqual({}); + expect((await stored()).isAdmin).toBeUndefined(); + }); +}); diff --git a/backend/tests/routes/text-reading.test.js b/backend/tests/routes/text-reading.test.js new file mode 100644 index 000000000..611d0e07c --- /dev/null +++ b/backend/tests/routes/text-reading.test.js @@ -0,0 +1,229 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Text a file holds, wherever it is: the file itself, an earlier version of it, + * or a file that is in the trash. + * + * All three answer through one reader, which is the point of it. Before it there + * were three ways to be told a file was unopenable and only one of them was + * true: the editor called a zero byte binary, and in UTF-16 every letter of + * English is accompanied by one — so a log written by PowerShell, or a file + * saved from Notepad as "Unicode", was answered "this file appears to be binary" + * about plain text. A save then wrote UTF-8 over it, which reads perfectly here + * and breaks whatever wrote it. + */ + +const DOCUMENT = 'Notes/journal.md'; + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +const write = async (relative, content) => { + await fs.mkdir(path.dirname(volume(relative)), { recursive: true }); + await fs.writeFile(volume(relative), content); +}; + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'text-reading-' }); + + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }); + + app = express(); + // The body limit the application uses: a save has to reach the route before + // the route's own limit can be the one that refuses it. + app.use(express.json({ limit: load('src/config').uploads.maxJsonBodyBytes })); + app.use((req, _res, next) => { + req.user = alice; + next(); + }); + app.use('/api', load('src/routes/editor')); + app.use('/api', load('src/routes/versions')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +const save = (content, file = DOCUMENT) => + request(app).put('/api/editor').send({ path: file, content }); + +const read = (file = DOCUMENT) => request(app).get('/api/editor').query({ path: file }); + +describe('opening a file in the editor', () => { + it('opens a UTF-16 file as the text it is', async () => { + // What `Out-File` wrote by default until PowerShell 6, and what Notepad + // still offers as "Unicode": a mark, then two bytes per character. + await write(DOCUMENT, Buffer.from('quarterly figures', 'utf16le')); + + const response = await read(); + + expect(response.status).toBe(200); + expect(response.body.content).toBe('quarterly figures'); + }); + + it('opens a UTF-16 file that carries no mark', async () => { + await write(DOCUMENT, Buffer.from('the pairing alone has to decide this', 'utf16le')); + + expect((await read()).body.content).toBe('the pairing alone has to decide this'); + }); + + it('still refuses a file that really is binary', async () => { + await write( + DOCUMENT, + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]) + ); + + expect((await read()).status).toBe(415); + }); + + /** + * The editor is opened from the Markdown preview, which has just downloaded + * the same file: the second read is a revalidation, not a download. + */ + it('answers a browser that already holds the file', async () => { + await write(DOCUMENT, 'quarterly figures'); + const first = await read(); + expect(first.headers.etag).toBeTruthy(); + + const again = await read().set('If-None-Match', first.headers.etag); + + expect(again.status).toBe(304); + expect(again.text).toBe(''); + }); + + it('hands the file over again once it has changed', async () => { + await write(DOCUMENT, 'quarterly figures'); + const first = await read(); + await write(DOCUMENT, 'revised figures'); + + const again = await read().set('If-None-Match', first.headers.etag); + + expect(again.status).toBe(200); + expect(again.body.content).toBe('revised figures'); + }); +}); + +describe('saving from the editor', () => { + /** + * The editor opens two megabytes and saves through a JSON body, whose limit + * was Express's own default of 100 kB: a file between the two opened and + * could never be saved, answered "request entity too large" — which names + * neither limit. The body limit is now derived from the editor's. + */ + it('saves a file larger than a default JSON body', async () => { + await write(DOCUMENT, 'small'); + const bigger = 'x'.repeat(200 * 1024); + + expect((await save(bigger)).status).toBe(200); + + expect(await fs.readFile(volume(DOCUMENT), 'utf8')).toBe(bigger); + }); + + it('writes back in the encoding the file already had', async () => { + await write(DOCUMENT, Buffer.from('first', 'utf16le')); + + expect((await save('second')).status).toBe(200); + + const bytes = await fs.readFile(volume(DOCUMENT)); + expect(bytes.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xfe])); + expect(bytes.toString('utf16le').replace('', '')).toBe('second'); + // And it reads back as what was typed, not as two bytes per character. + expect((await read()).body.content).toBe('second'); + }); + + it('writes a new file in UTF-8', async () => { + expect((await save('brand new')).status).toBe(200); + + expect(await fs.readFile(volume(DOCUMENT), 'utf8')).toBe('brand new'); + }); + + /** + * The size limit was checked when opening and not when saving, so a paste + * larger than the limit was written and then could not be opened again. + */ + it('refuses a save larger than the editor can open', async () => { + await write(DOCUMENT, 'small'); + const limit = load('src/services/textEditorService').MAX_EDITOR_FILE_SIZE; + + const refused = await save('x'.repeat(limit + 1)); + + expect(refused.status).toBe(400); + expect(await fs.readFile(volume(DOCUMENT), 'utf8')).toBe('small'); + }); + + /** + * In UTF-16 the same text is twice the bytes, and the bytes are what the + * limit is about: a file just under it in UTF-8 is over it here. + */ + it('counts the bytes it is about to write, not the characters', async () => { + await write(DOCUMENT, Buffer.from('small', 'utf16le')); + const limit = load('src/services/textEditorService').MAX_EDITOR_FILE_SIZE; + + const refused = await save('x'.repeat(limit - 10)); + + expect(refused.status).toBe(400); + }); +}); + +describe('reading a version as text', () => { + const textOf = (id, forPath = DOCUMENT) => + request(app).get(`/api/versions/${id}/text`).query({ path: forPath }); + + const history = () => request(app).get('/api/versions').query({ path: DOCUMENT }); + + it('hands over what a version holds, with its name', async () => { + await save('first'); + await save('second'); + const [version] = (await history()).body.versions; + + const response = await textOf(version.id); + + expect(response.status).toBe(200); + expect(response.body.content).toBe('first'); + expect(response.body.name).toBe('journal.md'); + }); + + it('decodes a version written in UTF-16', async () => { + await write(DOCUMENT, Buffer.from('first', 'utf16le')); + await save('second'); + const [version] = (await history()).body.versions; + + expect((await textOf(version.id)).body.content).toBe('first'); + }); + + it('refuses a version that belongs to another file', async () => { + await save('first'); + await save('second'); + const [version] = (await history()).body.versions; + await save('elsewhere', 'Notes/other.md'); + + expect((await textOf(version.id, 'Notes/other.md')).status).toBe(404); + }); + + /** A version is read, never kept: two people's rights differ on the same bytes. */ + it('never lets a version be cached', async () => { + await save('first'); + await save('second'); + const [version] = (await history()).body.versions; + + expect((await textOf(version.id)).headers['cache-control']).toBe('private, no-store'); + }); +}); diff --git a/backend/tests/routes/thumbnail-queue.test.js b/backend/tests/routes/thumbnail-queue.test.js new file mode 100644 index 000000000..27ec17bfa --- /dev/null +++ b/backend/tests/routes/thumbnail-queue.test.js @@ -0,0 +1,141 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Asking for a thumbnail that is not made yet. + * + * A thumbnail used to be made inside the request that asked for it. A folder of + * five hundred pictures is five hundred held requests, a video on a slow disk + * holds one for minutes, and nothing could be said about which of them mattered + * — the tile somebody is looking at waited behind the one scrolled past. + * + * The request now answers with what is already there, or says it has queued the + * work and asks the caller to come back. What is worth doing first is decided + * in the queue, where it can be. + */ + +const PICTURE = 'Pictures/one.png'; + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +// A one-pixel PNG: small enough to be made instantly, real enough for sharp. +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'thumbnail-queue-' }); + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }); + await fs.mkdir(volume('Pictures'), { recursive: true }); + await fs.writeFile(volume('Pictures', 'one.png'), ONE_PIXEL_PNG); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = alice; + next(); + }); + app.use('/api', load('src/routes/thumbnails')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/thumbnailService').stopThumbnailCacheCleanup?.(); + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +const ask = (query = '') => request(app).get(`/api/thumbnails/${PICTURE}${query}`); + +/** As the file browser does: ask again while the answer says it is on its way. */ +const askUntilMade = async () => { + for (let attempt = 0; attempt < 30; attempt += 1) { + const asked = await ask(); + if (asked.body.thumbnail) return asked; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('the thumbnail was never made'); +}; + +describe('asking for a thumbnail', () => { + it('says it has been queued rather than holding the request', async () => { + const first = await ask(); + + expect(first.status).toBe(202); + expect(first.body.pending).toBe(true); + expect(first.body.thumbnail).toBeFalsy(); + }); + + it('hands it over once it is made', async () => { + const made = await askUntilMade(); + + expect(made.status).toBe(200); + expect(made.body.pending).toBe(false); + expect(made.body.thumbnail).toMatch(/^\/static\/thumbnails\//); + }); + + /** + * Once it exists, asking again is answered at once rather than queued — and + * with the same picture. What this cannot show from outside is which of the + * two short-circuits answered, the route's own cache check or the queue + * finding the file already there; both give the same answer, which is why + * there is no test claiming otherwise. + */ + it('answers at once afterwards, with the same picture', async () => { + const made = await askUntilMade(); + + const again = await ask(); + + expect(again.status).toBe(200); + // The same cached picture; the proof on the end of the URL is minted fresh + // each time, so it is the file the two answers have to agree on. + expect(again.body.thumbnail.split('?')[0]).toBe(made.body.thumbnail.split('?')[0]); + }); + + /** + * A prefetch is for what somebody has not looked at yet. It must never take + * the place of the tile they are looking at. + */ + it('takes a prefetch at a lower priority', async () => { + const prefetch = await ask('?background=1'); + + expect([200, 202]).toContain(prefetch.status); + expect(await askUntilMade()).toBeTruthy(); + }); + + it('refuses a file type that has no thumbnail', async () => { + await fs.writeFile(volume('Pictures', 'notes.txt'), 'not a picture'); + + const asked = await request(app).get('/api/thumbnails/Pictures/notes.txt'); + + expect(asked.status).toBe(400); + }); + + it('says nothing at all when thumbnails are switched off', async () => { + await load('src/services/settingsService').setSystemSetting('system', 'thumbnails', { + enabled: false, + }); + + const asked = await ask(); + + expect(asked.status).toBe(200); + expect(asked.body.thumbnail).toBe(''); + }); +}); diff --git a/backend/tests/routes/thumbnails-token.test.js b/backend/tests/routes/thumbnails-token.test.js index 54c0e3057..432413945 100644 --- a/backend/tests/routes/thumbnails-token.test.js +++ b/backend/tests/routes/thumbnails-token.test.js @@ -62,8 +62,22 @@ afterEach(async () => { }); describe('asking for a thumbnail', () => { + /** + * The first ask may find the thumbnail still being made, which is answered + * 202 and asked again — as the file browser does. + */ + const askForThumbnail = async () => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const asked = await request(app).get(`/api/thumbnails/${PICTURE}`); + if (asked.status === 200 && asked.body.thumbnail) return asked; + expect(asked.status).toBe(202); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('the thumbnail was never made'); + }; + it('hands back a URL that opens, and only with what it handed back', async () => { - const asked = await request(app).get(`/api/thumbnails/${PICTURE}`); + const asked = await askForThumbnail(); expect(asked.status).toBe(200); const url = asked.body.thumbnail; diff --git a/backend/tests/routes/trash-text.test.js b/backend/tests/routes/trash-text.test.js new file mode 100644 index 000000000..2ffa12c88 --- /dev/null +++ b/backend/tests/routes/trash-text.test.js @@ -0,0 +1,159 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Reading a file that is in the trash. + * + * Deciding whether to restore something or delete it for good means looking at + * it, and until now the trash could only be looked at from the outside: a name, + * a size and a date. The only route that reached into an item listed a deleted + * folder's entries; nothing could open one. + * + * Read only, deliberately: there is no route that writes into the trash, so a + * file goes back to a volume before it can be changed. + */ + +let env; +let app; +let users; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +const write = async (relative, content) => { + await fs.mkdir(path.dirname(volume(relative)), { recursive: true }); + await fs.writeFile(volume(relative), content); +}; + +const as = (who) => ({ + get: (url) => request(app).get(url).set('x-test-user', who), + del: (url, body) => request(app).delete(url).set('x-test-user', who).send(body), +}); + +const trashAs = (who, parent, name) => + as(who).del('/api/files', { items: [{ path: parent, name }] }); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'trash-text-', env: { USER_DIR_ENABLED: 'true' } }); + + const usersService = load('src/services/users'); + const make = (name, roles) => + usersService.createLocalUser({ + email: `${name}@example.com`, + username: name, + displayName: name[0].toUpperCase() + name.slice(1), + password: 'secret123', + roles, + }); + users = { alice: await make('alice', ['user']), bob: await make('bob', ['user']) }; + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + const who = req.get('x-test-user'); + if (who) req.user = users[who]; + next(); + }); + app.use('/api', load('src/routes/files')); + app.use('/api', load('src/routes/trash')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +const textOf = (who, id, entryPath) => + as(who).get( + `/api/trash/items/${id}/text${entryPath ? `?path=${encodeURIComponent(entryPath)}` : ''}` + ); + +describe('a file in the trash', () => { + it('shows its text, with the name it had', async () => { + await write('Projects/report.txt', 'quarterly figures'); + const { body } = await trashAs('alice', 'Projects', 'report.txt'); + const id = body.items[0].trashItemId; + + const response = await textOf('alice', id); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ name: 'report.txt', content: 'quarterly figures' }); + }); + + it('shows a file from inside a deleted folder', async () => { + await write('Projects/notes/minutes.md', 'what was decided'); + const { body } = await trashAs('alice', 'Projects', 'notes'); + const id = body.items[0].trashItemId; + + const response = await textOf('alice', id, 'minutes.md'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ name: 'minutes.md', content: 'what was decided' }); + }); + + it('decodes a file written in UTF-16, as the editor does', async () => { + await write('Projects/export.txt', Buffer.from('written on Windows', 'utf16le')); + const { body } = await trashAs('alice', 'Projects', 'export.txt'); + + const response = await textOf('alice', body.items[0].trashItemId); + + expect(response.body.content).toBe('written on Windows'); + }); + + it('refuses a file that is not text', async () => { + await write( + 'Projects/image.bin', + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 13, 1, 2, 3, 4]) + ); + const { body } = await trashAs('alice', 'Projects', 'image.bin'); + + expect((await textOf('alice', body.items[0].trashItemId)).status).toBe(415); + }); + + it('refuses a folder', async () => { + await write('Projects/notes/minutes.md', 'what was decided'); + const { body } = await trashAs('alice', 'Projects', 'notes'); + + expect((await textOf('alice', body.items[0].trashItemId)).status).toBe(400); + }); + + /** The whole point of the item id: nothing outside the deleted item is reachable. */ + it('refuses a path that climbs out of the item', async () => { + await write('Projects/notes/minutes.md', 'what was decided'); + await write('Projects/secret.txt', 'not deleted'); + const { body } = await trashAs('alice', 'Projects', 'notes'); + + const response = await textOf('alice', body.items[0].trashItemId, '../secret.txt'); + + expect(response.status).toBe(400); + }); + + it('is not readable by somebody it is not in the trash of', async () => { + await write('Projects/report.txt', 'quarterly figures'); + const { body } = await trashAs('alice', 'Projects', 'report.txt'); + + const response = await textOf('bob', body.items[0].trashItemId); + + expect(response.status).toBe(404); + }); + + it('answers nothing for an item that is not there', async () => { + expect((await textOf('alice', 'nosuchitem')).status).toBe(404); + }); + + /** What one person may read is decided for that person, so no cache holds it. */ + it('is never cached', async () => { + await write('Projects/report.txt', 'quarterly figures'); + const { body } = await trashAs('alice', 'Projects', 'report.txt'); + + const response = await textOf('alice', body.items[0].trashItemId); + + expect(response.headers['cache-control']).toBe('private, no-store'); + }); +}); diff --git a/backend/tests/routes/two-factor.test.js b/backend/tests/routes/two-factor.test.js new file mode 100644 index 000000000..c38f2ba08 --- /dev/null +++ b/backend/tests/routes/two-factor.test.js @@ -0,0 +1,243 @@ +import express from 'express'; +import request from 'supertest'; +import session from 'express-session'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * A second factor on a local account. + * + * A password is the whole of what stood between somebody's files and whoever + * had that password — from a reused one, a phishing page, a machine left + * signed in somewhere else. A code from an authenticator answers all three, + * and the recovery codes answer the obvious objection to it. + * + * Two things here are worth stating plainly, because they are the difference + * between a second factor and the appearance of one: the password step does + * not sign anybody in, and a wrong code counts against the same lockout a + * wrong password does. + */ + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); + +const PASSWORD = 'secret123'; + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'two-factor-' }); + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: PASSWORD, + roles: ['user'], + }); + + app = express(); + app.use(express.json()); + app.use( + session({ + secret: 'two-factor-tests', + resave: false, + saveUninitialized: false, + }) + ); + app.use('/api/auth', load('src/routes/auth')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +/** One browser: a cookie jar that survives between requests. */ +const agent = () => request.agent(app); + +const signIn = (client, password = PASSWORD) => + client.post('/api/auth/login').send({ email: 'alice@example.com', password }); + +/** Turn it on the way the account screen does, and keep what it shows once. */ +const turnOn = async (client) => { + await signIn(client); + const started = await client.post('/api/auth/totp/start').send({}); + expect(started.status).toBe(200); + const totp = load('src/utils/totp'); + // Confirmed with the previous step's code, which the window still accepts. + // A code is good for one sign-in, so confirming with the current one would + // leave nothing for the sign-in that follows to use — see the test below. + const code = totp.totpCode(started.body.secret, { at: Date.now() - 30_000 }); + const confirmed = await client.post('/api/auth/totp/confirm').send({ code }); + expect(confirmed.status).toBe(200); + return { secret: started.body.secret, recoveryCodes: confirmed.body.recoveryCodes }; +}; + +describe('turning a second factor on', () => { + it('changes nothing until a code proves the phone holds the same secret', async () => { + const client = agent(); + await signIn(client); + await client.post('/api/auth/totp/start').send({}); + + // Still nothing: the secret was drawn, not confirmed. + const again = agent(); + const signedIn = await signIn(again); + expect(signedIn.body.totpRequired).toBeUndefined(); + expect(signedIn.body.user).toBeTruthy(); + }); + + it('hands over recovery codes once, and asks for a code from then on', async () => { + const client = agent(); + const { recoveryCodes } = await turnOn(client); + + expect(recoveryCodes.length).toBeGreaterThan(0); + + const fresh = agent(); + const signedIn = await signIn(fresh); + expect(signedIn.body).toEqual({ totpRequired: true }); + expect(signedIn.body.user).toBeUndefined(); + }); + + it('is refused to somebody who did not sign in with their password', async () => { + const client = agent(); + await turnOn(client); + // A second browser that only got as far as the password step. + const half = agent(); + await signIn(half); + + expect((await half.post('/api/auth/totp/start').send({})).status).toBe(401); + }); +}); + +describe('the second step', () => { + it('signs in with the code from the authenticator', async () => { + const client = agent(); + const { secret } = await turnOn(client); + const fresh = agent(); + await signIn(fresh); + + const finished = await fresh + .post('/api/auth/login/totp') + .send({ code: load('src/utils/totp').totpCode(secret) }); + + expect(finished.status).toBe(200); + expect(finished.body.user.email).toBe('alice@example.com'); + expect(finished.body.usedRecoveryCode).toBe(false); + }); + + /** Thirty seconds, and one sign-in: a code seen once is spent. */ + it('refuses the same code a second time', async () => { + const client = agent(); + const { secret } = await turnOn(client); + const code = load('src/utils/totp').totpCode(secret); + + const first = agent(); + await signIn(first); + expect((await first.post('/api/auth/login/totp').send({ code })).status).toBe(200); + + const second = agent(); + await signIn(second); + expect((await second.post('/api/auth/login/totp').send({ code })).status).toBe(401); + }); + + it('signs in with a recovery code, once', async () => { + const client = agent(); + const { recoveryCodes } = await turnOn(client); + const fresh = agent(); + await signIn(fresh); + + const finished = await fresh.post('/api/auth/login/totp').send({ code: recoveryCodes[0] }); + + expect(finished.status).toBe(200); + expect(finished.body.usedRecoveryCode).toBe(true); + + // The same code again is no longer a way in. + const third = agent(); + await signIn(third); + expect((await third.post('/api/auth/login/totp').send({ code: recoveryCodes[0] })).status).toBe( + 401 + ); + }); + + /** + * The password step must not be a session with a flag on it: nothing but the + * account id on the session signs anybody in, and this state does not set it. + */ + it('is not signed in between the password and the code', async () => { + const client = agent(); + await turnOn(client); + const fresh = agent(); + await signIn(fresh); + + const me = await fresh.get('/api/auth/me'); + + expect(me.body.user ?? null).toBeNull(); + }); + + it('refuses a code for a sign-in nobody started', async () => { + const client = agent(); + await turnOn(client); + + const stranger = agent(); + expect((await stranger.post('/api/auth/login/totp').send({ code: '000000' })).status).toBe(401); + }); + + /** A wrong code is not a place to guess a million times. */ + it('counts a wrong code against the same lockout a wrong password does', async () => { + const client = agent(); + await turnOn(client); + const fresh = agent(); + await signIn(fresh); + + for (let attempt = 0; attempt < 10; attempt += 1) { + await fresh.post('/api/auth/login/totp').send({ code: '000000' }); + } + + const locked = await load('src/services/users/lockout').isLocked(alice.id); + expect(locked).toBe(true); + }); +}); + +describe('turning it off, and new recovery codes', () => { + it('asks for the password first', async () => { + const client = agent(); + await turnOn(client); + + expect((await client.delete('/api/auth/totp').send({ password: 'wrong' })).status).toBe(401); + expect( + (await client.post('/api/auth/totp/recovery-codes').send({ password: 'wrong' })).status + ).toBe(401); + }); + + it('retires the old recovery codes when new ones are drawn', async () => { + const client = agent(); + const { recoveryCodes } = await turnOn(client); + + const replaced = await client + .post('/api/auth/totp/recovery-codes') + .send({ password: PASSWORD }); + + expect(replaced.status).toBe(200); + expect(replaced.body.recoveryCodes).not.toEqual(recoveryCodes); + + const fresh = agent(); + await signIn(fresh); + expect((await fresh.post('/api/auth/login/totp').send({ code: recoveryCodes[0] })).status).toBe( + 401 + ); + }); + + it('goes back to a password on its own', async () => { + const client = agent(); + await turnOn(client); + + expect((await client.delete('/api/auth/totp').send({ password: PASSWORD })).status).toBe(204); + + const fresh = agent(); + const signedIn = await signIn(fresh); + expect(signedIn.body.user).toBeTruthy(); + }); +}); diff --git a/backend/tests/routes/version-marks.test.js b/backend/tests/routes/version-marks.test.js new file mode 100644 index 000000000..360836b89 --- /dev/null +++ b/backend/tests/routes/version-marks.test.js @@ -0,0 +1,126 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * The mark in a listing that says a file has earlier versions. + * + * Counted once for the whole folder rather than once per row: a folder of three + * hundred files costs the same query as a folder of three. What it answers is + * what the file browser needs to show a small clock beside a name — which is + * how anybody finds out there is a history to look at. + */ + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +const save = (file, content) => request(app).put('/api/editor').send({ path: file, content }); + +const listing = (folder) => request(app).get(`/api/browse/${folder}`); + +const named = (body, name) => body.items.find((item) => item.name === name); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'version-marks-' }); + + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }); + await fs.mkdir(volume('Notes'), { recursive: true }); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = alice; + next(); + }); + app.use('/api', load('src/routes/browse')); + app.use('/api', load('src/routes/editor')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +describe('a folder listing', () => { + it('marks the files that have versions, and how many', async () => { + await save('Notes/journal.md', 'first'); + await save('Notes/journal.md', 'second'); + await save('Notes/journal.md', 'third'); + await save('Notes/once.md', 'only ever saved once'); + + const response = await listing('Notes'); + + expect(response.status).toBe(200); + expect(named(response.body, 'journal.md').versions).toMatchObject({ count: 2 }); + expect(named(response.body, 'journal.md').versions.bytes).toBeGreaterThan(0); + // A file saved once replaced nothing, so it has no history and no mark. + expect(named(response.body, 'once.md').versions).toBeUndefined(); + }); + + it('says whether histories may be seen here at all', async () => { + const response = await listing('Notes'); + + expect(response.body.access.canSeeVersions).toBe(true); + }); + + /** A folder's own files, not its subfolders': the count must not climb. */ + it('counts only the files directly in the folder', async () => { + await fs.mkdir(volume('Notes/deeper'), { recursive: true }); + await save('Notes/deeper/inside.md', 'first'); + await save('Notes/deeper/inside.md', 'second'); + + const response = await listing('Notes'); + + expect(named(response.body, 'deeper').versions).toBeUndefined(); + expect(named(response.body, 'deeper')).toBeTruthy(); + }); + + it('leaves the marks out for somebody who turned them off', async () => { + await save('Notes/journal.md', 'first'); + await save('Notes/journal.md', 'second'); + await load('src/services/settingsService').setUserSetting(alice.id, 'showVersionMarks', false); + + const response = await listing('Notes'); + + expect(named(response.body, 'journal.md').versions).toBeUndefined(); + }); + + /** + * A listing is not worth failing over a count. The history is still one + * right-click away, so a folder whose marks cannot be counted still lists. + */ + it('still lists a folder whose versions cannot be counted', async () => { + await save('Notes/journal.md', 'first'); + await save('Notes/journal.md', 'second'); + const store = load('src/services/versions/store'); + const original = store.countKeptInFolder; + store.countKeptInFolder = () => { + throw new Error('the index is unreadable'); + }; + + try { + const response = await listing('Notes'); + + expect(response.status).toBe(200); + expect(named(response.body, 'journal.md')).toBeTruthy(); + expect(named(response.body, 'journal.md').versions).toBeUndefined(); + } finally { + store.countKeptInFolder = original; + } + }); +}); diff --git a/backend/tests/routes/versions-admin.test.js b/backend/tests/routes/versions-admin.test.js new file mode 100644 index 000000000..c617ee388 --- /dev/null +++ b/backend/tests/routes/versions-admin.test.js @@ -0,0 +1,190 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Every file that has a history, for an administrator. + * + * The routes beside these answer about one file, named by its path, with that + * file's own rights — right for somebody looking at a document they have open, + * and no use at all for "where has the space gone". A history whose file was + * deleted outside the application has no file left to authorise against, and it + * is exactly the kind nobody goes looking for. + * + * So a history is named here by its own id, and every route is behind the + * administrator check. + */ + +let env; +let app; +let users; + +const load = (relative) => require(modulePath(relative)); +const volume = (...segments) => path.join(env.volumeDir, ...segments); + +const save = (who, file, content) => + request(app).put('/api/editor').set('x-test-user', who).send({ path: file, content }); + +const as = (who) => ({ + get: (url) => request(app).get(url).set('x-test-user', who), + post: (url, body) => request(app).post(url).set('x-test-user', who).send(body), +}); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'versions-admin-' }); + + const usersService = load('src/services/users'); + const make = (name, roles) => + usersService.createLocalUser({ + email: `${name}@example.com`, + username: name, + displayName: name[0].toUpperCase() + name.slice(1), + password: 'secret123', + roles, + }); + users = { admin: await make('admin', ['admin']), alice: await make('alice', ['user']) }; + + await fs.mkdir(volume('Notes'), { recursive: true }); + + app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + const who = req.get('x-test-user'); + if (who) req.user = users[who]; + next(); + }); + app.use('/api', load('src/routes/editor')); + app.use('/api', load('src/routes/versionsAdmin')); + app.use(load('src/middleware/errorHandler').errorHandler); +}); + +afterEach(async () => { + load('src/services/trash/maintenance').stop(); + await env.cleanup(); +}); + +/** Two saves make one version; that is the whole setup every test here needs. */ +const withHistory = async (file) => { + await save('alice', file, 'first'); + await save('alice', file, 'second'); +}; + +describe('the list of files that have versions', () => { + it('names every file with a history, and what it costs', async () => { + await withHistory('Notes/journal.md'); + await withHistory('Notes/other.md'); + + const response = await as('admin').get('/api/versions/admin/files'); + + expect(response.status).toBe(200); + expect(response.body.total).toBe(2); + expect(response.body.files.map((file) => file.name).sort()).toEqual(['journal.md', 'other.md']); + expect(response.body.totalVersions).toBe(2); + expect(response.body.totalBytes).toBeGreaterThan(0); + }); + + it('narrows to what the search names', async () => { + await withHistory('Notes/journal.md'); + await withHistory('Notes/other.md'); + + const response = await as('admin').get('/api/versions/admin/files?q=journal'); + + expect(response.body.files.map((file) => file.name)).toEqual(['journal.md']); + }); + + it('refuses an order it does not know, rather than picking one', async () => { + const response = await as('admin').get('/api/versions/admin/files?sort=whatever'); + + expect(response.status).toBe(400); + }); + + it('is refused to somebody who is not an administrator', async () => { + await withHistory('Notes/journal.md'); + + expect((await as('alice').get('/api/versions/admin/files')).status).toBe(403); + }); + + it('is refused to nobody at all', async () => { + expect((await request(app).get('/api/versions/admin/files')).status).toBe(403); + }); + + /** An administrator's list of what is on the disks is nobody's cache to keep. */ + it('is never cached', async () => { + const response = await as('admin').get('/api/versions/admin/files'); + + expect(response.headers['cache-control']).toBe('private, no-store'); + }); +}); + +describe('one history, by its id', () => { + it('reads back its versions', async () => { + await withHistory('Notes/journal.md'); + const [file] = (await as('admin').get('/api/versions/admin/files')).body.files; + + const response = await as('admin').get(`/api/versions/admin/files/${file.id}`); + + expect(response.status).toBe(200); + expect(response.body.file.name).toBe('journal.md'); + expect(response.body.versions).toHaveLength(1); + }); + + it('answers nothing for a history that does not exist', async () => { + expect((await as('admin').get('/api/versions/admin/files/nosuch')).status).toBe(404); + }); + + it('is refused to somebody who is not an administrator', async () => { + await withHistory('Notes/journal.md'); + const [file] = (await as('admin').get('/api/versions/admin/files')).body.files; + + expect((await as('alice').get(`/api/versions/admin/files/${file.id}`)).status).toBe(403); + }); +}); + +describe('deleting versions from the administrator side', () => { + it('deletes the ones it is given, and leaves the file alone', async () => { + await withHistory('Notes/journal.md'); + const [file] = (await as('admin').get('/api/versions/admin/files')).body.files; + const { versions } = (await as('admin').get(`/api/versions/admin/files/${file.id}`)).body; + + const response = await as('admin').post(`/api/versions/admin/files/${file.id}/delete`, { + ids: [versions[0].id], + }); + + expect(response.status).toBe(200); + expect(response.body.deleted).toBe(1); + expect(await fs.readFile(volume('Notes/journal.md'), 'utf8')).toBe('second'); + }); + + it('empties a whole history when asked to', async () => { + await save('alice', 'Notes/journal.md', 'first'); + await save('alice', 'Notes/journal.md', 'second'); + await save('alice', 'Notes/journal.md', 'third'); + const [file] = (await as('admin').get('/api/versions/admin/files')).body.files; + + const response = await as('admin').post(`/api/versions/admin/files/${file.id}/delete`, { + all: true, + }); + + expect(response.body.deleted).toBe(2); + expect(response.body.remaining).toBe(0); + expect(await fs.readFile(volume('Notes/journal.md'), 'utf8')).toBe('third'); + }); + + it('is refused to somebody who is not an administrator', async () => { + await withHistory('Notes/journal.md'); + const [file] = (await as('admin').get('/api/versions/admin/files')).body.files; + + const response = await as('alice').post(`/api/versions/admin/files/${file.id}/delete`, { + all: true, + }); + + expect(response.status).toBe(403); + expect( + (await as('admin').get(`/api/versions/admin/files/${file.id}`)).body.versions + ).toHaveLength(1); + }); +}); diff --git a/backend/tests/utils/session-store.test.js b/backend/tests/utils/session-store.test.js new file mode 100644 index 000000000..26d8a9220 --- /dev/null +++ b/backend/tests/utils/session-store.test.js @@ -0,0 +1,102 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { BetterSqliteSessionStore } from '../../src/utils/betterSqliteSessionStore.js'; + +/** + * Where sessions live. + * + * They were kept by `connect-sqlite3`, which opens its database when the module + * is required and leaves every deleted row's pages in the file for ever: a + * burst of sign-ins — a script, a scanner — grew it and it never shrank again. + * + * What this store adds beyond holding them is the ability to answer "whose?": + * changing a password has to end the sessions opened with the old one, and + * that is not a thing a key-value store can be asked. + */ + +let directory; +let store; + +const cookie = { originalMaxAge: null, expires: null, httpOnly: true, path: '/' }; + +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'session-store-')); + store = new BetterSqliteSessionStore(path.join(directory, 'inner', 'sessions.db')); +}); + +afterEach(() => { + store.close(); + fs.rmSync(directory, { recursive: true, force: true }); +}); + +const set = (sid, data) => new Promise((resolve) => store.set(sid, data, resolve)); +const get = (sid) => new Promise((resolve) => store.get(sid, (_e, sess) => resolve(sess))); + +describe('the session store', () => { + /** + * Requiring the module must not write to the disk: it happens wherever the + * cache directory is not there yet, including the check that every module + * loads. + */ + it('creates nothing until it is asked for something', () => { + expect(fs.existsSync(path.join(directory, 'inner'))).toBe(false); + + store.ready(); + + expect(fs.existsSync(path.join(directory, 'inner', 'sessions.db'))).toBe(true); + }); + + it('keeps a session and gives it back', async () => { + await set('one', { cookie, localUserId: 'alice' }); + + expect(await get('one')).toMatchObject({ localUserId: 'alice' }); + }); + + it('forgets one that has expired', async () => { + await set('old', { cookie: { ...cookie, expires: new Date(Date.now() - 1000) } }); + + expect(await get('old')).toBeUndefined(); + }); + + /** What a password change is for: every session opened with the old one. */ + it('ends every session of one account, and leaves the others', async () => { + await set('a1', { cookie, localUserId: 'alice' }); + await set('a2', { cookie, localUserId: 'alice' }); + await set('b1', { cookie, localUserId: 'bob' }); + + store.destroyByUser('alice'); + + expect(await get('a1')).toBeUndefined(); + expect(await get('a2')).toBeUndefined(); + expect(await get('b1')).toMatchObject({ localUserId: 'bob' }); + }); + + /** The browser doing the changing keeps its own session. */ + it('spares the session it is told to spare', async () => { + await set('keep', { cookie, localUserId: 'alice' }); + await set('drop', { cookie, localUserId: 'alice' }); + + store.destroyByUser('alice', 'keep'); + + expect(await get('keep')).toMatchObject({ localUserId: 'alice' }); + expect(await get('drop')).toBeUndefined(); + }); + + /** + * A row that is not JSON must be skipped, not fail the statement: SQLite + * promises no order for the terms of an AND, so json_extract would raise. + */ + it('is not stopped by a row that is not JSON', async () => { + store.ready(); + store.db + .prepare('INSERT INTO sessions (sid, expired, sess) VALUES (?, ?, ?)') + .run('broken', Date.now() + 60_000, 'not json at all'); + await set('mine', { cookie, localUserId: 'alice' }); + + expect(() => store.destroyByUser('alice')).not.toThrow(); + expect(await get('mine')).toBeUndefined(); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index dd645486c..15bde9e02 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@uppy/core": "^5.2.0", "@uppy/drop-target": "^4.1.0", "@uppy/status-bar": "^5.1.0", + "@uppy/tus": "^5.1.1", "@uppy/xhr-upload": "^5.1.1", "@vueuse/components": "^10.9.0", "@vueuse/core": "^10.9.0", @@ -45,6 +46,7 @@ "marked": "^12.0.2", "nanoid": "^5.0.7", "pinia": "^2.1.7", + "qrcode-generator": "^2.0.4", "tippy.js": "^6.3.7", "vue": "^3.4.21", "vue-codemirror": "^6.1.1", diff --git a/frontend/src/api/archive.api.js b/frontend/src/api/archive.api.js new file mode 100644 index 000000000..ce098d782 --- /dev/null +++ b/frontend/src/api/archive.api.js @@ -0,0 +1,62 @@ +import { buildUrl, normalizePath, requestJson, requestRaw, requestStream } from './http'; + +/** + * Looking inside an archive without unpacking it. + * + * The archive is named by its path, like any other file, and where to look + * inside it is a separate parameter — never one path with the archive in the + * middle of it. The server decides what this person may open and what a name + * inside the archive is allowed to mean. + */ + +const pathQuery = (path) => `path=${encodeURIComponent(normalizePath(path))}`; + +/** One level of an archive: `{ path, name, inside, entries, total, outside }`. */ +async function browseArchive(path, inside = '') { + const position = inside ? `&inside=${encodeURIComponent(inside)}` : ''; + return requestJson(`/api/archive/list?${pathQuery(path)}${position}`, { method: 'GET' }); +} + +/** Where one entry downloads from: a plain link, so the browser saves it as it does any file. */ +function archiveEntryUrl(path, entry) { + return buildUrl(`/api/archive/entry?${pathQuery(path)}&entry=${encodeURIComponent(entry)}`); +} + +/** + * The bytes of one entry, to look at rather than to keep. + * + * The same address the download link points at, asked for as data. The server + * answers it as an attachment and tells the browser not to guess at its type, + * which is what keeps somebody else's HTML from ever running as a page on this + * origin — so what is read here is drawn by the panel itself, never handed to + * the browser as something to open. + */ +async function readArchiveEntry(path, entry, options = {}) { + return requestRaw(`/api/archive/entry?${pathQuery(path)}&entry=${encodeURIComponent(entry)}`, { + method: 'GET', + signal: options.signal, + }); +} + +/** + * Take entries out of an archive, onto the volume. + * + * A folder stands for everything under it. Where they land is the folder the + * archive is in unless `destination` names another one — the server asks for + * the right to write there either way. The endpoint answers with the same + * stream of events the other archive operations write — start, progress, done + * or error — so `onEvent` sees each one as it arrives. + */ +async function extractFromArchive(path, entries, options = {}) { + const destination = options.destination + ? { destination: normalizePath(options.destination) } + : {}; + return requestStream('/api/archive/extract', { + method: 'POST', + body: JSON.stringify({ path: normalizePath(path), entries, ...destination }), + onEvent: options.onEvent, + signal: options.signal, + }); +} + +export { browseArchive, archiveEntryUrl, readArchiveEntry, extractFromArchive }; diff --git a/frontend/src/api/auth.api.js b/frontend/src/api/auth.api.js index 3fb89f236..889638df5 100644 --- a/frontend/src/api/auth.api.js +++ b/frontend/src/api/auth.api.js @@ -18,6 +18,39 @@ const login = ({ email, password }) => body: JSON.stringify({ email, password }), }); +/** + * The second step of a sign-in, when the account asks for a code. + * + * Which account is being signed in is the server's to know — it has been + * holding that since the password was right — so nothing here names one. + */ +const submitTotpCode = (code) => + requestJson('/api/auth/login/totp', { + method: 'POST', + body: JSON.stringify({ code }), + }); + +/** Whether this account asks for a code, and how many recovery codes are left. */ +const fetchTwoFactorStatus = () => requestJson('/api/auth/totp', { method: 'GET' }); + +/** A secret to show once. Nothing is on until a code confirms it. */ +const startTwoFactorEnrolment = () => + requestJson('/api/auth/totp/start', { method: 'POST', body: JSON.stringify({}) }); + +/** Turn it on, and receive the recovery codes — the only time they can be read. */ +const confirmTwoFactorEnrolment = (code) => + requestJson('/api/auth/totp/confirm', { method: 'POST', body: JSON.stringify({ code }) }); + +/** New recovery codes, which retire the ones before them. */ +const replaceRecoveryCodes = (password) => + requestJson('/api/auth/totp/recovery-codes', { + method: 'POST', + body: JSON.stringify({ password }), + }); + +const disableTwoFactor = (password) => + requestJson('/api/auth/totp', { method: 'DELETE', body: JSON.stringify({ password }) }); + const logout = () => requestJson('/api/auth/logout', { method: 'POST', @@ -30,4 +63,17 @@ async function changePassword({ currentPassword, newPassword }) { }); } -export { fetchAuthStatus, setupAccount, fetchCurrentUser, login, logout, changePassword }; +export { + fetchAuthStatus, + setupAccount, + fetchCurrentUser, + login, + logout, + changePassword, + submitTotpCode, + fetchTwoFactorStatus, + startTwoFactorEnrolment, + confirmTwoFactorEnrolment, + replaceRecoveryCodes, + disableTwoFactor, +}; diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index d3295f11a..b9d879dc3 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -15,3 +15,5 @@ export * from './collabora.api'; export * from './features.api'; export * from './terminal.api'; export * from './trash.api'; +export * from './versions.api'; +export * from './archive.api'; diff --git a/frontend/src/api/onlyoffice.api.js b/frontend/src/api/onlyoffice.api.js index b679de5de..24abf599b 100644 --- a/frontend/src/api/onlyoffice.api.js +++ b/frontend/src/api/onlyoffice.api.js @@ -1,13 +1,190 @@ // /api/onlyoffice.api.js -import { requestJson, normalizePath } from './http'; +import { buildUrl, requestJson, normalizePath } from './http'; -export async function fetchOnlyOfficeConfig(path, mode = 'edit') { +export async function fetchOnlyOfficeConfig(path, mode = 'edit', { versionId } = {}) { const normalizedPath = normalizePath(path || ''); if (!normalizedPath) throw new Error('Path is required.'); return requestJson('/api/onlyoffice/config', { method: 'POST', - body: JSON.stringify({ path: normalizedPath, mode }), + body: JSON.stringify({ + path: normalizedPath, + mode, + ...(versionId ? { versionId } : {}), + }), + }); +} + +/** + * The document is really open, and goes on being open. + * + * Sent once ONLYOFFICE reports the document ready, then on a timer: the + * configuration alone says nothing about whether the document opened, so + * presence starts here rather than there. + */ +export async function heartbeatOnlyOfficeSession(path, { sessionId } = {}) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath || !sessionId) return { active: false }; + + return requestJson('/api/onlyoffice/session-heartbeat', { + method: 'POST', + body: JSON.stringify({ path: normalizedPath, sessionId }), + }); +} + +/** + * The editing session is over. + * + * `beacon` is for a page being unloaded. A tab being closed gives one + * synchronous moment, and an ordinary request started in it is cancelled along + * with everything else — `sendBeacon` hands the request to the browser, which + * sends it after the page is gone, with the same cookies. `keepalive` is the + * same idea through `fetch`, and is what answers when a browser has no + * `sendBeacon`; it is also what makes the reply readable, which is why the + * preview — which has time — uses it. + */ +export async function endOnlyOfficeSession(path, { sessionId, beacon = false } = {}) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath || !sessionId) return null; + const body = JSON.stringify({ path: normalizedPath, sessionId }); + + if (beacon && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') { + try { + const handedOver = navigator.sendBeacon( + buildUrl('/api/onlyoffice/session-end'), + // Typed, because the server reads JSON bodies and nothing else: a + // beacon sent as text/plain arrives with an empty body. + new Blob([body], { type: 'application/json' }) + ); + if (handedOver) return null; + } catch (_) { + // A browser that refused the beacon still has the request below. + } + } + + return requestJson('/api/onlyoffice/session-end', { + method: 'POST', + body, + keepalive: true, + }); +} + +/** + * Rename the open document from the editor's title bar. + * + * The session id goes with it so the server can keep that session pointing at + * the file; a save arriving afterwards would otherwise recreate the old name. + */ +export async function renameOnlyOfficeDocument(path, { sessionId, newName } = {}) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath || !sessionId || !newName) { + throw new Error('Path, session and new name are required.'); + } + + return requestJson('/api/onlyoffice/rename', { + method: 'POST', + body: JSON.stringify({ path: normalizedPath, sessionId, newName }), + }); +} + +/** + * Ask the server to write what the editor is holding, now. + * + * Answers as soon as the command is queued; the document is written through the + * ordinary callback. `close` is the flush on the way out, `auto` the periodic + * one — the server coalesces the two rather than queueing them side by side. + */ +export async function requestOnlyOfficeForceSave(path, { sessionId, reason = 'close' } = {}) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath || !sessionId) return { queued: false }; + + return requestJson('/api/onlyoffice/force-save', { + method: 'POST', + body: JSON.stringify({ path: normalizedPath, sessionId, reason }), + }); +} + +/** + * Wait until somebody joins or leaves a document. + * + * Held open by the server for up to twenty-five seconds, so an open folder can + * keep its marks current without asking every second. + */ +export async function waitForOnlyOfficeActivityVersion(since, options = {}) { + const query = Number.isInteger(since) ? `?since=${since}` : ''; + return requestJson(`/api/onlyoffice/activity-version${query}`, { + method: 'GET', + ...options, + }); +} + +/** + * Save the open document under another name, into the folder it came from. + * + * ONLYOFFICE converts the document and hands over a URL to fetch the result + * from; the server is what writes it, so it never leaves the volume. + */ +export async function saveOnlyOfficeDocumentAs(path, { url, title } = {}) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath || !url || !title) { + throw new Error('Path, document URL and title are required.'); + } + + return requestJson('/api/onlyoffice/save-as', { + method: 'POST', + body: JSON.stringify({ path: normalizedPath, url, title }), + }); +} + +/** The document's history, in the shape the editor's own history panel reads. */ +export async function fetchOnlyOfficeHistory(path) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath) throw new Error('Path is required.'); + + return requestJson('/api/onlyoffice/history', { + method: 'POST', + body: JSON.stringify({ path: normalizedPath }), + }); +} + +/** Where one entry of that history is fetched from. */ +export async function fetchOnlyOfficeHistoryData(path, { version, versionId } = {}) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath) throw new Error('Path is required.'); + + return requestJson('/api/onlyoffice/history-data', { + method: 'POST', + body: JSON.stringify({ + path: normalizedPath, + version, + ...(versionId ? { versionId } : {}), + }), + }); +} + +/** + * The people the editor offers when a comment starts with @. + * + * ONLYOFFICE takes the whole list and filters it itself as the name is typed, + * so there is no search term to pass. + */ +export async function fetchOnlyOfficeMentionUsers() { + return requestJson('/api/onlyoffice/users', { method: 'GET' }); +} + +/** + * Report a comment that mentions somebody. + * + * The comment is already in the document; this is the separate notification + * step, which ONLYOFFICE leaves to the integration. + */ +export async function notifyOnlyOfficeMention(path, { emails, actionLink, comment } = {}) { + const normalizedPath = normalizePath(path || ''); + if (!normalizedPath) throw new Error('Path is required.'); + + return requestJson('/api/onlyoffice/notify', { + method: 'POST', + body: JSON.stringify({ path: normalizedPath, emails, actionLink, comment }), }); } diff --git a/frontend/src/api/trash.api.js b/frontend/src/api/trash.api.js index 87259ab23..e1eb97182 100644 --- a/frontend/src/api/trash.api.js +++ b/frontend/src/api/trash.api.js @@ -26,6 +26,17 @@ async function getTrashEntries(id, entryPath = '') { }); } +/** + * The text of a file in the trash — the item itself, or a file at `entryPath` + * inside a deleted folder — to read, never to change: `{ name, content, … }`. + */ +async function getTrashFileText(id, entryPath = '') { + const query = entryPath ? `?path=${encodeURIComponent(entryPath)}` : ''; + return requestJson(`/api/trash/items/${encodeURIComponent(id)}/text${query}`, { + method: 'GET', + }); +} + /** Put back entries from inside a deleted folder; the rest of it stays in the trash. */ async function restoreTrashEntries(id, paths, { shares } = {}) { return post( @@ -81,6 +92,7 @@ async function runTrashMaintenance() { export { getTrash, getTrashEntries, + getTrashFileText, restoreTrashItems, restoreTrashEntries, restoreTrashItemsTo, diff --git a/frontend/src/api/versions.api.js b/frontend/src/api/versions.api.js new file mode 100644 index 000000000..6a0b89374 --- /dev/null +++ b/frontend/src/api/versions.api.js @@ -0,0 +1,109 @@ +import { buildUrl, normalizePath, requestJson } from './http'; + +/** + * A file's history: its earlier versions, and what can be done with them. A + * version is always reached through the file it belongs to, named by its path; + * the server decides what this person may see and do. + */ + +const pathQuery = (path) => `path=${encodeURIComponent(normalizePath(path))}`; +const versionEndpoint = (id, suffix = '') => `/api/versions/${encodeURIComponent(id)}${suffix}`; + +const send = (endpoint, method, body) => + requestJson(endpoint, { method, body: JSON.stringify(body) }); + +/** The versions of a file, newest first, what the file is now, and what may be done. */ +async function getVersions(path) { + return requestJson(`/api/versions?${pathQuery(path)}`, { method: 'GET' }); +} + +/** Where a version downloads from: a plain link, so the browser saves it as it does any file. */ +function getVersionDownloadUrl(path, id) { + return buildUrl(`${versionEndpoint(id, '/content')}?${pathQuery(path)}`); +} + +/** The text of a version, to read and never to change: `{ name, content, … }`. */ +async function getVersionText(path, id) { + return requestJson(`${versionEndpoint(id, '/text')}?${pathQuery(path)}`, { method: 'GET' }); +} + +/** Put the file back as the version had it; what it holds now becomes a version. */ +async function restoreVersion(path, id) { + return send(versionEndpoint(id, '/restore'), 'POST', { path: normalizePath(path) }); +} + +/** Take a version out as a new file in `destination`. */ +async function copyVersionTo(path, id, destination) { + return send(versionEndpoint(id, '/copy'), 'POST', { + path: normalizePath(path), + destination: normalizePath(destination), + }); +} + +/** Put a version's content over another existing file. */ +async function replaceWithVersion(path, id, target) { + return send(versionEndpoint(id, '/replace'), 'POST', { + path: normalizePath(path), + target: normalizePath(target), + }); +} + +/** Name a version, or pin it: `{ label?, pinned? }`. */ +async function updateVersion(path, id, changes) { + return send(versionEndpoint(id), 'PATCH', { path: normalizePath(path), ...changes }); +} + +/** Delete versions for good: `{ ids }`, or `{ all: true }`. */ +async function deleteVersions(path, { ids, all = false } = {}) { + return send('/api/versions/delete', 'POST', { + path: normalizePath(path), + ...(all ? { all: true } : { ids }), + }); +} + +/** + * The administrator's side: every file that has a history, wherever it is. + * + * Addressed by the history's own id rather than by a path, because the ones + * worth finding include files that no longer exist — a history whose file was + * deleted outside the application has no path left to ask about. + */ + +/** A page of the files that have versions: `{ files, total, totalBytes, zones, … }`. */ +async function getVersionedFiles({ zone, state, q, sort, limit, offset } = {}) { + const query = new URLSearchParams(); + if (zone) query.set('zone', zone); + if (state) query.set('state', state); + if (q) query.set('q', q); + if (sort) query.set('sort', sort); + if (Number.isFinite(limit)) query.set('limit', String(limit)); + if (Number.isFinite(offset) && offset > 0) query.set('offset', String(offset)); + const suffix = query.toString(); + return requestJson(`/api/versions/admin/files${suffix ? `?${suffix}` : ''}`, { method: 'GET' }); +} + +/** One history and its versions, by id. */ +async function getVersionedFile(id) { + return requestJson(`/api/versions/admin/files/${encodeURIComponent(id)}`, { method: 'GET' }); +} + +/** Delete versions of one history: `{ ids }`, or `{ all: true }`. */ +async function deleteVersionsOfFile(id, { ids, all = false } = {}) { + return send(`/api/versions/admin/files/${encodeURIComponent(id)}/delete`, 'POST', { + ...(all ? { all: true } : { ids }), + }); +} + +export { + getVersions, + getVersionDownloadUrl, + getVersionText, + restoreVersion, + copyVersionTo, + replaceWithVersion, + updateVersion, + deleteVersions, + getVersionedFiles, + getVersionedFile, + deleteVersionsOfFile, +}; diff --git a/frontend/src/components/ArchivePasswordDialog.vue b/frontend/src/components/ArchivePasswordDialog.vue new file mode 100644 index 000000000..adce41c4f --- /dev/null +++ b/frontend/src/components/ArchivePasswordDialog.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/components/ExplorerContextMenu.vue b/frontend/src/components/ExplorerContextMenu.vue index 7fccbd8cb..090f1b0f8 100644 --- a/frontend/src/components/ExplorerContextMenu.vue +++ b/frontend/src/components/ExplorerContextMenu.vue @@ -22,11 +22,13 @@ import { ShareIcon, ArchiveBoxArrowDownIcon, ArrowUpOnSquareIcon, + ClockIcon, } from '@heroicons/vue/24/outline'; import { StarIcon as StarSolid } from '@heroicons/vue/24/solid'; import { useFavoriteEditor } from '@/composables/useFavoriteEditor'; import { useTerminalStore } from '@/stores/terminal'; import { useFeaturesStore } from '@/stores/features'; +import { useVersionsPanelStore } from '@/stores/versionsPanel'; import { isTerminalExtension } from '@/config/terminal'; // Icons import { @@ -47,6 +49,7 @@ const favoritesStore = useFavoritesStore(); const { openEditorForFavorite } = useFavoriteEditor(); const terminalStore = useTerminalStore(); const featuresStore = useFeaturesStore(); +const versionsPanel = useVersionsPanelStore(); const router = useRouter(); const isOpen = ref(false); @@ -252,6 +255,25 @@ const runGetInfo = () => { infoPanel.open(primaryItem.value); }; +/** + * A file's history, where there can be one: a single file, versions switched + * on, and — through a share — a share whose owner shows it. + */ +const canShowVersions = computed( + () => + featuresStore.versionsEnabled && + contextKind.value === 'file' && + isSingleItemSelected.value && + Boolean(primaryItem.value) && + fileStore.currentPathData?.canSeeVersions !== false +); + +const runShowVersions = () => { + if (!canShowVersions.value) return; + infoPanel.close(); + versionsPanel.open(primaryItem.value); +}; + const runOpenWithEditor = () => { if (!primaryItem.value) return; const item = primaryItem.value; @@ -408,11 +430,15 @@ const menuSections = computed(() => { } const sections = []; - sections.push([ + const infoSection = [ mk('get-info', t('context.getInfo'), InfoRound, runGetInfo, { disabled: !primaryItem.value, }), - ]); + ]; + if (canShowVersions.value) { + infoSection.push(mk('versions', t('versions.menu'), ClockIcon, runShowVersions)); + } + sections.push(infoSection); // Add "Open with Editor" for files only if (contextKind.value === 'file') { diff --git a/frontend/src/components/FileObject.vue b/frontend/src/components/FileObject.vue index 39a4c8e04..346eb46da 100644 --- a/frontend/src/components/FileObject.vue +++ b/frontend/src/components/FileObject.vue @@ -19,6 +19,9 @@ import MiddleEllipsis from '@/components/MiddleEllipsis.vue'; import { ellipses } from '@/utils/ellipses'; import { useInputMode } from '@/composables/useInputMode'; import { CheckIcon } from '@heroicons/vue/20/solid'; +import { ClockIcon, PencilSquareIcon } from '@heroicons/vue/24/outline'; +import { useI18n } from 'vue-i18n'; +import { useVersionsPanelStore } from '@/stores/versionsPanel'; import { useFileDragDrop } from '@/composables/useFileDragDrop'; const props = defineProps(['item', 'view']); @@ -78,6 +81,51 @@ const isCut = computed(() => const selected = computed(() => isSelected(props.item)); +/** + * Somebody has this document open in an editor. + * + * Advisory, never a lock: the file can still be copied, moved, renamed or + * deleted. The mark is there so nobody does any of those by accident while an + * editor is about to write a newer version of it. + */ +const onlyofficeActivity = computed(() => props.item?.onlyofficeActivity || null); +const onlyofficeActivityLabel = computed(() => { + const activity = onlyofficeActivity.value; + if (!activity?.active) return ''; + const users = Array.isArray(activity.users) ? activity.users.filter(Boolean) : []; + return users.length > 0 + ? t('onlyoffice.editingBy', { names: users.join(', ') }) + : t('onlyoffice.editingNow'); +}); + +/** + * The file has earlier versions, and how many. + * + * Sent with the listing when the person asked to see it and may see this + * file's history at all — a share hands out neither the history nor the fact + * that there is one unless its owner said so. Nothing is decided here: the + * mark is there when the count is. + */ +const { t } = useI18n(); +const versionsPanel = useVersionsPanelStore(); +const versionCount = computed(() => { + const count = Number(props.item?.versions?.count); + return Number.isFinite(count) && count > 0 ? count : 0; +}); +// `(key, named, plural)`, as the Versions panel calls it: the third argument of +// the other overload is a bag of options, not a bag of values. +const versionsLabel = computed(() => + versionCount.value ? t('versions.mark', { count: versionCount.value }, versionCount.value) : '' +); +/** + * Straight to the history, rather than the row's own click: it is the one thing + * the mark could mean, and the right-click route stays as it was. + */ +const openVersions = () => { + if (!versionCount.value) return; + versionsPanel.open(props.item); +}; + const showSelectionControl = computed(() => !isTouchDevice.value || selectionMode.value); const selectionButtonBaseClass = @@ -261,6 +309,28 @@ if (isTouchDevice.value) { > + + + + @@ -316,7 +386,28 @@ if (isTouchDevice.value) { /> @@ -374,7 +465,28 @@ if (isTouchDevice.value) { />

@@ -448,12 +560,34 @@ if (isTouchDevice.value) { />

- +
{{ getKindLabel(item) }} diff --git a/frontend/src/components/InfoPanel.vue b/frontend/src/components/InfoPanel.vue index 87a11dd12..ebc0fffbf 100644 --- a/frontend/src/components/InfoPanel.vue +++ b/frontend/src/components/InfoPanel.vue @@ -2,6 +2,9 @@ import { computed, onMounted, onBeforeUnmount, watch, ref } from 'vue'; import { XMarkIcon } from '@heroicons/vue/24/outline'; import { useInfoPanelStore } from '@/stores/infoPanel'; +import { useVersionsPanelStore } from '@/stores/versionsPanel'; +import { useFeaturesStore } from '@/stores/features'; +import { useFileStore } from '@/stores/fileStore'; import { formatBytes, formatDate } from '@/utils'; import { getKindLabel } from '@/utils/fileKinds'; import FileIcon from '@/icons/FileIcon.vue'; @@ -17,6 +20,24 @@ const item = computed(() => store.item); const relativePath = computed(() => store.relativePath); const { t } = useI18n(); +const featuresStore = useFeaturesStore(); +const versionsPanel = useVersionsPanelStore(); +const fileStore = useFileStore(); +// Through a share whose owner keeps the history hidden, the listing says so. +const canShowVersions = computed( + () => + featuresStore.versionsEnabled && + Boolean(item.value) && + !['directory', 'volume'].includes(item.value.kind) && + fileStore.currentPathData?.canSeeVersions !== false +); +const openVersions = () => { + const target = item.value; + if (!target) return; + store.close(); + versionsPanel.open(target); +}; + const title = computed(() => item.value?.name || t('common.details')); const kindLabel = computed(() => (item.value ? getKindLabel(item.value) : '')); @@ -267,6 +288,16 @@ onBeforeUnmount(() => {

+ +
+import { computed } from 'vue'; +import { useI18n } from 'vue-i18n'; +import ModalDialog from '@/components/ModalDialog.vue'; +import { useOnlyOfficeTransferConfirm } from '@/composables/useOnlyOfficeTransferConfirm'; + +const { isOpen, activeItems, cancel, confirm } = useOnlyOfficeTransferConfirm(); +const { t } = useI18n(); + +const itemLabel = computed(() => { + const names = activeItems.value + .slice(0, 2) + .map((item) => item.name) + .join(', '); + const remaining = activeItems.value.length - Math.min(activeItems.value.length, 2); + return remaining > 0 ? `${names} ${t('onlyoffice.andOthers', { count: remaining })}` : names; +}); + + + diff --git a/frontend/src/components/VersionsPanel.vue b/frontend/src/components/VersionsPanel.vue new file mode 100644 index 000000000..17b54bb6d --- /dev/null +++ b/frontend/src/components/VersionsPanel.vue @@ -0,0 +1,728 @@ + + + + + diff --git a/frontend/src/composables/fileUploader.js b/frontend/src/composables/fileUploader.js index 1801775f3..ce27a9464 100644 --- a/frontend/src/composables/fileUploader.js +++ b/frontend/src/composables/fileUploader.js @@ -1,7 +1,10 @@ import { ref, onMounted, onBeforeUnmount, markRaw } from 'vue'; import Uppy from '@uppy/core'; import XHRUpload from '@uppy/xhr-upload'; +import Tus from '@uppy/tus'; import { useUppyStore } from '@/stores/uppyStore'; +import { useAppSettings } from '@/stores/appSettings'; +import { resolveUploadMode } from '@/utils/uploadMode'; import { useFileStore } from '@/stores/fileStore'; import { useNotificationsStore } from '@/stores/notifications'; import { apiBase, normalizePath } from '@/api'; @@ -11,6 +14,7 @@ import DropTarget from '@uppy/drop-target'; export function useFileUploader() { // Filtering is centralized in utils/uploads const uppyStore = useUppyStore(); + const appSettings = useAppSettings(); const fileStore = useFileStore(); const notificationsStore = useNotificationsStore(); const inputRef = ref(null); @@ -58,17 +62,49 @@ export function useFileUploader() { store: uppyStore, }); - uppy.use(XHRUpload, { - endpoint: `${apiBase}/api/upload`, - formData: true, - fieldName: 'filedata', - bundle: false, - responseType: 'json', - // Uppy v5 expects `allowedMetaFields` to be `true` (all) or an explicit list. - // `null` results in *no* metadata being sent, which breaks `uploadTo`/`relativePath`. - allowedMetaFields: true, - withCredentials: true, - }); + /** + * How the next upload goes out. + * + * A direct upload is one request and much faster, and it is what an upload + * has always been here. It is also what a reverse proxy refuses outright + * once the body passes whatever limit it enforces — and what a dropped + * connection loses entirely, however far it had got. Chunked uploads answer + * both: each part is small enough to pass, and one that fails is retried + * without the parts already there being sent again. + * + * Which one is used is the administrator's to decide; the default is + * unchanged, so nothing about an upload moves until somebody asks. + */ + const { chunkedEnabled, chunkSizeBytes } = resolveUploadMode(appSettings.state?.uploads || {}); + + if (chunkedEnabled) { + uppy.use(Tus, { + endpoint: `${apiBase}/api/upload/tus`, + chunkSize: chunkSizeBytes, + withCredentials: true, + // The routing fields the server reads from the upload's metadata. Named + // rather than `true`: Uppy stringifies every field it is told to send, + // so a field only folder uploads carry would arrive as the literal + // "undefined" on every other upload. + allowedMetaFields: ['uploadTo', 'relativePath', 'resolvedRelativePath', 'uploadBatchId'], + // One retry ladder for the whole transfer rather than per request: a + // proxy restarting costs a pause, not the upload. + retryDelays: [0, 1000, 3000, 5000, 10000], + removeFingerprintOnSuccess: true, + }); + } else { + uppy.use(XHRUpload, { + endpoint: `${apiBase}/api/upload`, + formData: true, + fieldName: 'filedata', + bundle: false, + responseType: 'json', + // Uppy v5 expects `allowedMetaFields` to be `true` (all) or an explicit list. + // `null` results in *no* metadata being sent, which breaks `uploadTo`/`relativePath`. + allowedMetaFields: true, + withCredentials: true, + }); + } // Cookies carry auth; no token headers uppy.on('file-added', (file) => { @@ -112,8 +148,7 @@ export function useFileUploader() { const current = normalizePath(fileStore.currentPath || ''); const files = Array.isArray(batchFiles) ? batchFiles : []; const targetsCurrentPath = - files.length > 0 && - files.every((f) => normalizePath(f?.meta?.uploadTo || '') === current); + files.length > 0 && files.every((f) => normalizePath(f?.meta?.uploadTo || '') === current); if (!targetsCurrentPath) return; if (canUploadToCurrentPath()) return; diff --git a/frontend/src/composables/navigation.js b/frontend/src/composables/navigation.js index ce957b2c9..3274d6ba2 100644 --- a/frontend/src/composables/navigation.js +++ b/frontend/src/composables/navigation.js @@ -2,11 +2,14 @@ import { useRouter, useRoute } from 'vue-router'; import { withViewTransition } from '@/utils'; import { isEditableExtension } from '@/config/editor'; import { usePreviewManager } from '@/plugins/preview/manager'; +import { useAppSettings } from '@/stores/appSettings'; +import { documentRoute } from '@/utils/documentRoute'; export function useNavigation() { const router = useRouter(); const route = useRoute(); const previewManager = usePreviewManager(); + const appSettings = useAppSettings(); const navigate = withViewTransition((to) => router.push(to)); const goPrev = withViewTransition(() => router.back()); @@ -39,19 +42,45 @@ export function useNavigation() { return; } + const extensionFromKind = kind.toLowerCase(); + const extensionFromName = name.includes('.') ? name.split('.').pop().toLowerCase() : ''; + const editable = + isEditableExtension(extensionFromKind) || isEditableExtension(extensionFromName); + const basePath = item.path ? `${item.path}/${name}` : name; + const fullPath = basePath.replace(/^\/+/, ''); + const encodedPath = fullPath.split('/').map(encodeURIComponent).join('/'); + + // A tab of its own, when that is what this account asked for. + // + // One decision for every kind of file rather than one per plugin: a + // spreadsheet and a photograph open the same way, because a preference that + // holds for some files and not others is a preference nobody can predict. + // Both addresses already exist — `/open` for anything with a preview, + // `/editor` for anything the text editor opens — so this is the browser + // being handed one of them instead of this page filling itself. + if (appSettings.userSettings?.documentsOpenInNewTab) { + // Asked once: matching a plugin builds a context and walks the list. + const previewable = Boolean(previewManager.findPlugin(item)); + const target = previewable + ? documentRoute(fullPath) + : editable + ? { path: `/editor/${encodedPath}` } + : null; + + if (target) { + // `noopener` because the page opened must not be able to reach back + // into this one through `window.opener`. + window.open(router.resolve(target).href, '_blank', 'noopener'); + return; + } + } + // Files: try preview first (no view transition – avoids double animations) if (previewManager.open(item)) { return; } - const extensionFromKind = kind.toLowerCase(); - const extensionFromName = name.includes('.') ? name.split('.').pop().toLowerCase() : ''; - - if (isEditableExtension(extensionFromKind) || isEditableExtension(extensionFromName)) { - const basePath = item.path ? `${item.path}/${name}` : name; - const fileToEdit = basePath.replace(/^\/+/, ''); - // Encode each segment for editor path - const encodedPath = fileToEdit.split('/').map(encodeURIComponent).join('/'); + if (editable) { navigate({ path: `/editor/${encodedPath}` }); return; } diff --git a/frontend/src/composables/useFileDragDrop.js b/frontend/src/composables/useFileDragDrop.js index a8dfd067a..13d7484de 100644 --- a/frontend/src/composables/useFileDragDrop.js +++ b/frontend/src/composables/useFileDragDrop.js @@ -1,6 +1,7 @@ import { ref } from 'vue'; import { useFileStore } from '@/stores/fileStore'; import { moveItems, normalizePath } from '@/api'; +import { useOnlyOfficeTransferConfirm } from '@/composables/useOnlyOfficeTransferConfirm'; import { useInputMode } from '@/composables/useInputMode'; /** @@ -9,6 +10,7 @@ import { useInputMode } from '@/composables/useInputMode'; */ export function useFileDragDrop() { const fileStore = useFileStore(); + const onlyOfficeTransferConfirm = useOnlyOfficeTransferConfirm(); const { isTouchDevice } = useInputMode(); const isDraggingOver = ref(false); const dragOverTarget = ref(null); @@ -23,7 +25,9 @@ export function useFileDragDrop() { const types = event?.dataTransfer?.types; if (!types) return false; // Our internal drags set application/json and a text/plain fallback for Safari. - return Array.from(types).includes('application/json') || Array.from(types).includes('text/plain'); + return ( + Array.from(types).includes('application/json') || Array.from(types).includes('text/plain') + ); }; const serializeItems = (items) => @@ -254,6 +258,12 @@ export function useFileDragDrop() { return; } + // Moving a document somebody has open in an editor is allowed, and worth + // asking about: the editor will write where the file used to be, and the + // save that follows lands under the old name. + const confirmed = await onlyOfficeTransferConfirm.requestConfirmation(draggedItems); + if (!confirmed) return; + try { // Prepare payload for moveItems API const movePayload = serializeItems(draggedItems); diff --git a/frontend/src/composables/useOnlyOfficeActivity.js b/frontend/src/composables/useOnlyOfficeActivity.js new file mode 100644 index 000000000..339b27c1e --- /dev/null +++ b/frontend/src/composables/useOnlyOfficeActivity.js @@ -0,0 +1,86 @@ +import { waitForOnlyOfficeActivityVersion } from '@/api'; + +/** + * Keeping the "being edited" marks in a listing current. + * + * The server holds the request open until somebody joins or leaves a document, + * so this costs nothing while nothing happens — which is what makes presence + * affordable to show at all. A hidden tab stops asking entirely. + * + * @param {object} options + * @param {object} options.featuresStore + * @param {() => Promise} options.refresh list the current folder again + */ +export const useOnlyOfficeActivity = ({ featuresStore, refresh }) => { + let activityVersion = null; + let started = false; + let pollController = null; + let visibilityHandlerBound = false; + + const waitForVisibility = () => + new Promise((resolve) => { + if (typeof document === 'undefined' || document.visibilityState !== 'hidden') { + resolve(); + return; + } + document.addEventListener('visibilitychange', resolve, { once: true }); + }); + + const start = async () => { + if (started || typeof window === 'undefined') return; + + // The route only exists where ONLYOFFICE is configured, so asking for it + // anywhere else is a 404 — answered at once, retried a second later, for as + // long as the tab stays open. Every one of them is logged server-side with + // a stack trace, and the mark it feeds cannot show anything without a + // Document Server anyway. + await featuresStore.ensureLoaded(); + if (!featuresStore.onlyofficeEnabled) return; + // Another navigation may have got here while features were loading. + if (started) return; + started = true; + + const poll = async () => { + while (started) { + await waitForVisibility(); + if (!started) return; + + const controller = new AbortController(); + pollController = controller; + try { + const response = await waitForOnlyOfficeActivityVersion(activityVersion, { + signal: controller.signal, + }); + const nextVersion = Number(response?.version); + const changed = + Number.isInteger(nextVersion) && + activityVersion !== null && + nextVersion !== activityVersion; + if (Number.isInteger(nextVersion)) activityVersion = nextVersion; + if (changed) await refresh(); + } catch (error) { + if (error?.name !== 'AbortError') { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } finally { + if (pollController === controller) pollController = null; + } + } + }; + + if (!visibilityHandlerBound && typeof document !== 'undefined') { + visibilityHandlerBound = true; + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') pollController?.abort(); + }); + } + void poll(); + }; + + const stop = () => { + started = false; + pollController?.abort(); + }; + + return { start, stop }; +}; diff --git a/frontend/src/composables/useOnlyOfficeTransferConfirm.js b/frontend/src/composables/useOnlyOfficeTransferConfirm.js new file mode 100644 index 000000000..a2d2ee0bf --- /dev/null +++ b/frontend/src/composables/useOnlyOfficeTransferConfirm.js @@ -0,0 +1,57 @@ +import { computed, ref } from 'vue'; + +let instance = null; + +export function useOnlyOfficeTransferConfirm() { + if (instance) return instance; + + const isOpen = ref(false); + const pendingItems = ref([]); + let resolvePending = null; + + const activeItems = computed(() => + pendingItems.value.filter((item) => item?.onlyofficeActivity?.active) + ); + + const requestConfirmation = (items) => { + const itemsBeingEdited = (Array.isArray(items) ? items : []).filter( + (item) => item?.onlyofficeActivity?.active + ); + if (itemsBeingEdited.length === 0) return Promise.resolve(true); + + // A second question replaces the one on screen, and the first must not be + // left unanswered: whoever is waiting on it is holding a transfer open, + // for as long as the tab lives. It is answered as refused — nobody said + // yes to it, and a transfer that does not happen is the safe half of the + // question this asks. + if (resolvePending) settle(false); + + pendingItems.value = itemsBeingEdited; + isOpen.value = true; + + return new Promise((resolve) => { + resolvePending = resolve; + }); + }; + + const settle = (confirmed) => { + if (!resolvePending) return; + const resolve = resolvePending; + resolvePending = null; + isOpen.value = false; + pendingItems.value = []; + resolve(confirmed); + }; + + const cancel = () => settle(false); + const confirm = () => settle(true); + + instance = { + isOpen, + activeItems, + requestConfirmation, + cancel, + confirm, + }; + return instance; +} diff --git a/frontend/src/composables/usePageTitle.js b/frontend/src/composables/usePageTitle.js new file mode 100644 index 000000000..6448e6771 --- /dev/null +++ b/frontend/src/composables/usePageTitle.js @@ -0,0 +1,21 @@ +import { computed, unref } from 'vue'; +import { useTitle } from '@vueuse/core'; +import { useAppSettings } from '@/stores/appSettings'; +import { composeTitle } from '@/utils/pageTitle'; + +/** + * Keep the browser tab's title as the page's name and the instance's. + * + * The instance's name is the one set in Settings → Branding, read from the + * settings every page already loads — signed in or not, a share visitor + * included — so a new name reaches every open tab without a reload. + * + * @param {import('vue').MaybeRef} page what the page is; empty for a + * page that is the instance itself, such as signing in + */ +export function usePageTitle(page = '') { + const appSettings = useAppSettings(); + const title = computed(() => composeTitle(unref(page), appSettings.state?.branding?.appName)); + useTitle(title); + return title; +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 32413e1a7..8ab1b576a 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Sie haben ungespeicherte Änderungen. Ohne Speichern schließen?" + "confirmCloseWithoutSaving": "Sie haben ungespeicherte Änderungen. Ohne Speichern schließen?", + "trashReadOnly": "Im Papierkorb · schreibgeschützt", + "versionReadOnly": "Frühere Version, schreibgeschützt" }, "status": { "updated": "Erfolgreich aktualisiert", @@ -113,7 +115,8 @@ "label": "Meine freigegebenen Dateien", "path": "z. B. Bilder/Urlaub", "search": "Dateien und Ordner durchsuchen…", - "volumeLabel": "e.g. Documents, Projects, Media" + "volumeLabel": "e.g. Documents, Projects, Media", + "totpCode": "123456" }, "loading": { "default": "Wird geladen…", @@ -153,7 +156,8 @@ "labelRequired": "Label is required", "pathRequired": "Path is required", "saveVolume": "Failed to save volume", - "removeVolume": "Failed to remove volume" + "removeVolume": "Failed to remove volume", + "totpCodeRequired": "Geben Sie den Code ein." }, "serverErrors": { "AUTH_REQUIRED": "Authentifizierung erforderlich", @@ -319,7 +323,8 @@ "dateTaken": "Aufnahmedatum: {date}", "camera": "Kamera: {makeModel}", "lens": "Objektiv: {lens}", - "duration": "Dauer: {seconds}s" + "duration": "Dauer: {seconds}s", + "versions": "Versionen" }, "auth": { "preparing": "Ihr Explorer wird vorbereitet…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "Fügen Sie {suffix} zu Ihrem Benutzernamen hinzu", "bulletPasswordSame": "Ihr Passwort bleibt gleich.", "bulletUpdateUsers": "Sie können bestehende Benutzer im Admin-Menü aktualisieren" - } + }, + "totpCode": "Code", + "totpExplain": "Ihr Passwort wurde akzeptiert. Geben Sie den Code aus Ihrer Authenticator-App ein oder einen Ihrer Wiederherstellungscodes.", + "totpSubmit": "Anmelden" }, "setup": { "headline": "Lassen Sie uns loslegen", @@ -392,7 +400,10 @@ "security": "Sicherheit", "accessControl": "Zugriffskontrolle", "adminUsers": "Benutzerverwaltung", - "trash": "Papierkorb und Versionen" + "trash": "Papierkorb und Versionen", + "fileVersions": "Dateiversionen", + "uploads": "Uploads", + "accountTwoFactor": "Zwei-Faktor" }, "about": { "subtitle": "Build-Informationen für diese Anwendung anzeigen.", @@ -442,7 +453,11 @@ "months": "Monate", "skipHome": "Startseite überspringen", "skipHomeHelp": "Leitet beim Besuch der Startseite automatisch zur ersten Volume weiter. Wenn nicht gesetzt, wird die Serverkonfiguration verwendet.", - "useEnvSetting": "Servereinstellung verwenden" + "useEnvSetting": "Servereinstellung verwenden", + "showVersionMarks": "Dateien mit Versionen kennzeichnen", + "showVersionMarksHelp": "Ein kleines Zeichen in der Liste bei Dateien mit früheren Versionen, mit deren Anzahl. Ein Klick öffnet den Verlauf.", + "documentsOpenInNewTab": "Dokumente in einem neuen Tab öffnen", + "documentsOpenInNewTabHelp": "Eine Datei bekommt beim Öffnen einen eigenen Browser-Tab, sodass mehrere offen bleiben, während Sie weiter stöbern. Aus öffnet sie sich wie bisher über dem Ordner." }, "thumbs": { "subtitle": "Vorschau-Miniaturen für Bilder und Videos anpassen.", @@ -615,6 +630,92 @@ "sharedSpace": "Versionen und Papierkorb teilen sich den reservierten Bereich jedes Volumes. Wird er knapp, gehen zuerst ältere Versionen, dann Papierkorbelemente, dann die letzte Version jeder Datei und zuletzt angeheftete Versionen.", "environmentNote": "Standardwerte stammen aus VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE und VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Dateiversionen", + "intro": "Alle Dateien mit früheren Versionen, wo immer sie liegen, und wie viel Platz diese belegen. Hier erscheinen Pfade aus allen Bereichen, persönliche Ordner eingeschlossen — deshalb ist diese Seite Administratoren vorbehalten.", + "search": "Pfad enthält", + "searchPlaceholder": "Teil eines Namens oder Ordners", + "zone": "Bereich", + "anyZone": "Alle Bereiche", + "state": "Zustand", + "anyState": "Alle", + "sort": "Sortieren nach", + "sortBytes": "Belegter Platz", + "sortCount": "Anzahl der Versionen", + "sortNewest": "Neueste Version", + "sortPath": "Pfad", + "summary": "{files} Dateien, {versions} Versionen, {size}", + "file": "Datei", + "count": "Versionen", + "size": "Größe", + "newest": "Neueste", + "states": { + "live": "Vorhanden", + "trashed": "Im Papierkorb", + "orphaned": "Verschwunden" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Persönlicher Ordner {name}", + "user-volume": "Zugewiesenes Volume {name}" + }, + "zoneUnknown": "Unbekannter Bereich", + "pinned": "Angeheftet", + "unavailable": "Volume nicht verfügbar", + "deleteAll": "Verlauf löschen", + "deleteSelected": "{count} Version löschen | {count} Versionen löschen", + "deleteForGood": "Endgültig löschen", + "confirmAllTitle": "Alle Versionen von {name} löschen?", + "confirmSomeTitle": "{count} Version löschen? | {count} Versionen löschen?", + "confirmMessage": "Dieser Inhalt wird von der Festplatte entfernt. Die Datei selbst bleibt unberührt, und nichts davon lässt sich rückgängig machen.", + "none": "Keine Datei hat frühere Versionen.", + "loadFailed": "Die Liste konnte nicht gelesen werden.", + "detailFailed": "Dieser Verlauf konnte nicht gelesen werden.", + "deleteFailed": "Die Versionen konnten nicht gelöscht werden.", + "previous": "Zurück", + "next": "Weiter", + "range": "{from} bis {to} von {total}", + "deleteSelectedNone": "Angehakte Versionen löschen" + }, + "uploads": { + "title": "Uploads", + "chunkSize": "Segmentgröße", + "chunkSizeHelp": "Maximale Größe jeder Upload-Anfrage. Halten Sie diese unter dem Limit Ihres Reverse-Proxys. Kleinere Segmente (8–32 MiB) sorgen für einen flüssigeren Fortschritt; sehr große Segmente zeigen sichtbare Sprünge (der Server schreibt jedes Segment vor dem nächsten).", + "subtitle": "Konfigurieren Sie, wie Dateien an den Server gesendet werden.", + "chunkedEnable": "Segmentierte Uploads aktivieren", + "chunkedEnableHelp": "Verwendet TUS, um große Dateien über mehrere kleinere Anfragen zu senden.", + "chunkSizeInvalid": "Geben Sie eine Segmentgröße von 1 bis {max} MiB ein." + }, + "twoFactor": { + "title": "Zwei-Faktor-Authentifizierung", + "intro": "Ein Code von Ihrem Telefon, zusätzlich zum Passwort.", + "notLocalUser": "Dieses Konto meldet sich über einen Identitätsanbieter an; dort gehört sein zweiter Faktor hin.", + "off": "Die Zwei-Faktor-Authentifizierung ist für dieses Konto aus.", + "on": "Die Zwei-Faktor-Authentifizierung ist an.", + "turnOn": "Einschalten", + "turnOff": "Ausschalten", + "scan": "Scannen Sie dies mit Ihrer Authenticator-App.", + "orType": "Oder geben Sie dieses Geheimnis in der App ein:", + "qrLabel": "QR-Code für Ihre Authenticator-App", + "codeLabel": "Code aus der App", + "confirm": "Bestätigen", + "confirmPassword": "Ihr Passwort", + "codesTitle": "Wiederherstellungscodes", + "codesExplain": "Bewahren Sie diese woanders als auf dem Telefon auf. Jeder meldet Sie einmal an, für den Tag, an dem das Telefon fehlt. Sie werden jetzt gezeigt und nie wieder.", + "copyCodes": "Kopieren", + "copied": "Kopiert.", + "codesLeft": "Ein Wiederherstellungscode übrig | {count} Wiederherstellungscodes übrig", + "newCodesButton": "Neue Codes erzeugen", + "newCodes": "Neue Wiederherstellungscodes erzeugt. Die vorherigen funktionieren nicht mehr.", + "turnedOn": "Die Zwei-Faktor-Authentifizierung ist an.", + "turnedOff": "Die Zwei-Faktor-Authentifizierung ist aus.", + "loadFailed": "Die Zwei-Faktor-Einstellungen dieses Kontos konnten nicht gelesen werden.", + "startFailed": "Die Einrichtung des zweiten Faktors konnte nicht gestartet werden.", + "confirmFailed": "Dieser Code stimmt nicht.", + "codesFailed": "Es konnten keine neuen Wiederherstellungscodes erzeugt werden.", + "disableFailed": "Die Zwei-Faktor-Authentifizierung konnte nicht ausgeschaltet werden.", + "wrongPassword": "Dieses Passwort stimmt nicht." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "{count} Freigabelink wiederhergestellt | {count} Freigabelinks wiederhergestellt", "dropped": "{count} Freigabelink gelöscht | {count} Freigabelinks gelöscht" } + }, + "versions": { + "title": "Versionen", + "menu": "Versionen", + "aria": "Dateiversionen", + "current": "Aktuelle Version", + "empty": "Noch keine frühere Version. Jedes Speichern behält, was es ersetzt.", + "disabled": "Dateiversionen sind ausgeschaltet: Beim Speichern werden keine neuen mehr behalten. Die folgenden bleiben bis zu ihrem Ablauf.", + "loadFailed": "Die Versionen konnten nicht geladen werden.", + "notShared": "Der Verlauf dieser Datei ist nicht für Sie freigegeben.", + "unavailable": "Ihr Inhalt fehlt auf der Festplatte.", + "unknownAuthor": "Unbekannter Autor", + "shareLink": "Jemand mit dem Link", + "pinned": "Angeheftet", + "aside": "Beiseitegelegt", + "asideHelp": "Von einem Editor gespeichert, der vor einer Wiederherstellung geöffnet wurde: hier behalten, statt die Wiederherstellung rückgängig zu machen.", + "total": "{count} Version, {size} | {count} Versionen, {size}", + "source": { + "editor": "Texteditor", + "shareEditor": "Editor über eine Freigabe", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Wiederhergestellte Version", + "external": "Außerhalb der App geändert" + }, + "actions": { + "menu": "Aktionen für die Version", + "select": "Diese Version auswählen", + "selectAll": "Alle auswählen", + "deleteSelected": "Auswahl löschen ({count})", + "deleteAll": "Alle löschen", + "preview": "Schreibgeschützt öffnen", + "download": "Herunterladen", + "restore": "Wiederherstellen", + "rename": "Benennen…", + "pin": "Anheften", + "unpin": "Lösen", + "delete": "Löschen" + }, + "confirm": { + "restoreTitle": "Diese Version wiederherstellen?", + "restoreMessage": "„{name}“ erhält seinen Inhalt vom {date} zurück. Der aktuelle Inhalt wird als Version behalten.", + "deleteTitle": "Diese Version löschen? | {count} Versionen löschen?", + "deleteMessage": "Diese Version wird endgültig gelöscht. | Diese {count} Versionen werden endgültig gelöscht.", + "deleteAllTitle": "Alle Versionen löschen?", + "deleteAllMessage": "Alle früheren Versionen von „{name}“ werden endgültig gelöscht, angeheftete eingeschlossen. Die Datei selbst bleibt." + }, + "rename": { + "title": "Diese Version benennen", + "placeholder": "Zum Beispiel: an den Kunden gesendet", + "help": "Mit einem Namen ist eine Version leicht zu finden. Heften Sie sie an, um sie von der automatischen Bereinigung auszunehmen." + }, + "results": { + "restored": "Version wiederhergestellt", + "unchanged": "Die Datei hat bereits diesen Inhalt", + "deleted": "{count} Version gelöscht | {count} Versionen gelöscht", + "renamed": "Version benannt", + "pinned": "Version angeheftet: Die automatische Bereinigung behält sie", + "unpinned": "Version gelöst" + }, + "errors": { + "action": "Die Aktion für diese Version ist fehlgeschlagen" + }, + "mark": "{count} frühere Version | {count} frühere Versionen" + }, + "onlyoffice": { + "renamedHeading": "Umbenannt", + "renamedBody": "Das Dokument heißt jetzt {name}.", + "renameFailed": "Umbenennen in {name} nicht möglich", + "andOthers": "und {count} weitere", + "transferHeading": "Datei wird bearbeitet", + "transferBody": "Diese Datei ist in OnlyOffice geöffnet. Fortfahren kann einen laufenden Speichervorgang stören. Möchten Sie fortfahren?", + "transferCancel": "Abbrechen", + "transferConfirm": "Fortfahren", + "editingBy": "Wird in ONLYOFFICE bearbeitet von: {names}", + "editingNow": "Wird gerade in ONLYOFFICE bearbeitet", + "savedAsHeading": "In diesem Ordner gespeichert", + "savedAsBody": "{name} wurde neben dem Original abgelegt.", + "saveAsFailed": "{name} konnte nicht gespeichert werden" + }, + "preview": { + "nothingOpensIt": "Hier kann nichts {name} öffnen.", + "backToFolder": "Zurück zum Ordner" + }, + "archive": { + "password": { + "title": "Passwort erforderlich", + "description": "Dieses Archiv ist geschützt. Geben Sie sein Passwort ein, um es zu entpacken.", + "label": "Archiv-Passwort", + "invalid": "Falsches Passwort oder das Archiv ist beschädigt.", + "submit": "Entpacken", + "extracting": "Wird entpackt..." + }, + "breadcrumb": "Im Archiv", + "empty": "Dieser Ordner ist leer.", + "download": "Herunterladen", + "downloadNamed": "{name} herunterladen", + "outside": "Ein Eintrag wird nicht angezeigt: Sein Name verweist aus dem Archiv heraus. | {count} Einträge werden nicht angezeigt: Ihre Namen verweisen aus dem Archiv heraus.", + "unreadable": "Dieses Archiv konnte nicht gelesen werden.", + "readFailed": "Diese Datei konnte nicht gelesen werden.", + "notReadable": "Dieser Dateityp kann hier nicht angezeigt werden.", + "tooBigToRead": "Zu groß für die Anzeige hier: {size}, das Limit liegt bei {ceiling}. Laden Sie die Datei herunter oder entpacken Sie sie.", + "extract": "Hier entpacken", + "extractNamed": "{name} hier entpacken", + "selectAll": "Alles hier auswählen", + "selectNamed": "{name} auswählen", + "selected": "Ein Element ausgewählt | {count} Elemente ausgewählt", + "extracted": "Entpackt: {name}", + "extractedNothing": "Es kam nichts heraus.", + "extractFailed": "Dies konnte nicht entpackt werden.", + "count": "Ein Eintrag in diesem Archiv | {count} Einträge in diesem Archiv" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8c6a72918..f880a949b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "You have unsaved changes. Close without saving?" + "confirmCloseWithoutSaving": "You have unsaved changes. Close without saving?", + "trashReadOnly": "In the trash · read only", + "versionReadOnly": "Earlier version, read-only" }, "status": { "updated": "Updated successfully", @@ -113,7 +115,8 @@ "label": "My Shared Files", "path": "e.g. Pictures/Holidays", "search": "Search files and folders…", - "volumeLabel": "e.g. Documents, Projects, Media" + "volumeLabel": "e.g. Documents, Projects, Media", + "totpCode": "123456" }, "loading": { "default": "Loading…", @@ -153,7 +156,8 @@ "labelRequired": "Label is required", "pathRequired": "Path is required", "saveVolume": "Failed to save volume", - "removeVolume": "Failed to remove volume" + "removeVolume": "Failed to remove volume", + "totpCodeRequired": "Enter the code." }, "serverErrors": { "AUTH_REQUIRED": "Authentication required", @@ -319,7 +323,8 @@ "dateTaken": "Date Taken: {date}", "camera": "Camera: {makeModel}", "lens": "Lens: {lens}", - "duration": "Duration: {seconds}s" + "duration": "Duration: {seconds}s", + "versions": "Versions" }, "auth": { "preparing": "Preparing your explorer…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "Add {suffix} to your username", "bulletPasswordSame": "Your password stays the same.", "bulletUpdateUsers": "You can update existing users from Admin Menu" - } + }, + "totpCode": "Code", + "totpExplain": "Your password was accepted. Enter the code from your authenticator app, or one of your recovery codes.", + "totpSubmit": "Sign in" }, "setup": { "headline": "Let's get set up", @@ -392,7 +400,10 @@ "security": "Security", "accessControl": "Access Control", "adminUsers": "User Management", - "trash": "Trash and versions" + "trash": "Trash and versions", + "fileVersions": "File versions", + "uploads": "Uploads", + "accountTwoFactor": "Two-factor" }, "about": { "subtitle": "View build information for this application.", @@ -442,7 +453,11 @@ "months": "Months", "skipHome": "Skip home page", "skipHomeHelp": "Automatically redirect to the first volume when visiting the home page. If not set, follows the server configuration.", - "useEnvSetting": "Use server setting" + "useEnvSetting": "Use server setting", + "showVersionMarks": "Mark files that have versions", + "showVersionMarksHelp": "A small mark in the listing on any file with earlier versions, with how many. Click it to open the history.", + "documentsOpenInNewTab": "Open documents in a new tab", + "documentsOpenInNewTabHelp": "Opening a file gives it a browser tab of its own, so several stay open while you keep browsing. Off, it opens over the folder as it does today." }, "thumbs": { "subtitle": "Customize preview thumbnails for images and videos.", @@ -615,6 +630,92 @@ "sharedSpace": "Versions and the trash share each volume's reserved space. When it runs short, older versions go first, then trash items, then each file's latest version, and pinned versions last.", "environmentNote": "Defaults come from VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE and VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "File versions", + "intro": "Every file that has earlier versions, wherever it is, and what they take up. Paths from every space appear here, personal folders included — which is why this page is for administrators.", + "search": "Path contains", + "searchPlaceholder": "Part of a name or folder", + "zone": "Space", + "anyZone": "Any space", + "state": "Status", + "anyState": "Any", + "sort": "Sort by", + "sortBytes": "Space used", + "sortCount": "Number of versions", + "sortNewest": "Most recent version", + "sortPath": "Path", + "summary": "{files} files, {versions} versions, {size}", + "file": "File", + "count": "Versions", + "size": "Size", + "newest": "Most recent", + "states": { + "live": "Present", + "trashed": "In the trash", + "orphaned": "Gone" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Personal folder {name}", + "user-volume": "Assigned volume {name}" + }, + "zoneUnknown": "Unknown space", + "pinned": "Pinned", + "unavailable": "Volume unavailable", + "deleteAll": "Delete the history", + "deleteSelected": "Delete {count} version | Delete {count} versions", + "deleteForGood": "Delete for good", + "confirmAllTitle": "Delete every version of {name}?", + "confirmSomeTitle": "Delete {count} version? | Delete {count} versions?", + "confirmMessage": "That content is removed from the disk. The file itself is not touched, and nothing here can be undone.", + "none": "No file has earlier versions.", + "loadFailed": "The list could not be read.", + "detailFailed": "This history could not be read.", + "deleteFailed": "The versions could not be deleted.", + "previous": "Previous", + "next": "Next", + "range": "{from} to {to} of {total}", + "deleteSelectedNone": "Delete the versions you tick" + }, + "uploads": { + "title": "Uploads", + "chunkSize": "Chunk size", + "chunkSizeHelp": "Maximum size of each upload request. Keep this below your reverse proxy limit. Smaller chunks (8–32 MiB) give smoother progress; very large chunks show visible steps (the server writes each chunk before the next).", + "subtitle": "Configure how files are sent to the server.", + "chunkedEnable": "Enable chunked uploads", + "chunkedEnableHelp": "Use TUS to send large files through multiple smaller requests.", + "chunkSizeInvalid": "Enter a chunk size from 1 to {max} MiB." + }, + "twoFactor": { + "title": "Two-factor authentication", + "intro": "A code from your phone, on top of your password.", + "notLocalUser": "This account signs in through an identity provider, which is where its second factor belongs.", + "off": "Two-factor authentication is off for this account.", + "on": "Two-factor authentication is on.", + "turnOn": "Turn on", + "turnOff": "Turn off", + "scan": "Scan this with your authenticator app.", + "orType": "Or type this secret into the app:", + "qrLabel": "QR code for your authenticator app", + "codeLabel": "Code from the app", + "confirm": "Confirm", + "confirmPassword": "Your password", + "codesTitle": "Recovery codes", + "codesExplain": "Keep these somewhere other than your phone. Each one signs you in once, for the day the phone is not there. They are shown now and never again.", + "copyCodes": "Copy", + "copied": "Copied.", + "codesLeft": "One recovery code left | {count} recovery codes left", + "newCodesButton": "Draw new codes", + "newCodes": "New recovery codes drawn. The ones before them no longer work.", + "turnedOn": "Two-factor authentication is on.", + "turnedOff": "Two-factor authentication is off.", + "loadFailed": "Could not read this account's two-factor settings.", + "startFailed": "Could not start setting up a second factor.", + "confirmFailed": "That code is not right.", + "codesFailed": "Could not draw new recovery codes.", + "disableFailed": "Could not turn two-factor authentication off.", + "wrongPassword": "That password is not right." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "{count} share link brought back | {count} share links brought back", "dropped": "{count} share link deleted | {count} share links deleted" } + }, + "versions": { + "title": "Versions", + "menu": "Versions", + "aria": "File versions", + "current": "Current version", + "empty": "No earlier version yet. Each save keeps what it replaces.", + "disabled": "File versions are switched off: saves no longer keep new ones. Those below stay until they expire.", + "loadFailed": "The versions could not be loaded.", + "notShared": "The history of this file is not shared with you.", + "unavailable": "Its content is missing from the disk.", + "unknownAuthor": "Unknown author", + "shareLink": "Someone with the link", + "pinned": "Pinned", + "aside": "Set aside", + "asideHelp": "Saved by an editor opened before a restore: kept here rather than undoing the restore.", + "total": "{count} version, {size} | {count} versions, {size}", + "source": { + "editor": "Text editor", + "shareEditor": "Editor through a share", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Restored version", + "external": "Changed outside the app" + }, + "actions": { + "menu": "Version actions", + "select": "Select this version", + "selectAll": "Select all", + "deleteSelected": "Delete selected ({count})", + "deleteAll": "Delete all", + "preview": "Open read-only", + "download": "Download", + "restore": "Restore", + "rename": "Name…", + "pin": "Pin", + "unpin": "Unpin", + "delete": "Delete" + }, + "confirm": { + "restoreTitle": "Restore this version?", + "restoreMessage": "\"{name}\" goes back to its content of {date}. What it holds now is kept as a version.", + "deleteTitle": "Delete this version? | Delete {count} versions?", + "deleteMessage": "This version is deleted for good. | These {count} versions are deleted for good.", + "deleteAllTitle": "Delete all versions?", + "deleteAllMessage": "Every earlier version of \"{name}\" is deleted for good, pinned ones included. The file itself stays." + }, + "rename": { + "title": "Name this version", + "placeholder": "For example: sent to the client", + "help": "A name makes a version easy to find. Pin it to keep it out of the automatic cleanup." + }, + "results": { + "restored": "Version restored", + "unchanged": "The file already has this content", + "deleted": "{count} version deleted | {count} versions deleted", + "renamed": "Version named", + "pinned": "Version pinned: the automatic cleanup keeps it", + "unpinned": "Version unpinned" + }, + "errors": { + "action": "The action on this version failed" + }, + "mark": "{count} earlier version | {count} earlier versions" + }, + "onlyoffice": { + "renamedHeading": "Renamed", + "renamedBody": "The document is now called {name}.", + "renameFailed": "Could not rename to {name}", + "andOthers": "and {count} more", + "transferHeading": "File is being edited", + "transferBody": "This file is open in OnlyOffice. Continuing may disturb a save in progress. Do you want to continue?", + "transferCancel": "Cancel", + "transferConfirm": "Continue", + "editingBy": "Being edited in ONLYOFFICE by {names}", + "editingNow": "Being edited in ONLYOFFICE", + "savedAsHeading": "Saved to this folder", + "savedAsBody": "{name} was added next to the original.", + "saveAsFailed": "Could not save {name}" + }, + "preview": { + "nothingOpensIt": "Nothing here can open {name}.", + "backToFolder": "Back to the folder" + }, + "archive": { + "password": { + "title": "Password required", + "description": "This archive is protected. Enter its password to extract it.", + "label": "Archive password", + "invalid": "Incorrect password, or the archive is damaged.", + "submit": "Extract", + "extracting": "Extracting..." + }, + "breadcrumb": "Inside the archive", + "empty": "This folder is empty.", + "download": "Download", + "downloadNamed": "Download {name}", + "outside": "One entry is not shown: its name points outside the archive. | {count} entries are not shown: their names point outside the archive.", + "unreadable": "This archive could not be read.", + "readFailed": "This file could not be read.", + "notReadable": "This kind of file cannot be shown here.", + "tooBigToRead": "Too large to show here: {size}, and up to {ceiling} can be shown. Download it or extract it to open it.", + "extract": "Extract here", + "extractNamed": "Extract {name} here", + "selectAll": "Select everything here", + "selectNamed": "Select {name}", + "selected": "One selected | {count} selected", + "extracted": "Extracted: {name}", + "extractedNothing": "Nothing came out of it.", + "extractFailed": "This could not be extracted.", + "count": "One entry in this archive | {count} entries in this archive" } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 768b8a041..622f7bf78 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Tienes cambios sin guardar. ¿Cerrar sin guardar?" + "confirmCloseWithoutSaving": "Tienes cambios sin guardar. ¿Cerrar sin guardar?", + "trashReadOnly": "En la papelera · solo lectura", + "versionReadOnly": "Versión anterior, solo lectura" }, "status": { "updated": "Actualizado correctamente", @@ -113,7 +115,8 @@ "label": "Mis archivos compartidos", "path": "p. ej. Imágenes/Vacaciones", "search": "Buscar archivos y carpetas…", - "volumeLabel": "" + "volumeLabel": "", + "totpCode": "123456" }, "loading": { "default": "Cargando…", @@ -153,7 +156,8 @@ "labelRequired": "", "pathRequired": "", "saveVolume": "", - "removeVolume": "" + "removeVolume": "", + "totpCodeRequired": "Introduzca el código." }, "serverErrors": { "AUTH_REQUIRED": "Se requiere autenticación", @@ -319,7 +323,8 @@ "dateTaken": "Fecha de captura: {date}", "camera": "Cámara: {makeModel}", "lens": "Lente: {lens}", - "duration": "Duración: {seconds}s" + "duration": "Duración: {seconds}s", + "versions": "Versiones" }, "auth": { "preparing": "Preparando tu explorador…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "Añade {suffix} a tu nombre de usuario", "bulletPasswordSame": "Tu contraseña se mantiene igual.", "bulletUpdateUsers": "Puedes actualizar usuarios existentes desde el menú de administración" - } + }, + "totpCode": "Código", + "totpExplain": "Su contraseña se ha aceptado. Introduzca el código de su aplicación de autenticación o uno de sus códigos de recuperación.", + "totpSubmit": "Iniciar sesión" }, "setup": { "headline": "Vamos a configurarlo", @@ -392,7 +400,10 @@ "security": "Seguridad", "accessControl": "Control de acceso", "adminUsers": "Gestión de usuarios", - "trash": "Papelera y versiones" + "trash": "Papelera y versiones", + "fileVersions": "Versiones de archivos", + "uploads": "Subidas", + "accountTwoFactor": "Doble factor" }, "about": { "subtitle": "Ver la información de compilación de esta aplicación.", @@ -442,7 +453,11 @@ "months": "Meses", "skipHome": "Saltar inicio", "skipHomeHelp": "Redirige automáticamente a la primera unidad al visitar la pantalla de inicio. Si no se establece, usa la configuración del servidor.", - "useEnvSetting": "Usar configuración del servidor" + "useEnvSetting": "Usar configuración del servidor", + "showVersionMarks": "Marcar los archivos con versiones", + "showVersionMarksHelp": "Una pequeña marca en la lista sobre los archivos con versiones anteriores, con su número. Haga clic para abrir el historial.", + "documentsOpenInNewTab": "Abrir los documentos en una pestaña nueva", + "documentsOpenInNewTabHelp": "Al abrir un archivo se le da su propia pestaña del navegador, así varios siguen abiertos mientras usted sigue navegando. Desactivado, se abre sobre la carpeta como hasta ahora." }, "thumbs": { "subtitle": "Personaliza las miniaturas de vista previa para imágenes y vídeos.", @@ -615,6 +630,92 @@ "sharedSpace": "Las versiones y la papelera comparten el espacio reservado de cada volumen. Cuando falta, primero se van las versiones antiguas, luego los elementos de la papelera, luego la última versión de cada archivo y, por último, las versiones fijadas.", "environmentNote": "Los valores predeterminados vienen de VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE y VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Versiones de archivos", + "intro": "Todos los archivos con versiones anteriores, estén donde estén, y el espacio que ocupan. Aquí aparecen rutas de todos los espacios, incluidas las carpetas personales: por eso esta página es solo para administradores.", + "search": "La ruta contiene", + "searchPlaceholder": "Parte de un nombre o de una carpeta", + "zone": "Espacio", + "anyZone": "Todos los espacios", + "state": "Estado", + "anyState": "Todos", + "sort": "Ordenar por", + "sortBytes": "Espacio ocupado", + "sortCount": "Número de versiones", + "sortNewest": "Versión más reciente", + "sortPath": "Ruta", + "summary": "{files} archivos, {versions} versiones, {size}", + "file": "Archivo", + "count": "Versiones", + "size": "Tamaño", + "newest": "Más reciente", + "states": { + "live": "Presente", + "trashed": "En la papelera", + "orphaned": "Desaparecido" + }, + "zoneKinds": { + "volume": "Volumen {name}", + "personal": "Carpeta personal {name}", + "user-volume": "Volumen asignado {name}" + }, + "zoneUnknown": "Espacio desconocido", + "pinned": "Fijada", + "unavailable": "Volumen no disponible", + "deleteAll": "Eliminar el historial", + "deleteSelected": "Eliminar {count} versión | Eliminar {count} versiones", + "deleteForGood": "Eliminar definitivamente", + "confirmAllTitle": "¿Eliminar todas las versiones de {name}?", + "confirmSomeTitle": "¿Eliminar {count} versión? | ¿Eliminar {count} versiones?", + "confirmMessage": "Ese contenido se borra del disco. El archivo en sí no se toca, y nada de esto se puede deshacer.", + "none": "Ningún archivo tiene versiones anteriores.", + "loadFailed": "No se pudo leer la lista.", + "detailFailed": "No se pudo leer este historial.", + "deleteFailed": "No se pudieron eliminar las versiones.", + "previous": "Anterior", + "next": "Siguiente", + "range": "{from} a {to} de {total}", + "deleteSelectedNone": "Eliminar las versiones marcadas" + }, + "uploads": { + "title": "Subidas", + "chunkSize": "Tamaño de fragmento", + "chunkSizeHelp": "Tamaño máximo de cada solicitud de subida. Mantenlo por debajo del límite de tu proxy inverso. Los fragmentos más pequeños (8–32 MiB) ofrecen un progreso más fluido; los fragmentos muy grandes muestran saltos visibles (el servidor escribe cada fragmento antes del siguiente).", + "subtitle": "Configura cómo se envían los archivos al servidor.", + "chunkedEnable": "Activar subidas por fragmentos", + "chunkedEnableHelp": "Usa TUS para enviar archivos grandes en varias solicitudes más pequeñas.", + "chunkSizeInvalid": "Introduce un tamaño de fragmento de 1 a {max} MiB." + }, + "twoFactor": { + "title": "Autenticación de doble factor", + "intro": "Un código desde su teléfono, además de su contraseña.", + "notLocalUser": "Esta cuenta inicia sesión mediante un proveedor de identidad, que es donde corresponde su segundo factor.", + "off": "El doble factor está desactivado en esta cuenta.", + "on": "El doble factor está activado.", + "turnOn": "Activar", + "turnOff": "Desactivar", + "scan": "Escanee esto con su aplicación de autenticación.", + "orType": "O escriba este secreto en la aplicación:", + "qrLabel": "Código QR para su aplicación de autenticación", + "codeLabel": "Código de la aplicación", + "confirm": "Confirmar", + "confirmPassword": "Su contraseña", + "codesTitle": "Códigos de recuperación", + "codesExplain": "Guárdelos en un sitio que no sea su teléfono. Cada uno sirve para un inicio de sesión, para el día en que el teléfono no esté. Se muestran ahora y nunca más.", + "copyCodes": "Copiar", + "copied": "Copiado.", + "codesLeft": "Queda un código de recuperación | Quedan {count} códigos de recuperación", + "newCodesButton": "Generar códigos nuevos", + "newCodes": "Nuevos códigos de recuperación generados. Los anteriores ya no funcionan.", + "turnedOn": "El doble factor está activado.", + "turnedOff": "El doble factor está desactivado.", + "loadFailed": "No se han podido leer los ajustes de doble factor de esta cuenta.", + "startFailed": "No se ha podido iniciar la configuración del segundo factor.", + "confirmFailed": "Ese código no es correcto.", + "codesFailed": "No se han podido generar códigos de recuperación nuevos.", + "disableFailed": "No se ha podido desactivar el doble factor.", + "wrongPassword": "Esa contraseña no es correcta." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "{count} enlace compartido recuperado | {count} enlaces compartidos recuperados", "dropped": "{count} enlace compartido eliminado | {count} enlaces compartidos eliminados" } + }, + "versions": { + "title": "Versiones", + "menu": "Versiones", + "aria": "Versiones del archivo", + "current": "Versión actual", + "empty": "Aún no hay versiones anteriores. Cada guardado conserva lo que reemplaza.", + "disabled": "Las versiones de archivos están desactivadas: los guardados ya no conservan nuevas. Las de abajo permanecen hasta que caduquen.", + "loadFailed": "No se pudieron cargar las versiones.", + "notShared": "El historial de este archivo no está compartido contigo.", + "unavailable": "Su contenido falta en el disco.", + "unknownAuthor": "Autor desconocido", + "shareLink": "Alguien con el enlace", + "pinned": "Fijada", + "aside": "Apartada", + "asideHelp": "Guardada por un editor abierto antes de una restauración: se conserva aquí en lugar de deshacer la restauración.", + "total": "{count} versión, {size} | {count} versiones, {size}", + "source": { + "editor": "Editor de texto", + "shareEditor": "Editor a través de un enlace compartido", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Versión restaurada", + "external": "Modificado fuera de la aplicación" + }, + "actions": { + "menu": "Acciones de la versión", + "select": "Seleccionar esta versión", + "selectAll": "Seleccionar todo", + "deleteSelected": "Eliminar la selección ({count})", + "deleteAll": "Eliminar todo", + "preview": "Abrir en solo lectura", + "download": "Descargar", + "restore": "Restaurar", + "rename": "Nombrar…", + "pin": "Fijar", + "unpin": "Dejar de fijar", + "delete": "Eliminar" + }, + "confirm": { + "restoreTitle": "¿Restaurar esta versión?", + "restoreMessage": "«{name}» recupera su contenido del {date}. Lo que contiene ahora se conserva como versión.", + "deleteTitle": "¿Eliminar esta versión? | ¿Eliminar {count} versiones?", + "deleteMessage": "Esta versión se elimina definitivamente. | Estas {count} versiones se eliminan definitivamente.", + "deleteAllTitle": "¿Eliminar todas las versiones?", + "deleteAllMessage": "Todas las versiones anteriores de «{name}» se eliminan definitivamente, incluidas las fijadas. El archivo en sí se mantiene." + }, + "rename": { + "title": "Nombrar esta versión", + "placeholder": "Por ejemplo: enviada al cliente", + "help": "Un nombre facilita encontrar una versión. Fíjala para excluirla de la limpieza automática." + }, + "results": { + "restored": "Versión restaurada", + "unchanged": "El archivo ya tiene este contenido", + "deleted": "{count} versión eliminada | {count} versiones eliminadas", + "renamed": "Versión nombrada", + "pinned": "Versión fijada: la limpieza automática la conserva", + "unpinned": "Versión desfijada" + }, + "errors": { + "action": "La acción sobre esta versión falló" + }, + "mark": "{count} versión anterior | {count} versiones anteriores" + }, + "onlyoffice": { + "renamedHeading": "Renombrado", + "renamedBody": "El documento se llama ahora {name}.", + "renameFailed": "No se ha podido renombrar a {name}", + "andOthers": "y {count} más", + "transferHeading": "El archivo se está editando", + "transferBody": "Este archivo está abierto en OnlyOffice. Continuar puede interrumpir un guardado en curso. ¿Quieres continuar?", + "transferCancel": "Cancelar", + "transferConfirm": "Continuar", + "editingBy": "Se está editando en ONLYOFFICE: {names}", + "editingNow": "Se está editando en ONLYOFFICE", + "savedAsHeading": "Guardado en esta carpeta", + "savedAsBody": "{name} se ha añadido junto al original.", + "saveAsFailed": "No se ha podido guardar {name}" + }, + "preview": { + "nothingOpensIt": "Aquí nada puede abrir {name}.", + "backToFolder": "Volver a la carpeta" + }, + "archive": { + "password": { + "title": "Contraseña requerida", + "description": "Este archivo comprimido está protegido. Introduce su contraseña para extraerlo.", + "label": "Contraseña del archivo comprimido", + "invalid": "Contraseña incorrecta o el archivo comprimido está dañado.", + "submit": "Extraer", + "extracting": "Extrayendo..." + }, + "breadcrumb": "Dentro del archivo comprimido", + "empty": "Esta carpeta está vacía.", + "download": "Descargar", + "downloadNamed": "Descargar {name}", + "outside": "No se muestra una entrada: su nombre apunta fuera del archivo comprimido. | No se muestran {count} entradas: sus nombres apuntan fuera del archivo comprimido.", + "unreadable": "No se ha podido leer este archivo comprimido.", + "readFailed": "No se ha podido leer este archivo.", + "notReadable": "Este tipo de archivo no se puede mostrar aquí.", + "tooBigToRead": "Demasiado grande para mostrarlo aquí: {size}, el límite es {ceiling}. Descárguelo o extráigalo para abrirlo.", + "extract": "Extraer aquí", + "extractNamed": "Extraer {name} aquí", + "selectAll": "Seleccionar todo aquí", + "selectNamed": "Seleccionar {name}", + "selected": "Un elemento seleccionado | {count} elementos seleccionados", + "extracted": "Extraído: {name}", + "extractedNothing": "No ha salido nada.", + "extractFailed": "No se ha podido extraer esto.", + "count": "Una entrada en este archivo comprimido | {count} entradas en este archivo comprimido" } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 6411cc892..31d5b24f8 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Vous avez des modifications non enregistrées. Fermer sans enregistrer ?" + "confirmCloseWithoutSaving": "Vous avez des modifications non enregistrées. Fermer sans enregistrer ?", + "trashReadOnly": "Dans la corbeille · lecture seule", + "versionReadOnly": "Version antérieure, lecture seule" }, "status": { "updated": "Mis à jour avec succès", @@ -113,7 +115,8 @@ "label": "Mes fichiers partagés", "path": "ex. Images/Vacances", "search": "Rechercher des fichiers et des dossiers…", - "volumeLabel": "e.g. Documents, Projects, Media" + "volumeLabel": "e.g. Documents, Projects, Media", + "totpCode": "123456" }, "loading": { "default": "Chargement…", @@ -153,7 +156,8 @@ "labelRequired": "Label is required", "pathRequired": "Path is required", "saveVolume": "Failed to save volume", - "removeVolume": "Failed to remove volume" + "removeVolume": "Failed to remove volume", + "totpCodeRequired": "Saisissez le code." }, "serverErrors": { "AUTH_REQUIRED": "Authentification requise", @@ -319,7 +323,8 @@ "dateTaken": "Date de prise : {date}", "camera": "Appareil photo : {makeModel}", "lens": "Objectif : {lens}", - "duration": "Durée : {seconds}s" + "duration": "Durée : {seconds}s", + "versions": "Versions" }, "auth": { "preparing": "Préparation de votre explorateur…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "Ajoutez {suffix} à votre nom d'utilisateur", "bulletPasswordSame": "Votre mot de passe reste inchangé.", "bulletUpdateUsers": "Vous pouvez mettre à jour les utilisateurs existants depuis le menu d'administration" - } + }, + "totpCode": "Code", + "totpExplain": "Votre mot de passe a été accepté. Saisissez le code de votre application d'authentification, ou l'un de vos codes de secours.", + "totpSubmit": "Se connecter" }, "setup": { "headline": "Procédons à la configuration", @@ -392,7 +400,10 @@ "security": "Sécurité", "accessControl": "Contrôle d'accès", "adminUsers": "Gestion des utilisateurs", - "trash": "Corbeille et versions" + "trash": "Corbeille et versions", + "fileVersions": "Versions de fichiers", + "uploads": "Téléversements", + "accountTwoFactor": "Double facteur" }, "about": { "subtitle": "Afficher les informations de build de cette application.", @@ -442,7 +453,11 @@ "months": "Mois", "skipHome": "Passer l’accueil", "skipHomeHelp": "Redirige automatiquement vers le premier volume depuis l’accueil. Si non défini, utilise la configuration serveur.", - "useEnvSetting": "Utiliser la configuration serveur" + "useEnvSetting": "Utiliser la configuration serveur", + "showVersionMarks": "Signaler les fichiers qui ont des versions", + "showVersionMarksHelp": "Une petite marque dans la liste sur les fichiers qui ont des versions antérieures, avec leur nombre. Cliquez dessus pour ouvrir l’historique.", + "documentsOpenInNewTab": "Ouvrir les documents dans un nouvel onglet", + "documentsOpenInNewTabHelp": "Ouvrir un fichier lui donne son propre onglet de navigateur : plusieurs restent ouverts pendant que vous continuez à naviguer. Désactivé, il s’ouvre par-dessus le dossier comme aujourd’hui." }, "thumbs": { "subtitle": "Personnalisez les vignettes d'aperçu pour les images et les vidéos.", @@ -615,6 +630,92 @@ "sharedSpace": "Les versions et la corbeille partagent l'espace réservé de chaque volume. Quand il manque, les versions anciennes partent d'abord, puis les éléments de la corbeille, puis la dernière version de chaque fichier, et les versions épinglées en dernier.", "environmentNote": "Les valeurs par défaut viennent de VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE et VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Versions de fichiers", + "intro": "Tous les fichiers qui ont des versions antérieures, où qu’ils soient, et la place qu’elles occupent. Les chemins de tous les espaces y figurent, dossiers personnels compris — c’est pourquoi cette page est réservée aux administrateurs.", + "search": "Le chemin contient", + "searchPlaceholder": "Une partie d’un nom ou d’un dossier", + "zone": "Espace", + "anyZone": "Tous les espaces", + "state": "État", + "anyState": "Tous", + "sort": "Trier par", + "sortBytes": "Place occupée", + "sortCount": "Nombre de versions", + "sortNewest": "Version la plus récente", + "sortPath": "Chemin", + "summary": "{files} fichiers, {versions} versions, {size}", + "file": "Fichier", + "count": "Versions", + "size": "Taille", + "newest": "La plus récente", + "states": { + "live": "Présent", + "trashed": "À la corbeille", + "orphaned": "Disparu" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Dossier personnel {name}", + "user-volume": "Volume attribué {name}" + }, + "zoneUnknown": "Espace inconnu", + "pinned": "Épinglée", + "unavailable": "Volume indisponible", + "deleteAll": "Supprimer l’historique", + "deleteSelected": "Supprimer {count} version | Supprimer {count} versions", + "deleteForGood": "Supprimer définitivement", + "confirmAllTitle": "Supprimer toutes les versions de {name} ?", + "confirmSomeTitle": "Supprimer {count} version ? | Supprimer {count} versions ?", + "confirmMessage": "Ce contenu est effacé du disque. Le fichier lui-même n’est pas touché, et rien ici ne peut être annulé.", + "none": "Aucun fichier n’a de version antérieure.", + "loadFailed": "La liste n’a pas pu être lue.", + "detailFailed": "Cet historique n’a pas pu être lu.", + "deleteFailed": "Les versions n’ont pas pu être supprimées.", + "previous": "Précédent", + "next": "Suivant", + "range": "{from} à {to} sur {total}", + "deleteSelectedNone": "Supprimer les versions cochées" + }, + "uploads": { + "title": "Téléversements", + "chunkSize": "Taille des chunks", + "chunkSizeHelp": "Taille maximale de chaque requête d’upload. Gardez-la sous la limite de votre reverse proxy. Des chunks plus petits (8–32 Mio) donnent une progression plus fluide ; de très gros chunks font apparaître des paliers (le serveur écrit chaque chunk avant le suivant).", + "subtitle": "Configurez le transport utilisé pour envoyer les fichiers vers le serveur.", + "chunkedEnable": "Activer les téléversements par chunks", + "chunkedEnableHelp": "Utilise TUS pour envoyer les gros fichiers en plusieurs requêtes plus petites.", + "chunkSizeInvalid": "Indiquez une taille de chunk comprise entre 1 et {max} Mio." + }, + "twoFactor": { + "title": "Authentification à double facteur", + "intro": "Un code depuis votre téléphone, en plus de votre mot de passe.", + "notLocalUser": "Ce compte se connecte via un fournisseur d'identité, et c'est là que son second facteur se règle.", + "off": "Le double facteur est désactivé sur ce compte.", + "on": "Le double facteur est activé.", + "turnOn": "Activer", + "turnOff": "Désactiver", + "scan": "Scannez ceci avec votre application d'authentification.", + "orType": "Ou saisissez ce secret dans l'application :", + "qrLabel": "QR code pour votre application d'authentification", + "codeLabel": "Code de l'application", + "confirm": "Confirmer", + "confirmPassword": "Votre mot de passe", + "codesTitle": "Codes de secours", + "codesExplain": "Conservez-les ailleurs que sur votre téléphone. Chacun vous connecte une fois, pour le jour où le téléphone n'est pas là. Ils sont affichés maintenant et plus jamais.", + "copyCodes": "Copier", + "copied": "Copié.", + "codesLeft": "Un code de secours restant | {count} codes de secours restants", + "newCodesButton": "Générer de nouveaux codes", + "newCodes": "Nouveaux codes de secours générés. Les précédents ne fonctionnent plus.", + "turnedOn": "Le double facteur est activé.", + "turnedOff": "Le double facteur est désactivé.", + "loadFailed": "Impossible de lire les réglages de double facteur de ce compte.", + "startFailed": "Impossible de démarrer la configuration du second facteur.", + "confirmFailed": "Ce code n'est pas le bon.", + "codesFailed": "Impossible de générer de nouveaux codes de secours.", + "disableFailed": "Impossible de désactiver le double facteur.", + "wrongPassword": "Ce mot de passe n'est pas le bon." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "{count} lien de partage rétabli | {count} liens de partage rétablis", "dropped": "{count} lien de partage supprimé | {count} liens de partage supprimés" } + }, + "versions": { + "title": "Versions", + "menu": "Versions", + "aria": "Versions du fichier", + "current": "Version actuelle", + "empty": "Aucune version antérieure pour l'instant. Chaque enregistrement garde ce qu'il remplace.", + "disabled": "Les versions de fichiers sont désactivées : les enregistrements n'en gardent plus de nouvelles. Celles ci-dessous restent jusqu'à leur expiration.", + "loadFailed": "Impossible de charger les versions.", + "notShared": "L'historique de ce fichier ne vous est pas partagé.", + "unavailable": "Son contenu est introuvable sur le disque.", + "unknownAuthor": "Auteur inconnu", + "shareLink": "Une personne disposant du lien", + "pinned": "Épinglée", + "aside": "Mise de côté", + "asideHelp": "Enregistrée par un éditeur ouvert avant une restauration : gardée ici plutôt que d'annuler la restauration.", + "total": "{count} version, {size} | {count} versions, {size}", + "source": { + "editor": "Éditeur de texte", + "shareEditor": "Éditeur via un partage", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Version restaurée", + "external": "Modifié hors de l'application" + }, + "actions": { + "menu": "Actions sur la version", + "select": "Sélectionner cette version", + "selectAll": "Tout sélectionner", + "deleteSelected": "Supprimer la sélection ({count})", + "deleteAll": "Tout supprimer", + "preview": "Ouvrir en lecture seule", + "download": "Télécharger", + "restore": "Restaurer", + "rename": "Nommer…", + "pin": "Épingler", + "unpin": "Désépingler", + "delete": "Supprimer" + }, + "confirm": { + "restoreTitle": "Restaurer cette version ?", + "restoreMessage": "« {name} » retrouve son contenu du {date}. Son contenu actuel est gardé comme version.", + "deleteTitle": "Supprimer cette version ? | Supprimer {count} versions ?", + "deleteMessage": "Cette version est supprimée définitivement. | Ces {count} versions sont supprimées définitivement.", + "deleteAllTitle": "Supprimer toutes les versions ?", + "deleteAllMessage": "Toutes les versions antérieures de « {name} » sont supprimées définitivement, épinglées comprises. Le fichier lui-même reste." + }, + "rename": { + "title": "Nommer cette version", + "placeholder": "Par exemple : envoyée au client", + "help": "Un nom permet de retrouver une version. Épinglez-la pour la protéger du nettoyage automatique." + }, + "results": { + "restored": "Version restaurée", + "unchanged": "Le fichier a déjà ce contenu", + "deleted": "{count} version supprimée | {count} versions supprimées", + "renamed": "Version nommée", + "pinned": "Version épinglée : le nettoyage automatique la garde", + "unpinned": "Version désépinglée" + }, + "errors": { + "action": "L'action sur cette version a échoué" + }, + "mark": "{count} version antérieure | {count} versions antérieures" + }, + "onlyoffice": { + "renamedHeading": "Renommé", + "renamedBody": "Le document s’appelle maintenant {name}.", + "renameFailed": "Impossible de renommer en {name}", + "andOthers": "et {count} autre(s)", + "transferHeading": "Fichier en cours d’édition", + "transferBody": "Ce fichier est ouvert dans OnlyOffice. Continuer peut perturber une sauvegarde en cours. Voulez-vous continuer ?", + "transferCancel": "Annuler", + "transferConfirm": "Continuer", + "editingBy": "Édition en cours dans ONLYOFFICE : {names}", + "editingNow": "Édition en cours dans ONLYOFFICE", + "savedAsHeading": "Enregistré dans ce dossier", + "savedAsBody": "{name} a été ajouté à côté de l’original.", + "saveAsFailed": "Impossible d’enregistrer {name}" + }, + "preview": { + "nothingOpensIt": "Rien ici ne sait ouvrir {name}.", + "backToFolder": "Retour au dossier" + }, + "archive": { + "password": { + "title": "Mot de passe requis", + "description": "Cette archive est protégée. Saisissez son mot de passe pour l'extraire.", + "label": "Mot de passe de l'archive", + "invalid": "Mot de passe incorrect ou archive endommagée.", + "submit": "Extraire", + "extracting": "Extraction en cours..." + }, + "breadcrumb": "Dans l'archive", + "empty": "Ce dossier est vide.", + "download": "Télécharger", + "downloadNamed": "Télécharger {name}", + "outside": "Une entrée n'est pas affichée : son nom pointe hors de l'archive. | {count} entrées ne sont pas affichées : leurs noms pointent hors de l'archive.", + "unreadable": "Cette archive n'a pas pu être lue.", + "readFailed": "Ce fichier n'a pas pu être lu.", + "notReadable": "Ce type de fichier ne peut pas être affiché ici.", + "tooBigToRead": "Trop volumineux pour être affiché ici : {size}, la limite est de {ceiling}. Téléchargez-le ou extrayez-le pour l'ouvrir.", + "extract": "Extraire ici", + "extractNamed": "Extraire {name} ici", + "selectAll": "Tout sélectionner ici", + "selectNamed": "Sélectionner {name}", + "selected": "Un élément sélectionné | {count} éléments sélectionnés", + "extracted": "Extrait : {name}", + "extractedNothing": "Rien n'en est sorti.", + "extractFailed": "Impossible d'extraire ceci.", + "count": "Une entrée dans cette archive | {count} entrées dans cette archive" } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index cba1c2111..2cdfbd4e1 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "आपके पास बिना सहेजे परिवर्तन हैं। बिना सहेजे बंद करें?" + "confirmCloseWithoutSaving": "आपके पास बिना सहेजे परिवर्तन हैं। बिना सहेजे बंद करें?", + "trashReadOnly": "रीसायकल बिन में · केवल पढ़ने के लिए", + "versionReadOnly": "पिछला संस्करण, केवल पढ़ने के लिए" }, "status": { "updated": "सफलतापूर्वक अपडेट किया गया", @@ -113,7 +115,8 @@ "label": "मेरी साझा की गई फ़ाइलें", "path": "उदाहरण: Pictures/Holidays", "search": "फ़ाइलें और फ़ोल्डर खोजें…", - "volumeLabel": "e.g. Documents, Projects, Media" + "volumeLabel": "e.g. Documents, Projects, Media", + "totpCode": "123456" }, "loading": { "default": "लोड हो रहा है…", @@ -153,7 +156,8 @@ "labelRequired": "Label is required", "pathRequired": "Path is required", "saveVolume": "Failed to save volume", - "removeVolume": "Failed to remove volume" + "removeVolume": "Failed to remove volume", + "totpCodeRequired": "कोड डालें।" }, "serverErrors": { "AUTH_REQUIRED": "प्रमाणीकरण आवश्यक है", @@ -319,7 +323,8 @@ "dateTaken": "खिंचने की तिथि: {date}", "camera": "कैमरा: {makeModel}", "lens": "लेंस: {lens}", - "duration": "अवधि: {seconds}s" + "duration": "अवधि: {seconds}s", + "versions": "संस्करण" }, "auth": { "preparing": "आपका एक्सप्लोरर तैयार किया जा रहा है…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "अपने उपयोगकर्ता नाम में {suffix} जोड़ें", "bulletPasswordSame": "आपका पासवर्ड वही रहेगा।", "bulletUpdateUsers": "आप व्यवस्थापक मेनू से मौजूदा उपयोगकर्ताओं को अपडेट कर सकते हैं" - } + }, + "totpCode": "कोड", + "totpExplain": "आपका पासवर्ड स्वीकार हो गया। अपने प्रमाणक ऐप का कोड डालें, या अपना कोई पुनर्प्राप्ति कोड।", + "totpSubmit": "साइन इन करें" }, "setup": { "headline": "सेटअप शुरू करें", @@ -392,7 +400,10 @@ "security": "सुरक्षा", "accessControl": "एक्सेस कंट्रोल", "adminUsers": "उपयोगकर्ता प्रबंधन", - "trash": "रीसायकल बिन और संस्करण" + "trash": "रीसायकल बिन और संस्करण", + "fileVersions": "फ़ाइल संस्करण", + "uploads": "अपलोड", + "accountTwoFactor": "दो-चरणीय" }, "about": { "subtitle": "इस एप्लिकेशन के बिल्ड संबंधी जानकारी देखें।", @@ -442,7 +453,11 @@ "months": "महीने", "skipHome": "होम पेज छोड़ें", "skipHomeHelp": "होम पेज पर आने पर पहले वॉल्यूम पर स्वचालित रूप से रीडायरेक्ट करें। यदि सेट नहीं है, तो सर्वर कॉन्फ़िगरेशन का पालन करें।", - "useEnvSetting": "सर्वर सेटिंग का उपयोग करें" + "useEnvSetting": "सर्वर सेटिंग का उपयोग करें", + "showVersionMarks": "संस्करण वाली फ़ाइलों पर निशान लगाएँ", + "showVersionMarksHelp": "सूची में उन फ़ाइलों पर एक छोटा निशान जिनके पुराने संस्करण हैं, उनकी संख्या के साथ। इतिहास खोलने के लिए उस पर क्लिक करें।", + "documentsOpenInNewTab": "दस्तावेज़ नए टैब में खोलें", + "documentsOpenInNewTabHelp": "कोई फ़ाइल खोलने पर उसे अपना ब्राउज़र टैब मिलता है, इसलिए ब्राउज़ करते हुए कई खुले रह सकते हैं। बंद होने पर वह आज की तरह फ़ोल्डर के ऊपर खुलती है।" }, "thumbs": { "subtitle": "छवियों और वीडियो के लिए पूर्वावलोकन थंबनेल अनुकूलित करें।", @@ -615,6 +630,92 @@ "sharedSpace": "संस्करण और रीसायकल बिन हर वॉल्यूम का आरक्षित स्थान साझा करते हैं। जगह कम पड़ने पर पहले पुराने संस्करण हटते हैं, फिर रीसायकल बिन के आइटम, फिर हर फ़ाइल का नवीनतम संस्करण, और अंत में पिन किए गए संस्करण।", "environmentNote": "डिफ़ॉल्ट मान VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE और VERSIONS_SESSION_CHECKPOINT_MINUTES से आते हैं।" } + }, + "fileVersions": { + "title": "फ़ाइल संस्करण", + "intro": "हर वह फ़ाइल जिसके पुराने संस्करण हैं, चाहे कहीं भी हो, और वे कितनी जगह लेते हैं। यहाँ हर स्थान के पथ दिखते हैं, निजी फ़ोल्डर सहित — इसीलिए यह पृष्ठ केवल प्रशासकों के लिए है।", + "search": "पथ में है", + "searchPlaceholder": "नाम या फ़ोल्डर का कोई हिस्सा", + "zone": "स्थान", + "anyZone": "सभी स्थान", + "state": "स्थिति", + "anyState": "सभी", + "sort": "इसके अनुसार क्रम", + "sortBytes": "घेरी गई जगह", + "sortCount": "संस्करणों की संख्या", + "sortNewest": "सबसे नया संस्करण", + "sortPath": "पथ", + "summary": "{files} फ़ाइलें, {versions} संस्करण, {size}", + "file": "फ़ाइल", + "count": "संस्करण", + "size": "आकार", + "newest": "सबसे नया", + "states": { + "live": "मौजूद", + "trashed": "रद्दी में", + "orphaned": "गायब" + }, + "zoneKinds": { + "volume": "वॉल्यूम {name}", + "personal": "निजी फ़ोल्डर {name}", + "user-volume": "सौंपा गया वॉल्यूम {name}" + }, + "zoneUnknown": "अज्ञात स्थान", + "pinned": "पिन किया", + "unavailable": "वॉल्यूम उपलब्ध नहीं", + "deleteAll": "इतिहास हटाएँ", + "deleteSelected": "{count} संस्करण हटाएँ | {count} संस्करण हटाएँ", + "deleteForGood": "हमेशा के लिए हटाएँ", + "confirmAllTitle": "{name} के सभी संस्करण हटाएँ?", + "confirmSomeTitle": "{count} संस्करण हटाएँ? | {count} संस्करण हटाएँ?", + "confirmMessage": "यह सामग्री डिस्क से मिटा दी जाती है। फ़ाइल स्वयं अछूती रहती है, और इसे पूर्ववत नहीं किया जा सकता।", + "none": "किसी फ़ाइल का पुराना संस्करण नहीं है।", + "loadFailed": "सूची पढ़ी नहीं जा सकी।", + "detailFailed": "यह इतिहास पढ़ा नहीं जा सका।", + "deleteFailed": "संस्करण हटाए नहीं जा सके।", + "previous": "पिछला", + "next": "अगला", + "range": "{total} में से {from} से {to}", + "deleteSelectedNone": "चुने गए संस्करण हटाएँ" + }, + "uploads": { + "title": "अपलोड", + "chunkSize": "चंक आकार", + "chunkSizeHelp": "प्रत्येक अपलोड अनुरोध का अधिकतम आकार। इसे अपनी रिवर्स प्रॉक्सी सीमा से कम रखें। छोटे चंक (8–32 MiB) अधिक सहज प्रगति देते हैं; बहुत बड़े चंक दृश्यमान चरण दिखाते हैं (सर्वर अगले से पहले प्रत्येक चंक लिखता है)।", + "subtitle": "कॉन्फ़िगर करें कि फ़ाइलें सर्वर पर कैसे भेजी जाती हैं।", + "chunkedEnable": "चंक्ड अपलोड सक्षम करें", + "chunkedEnableHelp": "बड़ी फ़ाइलों को कई छोटे अनुरोधों के माध्यम से भेजने के लिए TUS का उपयोग करें।", + "chunkSizeInvalid": "1 से {max} MiB के बीच चंक आकार दर्ज करें।" + }, + "twoFactor": { + "title": "दो-चरणीय प्रमाणीकरण", + "intro": "आपके पासवर्ड के साथ, फ़ोन से एक कोड भी।", + "notLocalUser": "यह खाता पहचान प्रदाता से साइन इन करता है, और दूसरा चरण वहीं सेट होता है।", + "off": "इस खाते पर दो-चरणीय प्रमाणीकरण बंद है।", + "on": "दो-चरणीय प्रमाणीकरण चालू है।", + "turnOn": "चालू करें", + "turnOff": "बंद करें", + "scan": "इसे अपने प्रमाणक ऐप से स्कैन करें।", + "orType": "या ऐप में यह गुप्त कुंजी लिखें:", + "qrLabel": "आपके प्रमाणक ऐप के लिए QR कोड", + "codeLabel": "ऐप का कोड", + "confirm": "पुष्टि करें", + "confirmPassword": "आपका पासवर्ड", + "codesTitle": "पुनर्प्राप्ति कोड", + "codesExplain": "इन्हें फ़ोन के अलावा कहीं और रखें। हर कोड एक बार साइन इन कराता है — उस दिन के लिए जब फ़ोन पास न हो। ये अभी दिख रहे हैं और फिर कभी नहीं।", + "copyCodes": "कॉपी करें", + "copied": "कॉपी हो गया।", + "codesLeft": "एक पुनर्प्राप्ति कोड बचा | {count} पुनर्प्राप्ति कोड बचे", + "newCodesButton": "नए कोड बनाएँ", + "newCodes": "नए पुनर्प्राप्ति कोड बन गए। पहले वाले अब काम नहीं करते।", + "turnedOn": "दो-चरणीय प्रमाणीकरण चालू है।", + "turnedOff": "दो-चरणीय प्रमाणीकरण बंद है।", + "loadFailed": "इस खाते की दो-चरणीय सेटिंग्स नहीं पढ़ी जा सकीं।", + "startFailed": "दूसरा चरण सेट करना शुरू नहीं हो सका।", + "confirmFailed": "यह कोड सही नहीं है।", + "codesFailed": "नए पुनर्प्राप्ति कोड नहीं बनाए जा सके।", + "disableFailed": "दो-चरणीय प्रमाणीकरण बंद नहीं हो सका।", + "wrongPassword": "यह पासवर्ड सही नहीं है।" } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "{count} साझा लिंक वापस लाया गया | {count} साझा लिंक वापस लाए गए", "dropped": "{count} साझा लिंक हटाया गया | {count} साझा लिंक हटाए गए" } + }, + "versions": { + "title": "संस्करण", + "menu": "संस्करण", + "aria": "फ़ाइल के संस्करण", + "current": "वर्तमान संस्करण", + "empty": "अभी कोई पिछला संस्करण नहीं है। हर बार सहेजने पर जो बदला जाता है, वह रखा जाता है।", + "disabled": "फ़ाइल संस्करण बंद हैं: सहेजने पर अब नए संस्करण नहीं रखे जाते। नीचे वाले अपनी अवधि पूरी होने तक रहेंगे।", + "loadFailed": "संस्करण लोड नहीं हो सके।", + "notShared": "इस फ़ाइल का इतिहास आपके साथ साझा नहीं है।", + "unavailable": "इसकी सामग्री डिस्क पर नहीं मिली।", + "unknownAuthor": "अज्ञात लेखक", + "shareLink": "लिंक वाला कोई व्यक्ति", + "pinned": "पिन किया गया", + "aside": "अलग रखा गया", + "asideHelp": "पुनर्स्थापना से पहले खुले संपादक ने सहेजा: पुनर्स्थापना को पलटने के बजाय यहाँ रखा गया।", + "total": "{count} संस्करण, {size} | {count} संस्करण, {size}", + "source": { + "editor": "टेक्स्ट संपादक", + "shareEditor": "साझाकरण के ज़रिए संपादक", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "पुनर्स्थापित संस्करण", + "external": "ऐप के बाहर बदला गया" + }, + "actions": { + "menu": "संस्करण की क्रियाएँ", + "select": "यह संस्करण चुनें", + "selectAll": "सभी चुनें", + "deleteSelected": "चुने हुए हटाएँ ({count})", + "deleteAll": "सभी हटाएँ", + "preview": "केवल पढ़ने के लिए खोलें", + "download": "डाउनलोड करें", + "restore": "पुनर्स्थापित करें", + "rename": "नाम दें…", + "pin": "पिन करें", + "unpin": "पिन हटाएँ", + "delete": "हटाएँ" + }, + "confirm": { + "restoreTitle": "यह संस्करण पुनर्स्थापित करें?", + "restoreMessage": "\"{name}\" अपनी {date} की सामग्री पर लौट आएगी। अभी की सामग्री एक संस्करण के रूप में रखी जाएगी।", + "deleteTitle": "यह संस्करण हटाएँ? | {count} संस्करण हटाएँ?", + "deleteMessage": "यह संस्करण स्थायी रूप से हटा दिया जाएगा। | ये {count} संस्करण स्थायी रूप से हटा दिए जाएँगे।", + "deleteAllTitle": "सभी संस्करण हटाएँ?", + "deleteAllMessage": "\"{name}\" के सभी पिछले संस्करण स्थायी रूप से हटा दिए जाएँगे, पिन किए गए भी। फ़ाइल स्वयं बनी रहेगी।" + }, + "rename": { + "title": "इस संस्करण को नाम दें", + "placeholder": "उदाहरण: ग्राहक को भेजा गया", + "help": "नाम से संस्करण ढूँढना आसान होता है। इसे स्वचालित सफ़ाई से बचाने के लिए पिन करें।" + }, + "results": { + "restored": "संस्करण पुनर्स्थापित हुआ", + "unchanged": "फ़ाइल में पहले से यही सामग्री है", + "deleted": "{count} संस्करण हटाया गया | {count} संस्करण हटाए गए", + "renamed": "संस्करण को नाम दिया गया", + "pinned": "संस्करण पिन हुआ: स्वचालित सफ़ाई इसे रखेगी", + "unpinned": "संस्करण का पिन हटाया गया" + }, + "errors": { + "action": "इस संस्करण पर क्रिया विफल रही" + }, + "mark": "{count} पुराना संस्करण | {count} पुराने संस्करण" + }, + "onlyoffice": { + "renamedHeading": "नाम बदला गया", + "renamedBody": "दस्तावेज़ का नाम अब {name} है।", + "renameFailed": "{name} में नाम नहीं बदला जा सका", + "andOthers": "और {count} अन्य", + "transferHeading": "फ़ाइल संपादित की जा रही है", + "transferBody": "यह फ़ाइल OnlyOffice में खुली है। जारी रखने से चल रही सेव प्रक्रिया बाधित हो सकती है। क्या आप जारी रखना चाहते हैं?", + "transferCancel": "रद्द करें", + "transferConfirm": "जारी रखें", + "editingBy": "ONLYOFFICE में संपादित किया जा रहा है: {names}", + "editingNow": "ONLYOFFICE में संपादित किया जा रहा है", + "savedAsHeading": "इस फ़ोल्डर में सहेजा गया", + "savedAsBody": "{name} को मूल फ़ाइल के बगल में जोड़ा गया।", + "saveAsFailed": "{name} को सहेजा नहीं जा सका" + }, + "preview": { + "nothingOpensIt": "यहाँ कुछ भी {name} को नहीं खोल सकता।", + "backToFolder": "फ़ोल्डर पर वापस" + }, + "archive": { + "password": { + "title": "पासवर्ड आवश्यक है", + "description": "यह संग्रह सुरक्षित है। इसे निकालने के लिए इसका पासवर्ड दर्ज करें।", + "label": "संग्रह का पासवर्ड", + "invalid": "पासवर्ड गलत है, या संग्रह क्षतिग्रस्त है।", + "submit": "निकालें", + "extracting": "निकाला जा रहा है..." + }, + "breadcrumb": "संग्रह के भीतर", + "empty": "यह फ़ोल्डर खाली है।", + "download": "डाउनलोड करें", + "downloadNamed": "{name} डाउनलोड करें", + "outside": "एक प्रविष्टि नहीं दिखाई गई: उसका नाम संग्रह के बाहर की ओर इशारा करता है। | {count} प्रविष्टियाँ नहीं दिखाई गईं: उनके नाम संग्रह के बाहर की ओर इशारा करते हैं।", + "unreadable": "यह संग्रह पढ़ा नहीं जा सका।", + "readFailed": "यह फ़ाइल पढ़ी नहीं जा सकी।", + "notReadable": "इस तरह की फ़ाइल यहाँ नहीं दिखाई जा सकती।", + "tooBigToRead": "यहाँ दिखाने के लिए बहुत बड़ी: {size}, सीमा {ceiling} है। इसे डाउनलोड करें या निकालकर खोलें।", + "extract": "यहाँ निकालें", + "extractNamed": "{name} यहाँ निकालें", + "selectAll": "यहाँ सब चुनें", + "selectNamed": "{name} चुनें", + "selected": "एक चुना गया | {count} चुने गए", + "extracted": "निकाला गया: {name}", + "extractedNothing": "इसमें से कुछ नहीं निकला।", + "extractFailed": "इसे निकाला नहीं जा सका।", + "count": "इस संग्रह में एक प्रविष्टि | इस संग्रह में {count} प्रविष्टियाँ" } } diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 3a3edcea3..0ed9fc6d5 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Hai modifiche non salvate. Chiudere senza salvare?" + "confirmCloseWithoutSaving": "Hai modifiche non salvate. Chiudere senza salvare?", + "trashReadOnly": "Nel cestino · sola lettura", + "versionReadOnly": "Versione precedente, sola lettura" }, "status": { "updated": "Aggiornato con successo", @@ -113,7 +115,8 @@ "label": "I miei file condivisi", "path": "es. Immagini/Vacanze", "search": "Cerca file e cartelle…", - "volumeLabel": "es. Documenti, Progetti, Media" + "volumeLabel": "es. Documenti, Progetti, Media", + "totpCode": "123456" }, "loading": { "default": "Caricamento…", @@ -153,7 +156,8 @@ "labelRequired": "L'etichetta è obbligatoria", "pathRequired": "Il percorso è obbligatorio", "saveVolume": "Impossibile salvare il volume", - "removeVolume": "Impossibile rimuovere il volume" + "removeVolume": "Impossibile rimuovere il volume", + "totpCodeRequired": "Inserisci il codice." }, "serverErrors": { "AUTH_REQUIRED": "Autenticazione richiesta", @@ -319,7 +323,8 @@ "dateTaken": "Data scatto: {date}", "camera": "Fotocamera: {makeModel}", "lens": "Obiettivo: {lens}", - "duration": "Durata: {seconds}s" + "duration": "Durata: {seconds}s", + "versions": "Versioni" }, "auth": { "preparing": "Preparazione del tuo explorer…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "Aggiungi {suffix} al tuo nome utente", "bulletPasswordSame": "La tua password resta la stessa.", "bulletUpdateUsers": "Puoi aggiornare gli utenti esistenti dal menu Admin" - } + }, + "totpCode": "Codice", + "totpExplain": "La password è stata accettata. Inserisci il codice della tua app di autenticazione, o uno dei codici di recupero.", + "totpSubmit": "Accedi" }, "setup": { "headline": "Configuriamo tutto", @@ -392,7 +400,10 @@ "security": "Sicurezza", "accessControl": "Controllo accessi", "adminUsers": "Gestione utenti", - "trash": "Cestino e versioni" + "trash": "Cestino e versioni", + "fileVersions": "Versioni dei file", + "uploads": "Caricamenti", + "accountTwoFactor": "Due fattori" }, "about": { "subtitle": "Visualizza le informazioni di build per questa applicazione.", @@ -442,7 +453,11 @@ "months": "Mesi", "skipHome": "Salta la home", "skipHomeHelp": "Reindirizza automaticamente al primo volume quando si visita la home. Se non impostato, usa la configurazione del server.", - "useEnvSetting": "Usa impostazione del server" + "useEnvSetting": "Usa impostazione del server", + "showVersionMarks": "Segnalare i file con versioni", + "showVersionMarksHelp": "Un piccolo segno nell’elenco sui file con versioni precedenti, con il loro numero. Un clic apre la cronologia.", + "documentsOpenInNewTab": "Aprire i documenti in una nuova scheda", + "documentsOpenInNewTabHelp": "Aprire un file gli dà una scheda del browser tutta sua, così più documenti restano aperti mentre continuate a navigare. Spento, si apre sopra la cartella come oggi." }, "thumbs": { "subtitle": "Personalizza le miniature di anteprima per immagini e video.", @@ -615,6 +630,92 @@ "sharedSpace": "Le versioni e il cestino condividono lo spazio riservato di ogni volume. Quando scarseggia, se ne vanno prima le versioni vecchie, poi gli elementi del cestino, poi l'ultima versione di ogni file e infine le versioni fissate.", "environmentNote": "I valori predefiniti provengono da VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE e VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Versioni dei file", + "intro": "Tutti i file che hanno versioni precedenti, ovunque si trovino, e lo spazio che occupano. Qui compaiono percorsi di ogni spazio, cartelle personali comprese: per questo la pagina è riservata agli amministratori.", + "search": "Il percorso contiene", + "searchPlaceholder": "Parte di un nome o di una cartella", + "zone": "Spazio", + "anyZone": "Tutti gli spazi", + "state": "Stato", + "anyState": "Tutti", + "sort": "Ordina per", + "sortBytes": "Spazio occupato", + "sortCount": "Numero di versioni", + "sortNewest": "Versione più recente", + "sortPath": "Percorso", + "summary": "{files} file, {versions} versioni, {size}", + "file": "File", + "count": "Versioni", + "size": "Dimensione", + "newest": "Più recente", + "states": { + "live": "Presente", + "trashed": "Nel cestino", + "orphaned": "Scomparso" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Cartella personale {name}", + "user-volume": "Volume assegnato {name}" + }, + "zoneUnknown": "Spazio sconosciuto", + "pinned": "Fissata", + "unavailable": "Volume non disponibile", + "deleteAll": "Eliminare la cronologia", + "deleteSelected": "Eliminare {count} versione | Eliminare {count} versioni", + "deleteForGood": "Eliminare definitivamente", + "confirmAllTitle": "Eliminare tutte le versioni di {name}?", + "confirmSomeTitle": "Eliminare {count} versione? | Eliminare {count} versioni?", + "confirmMessage": "Quel contenuto viene rimosso dal disco. Il file stesso non viene toccato e nulla di tutto ciò può essere annullato.", + "none": "Nessun file ha versioni precedenti.", + "loadFailed": "Non è stato possibile leggere l’elenco.", + "detailFailed": "Non è stato possibile leggere questa cronologia.", + "deleteFailed": "Non è stato possibile eliminare le versioni.", + "previous": "Precedente", + "next": "Successivo", + "range": "Da {from} a {to} di {total}", + "deleteSelectedNone": "Eliminare le versioni selezionate" + }, + "uploads": { + "title": "Caricamenti", + "chunkSize": "Dimensione dei blocchi", + "chunkSizeHelp": "Dimensione massima di ogni richiesta di caricamento. Mantienila sotto il limite del tuo reverse proxy. Blocchi più piccoli (8–32 MiB) offrono un avanzamento più fluido; blocchi molto grandi mostrano scatti visibili (il server scrive ogni blocco prima del successivo).", + "subtitle": "Configura come i file vengono inviati al server.", + "chunkedEnable": "Abilita caricamenti a blocchi", + "chunkedEnableHelp": "Usa TUS per inviare file di grandi dimensioni tramite più richieste più piccole.", + "chunkSizeInvalid": "Inserisci una dimensione dei blocchi da 1 a {max} MiB." + }, + "twoFactor": { + "title": "Autenticazione a due fattori", + "intro": "Un codice dal telefono, oltre alla password.", + "notLocalUser": "Questo account accede tramite un fornitore di identità, ed è lì che va impostato il secondo fattore.", + "off": "L'autenticazione a due fattori è disattivata su questo account.", + "on": "L'autenticazione a due fattori è attiva.", + "turnOn": "Attiva", + "turnOff": "Disattiva", + "scan": "Inquadra questo con la tua app di autenticazione.", + "orType": "Oppure digita questo segreto nell'app:", + "qrLabel": "Codice QR per la tua app di autenticazione", + "codeLabel": "Codice dall'app", + "confirm": "Conferma", + "confirmPassword": "La tua password", + "codesTitle": "Codici di recupero", + "codesExplain": "Conservali altrove che sul telefono. Ognuno vale per un accesso, per il giorno in cui il telefono non c'è. Sono mostrati ora e mai più.", + "copyCodes": "Copia", + "copied": "Copiato.", + "codesLeft": "Resta un codice di recupero | Restano {count} codici di recupero", + "newCodesButton": "Genera nuovi codici", + "newCodes": "Nuovi codici di recupero generati. I precedenti non funzionano più.", + "turnedOn": "L'autenticazione a due fattori è attiva.", + "turnedOff": "L'autenticazione a due fattori è disattivata.", + "loadFailed": "Non è stato possibile leggere le impostazioni a due fattori di questo account.", + "startFailed": "Non è stato possibile avviare la configurazione del secondo fattore.", + "confirmFailed": "Questo codice non è corretto.", + "codesFailed": "Non è stato possibile generare nuovi codici di recupero.", + "disableFailed": "Non è stato possibile disattivare l'autenticazione a due fattori.", + "wrongPassword": "Questa password non è corretta." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "{count} link di condivisione ripristinato | {count} link di condivisione ripristinati", "dropped": "{count} link di condivisione eliminato | {count} link di condivisione eliminati" } + }, + "versions": { + "title": "Versioni", + "menu": "Versioni", + "aria": "Versioni del file", + "current": "Versione attuale", + "empty": "Nessuna versione precedente per ora. Ogni salvataggio conserva ciò che sostituisce.", + "disabled": "Le versioni dei file sono disattivate: i salvataggi non ne conservano più di nuove. Quelle qui sotto restano fino alla scadenza.", + "loadFailed": "Impossibile caricare le versioni.", + "notShared": "La cronologia di questo file non è condivisa con te.", + "unavailable": "Il suo contenuto manca dal disco.", + "unknownAuthor": "Autore sconosciuto", + "shareLink": "Qualcuno con il link", + "pinned": "Fissata", + "aside": "Messa da parte", + "asideHelp": "Salvata da un editor aperto prima di un ripristino: conservata qui invece di annullare il ripristino.", + "total": "{count} versione, {size} | {count} versioni, {size}", + "source": { + "editor": "Editor di testo", + "shareEditor": "Editor tramite una condivisione", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Versione ripristinata", + "external": "Modificato fuori dall'app" + }, + "actions": { + "menu": "Azioni sulla versione", + "select": "Seleziona questa versione", + "selectAll": "Seleziona tutto", + "deleteSelected": "Elimina selezionate ({count})", + "deleteAll": "Elimina tutto", + "preview": "Apri in sola lettura", + "download": "Scarica", + "restore": "Ripristina", + "rename": "Assegna un nome…", + "pin": "Fissa", + "unpin": "Sblocca", + "delete": "Elimina" + }, + "confirm": { + "restoreTitle": "Ripristinare questa versione?", + "restoreMessage": "«{name}» torna al contenuto del {date}. Quello attuale viene conservato come versione.", + "deleteTitle": "Eliminare questa versione? | Eliminare {count} versioni?", + "deleteMessage": "Questa versione viene eliminata definitivamente. | Queste {count} versioni vengono eliminate definitivamente.", + "deleteAllTitle": "Eliminare tutte le versioni?", + "deleteAllMessage": "Tutte le versioni precedenti di «{name}» vengono eliminate definitivamente, comprese quelle fissate. Il file resta." + }, + "rename": { + "title": "Assegna un nome a questa versione", + "placeholder": "Per esempio: inviata al cliente", + "help": "Un nome rende facile ritrovare una versione. Fissala per escluderla dalla pulizia automatica." + }, + "results": { + "restored": "Versione ripristinata", + "unchanged": "Il file ha già questo contenuto", + "deleted": "{count} versione eliminata | {count} versioni eliminate", + "renamed": "Nome assegnato alla versione", + "pinned": "Versione fissata: la pulizia automatica la conserva", + "unpinned": "Versione sbloccata" + }, + "errors": { + "action": "L'azione su questa versione non è riuscita" + }, + "mark": "{count} versione precedente | {count} versioni precedenti" + }, + "onlyoffice": { + "renamedHeading": "Rinominato", + "renamedBody": "Il documento si chiama ora {name}.", + "renameFailed": "Impossibile rinominare in {name}", + "andOthers": "e altri {count}", + "transferHeading": "File in fase di modifica", + "transferBody": "Questo file è aperto in OnlyOffice. Continuare potrebbe interferire con un salvataggio in corso. Vuoi continuare?", + "transferCancel": "Annulla", + "transferConfirm": "Continua", + "editingBy": "In modifica in ONLYOFFICE da {names}", + "editingNow": "In modifica in ONLYOFFICE", + "savedAsHeading": "Salvato in questa cartella", + "savedAsBody": "{name} è stato aggiunto accanto all’originale.", + "saveAsFailed": "Impossibile salvare {name}" + }, + "preview": { + "nothingOpensIt": "Qui niente sa aprire {name}.", + "backToFolder": "Torna alla cartella" + }, + "archive": { + "password": { + "title": "Password richiesta", + "description": "Questo archivio è protetto. Inserisci la sua password per estrarlo.", + "label": "Password dell'archivio", + "invalid": "Password errata o archivio danneggiato.", + "submit": "Estrai", + "extracting": "Estrazione in corso..." + }, + "breadcrumb": "Dentro l'archivio", + "empty": "Questa cartella è vuota.", + "download": "Scarica", + "downloadNamed": "Scarica {name}", + "outside": "Una voce non è mostrata: il suo nome punta fuori dall'archivio. | {count} voci non sono mostrate: i loro nomi puntano fuori dall'archivio.", + "unreadable": "Non è stato possibile leggere questo archivio.", + "readFailed": "Non è stato possibile leggere questo file.", + "notReadable": "Questo tipo di file non può essere mostrato qui.", + "tooBigToRead": "Troppo grande per essere mostrato qui: {size}, il limite è {ceiling}. Scaricalo o estrailo per aprirlo.", + "extract": "Estrai qui", + "extractNamed": "Estrai {name} qui", + "selectAll": "Seleziona tutto qui", + "selectNamed": "Seleziona {name}", + "selected": "Un elemento selezionato | {count} elementi selezionati", + "extracted": "Estratto: {name}", + "extractedNothing": "Non ne è uscito nulla.", + "extractFailed": "Non è stato possibile estrarre questo.", + "count": "Una voce in questo archivio | {count} voci in questo archivio" } } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index a34348001..da40bb5bc 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -77,7 +77,9 @@ "theme": "테마", "editorSettings": "에디터 설정", "wrapLines": "자동 줄 바꿈", - "confirmCloseWithoutSaving": "저장되지 않은 변경 사항이 있습니다. 저장하지 않고 종료 하시겠습니까?" + "confirmCloseWithoutSaving": "저장되지 않은 변경 사항이 있습니다. 저장하지 않고 종료 하시겠습니까?", + "trashReadOnly": "휴지통 · 읽기 전용", + "versionReadOnly": "이전 버전, 읽기 전용" }, "status": { "updated": "업데이트 성공", @@ -113,7 +115,8 @@ "label": "내 공유 파일", "path": "예시: 사진/여행", "search": "파일 또는 폴더를 검색하세요…", - "volumeLabel": "예시: 문서, 프로젝트, 영상" + "volumeLabel": "예시: 문서, 프로젝트, 영상", + "totpCode": "123456" }, "loading": { "default": "로딩 중…", @@ -153,7 +156,8 @@ "labelRequired": "레이블이 필요합니다", "pathRequired": "경로가 필요합니다", "saveVolume": "볼륨 저장 실패", - "removeVolume": "볼륨 제거 실패" + "removeVolume": "볼륨 제거 실패", + "totpCodeRequired": "코드를 입력하세요." }, "serverErrors": { "AUTH_REQUIRED": "인증이 필요합니다", @@ -319,7 +323,8 @@ "dateTaken": "촬영된 날짜: {date}", "camera": "카메라: {makeModel}", "lens": "렌즈: {lens}", - "duration": "길이: {seconds}초" + "duration": "길이: {seconds}초", + "versions": "버전" }, "auth": { "preparing": "탐색기를 준비하는 중입니다…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "유저 이름 뒤에 {suffix}을(를) 추가하세요.", "bulletPasswordSame": "비밀번호는 이전과 동일합니다.", "bulletUpdateUsers": "관리자 메뉴에서 기존 유저 설정을 업데이트 할 수 있습니다." - } + }, + "totpCode": "코드", + "totpExplain": "비밀번호가 확인되었습니다. 인증 앱의 코드나 복구 코드 중 하나를 입력하세요.", + "totpSubmit": "로그인" }, "setup": { "headline": "초기 설정", @@ -392,7 +400,10 @@ "security": "보안", "accessControl": "접근 제어", "adminUsers": "유저 관리", - "trash": "휴지통 및 버전" + "trash": "휴지통 및 버전", + "fileVersions": "파일 버전", + "uploads": "업로드", + "accountTwoFactor": "2단계 인증" }, "about": { "subtitle": "애플리케이션의 빌드 정보를 확인합니다.", @@ -442,7 +453,11 @@ "months": "개월", "skipHome": "홈페이지 건너뛰기", "skipHomeHelp": "접속했을 때, 자동으로 첫 번째 볼륨으로 이동합니다. 설정하지 않을 경우 서버 설정을 따릅니다.", - "useEnvSetting": "서버 설정 사용" + "useEnvSetting": "서버 설정 사용", + "showVersionMarks": "버전이 있는 파일 표시", + "showVersionMarksHelp": "이전 버전이 있는 파일에 개수와 함께 목록에서 작은 표시를 붙입니다. 누르면 기록이 열립니다.", + "documentsOpenInNewTab": "문서를 새 탭에서 열기", + "documentsOpenInNewTabHelp": "파일을 열면 브라우저 탭이 따로 생기므로, 계속 탐색하는 동안 여러 개를 열어 둘 수 있습니다. 끄면 지금처럼 폴더 위에서 열립니다." }, "thumbs": { "subtitle": "사진, 영상 파일의 미리보기 썸네일 관련 설정을 커스터마이징하세요.", @@ -615,6 +630,92 @@ "sharedSpace": "버전과 휴지통은 각 볼륨의 예약 공간을 함께 사용합니다. 공간이 부족하면 오래된 버전, 휴지통 항목, 각 파일의 최신 버전 순으로 제거되며 고정된 버전은 마지막에 제거됩니다.", "environmentNote": "기본값은 VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE, VERSIONS_SESSION_CHECKPOINT_MINUTES에서 가져옵니다." } + }, + "fileVersions": { + "title": "파일 버전", + "intro": "이전 버전을 가진 모든 파일과 그 버전이 차지하는 용량입니다. 개인 폴더를 포함한 모든 공간의 경로가 나오므로 이 페이지는 관리자 전용입니다.", + "search": "경로에 포함", + "searchPlaceholder": "이름이나 폴더의 일부", + "zone": "공간", + "anyZone": "모든 공간", + "state": "상태", + "anyState": "전체", + "sort": "정렬 기준", + "sortBytes": "사용 용량", + "sortCount": "버전 수", + "sortNewest": "가장 최근 버전", + "sortPath": "경로", + "summary": "파일 {files}개, 버전 {versions}개, {size}", + "file": "파일", + "count": "버전", + "size": "크기", + "newest": "최근", + "states": { + "live": "있음", + "trashed": "휴지통", + "orphaned": "사라짐" + }, + "zoneKinds": { + "volume": "볼륨 {name}", + "personal": "개인 폴더 {name}", + "user-volume": "할당된 볼륨 {name}" + }, + "zoneUnknown": "알 수 없는 공간", + "pinned": "고정됨", + "unavailable": "볼륨을 사용할 수 없음", + "deleteAll": "기록 삭제", + "deleteSelected": "버전 {count}개 삭제", + "deleteForGood": "완전히 삭제", + "confirmAllTitle": "{name}의 모든 버전을 삭제할까요?", + "confirmSomeTitle": "버전 {count}개를 삭제할까요?", + "confirmMessage": "해당 내용은 디스크에서 지워집니다. 파일 자체는 그대로이며, 이 작업은 되돌릴 수 없습니다.", + "none": "이전 버전이 있는 파일이 없습니다.", + "loadFailed": "목록을 읽지 못했습니다.", + "detailFailed": "이 기록을 읽지 못했습니다.", + "deleteFailed": "버전을 삭제하지 못했습니다.", + "previous": "이전", + "next": "다음", + "range": "{total}개 중 {from}–{to}", + "deleteSelectedNone": "선택한 버전 삭제" + }, + "uploads": { + "title": "업로드", + "chunkSize": "청크 크기", + "chunkSizeHelp": "각 업로드 요청의 최대 크기입니다. 리버스 프록시 제한보다 낮게 유지하세요. 청크가 작을수록(8–32 MiB) 진행률이 더 부드럽게 표시되고, 청크가 매우 크면 눈에 띄는 단계가 나타납니다(서버가 다음 청크를 처리하기 전에 각 청크를 기록합니다).", + "subtitle": "파일을 서버로 전송하는 방식을 설정합니다.", + "chunkedEnable": "청크 업로드 활성화", + "chunkedEnableHelp": "TUS를 사용하여 대용량 파일을 여러 개의 작은 요청으로 나누어 전송합니다.", + "chunkSizeInvalid": "청크 크기를 1~{max} MiB 사이로 입력하세요." + }, + "twoFactor": { + "title": "2단계 인증", + "intro": "비밀번호에 더해, 휴대폰의 코드까지.", + "notLocalUser": "이 계정은 ID 공급자를 통해 로그인하므로 2단계 인증도 그쪽에서 설정합니다.", + "off": "이 계정의 2단계 인증이 꺼져 있습니다.", + "on": "2단계 인증이 켜져 있습니다.", + "turnOn": "켜기", + "turnOff": "끄기", + "scan": "인증 앱으로 이 코드를 스캔하세요.", + "orType": "또는 앱에 이 비밀키를 입력하세요:", + "qrLabel": "인증 앱용 QR 코드", + "codeLabel": "앱의 코드", + "confirm": "확인", + "confirmPassword": "비밀번호", + "codesTitle": "복구 코드", + "codesExplain": "휴대폰이 아닌 곳에 보관하세요. 각 코드는 한 번 로그인할 수 있으며, 휴대폰이 없는 날을 위한 것입니다. 지금만 표시되고 다시는 볼 수 없습니다.", + "copyCodes": "복사", + "copied": "복사했습니다.", + "codesLeft": "복구 코드 1개 남음 | 복구 코드 {count}개 남음", + "newCodesButton": "새 코드 만들기", + "newCodes": "새 복구 코드를 만들었습니다. 이전 코드는 더 이상 쓸 수 없습니다.", + "turnedOn": "2단계 인증이 켜졌습니다.", + "turnedOff": "2단계 인증이 꺼졌습니다.", + "loadFailed": "이 계정의 2단계 인증 설정을 읽지 못했습니다.", + "startFailed": "2단계 인증 설정을 시작하지 못했습니다.", + "confirmFailed": "코드가 올바르지 않습니다.", + "codesFailed": "새 복구 코드를 만들지 못했습니다.", + "disableFailed": "2단계 인증을 끄지 못했습니다.", + "wrongPassword": "비밀번호가 올바르지 않습니다." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "공유 링크 {count}개를 되살렸습니다", "dropped": "공유 링크 {count}개를 삭제했습니다" } + }, + "versions": { + "title": "버전", + "menu": "버전", + "aria": "파일 버전", + "current": "현재 버전", + "empty": "아직 이전 버전이 없습니다. 저장할 때마다 대체되는 내용이 보관됩니다.", + "disabled": "파일 버전이 꺼져 있습니다. 저장해도 새 버전이 보관되지 않습니다. 아래 버전은 만료될 때까지 남습니다.", + "loadFailed": "버전을 불러오지 못했습니다.", + "notShared": "이 파일의 기록은 공유되지 않았습니다.", + "unavailable": "디스크에서 내용을 찾을 수 없습니다.", + "unknownAuthor": "알 수 없는 작성자", + "shareLink": "링크를 가진 사용자", + "pinned": "고정됨", + "aside": "따로 보관됨", + "asideHelp": "복원 전에 열린 편집기가 저장한 내용입니다. 복원을 되돌리지 않도록 여기에 보관했습니다.", + "total": "버전 {count}개, {size}", + "source": { + "editor": "텍스트 편집기", + "shareEditor": "공유를 통한 편집기", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "복원된 버전", + "external": "앱 외부에서 변경됨" + }, + "actions": { + "menu": "버전 작업", + "select": "이 버전 선택", + "selectAll": "모두 선택", + "deleteSelected": "선택 항목 삭제 ({count})", + "deleteAll": "모두 삭제", + "preview": "읽기 전용으로 열기", + "download": "다운로드", + "restore": "복원", + "rename": "이름 지정…", + "pin": "고정", + "unpin": "고정 해제", + "delete": "삭제" + }, + "confirm": { + "restoreTitle": "이 버전을 복원할까요?", + "restoreMessage": "\"{name}\"이(가) {date}의 내용으로 돌아갑니다. 현재 내용은 버전으로 보관됩니다.", + "deleteTitle": "버전 {count}개를 삭제할까요?", + "deleteMessage": "버전 {count}개가 영구적으로 삭제됩니다.", + "deleteAllTitle": "모든 버전을 삭제할까요?", + "deleteAllMessage": "\"{name}\"의 모든 이전 버전이 고정된 버전까지 영구적으로 삭제됩니다. 파일 자체는 그대로 남습니다." + }, + "rename": { + "title": "이 버전의 이름 지정", + "placeholder": "예: 고객에게 보낸 버전", + "help": "이름을 지정하면 버전을 쉽게 찾을 수 있습니다. 자동 정리에서 제외하려면 고정하세요." + }, + "results": { + "restored": "버전을 복원했습니다", + "unchanged": "파일에 이미 이 내용이 있습니다", + "deleted": "버전 {count}개를 삭제했습니다", + "renamed": "버전 이름을 지정했습니다", + "pinned": "버전을 고정했습니다. 자동 정리에서 보존됩니다", + "unpinned": "버전 고정을 해제했습니다" + }, + "errors": { + "action": "이 버전에 대한 작업이 실패했습니다" + }, + "mark": "이전 버전 {count}개" + }, + "onlyoffice": { + "renamedHeading": "이름 변경됨", + "renamedBody": "문서 이름이 {name}(으)로 바뀌었습니다.", + "renameFailed": "{name}(으)로 이름을 바꾸지 못했습니다", + "andOthers": "외 {count}개", + "transferHeading": "파일 편집 중", + "transferBody": "이 파일은 OnlyOffice에서 열려 있습니다. 계속하면 진행 중인 저장 작업에 문제가 생길 수 있습니다. 계속하시겠습니까?", + "transferCancel": "취소", + "transferConfirm": "계속", + "editingBy": "ONLYOFFICE에서 편집 중: {names}", + "editingNow": "ONLYOFFICE에서 편집 중", + "savedAsHeading": "이 폴더에 저장됨", + "savedAsBody": "{name}이(가) 원본 옆에 추가되었습니다.", + "saveAsFailed": "{name}을(를) 저장하지 못했습니다" + }, + "preview": { + "nothingOpensIt": "여기에서 {name}을(를) 열 수 있는 것이 없습니다.", + "backToFolder": "폴더로 돌아가기" + }, + "archive": { + "password": { + "title": "비밀번호 필요", + "description": "이 압축 파일은 보호되어 있습니다. 압축을 풀려면 비밀번호를 입력하세요.", + "label": "압축 파일 비밀번호", + "invalid": "비밀번호가 올바르지 않거나 압축 파일이 손상되었습니다.", + "submit": "압축 풀기", + "extracting": "압축 푸는 중..." + }, + "breadcrumb": "압축 파일 안", + "empty": "이 폴더는 비어 있습니다.", + "download": "다운로드", + "downloadNamed": "{name} 다운로드", + "outside": "항목 {count}개는 표시되지 않습니다: 이름이 압축 파일 바깥을 가리킵니다.", + "unreadable": "이 압축 파일을 읽을 수 없습니다.", + "readFailed": "이 파일을 읽을 수 없습니다.", + "notReadable": "이런 종류의 파일은 여기에서 표시할 수 없습니다.", + "tooBigToRead": "여기에 표시하기에는 너무 큽니다: {size}, 한도는 {ceiling}입니다. 내려받거나 압축을 풀어서 여세요.", + "extract": "여기에 압축 풀기", + "extractNamed": "{name}을(를) 여기에 압축 풀기", + "selectAll": "여기 있는 항목 모두 선택", + "selectNamed": "{name} 선택", + "selected": "1개 선택됨 | {count}개 선택됨", + "extracted": "압축을 풀었습니다: {name}", + "extractedNothing": "나온 것이 없습니다.", + "extractFailed": "압축을 풀 수 없습니다.", + "count": "이 압축 파일의 항목 {count}개" } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 20fa3eb27..474e44c3e 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -77,7 +77,9 @@ "theme": "Thema", "editorSettings": "Editor-instellingen", "wrapLines": "Automatische terugloop", - "confirmCloseWithoutSaving": "Er zijn niet-opgeslagen wijzigingen. Sluiten zonder opslaan?" + "confirmCloseWithoutSaving": "Er zijn niet-opgeslagen wijzigingen. Sluiten zonder opslaan?", + "trashReadOnly": "In de prullenbak · alleen-lezen", + "versionReadOnly": "Eerdere versie, alleen-lezen" }, "status": { "updated": "Met succes bijgewerkt", @@ -113,7 +115,8 @@ "label": "Mijn gedeelde bestanden", "path": "bijv. Afbeeldingen/Vakanties", "search": "Bestanden en mappen zoeken…", - "volumeLabel": "bijv. Documenten, Projecten, Media" + "volumeLabel": "bijv. Documenten, Projecten, Media", + "totpCode": "123456" }, "loading": { "default": "Laden…", @@ -153,7 +156,8 @@ "labelRequired": "Label is vereist", "pathRequired": "Pad is vereist", "saveVolume": "Volume opslaan is mislukt", - "removeVolume": "Volume verwijderen is mislukt" + "removeVolume": "Volume verwijderen is mislukt", + "totpCodeRequired": "Voer de code in." }, "serverErrors": { "AUTH_REQUIRED": "Authenticatie vereist", @@ -319,7 +323,8 @@ "dateTaken": "Datum opname: {date}", "camera": "Camera: {makeModel}", "lens": "Lens: {lens}", - "duration": "Duur: {seconds}s" + "duration": "Duur: {seconds}s", + "versions": "Versies" }, "auth": { "preparing": "Verkenner voorbereiden…", @@ -343,7 +348,10 @@ "bulletPasswordSame": "Uw wachtwoord blijft hetzelfde.", "bulletUpdateUsers": "U kunt bestaande gebruikers bijwerken via het beheerdersmenu" }, - "announcementConfirm": "Markeren als gelezen" + "announcementConfirm": "Markeren als gelezen", + "totpCode": "Code", + "totpExplain": "Uw wachtwoord is geaccepteerd. Voer de code uit uw authenticatie-app in, of een van uw herstelcodes.", + "totpSubmit": "Aanmelden" }, "setup": { "headline": "Laten we alles opzetten", @@ -392,7 +400,10 @@ "security": "Beveiliging", "accessControl": "Toegangscontrole", "adminUsers": "Gebruikersbeheer", - "trash": "Prullenbak en versies" + "trash": "Prullenbak en versies", + "fileVersions": "Bestandsversies", + "uploads": "Uploads", + "accountTwoFactor": "Tweestaps" }, "about": { "subtitle": "Versieinformatie voor deze applicatie.", @@ -442,7 +453,11 @@ "months": "Maanden", "skipHome": "Startpagina overslaan", "skipHomeHelp": "Automatisch doorsturen naar het eerste volume bij het bezoeken van de startpagina. Als dit niet is ingesteld, wordt de serverconfiguratie gevolgd.", - "useEnvSetting": "Serverconfiguratie gebruiken" + "useEnvSetting": "Serverconfiguratie gebruiken", + "showVersionMarks": "Bestanden met versies markeren", + "showVersionMarksHelp": "Een klein teken in de lijst bij bestanden met eerdere versies, met het aantal. Klik erop om de geschiedenis te openen.", + "documentsOpenInNewTab": "Documenten in een nieuw tabblad openen", + "documentsOpenInNewTabHelp": "Een bestand openen geeft het een eigen browsertabblad, zodat er meerdere open blijven terwijl u verder bladert. Uit opent het over de map heen, zoals nu." }, "thumbs": { "subtitle": "Voorbeeldminiaturen aanpassen voor afbeeldingen en video's.", @@ -615,6 +630,92 @@ "sharedSpace": "Versies en de prullenbak delen de gereserveerde ruimte van elk volume. Wordt die krap, dan gaan eerst oudere versies, daarna items uit de prullenbak, dan de laatste versie van elk bestand en als laatste vastgezette versies.", "environmentNote": "Standaardwaarden komen uit VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE en VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Bestandsversies", + "intro": "Elk bestand met eerdere versies, waar het ook staat, en hoeveel ruimte die innemen. Hier verschijnen paden uit alle ruimtes, persoonlijke mappen inbegrepen — daarom is deze pagina alleen voor beheerders.", + "search": "Pad bevat", + "searchPlaceholder": "Deel van een naam of map", + "zone": "Ruimte", + "anyZone": "Alle ruimtes", + "state": "Status", + "anyState": "Alle", + "sort": "Sorteren op", + "sortBytes": "Gebruikte ruimte", + "sortCount": "Aantal versies", + "sortNewest": "Nieuwste versie", + "sortPath": "Pad", + "summary": "{files} bestanden, {versions} versies, {size}", + "file": "Bestand", + "count": "Versies", + "size": "Grootte", + "newest": "Nieuwste", + "states": { + "live": "Aanwezig", + "trashed": "In de prullenbak", + "orphaned": "Verdwenen" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Persoonlijke map {name}", + "user-volume": "Toegewezen volume {name}" + }, + "zoneUnknown": "Onbekende ruimte", + "pinned": "Vastgezet", + "unavailable": "Volume niet beschikbaar", + "deleteAll": "Geschiedenis verwijderen", + "deleteSelected": "{count} versie verwijderen | {count} versies verwijderen", + "deleteForGood": "Definitief verwijderen", + "confirmAllTitle": "Alle versies van {name} verwijderen?", + "confirmSomeTitle": "{count} versie verwijderen? | {count} versies verwijderen?", + "confirmMessage": "Die inhoud wordt van de schijf verwijderd. Het bestand zelf blijft ongemoeid, en niets hiervan kan ongedaan worden gemaakt.", + "none": "Geen enkel bestand heeft eerdere versies.", + "loadFailed": "De lijst kon niet worden gelezen.", + "detailFailed": "Deze geschiedenis kon niet worden gelezen.", + "deleteFailed": "De versies konden niet worden verwijderd.", + "previous": "Vorige", + "next": "Volgende", + "range": "{from} tot {to} van {total}", + "deleteSelectedNone": "Aangevinkte versies verwijderen" + }, + "uploads": { + "title": "Uploads", + "chunkSize": "Deelgrootte", + "chunkSizeHelp": "Maximale grootte van elk uploadverzoek. Houd dit onder de limiet van uw reverse proxy. Kleinere delen (8–32 MiB) geven een vloeiendere voortgang; zeer grote delen tonen zichtbare stappen (de server schrijft elk deel weg voor het volgende).", + "subtitle": "Stel in hoe bestanden naar de server worden verzonden.", + "chunkedEnable": "Uploads in delen inschakelen", + "chunkedEnableHelp": "Gebruik TUS om grote bestanden in meerdere kleinere verzoeken te verzenden.", + "chunkSizeInvalid": "Vul een deelgrootte van 1 tot {max} MiB in." + }, + "twoFactor": { + "title": "Tweestapsverificatie", + "intro": "Een code van uw telefoon, bovenop uw wachtwoord.", + "notLocalUser": "Dit account meldt zich aan via een identiteitsaanbieder; daar hoort de tweede stap thuis.", + "off": "Tweestapsverificatie staat uit voor dit account.", + "on": "Tweestapsverificatie staat aan.", + "turnOn": "Inschakelen", + "turnOff": "Uitschakelen", + "scan": "Scan dit met uw authenticatie-app.", + "orType": "Of typ dit geheim in de app:", + "qrLabel": "QR-code voor uw authenticatie-app", + "codeLabel": "Code uit de app", + "confirm": "Bevestigen", + "confirmPassword": "Uw wachtwoord", + "codesTitle": "Herstelcodes", + "codesExplain": "Bewaar deze ergens anders dan op uw telefoon. Elke code meldt u één keer aan, voor de dag dat de telefoon er niet is. Ze worden nu getoond en daarna nooit meer.", + "copyCodes": "Kopiëren", + "copied": "Gekopieerd.", + "codesLeft": "Nog één herstelcode | Nog {count} herstelcodes", + "newCodesButton": "Nieuwe codes maken", + "newCodes": "Nieuwe herstelcodes gemaakt. De vorige werken niet meer.", + "turnedOn": "Tweestapsverificatie staat aan.", + "turnedOff": "Tweestapsverificatie staat uit.", + "loadFailed": "De tweestapsinstellingen van dit account konden niet worden gelezen.", + "startFailed": "Het instellen van een tweede stap kon niet worden gestart.", + "confirmFailed": "Die code klopt niet.", + "codesFailed": "Er konden geen nieuwe herstelcodes worden gemaakt.", + "disableFailed": "Tweestapsverificatie kon niet worden uitgeschakeld.", + "wrongPassword": "Dat wachtwoord klopt niet." } }, "share": { @@ -801,5 +902,116 @@ "restored": "{count} deellink hersteld | {count} deellinks hersteld", "dropped": "{count} deellink verwijderd | {count} deellinks verwijderd" } + }, + "versions": { + "title": "Versies", + "menu": "Versies", + "aria": "Bestandsversies", + "current": "Huidige versie", + "empty": "Nog geen eerdere versie. Elke opslag bewaart wat hij vervangt.", + "disabled": "Bestandsversies zijn uitgeschakeld: opslaan bewaart geen nieuwe meer. De versies hieronder blijven tot ze verlopen.", + "loadFailed": "De versies konden niet worden geladen.", + "notShared": "De geschiedenis van dit bestand is niet met u gedeeld.", + "unavailable": "De inhoud ontbreekt op de schijf.", + "unknownAuthor": "Onbekende auteur", + "shareLink": "Iemand met de link", + "pinned": "Vastgezet", + "aside": "Apart gezet", + "asideHelp": "Opgeslagen door een editor die vóór een terugzetting was geopend: hier bewaard in plaats van de terugzetting ongedaan te maken.", + "total": "{count} versie, {size} | {count} versies, {size}", + "source": { + "editor": "Teksteditor", + "shareEditor": "Editor via een deellink", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Teruggezette versie", + "external": "Buiten de app gewijzigd" + }, + "actions": { + "menu": "Versieacties", + "select": "Deze versie selecteren", + "selectAll": "Alles selecteren", + "deleteSelected": "Selectie verwijderen ({count})", + "deleteAll": "Alles verwijderen", + "preview": "Alleen-lezen openen", + "download": "Downloaden", + "restore": "Terugzetten", + "rename": "Naam geven…", + "pin": "Vastzetten", + "unpin": "Losmaken", + "delete": "Verwijderen" + }, + "confirm": { + "restoreTitle": "Deze versie terugzetten?", + "restoreMessage": "\"{name}\" krijgt weer de inhoud van {date}. De huidige inhoud wordt als versie bewaard.", + "deleteTitle": "Deze versie verwijderen? | {count} versies verwijderen?", + "deleteMessage": "Deze versie wordt definitief verwijderd. | Deze {count} versies worden definitief verwijderd.", + "deleteAllTitle": "Alle versies verwijderen?", + "deleteAllMessage": "Elke eerdere versie van \"{name}\" wordt definitief verwijderd, vastgezette inbegrepen. Het bestand zelf blijft." + }, + "rename": { + "title": "Deze versie een naam geven", + "placeholder": "Bijvoorbeeld: naar de klant gestuurd", + "help": "Met een naam is een versie makkelijk terug te vinden. Zet hem vast om hem buiten de automatische opschoning te houden." + }, + "results": { + "restored": "Versie teruggezet", + "unchanged": "Het bestand heeft deze inhoud al", + "deleted": "{count} versie verwijderd | {count} versies verwijderd", + "renamed": "Versie benoemd", + "pinned": "Versie vastgezet: de automatische opschoning bewaart hem", + "unpinned": "Versie losgemaakt" + }, + "errors": { + "action": "De actie op deze versie is mislukt" + }, + "mark": "{count} eerdere versie | {count} eerdere versies" + }, + "onlyoffice": { + "renamedHeading": "Hernoemd", + "renamedBody": "Het document heet nu {name}.", + "renameFailed": "Hernoemen naar {name} is mislukt", + "andOthers": "en {count} andere", + "transferHeading": "Bestand wordt bewerkt", + "transferBody": "Dit bestand is geopend in OnlyOffice. Doorgaan kan een lopende opslag verstoren. Wilt u doorgaan?", + "transferCancel": "Annuleren", + "transferConfirm": "Doorgaan", + "editingBy": "Wordt bewerkt in ONLYOFFICE door {names}", + "editingNow": "Wordt bewerkt in ONLYOFFICE", + "savedAsHeading": "Opgeslagen in deze map", + "savedAsBody": "{name} is naast het origineel toegevoegd.", + "saveAsFailed": "{name} kon niet worden opgeslagen" + }, + "preview": { + "nothingOpensIt": "Hier kan niets {name} openen.", + "backToFolder": "Terug naar de map" + }, + "archive": { + "password": { + "title": "Wachtwoord vereist", + "description": "Dit archief is beveiligd. Voer het wachtwoord in om het uit te pakken.", + "label": "Wachtwoord van het archief", + "invalid": "Onjuist wachtwoord, of het archief is beschadigd.", + "submit": "Uitpakken", + "extracting": "Bezig met uitpakken..." + }, + "breadcrumb": "In het archief", + "empty": "Deze map is leeg.", + "download": "Downloaden", + "downloadNamed": "{name} downloaden", + "outside": "Eén item wordt niet getoond: de naam wijst buiten het archief. | {count} items worden niet getoond: hun namen wijzen buiten het archief.", + "unreadable": "Dit archief kon niet worden gelezen.", + "readFailed": "Dit bestand kon niet worden gelezen.", + "notReadable": "Dit soort bestand kan hier niet worden getoond.", + "tooBigToRead": "Te groot om hier te tonen: {size}, de limiet is {ceiling}. Download het of pak het uit om het te openen.", + "extract": "Hier uitpakken", + "extractNamed": "{name} hier uitpakken", + "selectAll": "Alles hier selecteren", + "selectNamed": "{name} selecteren", + "selected": "Eén item geselecteerd | {count} items geselecteerd", + "extracted": "Uitgepakt: {name}", + "extractedNothing": "Er kwam niets uit.", + "extractFailed": "Dit kon niet worden uitgepakt.", + "count": "Eén item in dit archief | {count} items in dit archief" } } diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index f9f7a1b3b..055406673 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Masz niezapisane zmiany. Zamknąć bez zapisywania?" + "confirmCloseWithoutSaving": "Masz niezapisane zmiany. Zamknąć bez zapisywania?", + "trashReadOnly": "W koszu · tylko do odczytu", + "versionReadOnly": "Wcześniejsza wersja, tylko do odczytu" }, "status": { "updated": "Pomyślnie zaktualizowano", @@ -113,7 +115,8 @@ "label": "Moje udostępnione pliki", "path": "np. Obrazy/Wakacje", "search": "Szukaj plików i folderów…", - "volumeLabel": "e.g. Documents, Projects, Media" + "volumeLabel": "e.g. Documents, Projects, Media", + "totpCode": "123456" }, "loading": { "default": "Ładowanie…", @@ -153,7 +156,8 @@ "labelRequired": "Label is required", "pathRequired": "Path is required", "saveVolume": "Failed to save volume", - "removeVolume": "Failed to remove volume" + "removeVolume": "Failed to remove volume", + "totpCodeRequired": "Wpisz kod." }, "serverErrors": { "AUTH_REQUIRED": "Wymagana autentykacja", @@ -319,7 +323,8 @@ "dateTaken": "Data wykonania: {date}", "camera": "Aparat: {makeModel}", "lens": "Obiektyw: {lens}", - "duration": "Czas trwania: {seconds}s" + "duration": "Czas trwania: {seconds}s", + "versions": "Wersje" }, "auth": { "preparing": "Trwa przygotowywanie eksploratora…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "Dodaj {suffix} do swojej nazwy użytkownika", "bulletPasswordSame": "Twoje hasło pozostaje bez zmian.", "bulletUpdateUsers": "Możesz zaktualizować istniejących użytkowników w menu administracyjnym" - } + }, + "totpCode": "Kod", + "totpExplain": "Hasło zostało przyjęte. Wpisz kod z aplikacji uwierzytelniającej albo jeden z kodów zapasowych.", + "totpSubmit": "Zaloguj się" }, "setup": { "headline": "Skonfigurujmy wszystko", @@ -392,7 +400,10 @@ "security": "Bezpieczeństwo", "accessControl": "Kontrola dostępu", "adminUsers": "Zarządzanie użytkownikami", - "trash": "Kosz i wersje" + "trash": "Kosz i wersje", + "fileVersions": "Wersje plików", + "uploads": "Przesyłanie", + "accountTwoFactor": "Dwa składniki" }, "about": { "subtitle": "Wyświetl informacje o kompilacji tej aplikacji.", @@ -442,7 +453,11 @@ "months": "Miesiące", "skipHome": "Pomiń stronę główną", "skipHomeHelp": "Automatycznie przekierowuje do pierwszego wolumenu przy otwieraniu strony głównej. Jeśli nie ustawiono, stosuje konfigurację serwera.", - "useEnvSetting": "Użyj ustawień serwera" + "useEnvSetting": "Użyj ustawień serwera", + "showVersionMarks": "Oznaczaj pliki, które mają wersje", + "showVersionMarksHelp": "Mały znak na liście przy plikach z wcześniejszymi wersjami, wraz z ich liczbą. Kliknięcie otwiera historię.", + "documentsOpenInNewTab": "Otwieraj dokumenty w nowej karcie", + "documentsOpenInNewTabHelp": "Otwarcie pliku daje mu własną kartę przeglądarki, więc kilka pozostaje otwartych, gdy przeglądasz dalej. Wyłączone — otwiera się nad folderem, tak jak dziś." }, "thumbs": { "subtitle": "Dostosuj miniatury podglądu dla obrazów i filmów.", @@ -615,6 +630,92 @@ "sharedSpace": "Wersje i kosz dzielą zarezerwowaną przestrzeń każdego woluminu. Gdy jej brakuje, najpierw znikają starsze wersje, potem elementy kosza, potem ostatnia wersja każdego pliku, a na końcu przypięte wersje.", "environmentNote": "Wartości domyślne pochodzą z VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE i VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Wersje plików", + "intro": "Wszystkie pliki, które mają wcześniejsze wersje, gdziekolwiek się znajdują, i ile te wersje zajmują. Widać tu ścieżki ze wszystkich przestrzeni, także z folderów osobistych — dlatego ta strona jest tylko dla administratorów.", + "search": "Ścieżka zawiera", + "searchPlaceholder": "Część nazwy lub folderu", + "zone": "Przestrzeń", + "anyZone": "Wszystkie przestrzenie", + "state": "Stan", + "anyState": "Wszystkie", + "sort": "Sortuj według", + "sortBytes": "Zajęte miejsce", + "sortCount": "Liczba wersji", + "sortNewest": "Najnowsza wersja", + "sortPath": "Ścieżka", + "summary": "Plików: {files}, wersji: {versions}, {size}", + "file": "Plik", + "count": "Wersje", + "size": "Rozmiar", + "newest": "Najnowsza", + "states": { + "live": "Obecny", + "trashed": "W koszu", + "orphaned": "Zniknął" + }, + "zoneKinds": { + "volume": "Wolumin {name}", + "personal": "Folder osobisty {name}", + "user-volume": "Przypisany wolumin {name}" + }, + "zoneUnknown": "Nieznana przestrzeń", + "pinned": "Przypięta", + "unavailable": "Wolumin niedostępny", + "deleteAll": "Usuń historię", + "deleteSelected": "Usuń {count} wersję | Usuń {count} wersje | Usuń {count} wersji", + "deleteForGood": "Usuń na zawsze", + "confirmAllTitle": "Usunąć wszystkie wersje pliku {name}?", + "confirmSomeTitle": "Usunąć {count} wersję? | Usunąć {count} wersje? | Usunąć {count} wersji?", + "confirmMessage": "Ta zawartość zostaje usunięta z dysku. Sam plik pozostaje nietknięty, a tej operacji nie można cofnąć.", + "none": "Żaden plik nie ma wcześniejszych wersji.", + "loadFailed": "Nie udało się odczytać listy.", + "detailFailed": "Nie udało się odczytać tej historii.", + "deleteFailed": "Nie udało się usunąć wersji.", + "previous": "Poprzednie", + "next": "Następne", + "range": "Od {from} do {to} z {total}", + "deleteSelectedNone": "Usuń zaznaczone wersje" + }, + "uploads": { + "title": "Przesyłanie", + "chunkSize": "Rozmiar fragmentu", + "chunkSizeHelp": "Maksymalny rozmiar każdego żądania przesyłania. Utrzymuj go poniżej limitu Twojego reverse proxy. Mniejsze fragmenty (8–32 MiB) zapewniają płynniejszy postęp; bardzo duże fragmenty powodują widoczne skoki (serwer zapisuje każdy fragment przed kolejnym).", + "subtitle": "Skonfiguruj sposób wysyłania plików na serwer.", + "chunkedEnable": "Włącz przesyłanie fragmentaryczne", + "chunkedEnableHelp": "Wykorzystuje protokół TUS, aby wysyłać duże pliki w wielu mniejszych żądaniach.", + "chunkSizeInvalid": "Podaj rozmiar fragmentu od 1 do {max} MiB." + }, + "twoFactor": { + "title": "Uwierzytelnianie dwuskładnikowe", + "intro": "Kod z telefonu, oprócz hasła.", + "notLocalUser": "To konto loguje się przez dostawcę tożsamości i tam należy ustawić drugi składnik.", + "off": "Uwierzytelnianie dwuskładnikowe jest wyłączone dla tego konta.", + "on": "Uwierzytelnianie dwuskładnikowe jest włączone.", + "turnOn": "Włącz", + "turnOff": "Wyłącz", + "scan": "Zeskanuj to aplikacją uwierzytelniającą.", + "orType": "Albo wpisz ten sekret w aplikacji:", + "qrLabel": "Kod QR dla aplikacji uwierzytelniającej", + "codeLabel": "Kod z aplikacji", + "confirm": "Potwierdź", + "confirmPassword": "Twoje hasło", + "codesTitle": "Kody zapasowe", + "codesExplain": "Przechowuj je gdzie indziej niż w telefonie. Każdy loguje raz, na dzień, w którym telefonu nie ma. Są pokazane teraz i nigdy więcej.", + "copyCodes": "Kopiuj", + "copied": "Skopiowano.", + "codesLeft": "Został jeden kod zapasowy | Zostały {count} kody zapasowe", + "newCodesButton": "Wygeneruj nowe kody", + "newCodes": "Wygenerowano nowe kody zapasowe. Poprzednie już nie działają.", + "turnedOn": "Uwierzytelnianie dwuskładnikowe jest włączone.", + "turnedOff": "Uwierzytelnianie dwuskładnikowe jest wyłączone.", + "loadFailed": "Nie udało się odczytać ustawień dwuskładnikowych tego konta.", + "startFailed": "Nie udało się rozpocząć konfiguracji drugiego składnika.", + "confirmFailed": "Ten kod jest nieprawidłowy.", + "codesFailed": "Nie udało się wygenerować nowych kodów zapasowych.", + "disableFailed": "Nie udało się wyłączyć uwierzytelniania dwuskładnikowego.", + "wrongPassword": "To hasło jest nieprawidłowe." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "Przywrócone linki udostępniania: {count}", "dropped": "Usunięte linki udostępniania: {count}" } + }, + "versions": { + "title": "Wersje", + "menu": "Wersje", + "aria": "Wersje pliku", + "current": "Bieżąca wersja", + "empty": "Brak wcześniejszych wersji. Każdy zapis zachowuje to, co zastępuje.", + "disabled": "Wersje plików są wyłączone: zapisy nie zachowują już nowych. Poniższe pozostają do wygaśnięcia.", + "loadFailed": "Nie udało się wczytać wersji.", + "notShared": "Historia tego pliku nie jest Ci udostępniona.", + "unavailable": "Brak jej zawartości na dysku.", + "unknownAuthor": "Nieznany autor", + "shareLink": "Ktoś z linkiem", + "pinned": "Przypięta", + "aside": "Odłożona", + "asideHelp": "Zapisana przez edytor otwarty przed przywróceniem: zachowana tutaj zamiast cofać przywrócenie.", + "total": "{count} wersja, {size} | {count} wersje, {size} | {count} wersji, {size}", + "source": { + "editor": "Edytor tekstu", + "shareEditor": "Edytor przez udostępnienie", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Przywrócona wersja", + "external": "Zmieniono poza aplikacją" + }, + "actions": { + "menu": "Działania na wersji", + "select": "Zaznacz tę wersję", + "selectAll": "Zaznacz wszystko", + "deleteSelected": "Usuń zaznaczone ({count})", + "deleteAll": "Usuń wszystkie", + "preview": "Otwórz tylko do odczytu", + "download": "Pobierz", + "restore": "Przywróć", + "rename": "Nazwij…", + "pin": "Przypnij", + "unpin": "Odepnij", + "delete": "Usuń" + }, + "confirm": { + "restoreTitle": "Przywrócić tę wersję?", + "restoreMessage": "„{name}” wraca do zawartości z {date}. Obecna zawartość zostaje zachowana jako wersja.", + "deleteTitle": "Usunąć tę wersję? | Usunąć {count} wersje? | Usunąć {count} wersji?", + "deleteMessage": "Ta wersja zostanie trwale usunięta. | Te {count} wersje zostaną trwale usunięte. | Tych {count} wersji zostanie trwale usuniętych.", + "deleteAllTitle": "Usunąć wszystkie wersje?", + "deleteAllMessage": "Wszystkie wcześniejsze wersje „{name}” zostaną trwale usunięte, łącznie z przypiętymi. Sam plik pozostaje." + }, + "rename": { + "title": "Nazwij tę wersję", + "placeholder": "Na przykład: wysłana do klienta", + "help": "Nazwa ułatwia odnalezienie wersji. Przypnij ją, aby wyłączyć ją z automatycznego czyszczenia." + }, + "results": { + "restored": "Wersja przywrócona", + "unchanged": "Plik ma już tę zawartość", + "deleted": "Usunięto {count} wersję | Usunięto {count} wersje | Usunięto {count} wersji", + "renamed": "Wersja nazwana", + "pinned": "Wersja przypięta: automatyczne czyszczenie ją zachowa", + "unpinned": "Wersja odpięta" + }, + "errors": { + "action": "Działanie na tej wersji nie powiodło się" + }, + "mark": "{count} wcześniejsza wersja | {count} wcześniejsze wersje | {count} wcześniejszych wersji" + }, + "onlyoffice": { + "renamedHeading": "Zmieniono nazwę", + "renamedBody": "Dokument nazywa się teraz {name}.", + "renameFailed": "Nie można zmienić nazwy na {name}", + "andOthers": "i {count} więcej", + "transferHeading": "Plik jest edytowany", + "transferBody": "Ten plik jest otwarty w OnlyOffice. Kontynuowanie może zakłócić trwający zapis. Czy chcesz kontynuować?", + "transferCancel": "Anuluj", + "transferConfirm": "Kontynuuj", + "editingBy": "Edytowane w ONLYOFFICE przez: {names}", + "editingNow": "Edytowane w ONLYOFFICE", + "savedAsHeading": "Zapisano w tym folderze", + "savedAsBody": "{name} został dodany obok oryginału.", + "saveAsFailed": "Nie można zapisać {name}" + }, + "preview": { + "nothingOpensIt": "Nic tutaj nie otworzy {name}.", + "backToFolder": "Powrót do folderu" + }, + "archive": { + "password": { + "title": "Wymagane hasło", + "description": "To archiwum jest chronione. Wprowadź hasło, aby je wypakować.", + "label": "Hasło archiwum", + "invalid": "Nieprawidłowe hasło lub archiwum jest uszkodzone.", + "submit": "Wypakuj", + "extracting": "Wypakowywanie..." + }, + "breadcrumb": "W archiwum", + "empty": "Ten folder jest pusty.", + "download": "Pobierz", + "downloadNamed": "Pobierz {name}", + "outside": "Jeden wpis nie jest pokazany: jego nazwa wskazuje poza archiwum. | {count} wpisy nie są pokazane: ich nazwy wskazują poza archiwum. | {count} wpisów nie jest pokazanych: ich nazwy wskazują poza archiwum.", + "unreadable": "Nie udało się odczytać tego archiwum.", + "readFailed": "Nie udało się odczytać tego pliku.", + "notReadable": "Tego rodzaju pliku nie można tutaj wyświetlić.", + "tooBigToRead": "Zbyt duży, aby go tutaj wyświetlić: {size}, limit to {ceiling}. Pobierz go lub wypakuj, aby otworzyć.", + "extract": "Wypakuj tutaj", + "extractNamed": "Wypakuj {name} tutaj", + "selectAll": "Zaznacz wszystko tutaj", + "selectNamed": "Zaznacz {name}", + "selected": "Zaznaczono jeden element | Zaznaczono {count} elementy", + "extracted": "Wypakowano: {name}", + "extractedNothing": "Nic z tego nie wyszło.", + "extractFailed": "Nie udało się tego wypakować.", + "count": "Jeden wpis w tym archiwum | {count} wpisy w tym archiwum | {count} wpisów w tym archiwum" } } diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 18ad20c04..f093fed36 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -77,7 +77,9 @@ "theme": "Tema", "editorSettings": "Configurações do editor", "wrapLines": "Quebrar linhas", - "confirmCloseWithoutSaving": "Você tem alterações não salvas. Fechar sem salvar?" + "confirmCloseWithoutSaving": "Você tem alterações não salvas. Fechar sem salvar?", + "trashReadOnly": "Na lixeira · somente leitura", + "versionReadOnly": "Versão anterior, somente leitura" }, "status": { "updated": "Atualizado com sucesso", @@ -113,7 +115,8 @@ "label": "Meus Arquivos Compartilhados", "path": "ex.: Fotos/Férias", "search": "Pesquisar arquivos e pastas…", - "volumeLabel": "ex.: Documentos, Projetos, Mídia" + "volumeLabel": "ex.: Documentos, Projetos, Mídia", + "totpCode": "123456" }, "loading": { "default": "Carregando…", @@ -153,7 +156,8 @@ "labelRequired": "O rótulo é obrigatório", "pathRequired": "O caminho é obrigatório", "saveVolume": "Falha ao salvar volume", - "removeVolume": "Falha ao remover volume" + "removeVolume": "Falha ao remover volume", + "totpCodeRequired": "Digite o código." }, "serverErrors": { "AUTH_REQUIRED": "Autenticação necessária", @@ -319,7 +323,8 @@ "dateTaken": "Data da Foto: {date}", "camera": "Câmera: {makeModel}", "lens": "Lente: {lens}", - "duration": "Duração: {seconds}s" + "duration": "Duração: {seconds}s", + "versions": "Versões" }, "auth": { "preparing": "Preparando seu explorador…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "Adicione {suffix} ao seu nome de usuário", "bulletPasswordSame": "Sua senha permanece a mesma.", "bulletUpdateUsers": "Você pode atualizar usuários existentes no menu de Administração" - } + }, + "totpCode": "Código", + "totpExplain": "Sua senha foi aceita. Digite o código do seu aplicativo autenticador, ou um dos seus códigos de recuperação.", + "totpSubmit": "Entrar" }, "setup": { "headline": "Vamos configurar", @@ -392,7 +400,10 @@ "security": "Segurança", "accessControl": "Controle de Acesso", "adminUsers": "Gerenciamento de Usuários", - "trash": "Lixeira e versões" + "trash": "Lixeira e versões", + "fileVersions": "Versões de arquivos", + "uploads": "Envios", + "accountTwoFactor": "Dois fatores" }, "about": { "subtitle": "Veja as informações de compilação deste aplicativo.", @@ -442,7 +453,11 @@ "months": "Meses", "skipHome": "Pular página inicial", "skipHomeHelp": "Redirecionar automaticamente para o primeiro volume ao visitar a página inicial. Se não definido, segue a configuração do servidor.", - "useEnvSetting": "Usar configuração do servidor" + "useEnvSetting": "Usar configuração do servidor", + "showVersionMarks": "Marcar arquivos que têm versões", + "showVersionMarksHelp": "Uma pequena marca na lista nos arquivos com versões anteriores, com a quantidade. Clique nela para abrir o histórico.", + "documentsOpenInNewTab": "Abrir documentos em uma nova aba", + "documentsOpenInNewTabHelp": "Abrir um arquivo lhe dá uma aba do navegador só dele, então vários continuam abertos enquanto você navega. Desligado, ele abre sobre a pasta como hoje." }, "thumbs": { "subtitle": "Personalize as pré-visualizações em miniatura de imagens e vídeos.", @@ -615,6 +630,92 @@ "sharedSpace": "Versões e lixeira dividem o espaço reservado de cada volume. Quando falta espaço, saem primeiro as versões antigas, depois os itens da lixeira, depois a última versão de cada arquivo e, por fim, as versões fixadas.", "environmentNote": "Os padrões vêm de VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE e VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Versões de arquivos", + "intro": "Todos os arquivos com versões anteriores, onde quer que estejam, e o espaço que ocupam. Aparecem aqui caminhos de todos os espaços, inclusive pastas pessoais — por isso esta página é só para administradores.", + "search": "O caminho contém", + "searchPlaceholder": "Parte de um nome ou de uma pasta", + "zone": "Espaço", + "anyZone": "Todos os espaços", + "state": "Situação", + "anyState": "Todos", + "sort": "Ordenar por", + "sortBytes": "Espaço ocupado", + "sortCount": "Número de versões", + "sortNewest": "Versão mais recente", + "sortPath": "Caminho", + "summary": "{files} arquivos, {versions} versões, {size}", + "file": "Arquivo", + "count": "Versões", + "size": "Tamanho", + "newest": "Mais recente", + "states": { + "live": "Presente", + "trashed": "Na lixeira", + "orphaned": "Desaparecido" + }, + "zoneKinds": { + "volume": "Volume {name}", + "personal": "Pasta pessoal {name}", + "user-volume": "Volume atribuído {name}" + }, + "zoneUnknown": "Espaço desconhecido", + "pinned": "Fixada", + "unavailable": "Volume indisponível", + "deleteAll": "Excluir o histórico", + "deleteSelected": "Excluir {count} versão | Excluir {count} versões", + "deleteForGood": "Excluir definitivamente", + "confirmAllTitle": "Excluir todas as versões de {name}?", + "confirmSomeTitle": "Excluir {count} versão? | Excluir {count} versões?", + "confirmMessage": "Esse conteúdo é removido do disco. O arquivo em si não é tocado, e nada disso pode ser desfeito.", + "none": "Nenhum arquivo tem versões anteriores.", + "loadFailed": "Não foi possível ler a lista.", + "detailFailed": "Não foi possível ler este histórico.", + "deleteFailed": "Não foi possível excluir as versões.", + "previous": "Anterior", + "next": "Próximo", + "range": "{from} a {to} de {total}", + "deleteSelectedNone": "Excluir as versões marcadas" + }, + "uploads": { + "title": "Envios", + "chunkSize": "Tamanho das partes", + "chunkSizeHelp": "Tamanho máximo de cada requisição de envio. Mantenha-o abaixo do limite do seu proxy reverso. Partes menores (8–32 MiB) dão um progresso mais fluido; partes muito grandes avançam aos saltos (o servidor grava cada parte antes da seguinte).", + "subtitle": "Configure como os arquivos são enviados ao servidor.", + "chunkedEnable": "Ativar envio em partes", + "chunkedEnableHelp": "Usar o TUS para enviar arquivos grandes por meio de várias requisições menores.", + "chunkSizeInvalid": "Informe um tamanho de parte de 1 a {max} MiB." + }, + "twoFactor": { + "title": "Autenticação em dois fatores", + "intro": "Um código do seu celular, além da sua senha.", + "notLocalUser": "Esta conta entra por um provedor de identidade, que é onde o segundo fator deve ser configurado.", + "off": "A autenticação em dois fatores está desligada nesta conta.", + "on": "A autenticação em dois fatores está ligada.", + "turnOn": "Ligar", + "turnOff": "Desligar", + "scan": "Escaneie isto com o seu aplicativo autenticador.", + "orType": "Ou digite este segredo no aplicativo:", + "qrLabel": "QR code para o seu aplicativo autenticador", + "codeLabel": "Código do aplicativo", + "confirm": "Confirmar", + "confirmPassword": "Sua senha", + "codesTitle": "Códigos de recuperação", + "codesExplain": "Guarde-os em outro lugar que não o celular. Cada um serve para uma entrada, para o dia em que o celular não estiver por perto. Eles aparecem agora e nunca mais.", + "copyCodes": "Copiar", + "copied": "Copiado.", + "codesLeft": "Resta um código de recuperação | Restam {count} códigos de recuperação", + "newCodesButton": "Gerar novos códigos", + "newCodes": "Novos códigos de recuperação gerados. Os anteriores não funcionam mais.", + "turnedOn": "A autenticação em dois fatores está ligada.", + "turnedOff": "A autenticação em dois fatores está desligada.", + "loadFailed": "Não foi possível ler as configurações de dois fatores desta conta.", + "startFailed": "Não foi possível começar a configurar o segundo fator.", + "confirmFailed": "Esse código não está certo.", + "codesFailed": "Não foi possível gerar novos códigos de recuperação.", + "disableFailed": "Não foi possível desligar a autenticação em dois fatores.", + "wrongPassword": "Essa senha não está certa." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "{count} link de compartilhamento recuperado | {count} links de compartilhamento recuperados", "dropped": "{count} link de compartilhamento excluído | {count} links de compartilhamento excluídos" } + }, + "versions": { + "title": "Versões", + "menu": "Versões", + "aria": "Versões do arquivo", + "current": "Versão atual", + "empty": "Ainda não há versões anteriores. Cada salvamento guarda o que substitui.", + "disabled": "As versões de arquivos estão desativadas: os salvamentos não guardam mais novas. As abaixo permanecem até expirarem.", + "loadFailed": "Não foi possível carregar as versões.", + "notShared": "O histórico deste arquivo não está compartilhado com você.", + "unavailable": "O conteúdo dela está faltando no disco.", + "unknownAuthor": "Autor desconhecido", + "shareLink": "Alguém com o link", + "pinned": "Fixada", + "aside": "Posta de lado", + "asideHelp": "Salva por um editor aberto antes de uma restauração: guardada aqui em vez de desfazer a restauração.", + "total": "{count} versão, {size} | {count} versões, {size}", + "source": { + "editor": "Editor de texto", + "shareEditor": "Editor por um compartilhamento", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Versão restaurada", + "external": "Alterado fora do aplicativo" + }, + "actions": { + "menu": "Ações da versão", + "select": "Selecionar esta versão", + "selectAll": "Selecionar tudo", + "deleteSelected": "Excluir seleção ({count})", + "deleteAll": "Excluir tudo", + "preview": "Abrir somente leitura", + "download": "Baixar", + "restore": "Restaurar", + "rename": "Nomear…", + "pin": "Fixar", + "unpin": "Desafixar", + "delete": "Excluir" + }, + "confirm": { + "restoreTitle": "Restaurar esta versão?", + "restoreMessage": "\"{name}\" volta ao conteúdo de {date}. O conteúdo atual é guardado como versão.", + "deleteTitle": "Excluir esta versão? | Excluir {count} versões?", + "deleteMessage": "Esta versão é excluída definitivamente. | Estas {count} versões são excluídas definitivamente.", + "deleteAllTitle": "Excluir todas as versões?", + "deleteAllMessage": "Todas as versões anteriores de \"{name}\" são excluídas definitivamente, inclusive as fixadas. O arquivo em si permanece." + }, + "rename": { + "title": "Nomear esta versão", + "placeholder": "Por exemplo: enviada ao cliente", + "help": "Um nome facilita encontrar uma versão. Fixe-a para mantê-la fora da limpeza automática." + }, + "results": { + "restored": "Versão restaurada", + "unchanged": "O arquivo já tem este conteúdo", + "deleted": "{count} versão excluída | {count} versões excluídas", + "renamed": "Versão nomeada", + "pinned": "Versão fixada: a limpeza automática a mantém", + "unpinned": "Versão desafixada" + }, + "errors": { + "action": "A ação nesta versão falhou" + }, + "mark": "{count} versão anterior | {count} versões anteriores" + }, + "onlyoffice": { + "renamedHeading": "Renomeado", + "renamedBody": "O documento agora se chama {name}.", + "renameFailed": "Não foi possível renomear para {name}", + "andOthers": "e mais {count}", + "transferHeading": "O arquivo está sendo editado", + "transferBody": "Este arquivo está aberto no OnlyOffice. Continuar pode atrapalhar um salvamento em andamento. Deseja continuar?", + "transferCancel": "Cancelar", + "transferConfirm": "Continuar", + "editingBy": "Sendo editado no ONLYOFFICE por {names}", + "editingNow": "Sendo editado no ONLYOFFICE", + "savedAsHeading": "Salvo nesta pasta", + "savedAsBody": "{name} foi adicionado ao lado do original.", + "saveAsFailed": "Não foi possível salvar {name}" + }, + "preview": { + "nothingOpensIt": "Nada aqui consegue abrir {name}.", + "backToFolder": "Voltar para a pasta" + }, + "archive": { + "password": { + "title": "Senha necessária", + "description": "Este arquivo está protegido. Digite a senha para extraí-lo.", + "label": "Senha do arquivo", + "invalid": "Senha incorreta, ou o arquivo está danificado.", + "submit": "Extrair", + "extracting": "Extraindo..." + }, + "breadcrumb": "Dentro do arquivo", + "empty": "Esta pasta está vazia.", + "download": "Baixar", + "downloadNamed": "Baixar {name}", + "outside": "Uma entrada não é exibida: o nome dela aponta para fora do arquivo. | {count} entradas não são exibidas: os nomes delas apontam para fora do arquivo.", + "unreadable": "Não foi possível ler este arquivo.", + "readFailed": "Não foi possível ler este arquivo.", + "notReadable": "Este tipo de arquivo não pode ser exibido aqui.", + "tooBigToRead": "Grande demais para exibir aqui: {size}, o limite é {ceiling}. Baixe ou extraia o arquivo para abri-lo.", + "extract": "Extrair aqui", + "extractNamed": "Extrair {name} aqui", + "selectAll": "Selecionar tudo aqui", + "selectNamed": "Selecionar {name}", + "selected": "Um item selecionado | {count} itens selecionados", + "extracted": "Extraído: {name}", + "extractedNothing": "Nada saiu dele.", + "extractFailed": "Não foi possível extrair isto.", + "count": "Uma entrada neste arquivo | {count} entradas neste arquivo" } } diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 00ff31fbf..c8885fc59 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Ai modificări nesalvate. Închide fără să salvezi?" + "confirmCloseWithoutSaving": "Ai modificări nesalvate. Închide fără să salvezi?", + "trashReadOnly": "În coșul de gunoi · doar citire", + "versionReadOnly": "Versiune anterioară, doar citire" }, "status": { "updated": "Actualizat cu succes", @@ -113,7 +115,8 @@ "label": "Fișierele mele partajate", "path": "ex. Imagini/Vacanțe", "search": "Caută fișiere și dosare…", - "volumeLabel": "ex. Documente, Proiecte, Media" + "volumeLabel": "ex. Documente, Proiecte, Media", + "totpCode": "123456" }, "loading": { "default": "Se încarcă…", @@ -153,7 +156,8 @@ "labelRequired": "Eticheta este obligatorie", "pathRequired": "Calea este obligatorie", "saveVolume": "Nu s-a putut salva volumul", - "removeVolume": "Nu s-a putut elimina volumul" + "removeVolume": "Nu s-a putut elimina volumul", + "totpCodeRequired": "Introduceți codul." }, "serverErrors": { "AUTH_REQUIRED": "Autentificare necesară", @@ -319,7 +323,8 @@ "dateTaken": "Data realizării: {date}", "camera": "Cameră: {makeModel}", "lens": "Obiectiv: {lens}", - "duration": "Durată: {seconds}s" + "duration": "Durată: {seconds}s", + "versions": "Versiuni" }, "auth": { "preparing": "Se pregătește explorerul…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "Adaugă {suffix} la numele tău de utilizator", "bulletPasswordSame": "Parola rămâne aceeași.", "bulletUpdateUsers": "Poți actualiza utilizatorii existenți din meniul Admin" - } + }, + "totpCode": "Cod", + "totpExplain": "Parola a fost acceptată. Introduceți codul din aplicația de autentificare sau unul dintre codurile de recuperare.", + "totpSubmit": "Conectați-vă" }, "setup": { "headline": "Să configurăm totul", @@ -392,7 +400,10 @@ "security": "Securitate", "accessControl": "Control acces", "adminUsers": "Gestionare utilizatori", - "trash": "Coș de gunoi și versiuni" + "trash": "Coș de gunoi și versiuni", + "fileVersions": "Versiuni de fișiere", + "uploads": "Încărcări", + "accountTwoFactor": "Doi factori" }, "about": { "subtitle": "Vezi informațiile de build pentru această aplicație.", @@ -442,7 +453,11 @@ "months": "Luni", "skipHome": "Sari peste pagina principală", "skipHomeHelp": "Redirectează automat către primul volum la deschiderea paginii principale. Dacă nu este setat, se folosește configurația serverului.", - "useEnvSetting": "Folosește setarea serverului" + "useEnvSetting": "Folosește setarea serverului", + "showVersionMarks": "Marchează fișierele care au versiuni", + "showVersionMarksHelp": "Un semn discret în listă pe fișierele cu versiuni anterioare, cu numărul lor. Un clic deschide istoricul.", + "documentsOpenInNewTab": "Deschide documentele într-o filă nouă", + "documentsOpenInNewTabHelp": "Deschiderea unui fișier îi dă o filă proprie de browser, așa că mai multe rămân deschise cât timp navigați. Oprit, se deschide peste dosar ca până acum." }, "thumbs": { "subtitle": "Personalizează miniaturile de previzualizare pentru imagini și video.", @@ -615,6 +630,92 @@ "sharedSpace": "Versiunile și coșul de gunoi împart spațiul rezervat al fiecărui volum. Când nu ajunge, pleacă întâi versiunile vechi, apoi elementele din coș, apoi ultima versiune a fiecărui fișier și, la final, versiunile fixate.", "environmentNote": "Valorile implicite provin din VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE și VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Versiuni de fișiere", + "intro": "Toate fișierele care au versiuni anterioare, oriunde s-ar afla, și spațiul pe care îl ocupă. Aici apar căi din toate spațiile, inclusiv din dosarele personale — de aceea pagina este rezervată administratorilor.", + "search": "Calea conține", + "searchPlaceholder": "O parte dintr-un nume sau dosar", + "zone": "Spațiu", + "anyZone": "Toate spațiile", + "state": "Stare", + "anyState": "Toate", + "sort": "Sortează după", + "sortBytes": "Spațiu ocupat", + "sortCount": "Numărul de versiuni", + "sortNewest": "Cea mai recentă versiune", + "sortPath": "Cale", + "summary": "{files} fișiere, {versions} versiuni, {size}", + "file": "Fișier", + "count": "Versiuni", + "size": "Dimensiune", + "newest": "Cea mai recentă", + "states": { + "live": "Prezent", + "trashed": "La coșul de gunoi", + "orphaned": "Dispărut" + }, + "zoneKinds": { + "volume": "Volumul {name}", + "personal": "Dosar personal {name}", + "user-volume": "Volum atribuit {name}" + }, + "zoneUnknown": "Spațiu necunoscut", + "pinned": "Fixată", + "unavailable": "Volum indisponibil", + "deleteAll": "Șterge istoricul", + "deleteSelected": "Șterge {count} versiune | Șterge {count} versiuni", + "deleteForGood": "Șterge definitiv", + "confirmAllTitle": "Ștergeți toate versiunile lui {name}?", + "confirmSomeTitle": "Ștergeți {count} versiune? | Ștergeți {count} versiuni?", + "confirmMessage": "Acest conținut este șters de pe disc. Fișierul în sine nu este atins, iar nimic din toate acestea nu poate fi anulat.", + "none": "Niciun fișier nu are versiuni anterioare.", + "loadFailed": "Lista nu a putut fi citită.", + "detailFailed": "Acest istoric nu a putut fi citit.", + "deleteFailed": "Versiunile nu au putut fi șterse.", + "previous": "Anterior", + "next": "Următor", + "range": "De la {from} la {to} din {total}", + "deleteSelectedNone": "Șterge versiunile bifate" + }, + "uploads": { + "title": "Încărcări", + "chunkSize": "Dimensiunea fragmentelor", + "chunkSizeHelp": "Dimensiunea maximă a fiecărei cereri de încărcare. Menține-o sub limita reverse proxy-ului. Fragmentele mai mici (8–32 MiB) oferă o progresie mai fluidă; fragmentele foarte mari afișează paliere vizibile (serverul scrie fiecare fragment înainte de următorul).", + "subtitle": "Configurează modul în care fișierele sunt trimise către server.", + "chunkedEnable": "Activează încărcările fragmentate", + "chunkedEnableHelp": "Folosește TUS pentru a trimite fișierele mari prin mai multe cereri mai mici.", + "chunkSizeInvalid": "Introduceți o dimensiune a fragmentelor între 1 și {max} MiB." + }, + "twoFactor": { + "title": "Autentificare în doi factori", + "intro": "Un cod de pe telefon, pe lângă parolă.", + "notLocalUser": "Acest cont se autentifică printr-un furnizor de identitate, acolo unde îi este locul celui de-al doilea factor.", + "off": "Autentificarea în doi factori este dezactivată pentru acest cont.", + "on": "Autentificarea în doi factori este activată.", + "turnOn": "Activați", + "turnOff": "Dezactivați", + "scan": "Scanați acest cod cu aplicația de autentificare.", + "orType": "Sau tastați acest secret în aplicație:", + "qrLabel": "Cod QR pentru aplicația de autentificare", + "codeLabel": "Codul din aplicație", + "confirm": "Confirmați", + "confirmPassword": "Parola dumneavoastră", + "codesTitle": "Coduri de recuperare", + "codesExplain": "Păstrați-le în altă parte decât pe telefon. Fiecare vă autentifică o dată, pentru ziua în care telefonul nu este la îndemână. Sunt afișate acum și niciodată după aceea.", + "copyCodes": "Copiați", + "copied": "Copiat.", + "codesLeft": "A mai rămas un cod de recuperare | Au mai rămas {count} coduri de recuperare", + "newCodesButton": "Generați coduri noi", + "newCodes": "Coduri de recuperare noi generate. Cele dinainte nu mai funcționează.", + "turnedOn": "Autentificarea în doi factori este activată.", + "turnedOff": "Autentificarea în doi factori este dezactivată.", + "loadFailed": "Setările de doi factori ale acestui cont nu au putut fi citite.", + "startFailed": "Configurarea celui de-al doilea factor nu a putut fi pornită.", + "confirmFailed": "Acest cod nu este corect.", + "codesFailed": "Nu au putut fi generate coduri de recuperare noi.", + "disableFailed": "Autentificarea în doi factori nu a putut fi dezactivată.", + "wrongPassword": "Această parolă nu este corectă." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "{count} link de partajare readus | {count} linkuri de partajare readuse", "dropped": "{count} link de partajare șters | {count} linkuri de partajare șterse" } + }, + "versions": { + "title": "Versiuni", + "menu": "Versiuni", + "aria": "Versiunile fișierului", + "current": "Versiunea actuală", + "empty": "Nicio versiune anterioară deocamdată. Fiecare salvare păstrează ce înlocuiește.", + "disabled": "Versiunile fișierelor sunt dezactivate: salvările nu mai păstrează altele noi. Cele de mai jos rămân până expiră.", + "loadFailed": "Versiunile nu au putut fi încărcate.", + "notShared": "Istoricul acestui fișier nu este partajat cu dvs.", + "unavailable": "Conținutul ei lipsește de pe disc.", + "unknownAuthor": "Autor necunoscut", + "shareLink": "Cineva cu linkul", + "pinned": "Fixată", + "aside": "Pusă deoparte", + "asideHelp": "Salvată de un editor deschis înainte de o restaurare: păstrată aici în loc să anuleze restaurarea.", + "total": "{count} versiune, {size} | {count} versiuni, {size}", + "source": { + "editor": "Editor de text", + "shareEditor": "Editor printr-o partajare", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Versiune restaurată", + "external": "Modificat în afara aplicației" + }, + "actions": { + "menu": "Acțiuni pentru versiune", + "select": "Selectați această versiune", + "selectAll": "Selectați tot", + "deleteSelected": "Ștergeți selecția ({count})", + "deleteAll": "Ștergeți tot", + "preview": "Deschideți doar în citire", + "download": "Descărcați", + "restore": "Restaurați", + "rename": "Denumiți…", + "pin": "Fixați", + "unpin": "Anulați fixarea", + "delete": "Ștergeți" + }, + "confirm": { + "restoreTitle": "Restaurați această versiune?", + "restoreMessage": "„{name}” revine la conținutul din {date}. Conținutul actual este păstrat ca versiune.", + "deleteTitle": "Ștergeți această versiune? | Ștergeți {count} versiuni?", + "deleteMessage": "Această versiune este ștearsă definitiv. | Aceste {count} versiuni sunt șterse definitiv.", + "deleteAllTitle": "Ștergeți toate versiunile?", + "deleteAllMessage": "Toate versiunile anterioare ale „{name}” sunt șterse definitiv, inclusiv cele fixate. Fișierul în sine rămâne." + }, + "rename": { + "title": "Denumiți această versiune", + "placeholder": "De exemplu: trimisă clientului", + "help": "Un nume face o versiune ușor de găsit. Fixați-o pentru a o feri de curățarea automată." + }, + "results": { + "restored": "Versiune restaurată", + "unchanged": "Fișierul are deja acest conținut", + "deleted": "{count} versiune ștearsă | {count} versiuni șterse", + "renamed": "Versiune denumită", + "pinned": "Versiune fixată: curățarea automată o păstrează", + "unpinned": "Fixarea versiunii a fost anulată" + }, + "errors": { + "action": "Acțiunea asupra acestei versiuni a eșuat" + }, + "mark": "{count} versiune anterioară | {count} versiuni anterioare" + }, + "onlyoffice": { + "renamedHeading": "Redenumit", + "renamedBody": "Documentul se numește acum {name}.", + "renameFailed": "Nu s-a putut redenumi în {name}", + "andOthers": "și încă {count}", + "transferHeading": "Fișierul este în curs de editare", + "transferBody": "Acest fișier este deschis în OnlyOffice. Continuarea poate perturba o salvare în curs. Vrei să continui?", + "transferCancel": "Anulează", + "transferConfirm": "Continuă", + "editingBy": "Se editează în ONLYOFFICE de către {names}", + "editingNow": "Se editează în ONLYOFFICE", + "savedAsHeading": "Salvat în acest folder", + "savedAsBody": "{name} a fost adăugat lângă original.", + "saveAsFailed": "{name} nu a putut fi salvat" + }, + "preview": { + "nothingOpensIt": "Nimic de aici nu poate deschide {name}.", + "backToFolder": "Înapoi la dosar" + }, + "archive": { + "password": { + "title": "Parolă necesară", + "description": "Această arhivă este protejată. Introdu parola pentru a o extrage.", + "label": "Parola arhivei", + "invalid": "Parolă incorectă sau arhivă deteriorată.", + "submit": "Extrage", + "extracting": "Se extrage..." + }, + "breadcrumb": "În arhivă", + "empty": "Acest folder este gol.", + "download": "Descarcă", + "downloadNamed": "Descarcă {name}", + "outside": "O intrare nu este afișată: numele ei indică în afara arhivei. | {count} intrări nu sunt afișate: numele lor indică în afara arhivei.", + "unreadable": "Această arhivă nu a putut fi citită.", + "readFailed": "Acest fișier nu a putut fi citit.", + "notReadable": "Acest tip de fișier nu poate fi afișat aici.", + "tooBigToRead": "Prea mare pentru a fi afișat aici: {size}, limita este {ceiling}. Descărcați-l sau extrageți-l pentru a-l deschide.", + "extract": "Extrage aici", + "extractNamed": "Extrage {name} aici", + "selectAll": "Selectați tot de aici", + "selectNamed": "Selectați {name}", + "selected": "Un element selectat | {count} elemente selectate", + "extracted": "Extras: {name}", + "extractedNothing": "Nu a ieșit nimic.", + "extractFailed": "Acest lucru nu a putut fi extras.", + "count": "O intrare în această arhivă | {count} intrări în această arhivă" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 2c7df246c..fd759bc99 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "У вас есть несохраненные изменения. Закрыть без сохранения?" + "confirmCloseWithoutSaving": "У вас есть несохраненные изменения. Закрыть без сохранения?", + "trashReadOnly": "В корзине · только чтение", + "versionReadOnly": "Предыдущая версия, только чтение" }, "status": { "updated": "Успешно обновлено", @@ -113,7 +115,8 @@ "label": "Мои общие файлы", "path": "например, Pictures/Holidays", "search": "Поиск файлов и папок…", - "volumeLabel": "например, Документы, Проекты, Медиа" + "volumeLabel": "например, Документы, Проекты, Медиа", + "totpCode": "123456" }, "loading": { "default": "Загрузка…", @@ -153,7 +156,8 @@ "labelRequired": "Требуется метка", "pathRequired": "Требуется путь", "saveVolume": "Не удалось сохранить том", - "removeVolume": "Не удалось удалить том" + "removeVolume": "Не удалось удалить том", + "totpCodeRequired": "Введите код." }, "serverErrors": { "AUTH_REQUIRED": "Требуется аутентификация", @@ -319,7 +323,8 @@ "dateTaken": "Дата съемки: {date}", "camera": "Камера: {makeModel}", "lens": "Объектив: {lens}", - "duration": "Длительность: {seconds}с" + "duration": "Длительность: {seconds}с", + "versions": "Версии" }, "auth": { "preparing": "Подготовка проводника…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "Добавьте {suffix} к вашему имени пользователя", "bulletPasswordSame": "Ваш пароль остается прежним.", "bulletUpdateUsers": "Вы можете обновить существующих пользователей в меню админа" - } + }, + "totpCode": "Код", + "totpExplain": "Пароль принят. Введите код из приложения-аутентификатора или один из резервных кодов.", + "totpSubmit": "Войти" }, "setup": { "headline": "Давайте настроим систему", @@ -392,7 +400,10 @@ "security": "Безопасность", "accessControl": "Контроль доступа", "adminUsers": "Управление пользователями", - "trash": "Корзина и версии" + "trash": "Корзина и версии", + "fileVersions": "Версии файлов", + "uploads": "Загрузки", + "accountTwoFactor": "Два фактора" }, "about": { "subtitle": "Информация о сборке приложения.", @@ -442,7 +453,11 @@ "months": "Месяцев", "skipHome": "Пропустить главную", "skipHomeHelp": "Автоматически перенаправляет к первому тому при заходе на главную. Если не указано, используется настройка сервера.", - "useEnvSetting": "Использовать серверную настройку" + "useEnvSetting": "Использовать серверную настройку", + "showVersionMarks": "Отмечать файлы, у которых есть версии", + "showVersionMarksHelp": "Небольшая отметка в списке у файлов с предыдущими версиями и их числом. Нажмите на неё, чтобы открыть историю.", + "documentsOpenInNewTab": "Открывать документы в новой вкладке", + "documentsOpenInNewTabHelp": "Открытый файл получает собственную вкладку браузера, так что несколько остаются открытыми, пока вы продолжаете просмотр. Выключено — открывается поверх папки, как сейчас." }, "thumbs": { "subtitle": "Настройте миниатюры предпросмотра для изображений и видео.", @@ -615,6 +630,92 @@ "sharedSpace": "Версии и корзина делят зарезервированное место каждого тома. Когда его не хватает, сначала удаляются старые версии, затем элементы корзины, затем последняя версия каждого файла и в последнюю очередь закреплённые версии.", "environmentNote": "Значения по умолчанию берутся из VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE и VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Версии файлов", + "intro": "Все файлы с предыдущими версиями, где бы они ни находились, и сколько места те занимают. Здесь видны пути из всех пространств, включая личные папки, — поэтому страница доступна только администраторам.", + "search": "Путь содержит", + "searchPlaceholder": "Часть имени или папки", + "zone": "Пространство", + "anyZone": "Все пространства", + "state": "Состояние", + "anyState": "Все", + "sort": "Сортировать по", + "sortBytes": "Занятое место", + "sortCount": "Число версий", + "sortNewest": "Самая новая версия", + "sortPath": "Путь", + "summary": "Файлов: {files}, версий: {versions}, {size}", + "file": "Файл", + "count": "Версии", + "size": "Размер", + "newest": "Самая новая", + "states": { + "live": "На месте", + "trashed": "В корзине", + "orphaned": "Исчез" + }, + "zoneKinds": { + "volume": "Том {name}", + "personal": "Личная папка {name}", + "user-volume": "Назначенный том {name}" + }, + "zoneUnknown": "Неизвестное пространство", + "pinned": "Закреплена", + "unavailable": "Том недоступен", + "deleteAll": "Удалить историю", + "deleteSelected": "Удалить {count} версию | Удалить {count} версии | Удалить {count} версий", + "deleteForGood": "Удалить навсегда", + "confirmAllTitle": "Удалить все версии файла {name}?", + "confirmSomeTitle": "Удалить {count} версию? | Удалить {count} версии? | Удалить {count} версий?", + "confirmMessage": "Это содержимое удаляется с диска. Сам файл не затрагивается, и отменить это нельзя.", + "none": "Ни у одного файла нет предыдущих версий.", + "loadFailed": "Не удалось прочитать список.", + "detailFailed": "Не удалось прочитать эту историю.", + "deleteFailed": "Не удалось удалить версии.", + "previous": "Назад", + "next": "Вперёд", + "range": "С {from} по {to} из {total}", + "deleteSelectedNone": "Удалить отмеченные версии" + }, + "uploads": { + "title": "Загрузки", + "chunkSize": "Размер части", + "chunkSizeHelp": "Максимальный размер каждого запроса загрузки. Держите его ниже лимита вашего обратного прокси. Меньшие части (8–32 MiB) дают более плавный прогресс; очень большие части показывают заметные скачки (сервер записывает каждую часть перед следующей).", + "subtitle": "Настройте способ отправки файлов на сервер.", + "chunkedEnable": "Включить загрузку по частям", + "chunkedEnableHelp": "Использует TUS для отправки больших файлов несколькими меньшими запросами.", + "chunkSizeInvalid": "Укажите размер части от 1 до {max} MiB." + }, + "twoFactor": { + "title": "Двухфакторная аутентификация", + "intro": "Код с телефона в дополнение к паролю.", + "notLocalUser": "Эта учётная запись входит через поставщика удостоверений — второй фактор настраивается там.", + "off": "Двухфакторная аутентификация для этой учётной записи выключена.", + "on": "Двухфакторная аутентификация включена.", + "turnOn": "Включить", + "turnOff": "Выключить", + "scan": "Отсканируйте это приложением-аутентификатором.", + "orType": "Или введите этот секрет в приложении:", + "qrLabel": "QR-код для приложения-аутентификатора", + "codeLabel": "Код из приложения", + "confirm": "Подтвердить", + "confirmPassword": "Ваш пароль", + "codesTitle": "Резервные коды", + "codesExplain": "Храните их не на телефоне. Каждый код даёт один вход — на тот день, когда телефона нет рядом. Они показываются сейчас и больше никогда.", + "copyCodes": "Копировать", + "copied": "Скопировано.", + "codesLeft": "Остался один резервный код | Осталось резервных кодов: {count}", + "newCodesButton": "Создать новые коды", + "newCodes": "Созданы новые резервные коды. Прежние больше не работают.", + "turnedOn": "Двухфакторная аутентификация включена.", + "turnedOff": "Двухфакторная аутентификация выключена.", + "loadFailed": "Не удалось прочитать настройки двухфакторной аутентификации.", + "startFailed": "Не удалось начать настройку второго фактора.", + "confirmFailed": "Этот код неверен.", + "codesFailed": "Не удалось создать новые резервные коды.", + "disableFailed": "Не удалось выключить двухфакторную аутентификацию.", + "wrongPassword": "Этот пароль неверен." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "Возвращено ссылок общего доступа: {count}", "dropped": "Удалено ссылок общего доступа: {count}" } + }, + "versions": { + "title": "Версии", + "menu": "Версии", + "aria": "Версии файла", + "current": "Текущая версия", + "empty": "Предыдущих версий пока нет. Каждое сохранение оставляет то, что заменяет.", + "disabled": "Версии файлов отключены: сохранения больше не оставляют новых. Версии ниже хранятся до истечения срока.", + "loadFailed": "Не удалось загрузить версии.", + "notShared": "История этого файла вам не открыта.", + "unavailable": "Её содержимое отсутствует на диске.", + "unknownAuthor": "Неизвестный автор", + "shareLink": "Кто-то по ссылке", + "pinned": "Закреплена", + "aside": "Отложена", + "asideHelp": "Сохранена редактором, открытым до восстановления: оставлена здесь, чтобы не отменять восстановление.", + "total": "{count} версия, {size} | {count} версии, {size} | {count} версий, {size}", + "source": { + "editor": "Текстовый редактор", + "shareEditor": "Редактор через общий доступ", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Восстановленная версия", + "external": "Изменено вне приложения" + }, + "actions": { + "menu": "Действия с версией", + "select": "Выбрать эту версию", + "selectAll": "Выбрать все", + "deleteSelected": "Удалить выбранные ({count})", + "deleteAll": "Удалить все", + "preview": "Открыть только для чтения", + "download": "Скачать", + "restore": "Восстановить", + "rename": "Назвать…", + "pin": "Закрепить", + "unpin": "Открепить", + "delete": "Удалить" + }, + "confirm": { + "restoreTitle": "Восстановить эту версию?", + "restoreMessage": "«{name}» вернётся к содержимому от {date}. Текущее содержимое сохранится как версия.", + "deleteTitle": "Удалить эту версию? | Удалить {count} версии? | Удалить {count} версий?", + "deleteMessage": "Эта версия будет удалена безвозвратно. | Эти {count} версии будут удалены безвозвратно. | Эти {count} версий будут удалены безвозвратно.", + "deleteAllTitle": "Удалить все версии?", + "deleteAllMessage": "Все предыдущие версии «{name}» будут удалены безвозвратно, включая закреплённые. Сам файл останется." + }, + "rename": { + "title": "Назвать эту версию", + "placeholder": "Например: отправлена клиенту", + "help": "Название помогает найти версию. Закрепите её, чтобы автоматическая очистка её не трогала." + }, + "results": { + "restored": "Версия восстановлена", + "unchanged": "У файла уже это содержимое", + "deleted": "Удалена {count} версия | Удалено {count} версии | Удалено {count} версий", + "renamed": "Версия названа", + "pinned": "Версия закреплена: автоматическая очистка её сохранит", + "unpinned": "Версия откреплена" + }, + "errors": { + "action": "Не удалось выполнить действие с версией" + }, + "mark": "{count} предыдущая версия | {count} предыдущие версии | {count} предыдущих версий" + }, + "onlyoffice": { + "renamedHeading": "Переименовано", + "renamedBody": "Документ теперь называется {name}.", + "renameFailed": "Не удалось переименовать в {name}", + "andOthers": "и еще {count}", + "transferHeading": "Файл редактируется", + "transferBody": "Этот файл открыт в OnlyOffice. Продолжение может нарушить текущее сохранение. Хотите продолжить?", + "transferCancel": "Отмена", + "transferConfirm": "Продолжить", + "editingBy": "Редактируется в ONLYOFFICE: {names}", + "editingNow": "Редактируется в ONLYOFFICE", + "savedAsHeading": "Сохранено в эту папку", + "savedAsBody": "{name} добавлен рядом с оригиналом.", + "saveAsFailed": "Не удалось сохранить {name}" + }, + "preview": { + "nothingOpensIt": "Здесь нечем открыть {name}.", + "backToFolder": "Назад к папке" + }, + "archive": { + "password": { + "title": "Требуется пароль", + "description": "Этот архив защищен. Введите пароль, чтобы его извлечь.", + "label": "Пароль архива", + "invalid": "Неверный пароль или архив поврежден.", + "submit": "Извлечь", + "extracting": "Извлечение..." + }, + "breadcrumb": "Внутри архива", + "empty": "Эта папка пуста.", + "download": "Скачать", + "downloadNamed": "Скачать {name}", + "outside": "Одна запись не показана: её имя указывает за пределы архива. | {count} записи не показаны: их имена указывают за пределы архива. | {count} записей не показано: их имена указывают за пределы архива.", + "unreadable": "Не удалось прочитать этот архив.", + "readFailed": "Не удалось прочитать этот файл.", + "notReadable": "Файл такого типа нельзя показать здесь.", + "tooBigToRead": "Слишком большой для просмотра здесь: {size}, предел — {ceiling}. Скачайте или извлеките файл, чтобы открыть его.", + "extract": "Извлечь сюда", + "extractNamed": "Извлечь {name} сюда", + "selectAll": "Выбрать всё здесь", + "selectNamed": "Выбрать {name}", + "selected": "Выбран один элемент | Выбрано элементов: {count}", + "extracted": "Извлечено: {name}", + "extractedNothing": "Ничего не извлечено.", + "extractFailed": "Не удалось это извлечь.", + "count": "Одна запись в этом архиве | {count} записи в этом архиве | {count} записей в этом архиве" } } diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 1ebdc0011..f7f255c28 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "Du har osparade ändringar. Stäng utan att spara?" + "confirmCloseWithoutSaving": "Du har osparade ändringar. Stäng utan att spara?", + "trashReadOnly": "I papperskorgen · skrivskyddad", + "versionReadOnly": "Tidigare version, skrivskyddad" }, "status": { "updated": "Uppdaterades", @@ -113,7 +115,8 @@ "label": "Mina utdelade filer", "path": "t.ex. Bilder/Semestrar", "search": "Sök efter filer och mappar…", - "volumeLabel": "e.g. Documents, Projects, Media" + "volumeLabel": "e.g. Documents, Projects, Media", + "totpCode": "123456" }, "loading": { "default": "Läser in…", @@ -153,7 +156,8 @@ "labelRequired": "Label is required", "pathRequired": "Path is required", "saveVolume": "Failed to save volume", - "removeVolume": "Failed to remove volume" + "removeVolume": "Failed to remove volume", + "totpCodeRequired": "Ange koden." }, "serverErrors": { "AUTH_REQUIRED": "Autentisering krävs", @@ -319,7 +323,8 @@ "dateTaken": "Datum: {date}", "camera": "Kamera: {makeModel}", "lens": "Lins: {lens}", - "duration": "Speltid: {seconds}s" + "duration": "Speltid: {seconds}s", + "versions": "Versioner" }, "auth": { "preparing": "Förbereder din utforskare…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "Lägg till {suffix} till ditt användarnamn", "bulletPasswordSame": "Ditt lösenord är det samma.", "bulletUpdateUsers": "Du kan uppdatera befintliga användare från Adminmenyn" - } + }, + "totpCode": "Kod", + "totpExplain": "Ditt lösenord godtogs. Ange koden från din autentiseringsapp, eller en av dina återställningskoder.", + "totpSubmit": "Logga in" }, "setup": { "headline": "Låt oss komma igång", @@ -392,7 +400,10 @@ "security": "Säkerhet", "accessControl": "Åtkomstkontroll", "adminUsers": "Användarhantering", - "trash": "Papperskorg och versioner" + "trash": "Papperskorg och versioner", + "fileVersions": "Filversioner", + "uploads": "Uppladdningar", + "accountTwoFactor": "Tvåfaktor" }, "about": { "subtitle": "Visa bygginformation för denna applikation.", @@ -442,7 +453,11 @@ "months": "Månader", "skipHome": "Hoppa över startsidan", "skipHomeHelp": "Omdirigerar automatiskt till den första volymen när startsidan öppnas. Om inget anges används serverinställningen.", - "useEnvSetting": "Använd serverinställning" + "useEnvSetting": "Använd serverinställning", + "showVersionMarks": "Märk filer som har versioner", + "showVersionMarksHelp": "Ett litet märke i listan på filer med tidigare versioner, med antalet. Klicka på det för att öppna historiken.", + "documentsOpenInNewTab": "Öppna dokument i en ny flik", + "documentsOpenInNewTabHelp": "En fil som öppnas får en egen webbläsarflik, så att flera kan stå öppna medan du bläddrar vidare. Av öppnas den ovanpå mappen som i dag." }, "thumbs": { "subtitle": "Anpassa förhandsvisningsminiatyrer för bilder och videor.", @@ -615,6 +630,92 @@ "sharedSpace": "Versioner och papperskorgen delar varje volyms reserverade utrymme. När det inte räcker försvinner först äldre versioner, sedan objekt i papperskorgen, sedan varje fils senaste version och sist fästa versioner.", "environmentNote": "Standardvärden kommer från VERSIONS_ENABLED, VERSIONS_KEEP_ALL_HOURS, VERSIONS_HOURLY_DAYS, VERSIONS_DAILY_DAYS, VERSIONS_MAX_PER_FILE och VERSIONS_SESSION_CHECKPOINT_MINUTES." } + }, + "fileVersions": { + "title": "Filversioner", + "intro": "Alla filer som har tidigare versioner, var de än ligger, och hur mycket plats de tar. Här syns sökvägar från alla utrymmen, personliga mappar inräknade — därför är sidan bara för administratörer.", + "search": "Sökvägen innehåller", + "searchPlaceholder": "En del av ett namn eller en mapp", + "zone": "Utrymme", + "anyZone": "Alla utrymmen", + "state": "Status", + "anyState": "Alla", + "sort": "Sortera efter", + "sortBytes": "Upptaget utrymme", + "sortCount": "Antal versioner", + "sortNewest": "Senaste versionen", + "sortPath": "Sökväg", + "summary": "{files} filer, {versions} versioner, {size}", + "file": "Fil", + "count": "Versioner", + "size": "Storlek", + "newest": "Senaste", + "states": { + "live": "Finns kvar", + "trashed": "I papperskorgen", + "orphaned": "Försvunnen" + }, + "zoneKinds": { + "volume": "Volymen {name}", + "personal": "Personlig mapp {name}", + "user-volume": "Tilldelad volym {name}" + }, + "zoneUnknown": "Okänt utrymme", + "pinned": "Fäst", + "unavailable": "Volymen är inte tillgänglig", + "deleteAll": "Ta bort historiken", + "deleteSelected": "Ta bort {count} version | Ta bort {count} versioner", + "deleteForGood": "Ta bort för gott", + "confirmAllTitle": "Ta bort alla versioner av {name}?", + "confirmSomeTitle": "Ta bort {count} version? | Ta bort {count} versioner?", + "confirmMessage": "Det innehållet tas bort från disken. Själva filen rörs inte, och ingenting av detta går att ångra.", + "none": "Ingen fil har tidigare versioner.", + "loadFailed": "Listan kunde inte läsas.", + "detailFailed": "Den här historiken kunde inte läsas.", + "deleteFailed": "Versionerna kunde inte tas bort.", + "previous": "Föregående", + "next": "Nästa", + "range": "{from} till {to} av {total}", + "deleteSelectedNone": "Ta bort de markerade versionerna" + }, + "uploads": { + "title": "Uppladdningar", + "chunkSize": "Delstorlek", + "chunkSizeHelp": "Maximal storlek för varje uppladdningsförfrågan. Håll den under gränsen för din omvända proxy. Mindre delar (8–32 MiB) ger jämnare förlopp; mycket stora delar ger synliga hopp (servern skriver varje del innan nästa).", + "subtitle": "Konfigurera hur filer skickas till servern.", + "chunkedEnable": "Aktivera uppladdning i delar", + "chunkedEnableHelp": "Använd TUS för att skicka stora filer via flera mindre förfrågningar.", + "chunkSizeInvalid": "Ange en delstorlek från 1 till {max} MiB." + }, + "twoFactor": { + "title": "Tvåfaktorsautentisering", + "intro": "En kod från telefonen, utöver lösenordet.", + "notLocalUser": "Det här kontot loggar in via en identitetsleverantör, och där hör den andra faktorn hemma.", + "off": "Tvåfaktorsautentisering är av för det här kontot.", + "on": "Tvåfaktorsautentisering är på.", + "turnOn": "Slå på", + "turnOff": "Stäng av", + "scan": "Skanna det här med din autentiseringsapp.", + "orType": "Eller skriv in den här hemligheten i appen:", + "qrLabel": "QR-kod för din autentiseringsapp", + "codeLabel": "Kod från appen", + "confirm": "Bekräfta", + "confirmPassword": "Ditt lösenord", + "codesTitle": "Återställningskoder", + "codesExplain": "Spara dem någon annanstans än på telefonen. Varje kod loggar in dig en gång, för dagen då telefonen inte finns till hands. De visas nu och aldrig igen.", + "copyCodes": "Kopiera", + "copied": "Kopierat.", + "codesLeft": "En återställningskod kvar | {count} återställningskoder kvar", + "newCodesButton": "Skapa nya koder", + "newCodes": "Nya återställningskoder skapade. De tidigare fungerar inte längre.", + "turnedOn": "Tvåfaktorsautentisering är på.", + "turnedOff": "Tvåfaktorsautentisering är av.", + "loadFailed": "Kontots tvåfaktorsinställningar kunde inte läsas.", + "startFailed": "Det gick inte att börja ställa in en andra faktor.", + "confirmFailed": "Den koden stämmer inte.", + "codesFailed": "Det gick inte att skapa nya återställningskoder.", + "disableFailed": "Det gick inte att stänga av tvåfaktorsautentisering.", + "wrongPassword": "Det lösenordet stämmer inte." } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "{count} delningslänk återställd | {count} delningslänkar återställda", "dropped": "{count} delningslänk borttagen | {count} delningslänkar borttagna" } + }, + "versions": { + "title": "Versioner", + "menu": "Versioner", + "aria": "Filversioner", + "current": "Aktuell version", + "empty": "Inga tidigare versioner än. Varje sparning behåller det den ersätter.", + "disabled": "Filversioner är avstängda: sparningar behåller inga nya. De nedan finns kvar tills de löper ut.", + "loadFailed": "Versionerna kunde inte läsas in.", + "notShared": "Den här filens historik är inte delad med dig.", + "unavailable": "Dess innehåll saknas på disken.", + "unknownAuthor": "Okänd författare", + "shareLink": "Någon med länken", + "pinned": "Fäst", + "aside": "Undanlagd", + "asideHelp": "Sparad av en redigerare som öppnades före en återställning: behålls här i stället för att ångra återställningen.", + "total": "{count} version, {size} | {count} versioner, {size}", + "source": { + "editor": "Textredigerare", + "shareEditor": "Redigerare via en delning", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "Återställd version", + "external": "Ändrad utanför appen" + }, + "actions": { + "menu": "Åtgärder för versionen", + "select": "Markera den här versionen", + "selectAll": "Markera alla", + "deleteSelected": "Ta bort markerade ({count})", + "deleteAll": "Ta bort alla", + "preview": "Öppna skrivskyddad", + "download": "Ladda ner", + "restore": "Återställ", + "rename": "Namnge…", + "pin": "Fäst", + "unpin": "Lossa", + "delete": "Ta bort" + }, + "confirm": { + "restoreTitle": "Återställa den här versionen?", + "restoreMessage": "”{name}” får tillbaka sitt innehåll från {date}. Det nuvarande innehållet behålls som en version.", + "deleteTitle": "Ta bort den här versionen? | Ta bort {count} versioner?", + "deleteMessage": "Den här versionen tas bort permanent. | Dessa {count} versioner tas bort permanent.", + "deleteAllTitle": "Ta bort alla versioner?", + "deleteAllMessage": "Alla tidigare versioner av ”{name}” tas bort permanent, även fästa. Själva filen finns kvar." + }, + "rename": { + "title": "Namnge den här versionen", + "placeholder": "Till exempel: skickad till kunden", + "help": "Ett namn gör en version lätt att hitta. Fäst den för att undanta den från den automatiska rensningen." + }, + "results": { + "restored": "Versionen återställd", + "unchanged": "Filen har redan det här innehållet", + "deleted": "{count} version borttagen | {count} versioner borttagna", + "renamed": "Versionen namngiven", + "pinned": "Versionen fäst: den automatiska rensningen behåller den", + "unpinned": "Versionen lossad" + }, + "errors": { + "action": "Åtgärden på den här versionen misslyckades" + }, + "mark": "{count} tidigare version | {count} tidigare versioner" + }, + "onlyoffice": { + "renamedHeading": "Namnet ändrat", + "renamedBody": "Dokumentet heter nu {name}.", + "renameFailed": "Det gick inte att byta namn till {name}", + "andOthers": "och {count} till", + "transferHeading": "Filen redigeras", + "transferBody": "Den här filen är öppen i OnlyOffice. Om du fortsätter kan en pågående sparning störas. Vill du fortsätta?", + "transferCancel": "Avbryt", + "transferConfirm": "Fortsätt", + "editingBy": "Redigeras i ONLYOFFICE av {names}", + "editingNow": "Redigeras i ONLYOFFICE", + "savedAsHeading": "Sparad i den här mappen", + "savedAsBody": "{name} lades till bredvid originalet.", + "saveAsFailed": "Det gick inte att spara {name}" + }, + "preview": { + "nothingOpensIt": "Inget här kan öppna {name}.", + "backToFolder": "Tillbaka till mappen" + }, + "archive": { + "password": { + "title": "Lösenord krävs", + "description": "Detta arkiv är skyddat. Ange lösenordet för att extrahera det.", + "label": "Arkivlösenord", + "invalid": "Felaktigt lösenord, eller så är arkivet skadat.", + "submit": "Extrahera", + "extracting": "Extraherar..." + }, + "breadcrumb": "Inuti arkivet", + "empty": "Den här mappen är tom.", + "download": "Ladda ner", + "downloadNamed": "Ladda ner {name}", + "outside": "En post visas inte: dess namn pekar utanför arkivet. | {count} poster visas inte: deras namn pekar utanför arkivet.", + "unreadable": "Det gick inte att läsa det här arkivet.", + "readFailed": "Filen kunde inte läsas.", + "notReadable": "Den här filtypen kan inte visas här.", + "tooBigToRead": "För stor för att visas här: {size}, gränsen är {ceiling}. Ladda ner eller packa upp filen för att öppna den.", + "extract": "Extrahera hit", + "extractNamed": "Extrahera {name} hit", + "selectAll": "Markera allt här", + "selectNamed": "Markera {name}", + "selected": "Ett objekt markerat | {count} objekt markerade", + "extracted": "Extraherat: {name}", + "extractedNothing": "Inget kom ut.", + "extractFailed": "Det gick inte att extrahera detta.", + "count": "En post i det här arkivet | {count} poster i det här arkivet" } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 54329945c..fa0d607c6 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "您有未保存的更改。要在不保存的情况下关闭吗?" + "confirmCloseWithoutSaving": "您有未保存的更改。要在不保存的情况下关闭吗?", + "trashReadOnly": "回收站中 · 只读", + "versionReadOnly": "历史版本,只读" }, "status": { "updated": "更新成功", @@ -113,7 +115,8 @@ "label": "我的共享文件", "path": "例如:Pictures/Holidays", "search": "搜索文件和文件夹…", - "volumeLabel": "e.g. Documents, Projects, Media" + "volumeLabel": "e.g. Documents, Projects, Media", + "totpCode": "123456" }, "loading": { "default": "加载中…", @@ -153,7 +156,8 @@ "labelRequired": "Label is required", "pathRequired": "Path is required", "saveVolume": "Failed to save volume", - "removeVolume": "Failed to remove volume" + "removeVolume": "Failed to remove volume", + "totpCodeRequired": "请输入验证码。" }, "serverErrors": { "AUTH_REQUIRED": "需要身份验证", @@ -319,7 +323,8 @@ "dateTaken": "拍摄日期:{date}", "camera": "相机:{makeModel}", "lens": "镜头:{lens}", - "duration": "时长:{seconds}s" + "duration": "时长:{seconds}s", + "versions": "版本" }, "auth": { "preparing": "正在为您准备 Explorer…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "在用户名后添加 {suffix}", "bulletPasswordSame": "您的密码保持不变。", "bulletUpdateUsers": "您可以在管理菜单中更新现有用户" - } + }, + "totpCode": "验证码", + "totpExplain": "密码已通过。请输入验证器应用中的验证码,或一个恢复码。", + "totpSubmit": "登录" }, "setup": { "headline": "开始设置", @@ -392,7 +400,10 @@ "security": "安全", "accessControl": "访问控制", "adminUsers": "用户管理", - "trash": "回收站和版本" + "trash": "回收站和版本", + "fileVersions": "文件版本", + "uploads": "上传", + "accountTwoFactor": "两步验证" }, "about": { "subtitle": "查看此应用的构建信息。", @@ -442,7 +453,11 @@ "months": "月", "skipHome": "跳过首页", "skipHomeHelp": "访问首页时自动重定向到第一个卷。未设置时遵循服务器配置。", - "useEnvSetting": "使用服务器设置" + "useEnvSetting": "使用服务器设置", + "showVersionMarks": "标记有版本的文件", + "showVersionMarksHelp": "在列表中为有历史版本的文件加一个小标记,并显示数量。点击即可打开历史记录。", + "documentsOpenInNewTab": "在新标签页中打开文档", + "documentsOpenInNewTabHelp": "打开文件时给它一个独立的浏览器标签页,这样你继续浏览时可以同时开着好几个。关闭后,它像现在一样覆盖在文件夹上打开。" }, "thumbs": { "subtitle": "自定义图片和视频的预览缩略图。", @@ -615,6 +630,92 @@ "sharedSpace": "版本与回收站共用每个卷的预留空间。空间不足时,依次移除旧版本、回收站项目、每个文件的最新版本,最后才是已固定的版本。", "environmentNote": "默认值来自 VERSIONS_ENABLED、VERSIONS_KEEP_ALL_HOURS、VERSIONS_HOURLY_DAYS、VERSIONS_DAILY_DAYS、VERSIONS_MAX_PER_FILE 和 VERSIONS_SESSION_CHECKPOINT_MINUTES。" } + }, + "fileVersions": { + "title": "文件版本", + "intro": "所有存在历史版本的文件,不论位于何处,以及它们占用的空间。这里会列出所有空间的路径,包括个人文件夹——因此本页面仅面向管理员。", + "search": "路径包含", + "searchPlaceholder": "名称或文件夹的一部分", + "zone": "空间", + "anyZone": "全部空间", + "state": "状态", + "anyState": "全部", + "sort": "排序方式", + "sortBytes": "占用空间", + "sortCount": "版本数量", + "sortNewest": "最新版本", + "sortPath": "路径", + "summary": "{files} 个文件,{versions} 个版本,{size}", + "file": "文件", + "count": "版本", + "size": "大小", + "newest": "最新", + "states": { + "live": "仍在", + "trashed": "在回收站", + "orphaned": "已消失" + }, + "zoneKinds": { + "volume": "卷 {name}", + "personal": "个人文件夹 {name}", + "user-volume": "分配的卷 {name}" + }, + "zoneUnknown": "未知空间", + "pinned": "已固定", + "unavailable": "卷不可用", + "deleteAll": "删除历史记录", + "deleteSelected": "删除 {count} 个版本", + "deleteForGood": "永久删除", + "confirmAllTitle": "删除 {name} 的全部版本?", + "confirmSomeTitle": "删除 {count} 个版本?", + "confirmMessage": "该内容将从磁盘上移除。文件本身不受影响,此操作无法撤销。", + "none": "没有文件存在历史版本。", + "loadFailed": "无法读取列表。", + "detailFailed": "无法读取此历史记录。", + "deleteFailed": "无法删除这些版本。", + "previous": "上一页", + "next": "下一页", + "range": "第 {from}–{to} 项,共 {total} 项", + "deleteSelectedNone": "删除勾选的版本" + }, + "uploads": { + "title": "上传", + "chunkSize": "分块大小", + "chunkSizeHelp": "每个上传请求的最大大小。请将其保持在反向代理限制以下。较小的分块(8–32 MiB)进度更平滑;非常大的分块会出现明显的阶段(服务器会先写入每个分块,再处理下一个)。", + "subtitle": "配置文件发送到服务器的方式。", + "chunkedEnable": "启用分块上传", + "chunkedEnableHelp": "使用 TUS 通过多个较小的请求发送大文件。", + "chunkSizeInvalid": "请输入 1 至 {max} MiB 之间的分块大小。" + }, + "twoFactor": { + "title": "两步验证", + "intro": "在密码之外,再加一个手机上的验证码。", + "notLocalUser": "此账号通过身份提供方登录,第二重验证应在那里设置。", + "off": "此账号的两步验证已关闭。", + "on": "两步验证已开启。", + "turnOn": "开启", + "turnOff": "关闭", + "scan": "用验证器应用扫描此二维码。", + "orType": "或在应用中输入此密钥:", + "qrLabel": "用于验证器应用的二维码", + "codeLabel": "应用中的验证码", + "confirm": "确认", + "confirmPassword": "您的密码", + "codesTitle": "恢复码", + "codesExplain": "请保存在手机之外的地方。每个恢复码只能登录一次,用于手机不在身边的那天。它们只显示这一次。", + "copyCodes": "复制", + "copied": "已复制。", + "codesLeft": "还剩 1 个恢复码 | 还剩 {count} 个恢复码", + "newCodesButton": "生成新的恢复码", + "newCodes": "已生成新的恢复码,之前的不再有效。", + "turnedOn": "两步验证已开启。", + "turnedOff": "两步验证已关闭。", + "loadFailed": "无法读取此账号的两步验证设置。", + "startFailed": "无法开始设置第二重验证。", + "confirmFailed": "验证码不正确。", + "codesFailed": "无法生成新的恢复码。", + "disableFailed": "无法关闭两步验证。", + "wrongPassword": "密码不正确。" } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "已恢复 {count} 个共享链接", "dropped": "已删除 {count} 个共享链接" } + }, + "versions": { + "title": "版本", + "menu": "版本", + "aria": "文件版本", + "current": "当前版本", + "empty": "暂无历史版本。每次保存都会保留被替换的内容。", + "disabled": "文件版本已关闭:保存时不再保留新版本。下面的版本会保留到过期。", + "loadFailed": "无法加载版本。", + "notShared": "此文件的历史未与你共享。", + "unavailable": "磁盘上找不到其内容。", + "unknownAuthor": "未知作者", + "shareLink": "持有链接的人", + "pinned": "已固定", + "aside": "已搁置", + "asideHelp": "由恢复之前打开的编辑器保存:保留在这里,以免撤销恢复。", + "total": "{count} 个版本,{size}", + "source": { + "editor": "文本编辑器", + "shareEditor": "通过共享的编辑器", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "恢复的版本", + "external": "在应用外修改" + }, + "actions": { + "menu": "版本操作", + "select": "选择此版本", + "selectAll": "全选", + "deleteSelected": "删除所选({count})", + "deleteAll": "全部删除", + "preview": "以只读方式打开", + "download": "下载", + "restore": "恢复", + "rename": "命名…", + "pin": "固定", + "unpin": "取消固定", + "delete": "删除" + }, + "confirm": { + "restoreTitle": "恢复此版本?", + "restoreMessage": "“{name}”将恢复为 {date} 的内容。当前内容会保留为一个版本。", + "deleteTitle": "删除 {count} 个版本?", + "deleteMessage": "{count} 个版本将被永久删除。", + "deleteAllTitle": "删除所有版本?", + "deleteAllMessage": "“{name}”的所有历史版本都将被永久删除,包括已固定的版本。文件本身保留。" + }, + "rename": { + "title": "为此版本命名", + "placeholder": "例如:已发送给客户", + "help": "命名后更容易找到版本。固定它可使其不被自动清理。" + }, + "results": { + "restored": "版本已恢复", + "unchanged": "文件已是此内容", + "deleted": "已删除 {count} 个版本", + "renamed": "版本已命名", + "pinned": "版本已固定:自动清理会保留它", + "unpinned": "已取消固定版本" + }, + "errors": { + "action": "对此版本的操作失败" + }, + "mark": "{count} 个历史版本" + }, + "onlyoffice": { + "renamedHeading": "已重命名", + "renamedBody": "文档现在名为 {name}。", + "renameFailed": "无法重命名为 {name}", + "andOthers": "以及其他 {count} 项", + "transferHeading": "文件正在编辑中", + "transferBody": "此文件已在 OnlyOffice 中打开。继续可能会干扰正在进行的保存。是否继续?", + "transferCancel": "取消", + "transferConfirm": "继续", + "editingBy": "正在 ONLYOFFICE 中编辑:{names}", + "editingNow": "正在 ONLYOFFICE 中编辑", + "savedAsHeading": "已保存到此文件夹", + "savedAsBody": "{name} 已添加到原文件旁边。", + "saveAsFailed": "无法保存 {name}" + }, + "preview": { + "nothingOpensIt": "这里没有什么能打开 {name}。", + "backToFolder": "返回文件夹" + }, + "archive": { + "password": { + "title": "需要密码", + "description": "此压缩包受密码保护。请输入密码以解压。", + "label": "压缩包密码", + "invalid": "密码不正确,或压缩包已损坏。", + "submit": "解压", + "extracting": "正在解压..." + }, + "breadcrumb": "压缩包内", + "empty": "此文件夹为空。", + "download": "下载", + "downloadNamed": "下载 {name}", + "outside": "有 {count} 个条目未显示:它们的名称指向压缩包之外。", + "unreadable": "无法读取此压缩包。", + "readFailed": "无法读取该文件。", + "notReadable": "此类文件无法在此显示。", + "tooBigToRead": "太大,无法在此显示:{size},上限为 {ceiling}。请下载或解压后打开。", + "extract": "解压到此处", + "extractNamed": "将 {name} 解压到此处", + "selectAll": "全选当前层级", + "selectNamed": "选择 {name}", + "selected": "已选 1 项 | 已选 {count} 项", + "extracted": "已解压:{name}", + "extractedNothing": "没有解压出任何内容。", + "extractFailed": "无法解压此项。", + "count": "此压缩包中有 {count} 个条目" } } diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 49fc439e3..33e290fe0 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -77,7 +77,9 @@ "theme": "Theme", "editorSettings": "Editor settings", "wrapLines": "Wrap Lines", - "confirmCloseWithoutSaving": "您有未儲存的變更。要在不儲存的情況下關閉嗎?" + "confirmCloseWithoutSaving": "您有未儲存的變更。要在不儲存的情況下關閉嗎?", + "trashReadOnly": "資源回收筒中 · 唯讀", + "versionReadOnly": "先前版本,唯讀" }, "status": { "updated": "更新成功", @@ -113,7 +115,8 @@ "label": "我的分享檔案", "path": "例如:Pictures/Holidays", "search": "搜尋檔案和檔案夾…", - "volumeLabel": "例如:Documents, Projects, Media" + "volumeLabel": "例如:Documents, Projects, Media", + "totpCode": "123456" }, "loading": { "default": "載入中…", @@ -153,7 +156,8 @@ "labelRequired": "需要標籤", "pathRequired": "需要路徑", "saveVolume": "儲存儲存卷失敗", - "removeVolume": "移除儲存卷失敗" + "removeVolume": "移除儲存卷失敗", + "totpCodeRequired": "請輸入驗證碼。" }, "serverErrors": { "AUTH_REQUIRED": "需要身份驗證", @@ -319,7 +323,8 @@ "dateTaken": "拍攝日期:{date}", "camera": "相機:{makeModel}", "lens": "鏡頭:{lens}", - "duration": "長度:{seconds}s" + "duration": "長度:{seconds}s", + "versions": "版本" }, "auth": { "preparing": "正在為您準備 Explorer…", @@ -343,7 +348,10 @@ "bulletAddSuffix": "在使用者名稱後新增 {suffix}", "bulletPasswordSame": "您的密碼保持不變。", "bulletUpdateUsers": "您可以在管理選單中更新現有使用者" - } + }, + "totpCode": "驗證碼", + "totpExplain": "密碼已通過。請輸入驗證器應用程式中的驗證碼,或一個復原碼。", + "totpSubmit": "登入" }, "setup": { "headline": "開始設定", @@ -392,7 +400,10 @@ "security": "安全", "accessControl": "權限控制", "adminUsers": "使用者管理", - "trash": "資源回收筒和版本" + "trash": "資源回收筒和版本", + "fileVersions": "檔案版本", + "uploads": "上傳", + "accountTwoFactor": "兩步驗證" }, "about": { "subtitle": "查看此應用的構建資訊。", @@ -442,7 +453,11 @@ "months": "月", "skipHome": "跳過首頁", "skipHomeHelp": "進入首頁時自動導向到第一個儲存卷。未設定時會使用伺服器預設。", - "useEnvSetting": "使用伺服器預設" + "useEnvSetting": "使用伺服器預設", + "showVersionMarks": "標示有版本的檔案", + "showVersionMarksHelp": "在清單中為有舊版本的檔案加上一個小標記,並顯示數量。點一下即可開啟歷程記錄。", + "documentsOpenInNewTab": "在新分頁中開啟文件", + "documentsOpenInNewTabHelp": "開啟檔案時給它一個獨立的瀏覽器分頁,這樣你繼續瀏覽時可以同時開著好幾個。關閉後,它像現在一樣覆蓋在資料夾上開啟。" }, "thumbs": { "subtitle": "自訂圖片和影片的預覽縮圖。", @@ -615,6 +630,92 @@ "sharedSpace": "版本與資源回收筒共用每個磁碟區的保留空間。空間不足時,依序移除舊版本、資源回收筒項目、每個檔案的最新版本,最後才是已釘選的版本。", "environmentNote": "預設值來自 VERSIONS_ENABLED、VERSIONS_KEEP_ALL_HOURS、VERSIONS_HOURLY_DAYS、VERSIONS_DAILY_DAYS、VERSIONS_MAX_PER_FILE 和 VERSIONS_SESSION_CHECKPOINT_MINUTES。" } + }, + "fileVersions": { + "title": "檔案版本", + "intro": "所有存在舊版本的檔案,不論位於何處,以及它們佔用的空間。這裡會列出所有空間的路徑,包括個人資料夾——因此本頁面僅供管理員使用。", + "search": "路徑包含", + "searchPlaceholder": "名稱或資料夾的一部分", + "zone": "空間", + "anyZone": "全部空間", + "state": "狀態", + "anyState": "全部", + "sort": "排序方式", + "sortBytes": "佔用空間", + "sortCount": "版本數量", + "sortNewest": "最新版本", + "sortPath": "路徑", + "summary": "{files} 個檔案,{versions} 個版本,{size}", + "file": "檔案", + "count": "版本", + "size": "大小", + "newest": "最新", + "states": { + "live": "仍在", + "trashed": "在回收筒", + "orphaned": "已消失" + }, + "zoneKinds": { + "volume": "磁碟區 {name}", + "personal": "個人資料夾 {name}", + "user-volume": "指派的磁碟區 {name}" + }, + "zoneUnknown": "未知空間", + "pinned": "已釘選", + "unavailable": "磁碟區無法使用", + "deleteAll": "刪除歷程記錄", + "deleteSelected": "刪除 {count} 個版本", + "deleteForGood": "永久刪除", + "confirmAllTitle": "刪除 {name} 的全部版本?", + "confirmSomeTitle": "刪除 {count} 個版本?", + "confirmMessage": "該內容會從磁碟上移除。檔案本身不受影響,且此操作無法復原。", + "none": "沒有檔案存在舊版本。", + "loadFailed": "無法讀取清單。", + "detailFailed": "無法讀取此歷程記錄。", + "deleteFailed": "無法刪除這些版本。", + "previous": "上一頁", + "next": "下一頁", + "range": "第 {from}–{to} 項,共 {total} 項", + "deleteSelectedNone": "刪除勾選的版本" + }, + "uploads": { + "title": "上傳", + "chunkSize": "分塊大小", + "chunkSizeHelp": "每個上傳請求的最大大小。請保持在反向代理的限制以下。較小的分塊(8–32 MiB)能提供更流暢的進度;非常大的分塊會顯示明顯的階段變化(伺服器會在寫入下一個分塊之前先寫入每個分塊)。", + "subtitle": "設定檔案傳送到伺服器的方式。", + "chunkedEnable": "啓用分塊上傳", + "chunkedEnableHelp": "使用 TUS 透過多個較小的請求傳送大型檔案。", + "chunkSizeInvalid": "請輸入 1 至 {max} MiB 之間的分塊大小。" + }, + "twoFactor": { + "title": "兩步驗證", + "intro": "在密碼之外,再加一個手機上的驗證碼。", + "notLocalUser": "這個帳號透過身分提供者登入,第二重驗證應在那裡設定。", + "off": "這個帳號的兩步驗證已關閉。", + "on": "兩步驗證已開啟。", + "turnOn": "開啟", + "turnOff": "關閉", + "scan": "用驗證器應用程式掃描這個 QR code。", + "orType": "或在應用程式中輸入這組密鑰:", + "qrLabel": "給驗證器應用程式的 QR code", + "codeLabel": "應用程式中的驗證碼", + "confirm": "確認", + "confirmPassword": "您的密碼", + "codesTitle": "復原碼", + "codesExplain": "請存放在手機以外的地方。每個復原碼只能登入一次,用於手機不在身邊的那天。它們只會顯示這一次。", + "copyCodes": "複製", + "copied": "已複製。", + "codesLeft": "還剩 1 個復原碼 | 還剩 {count} 個復原碼", + "newCodesButton": "產生新的復原碼", + "newCodes": "已產生新的復原碼,先前的不再有效。", + "turnedOn": "兩步驗證已開啟。", + "turnedOff": "兩步驗證已關閉。", + "loadFailed": "無法讀取這個帳號的兩步驗證設定。", + "startFailed": "無法開始設定第二重驗證。", + "confirmFailed": "驗證碼不正確。", + "codesFailed": "無法產生新的復原碼。", + "disableFailed": "無法關閉兩步驗證。", + "wrongPassword": "密碼不正確。" } }, "mediaPreview": { @@ -801,5 +902,116 @@ "restored": "已恢復 {count} 個分享連結", "dropped": "已刪除 {count} 個分享連結" } + }, + "versions": { + "title": "版本", + "menu": "版本", + "aria": "檔案版本", + "current": "目前版本", + "empty": "尚無先前版本。每次儲存都會保留被取代的內容。", + "disabled": "檔案版本已關閉:儲存時不再保留新版本。下方的版本會保留到過期。", + "loadFailed": "無法載入版本。", + "notShared": "此檔案的歷程未與你共用。", + "unavailable": "磁碟上找不到其內容。", + "unknownAuthor": "未知作者", + "shareLink": "持有連結的人", + "pinned": "已釘選", + "aside": "已擱置", + "asideHelp": "由還原之前開啟的編輯器儲存:保留在這裡,以免復原還原。", + "total": "{count} 個版本,{size}", + "source": { + "editor": "文字編輯器", + "shareEditor": "透過共用的編輯器", + "onlyoffice": "ONLYOFFICE", + "collabora": "Collabora", + "restore": "還原的版本", + "external": "在應用程式外修改" + }, + "actions": { + "menu": "版本動作", + "select": "選取此版本", + "selectAll": "全選", + "deleteSelected": "刪除所選({count})", + "deleteAll": "全部刪除", + "preview": "以唯讀方式開啟", + "download": "下載", + "restore": "還原", + "rename": "命名…", + "pin": "釘選", + "unpin": "取消釘選", + "delete": "刪除" + }, + "confirm": { + "restoreTitle": "還原此版本?", + "restoreMessage": "「{name}」將回到 {date} 的內容。目前的內容會保留為一個版本。", + "deleteTitle": "刪除 {count} 個版本?", + "deleteMessage": "{count} 個版本將被永久刪除。", + "deleteAllTitle": "刪除所有版本?", + "deleteAllMessage": "「{name}」的所有先前版本都將被永久刪除,包括已釘選的版本。檔案本身保留。" + }, + "rename": { + "title": "為此版本命名", + "placeholder": "例如:已寄給客戶", + "help": "命名後更容易找到版本。釘選它可避免被自動清理。" + }, + "results": { + "restored": "版本已還原", + "unchanged": "檔案已是此內容", + "deleted": "已刪除 {count} 個版本", + "renamed": "版本已命名", + "pinned": "版本已釘選:自動清理會保留它", + "unpinned": "已取消釘選版本" + }, + "errors": { + "action": "對此版本的動作失敗" + }, + "mark": "{count} 個舊版本" + }, + "onlyoffice": { + "renamedHeading": "已重新命名", + "renamedBody": "文件現在名為 {name}。", + "renameFailed": "無法重新命名為 {name}", + "andOthers": "以及其他 {count} 項", + "transferHeading": "檔案正在編輯中", + "transferBody": "此檔案已在 OnlyOffice 中開啟。繼續可能會干擾正在進行的儲存。是否繼續?", + "transferCancel": "取消", + "transferConfirm": "繼續", + "editingBy": "正在 ONLYOFFICE 中編輯:{names}", + "editingNow": "正在 ONLYOFFICE 中編輯", + "savedAsHeading": "已儲存到此資料夾", + "savedAsBody": "{name} 已新增至原檔案旁。", + "saveAsFailed": "無法儲存 {name}" + }, + "preview": { + "nothingOpensIt": "這裡沒有什麼能開啟 {name}。", + "backToFolder": "返回資料夾" + }, + "archive": { + "password": { + "title": "需要密碼", + "description": "此壓縮檔受密碼保護。請輸入密碼以解壓縮。", + "label": "壓縮檔密碼", + "invalid": "密碼不正確,或壓縮檔已損毀。", + "submit": "解壓縮", + "extracting": "正在解壓縮..." + }, + "breadcrumb": "壓縮檔內", + "empty": "此資料夾是空的。", + "download": "下載", + "downloadNamed": "下載 {name}", + "outside": "有 {count} 個項目未顯示:它們的名稱指向壓縮檔之外。", + "unreadable": "無法讀取此壓縮檔。", + "readFailed": "無法讀取這個檔案。", + "notReadable": "這類檔案無法在此顯示。", + "tooBigToRead": "太大,無法在此顯示:{size},上限為 {ceiling}。請下載或解壓縮後開啟。", + "extract": "解壓縮到此處", + "extractNamed": "將 {name} 解壓縮到此處", + "selectAll": "全選目前層級", + "selectNamed": "選擇 {name}", + "selected": "已選 1 項 | 已選 {count} 項", + "extracted": "已解壓縮:{name}", + "extractedNothing": "沒有解壓縮出任何內容。", + "extractFailed": "無法解壓縮此項。", + "count": "此壓縮檔中有 {count} 個項目" } } diff --git a/frontend/src/layouts/AuthLayout.vue b/frontend/src/layouts/AuthLayout.vue index aa531bf51..a2a59d166 100644 --- a/frontend/src/layouts/AuthLayout.vue +++ b/frontend/src/layouts/AuthLayout.vue @@ -2,8 +2,11 @@ import HeaderLogo from '@/components/HeaderLogo.vue'; import LanguageSelector from '@/components/LanguageSelector.vue'; import { useAppSettings } from '@/stores/appSettings'; +import { usePageTitle } from '@/composables/usePageTitle'; const appSettings = useAppSettings(); +// Signing in, or setting the instance up: the tab says which instance. +usePageTitle(''); const props = defineProps({ version: { type: String, required: true }, diff --git a/frontend/src/layouts/BrowserLayout.vue b/frontend/src/layouts/BrowserLayout.vue index 636baecbf..77797329c 100644 --- a/frontend/src/layouts/BrowserLayout.vue +++ b/frontend/src/layouts/BrowserLayout.vue @@ -12,7 +12,7 @@ import UserMenu from '@/components/UserMenu.vue'; import NotificationToastContainer from '@/components/NotificationToastContainer.vue'; import NotificationPanel from '@/components/NotificationPanel.vue'; import { RouterView, useRoute, useRouter } from 'vue-router'; -import { useTitle, useStorage, useEventListener, useMediaQuery } from '@vueuse/core'; +import { useStorage, useEventListener, useMediaQuery } from '@vueuse/core'; import PreviewHost from '@/plugins/preview/PreviewHost.vue'; import ExplorerContextMenu from '@/components/ExplorerContextMenu.vue'; @@ -20,7 +20,13 @@ import TerminalPanel from '@/components/TerminalPanel.vue'; import { useAuthStore } from '@/stores/auth'; import { useAppSettings } from '@/stores/appSettings'; import { useFeaturesStore } from '@/stores/features'; +import { useFileStore } from '@/stores/fileStore'; +import { useI18n } from 'vue-i18n'; +import { pageTitleFor } from '@/utils/pageTitle'; +import { usePageTitle } from '@/composables/usePageTitle'; import InfoPanel from '@/components/InfoPanel.vue'; +import VersionsPanel from '@/components/VersionsPanel.vue'; +import OnlyOfficeTransferConfirm from '@/components/OnlyOfficeTransferConfirm.vue'; import { useFileUploader } from '@/composables/fileUploader'; import { useKeyboardShortcuts } from '@/composables/keyboardShortcuts'; import SpotlightSearch from '@/components/SpotlightSearch.vue'; @@ -34,6 +40,8 @@ import FolderViewToolbar from '@/components/FolderViewToolbar.vue'; const route = useRoute(); const router = useRouter(); +const fileStore = useFileStore(); +const { t: translate, te } = useI18n(); const auth = useAuthStore(); const appSettings = useAppSettings(); const featuresStore = useFeaturesStore(); @@ -104,12 +112,13 @@ useEventListener(window, 'keydown', (e) => { } }); -const currentPathName = computed(() => { - const p = route.params.path; - const s = Array.isArray(p) ? p.join('/') : p || ''; - return s.split('/').filter(Boolean).pop() || 'Volumes'; +// What a share being browsed is called: at its top the address holds only its +// token, which names nothing. +const shareName = computed(() => { + const info = fileStore.currentPathData?.shareInfo; + return info?.label || info?.sourceFolderName || ''; }); -useTitle(currentPathName); +usePageTitle(computed(() => pageTitleFor(route, translate, { te, shareName: shareName.value }))); const showBrowseToolbar = computed(() => String(route.path || '').startsWith('/browse')); const showSidebarFavorites = computed( @@ -225,6 +234,8 @@ const handleGuestLogin = () => { + + diff --git a/frontend/src/plugins/archive/ArchiveEntryReader.vue b/frontend/src/plugins/archive/ArchiveEntryReader.vue new file mode 100644 index 000000000..8e8123276 --- /dev/null +++ b/frontend/src/plugins/archive/ArchiveEntryReader.vue @@ -0,0 +1,192 @@ + + + diff --git a/frontend/src/plugins/archive/ArchivePreview.vue b/frontend/src/plugins/archive/ArchivePreview.vue new file mode 100644 index 000000000..f9a8904ad --- /dev/null +++ b/frontend/src/plugins/archive/ArchivePreview.vue @@ -0,0 +1,385 @@ + + + + + diff --git a/frontend/src/plugins/archive/archivePreview.js b/frontend/src/plugins/archive/archivePreview.js new file mode 100644 index 000000000..1a47ed88f --- /dev/null +++ b/frontend/src/plugins/archive/archivePreview.js @@ -0,0 +1,27 @@ +import { useFeaturesStore } from '@/stores/features'; + +/** + * Opening an archive shows what is in it, rather than nothing. + * + * Which files count as archives is the server's answer, not a list kept here: + * it depends on the formats its 7-Zip was built with, and it is the same list + * the extraction offer is drawn from. A build without the RAR codec offers + * neither, rather than offering one and failing at the other. + */ +export const archivePreviewPlugin = () => ({ + id: 'core-archive-preview', + label: 'Archive', + priority: 25, + // The window is this component's own: the preview overlay fills the screen, + // which is right for a photograph and wrong for a listing. + standalone: true, + + match: (context) => { + const extension = String(context.extension || '').toLowerCase(); + if (!extension) return false; + const supported = useFeaturesStore().archiveExtensions; + return Array.isArray(supported) && supported.includes(extension); + }, + + component: () => import('./ArchivePreview.vue'), +}); diff --git a/frontend/src/plugins/archive/readable.js b/frontend/src/plugins/archive/readable.js new file mode 100644 index 000000000..74c7a455a --- /dev/null +++ b/frontend/src/plugins/archive/readable.js @@ -0,0 +1,46 @@ +/** + * What the panel can show of an entry without taking it out of the archive. + * + * One answer for the whole panel: the list asks it to decide whether a name + * opens something, and the reader asks it to decide what to draw. Two lists + * drifting apart is a name that offers to open and then says it cannot. + */ + +/** + * Images a browser decodes on its own. + * + * Deliberately not the explorer's own list of previewable images, which is + * longer: a raw file from a camera or a HEIC is shown elsewhere because the + * server converts it first, and nothing converts anything here — the bytes go + * straight from the archive into an ``. Offering those would be a name + * that opens onto a broken image. + */ +const BROWSER_IMAGES = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico', 'avif']); + +const MARKDOWN = new Set(['md', 'markdown']); + +const extensionOf = (name = '') => { + const dot = String(name).lastIndexOf('.'); + return dot > 0 + ? String(name) + .slice(dot + 1) + .toLowerCase() + : ''; +}; + +/** + * `'image'`, `'markdown'`, `'text'`, or null when the panel cannot show it. + * + * `isEditable` is handed in rather than imported so this stays a plain + * function: the explorer's notion of what counts as text comes from the + * server, through a store, and a decision this small should not need one. + */ +const entryKind = (name, isEditable) => { + const extension = extensionOf(name); + if (!extension) return null; + if (MARKDOWN.has(extension)) return 'markdown'; + if (BROWSER_IMAGES.has(extension)) return 'image'; + return isEditable(extension) ? 'text' : null; +}; + +export { entryKind, extensionOf }; diff --git a/frontend/src/plugins/index.js b/frontend/src/plugins/index.js index bf7ce64cb..1bcf4a1b9 100644 --- a/frontend/src/plugins/index.js +++ b/frontend/src/plugins/index.js @@ -4,10 +4,24 @@ import { videoPreviewPlugin } from '@/plugins/video/videoPreview'; import { audioPreviewPlugin } from '@/plugins/audio/audioPreview'; import { markdownPreviewPlugin } from '@/plugins/markdown/markdownPreview'; import { pdfPreviewPlugin } from '@/plugins/pdf/pdfPreview'; +import { archivePreviewPlugin } from '@/plugins/archive/archivePreview'; import { onlyofficePreviewPlugin } from '@/plugins/onlyoffice/onlyofficePreview'; import { collaboraPreviewPlugin } from '@/plugins/collabora/collaboraPreview'; import { useFeaturesStore } from '@/stores/features'; +/** + * When every plugin that is going to register itself has done so. + * + * The editors register asynchronously, once the server has said whether they + * are configured — which is right for the folder listing, where nothing waits + * on them, and wrong for a page that opens one document and has to decide + * whether anything can open it. Asking too early there would answer "nothing + * here opens this" about a document ONLYOFFICE was a moment from claiming. + */ +let pluginsReady = Promise.resolve(); + +export const whenPreviewPluginsReady = () => pluginsReady; + /** * @param {import('pinia').Pinia} pinia - Pinia instance * @param {Object} options - Installation options @@ -31,13 +45,13 @@ export const installPreviewPlugins = (pinia, options = {}) => { }); } - // Load ONLYOFFICE asynchronously (doesn't block startup) - if (!skipOnlyOffice) { - loadOnlyOfficePlugin(manager); - } - - // Load Collabora asynchronously (doesn't block startup) - loadCollaboraPlugin(manager); + // Load the editors asynchronously (doesn't block startup). Both settle + // rather than reject — each one already swallows its own failure — so + // whatever happens, `whenPreviewPluginsReady` resolves. + pluginsReady = Promise.all([ + skipOnlyOffice ? Promise.resolve() : loadOnlyOfficePlugin(manager), + loadCollaboraPlugin(manager), + ]).then(() => undefined); }; /** @@ -51,6 +65,7 @@ function registerCorePlugins(manager) { audioPreviewPlugin(), pdfPreviewPlugin(), markdownPreviewPlugin(), + archivePreviewPlugin(), ]; plugins.forEach((plugin) => manager.register(plugin)); diff --git a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue index 126223f8f..09aa96d25 100644 --- a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue +++ b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue @@ -25,9 +25,27 @@ + + diff --git a/frontend/src/views/EditorView.vue b/frontend/src/views/EditorView.vue index dafd340c7..04265167b 100644 --- a/frontend/src/views/EditorView.vue +++ b/frontend/src/views/EditorView.vue @@ -5,10 +5,16 @@ >

- {{ t('editor.editing') }} + {{ + isTrashViewer + ? t('editor.trashReadOnly') + : isVersionViewer + ? t('editor.versionReadOnly') + : t('editor.editing') + }}

- {{ normalizedPath || '—' }} + {{ displayPath || '—' }}

@@ -19,6 +25,7 @@ {{ t('editor.unsavedChanges') }}

diff --git a/frontend/src/views/settings/SettingsView.vue b/frontend/src/views/settings/SettingsView.vue index 6bb5b3383..800ad860d 100644 --- a/frontend/src/views/settings/SettingsView.vue +++ b/frontend/src/views/settings/SettingsView.vue @@ -12,6 +12,9 @@ import { PhotoIcon, KeyIcon, TrashIcon, + ClockIcon, + ArrowUpTrayIcon, + ShieldCheckIcon, UsersIcon, UserCircleIcon, } from '@heroicons/vue/24/outline'; @@ -43,6 +46,13 @@ const isLocalUser = computed(() => auth.currentUser?.provider === 'local'); // User-facing settings const userCategories = [ + { + key: 'account-two-factor', + i18nKey: 'accountTwoFactor', + name: 'Two-factor authentication', + icon: ShieldCheckIcon, + requiresLocal: true, + }, { key: 'account-password', i18nKey: 'accountPassword', @@ -82,6 +92,18 @@ const adminCategories = [ name: 'Trash', icon: TrashIcon, }, + { + key: 'file-versions', + i18nKey: 'fileVersions', + name: 'File versions', + icon: ClockIcon, + }, + { + key: 'uploads', + i18nKey: 'uploads', + name: 'Uploads', + icon: ArrowUpTrayIcon, + }, { key: 'access-control', i18nKey: 'accessControl', diff --git a/package-lock.json b/package-lock.json index 39403417e..7addc22ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nextexplorer", - "version": "2.2.7", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nextexplorer", - "version": "2.2.7", + "version": "3.1.0", "workspaces": [ "backend", "frontend", @@ -20,10 +20,12 @@ }, "backend": { "name": "finder", - "version": "2.2.7", + "version": "3.1.0", "license": "ISC", "dependencies": { "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", + "@tus/file-store": "^2.1.0", + "@tus/server": "^2.4.1", "adm-zip": "^0.5.16", "archiver": "^6.0.2", "axios": "^1.7.7", @@ -834,7 +836,7 @@ }, "frontend": { "name": "explorer", - "version": "2.2.7", + "version": "3.1.0", "dependencies": { "@codemirror/lang-javascript": "^6.2.2", "@codemirror/language-data": "^6.3.2", @@ -849,6 +851,7 @@ "@uppy/core": "^5.2.0", "@uppy/drop-target": "^4.1.0", "@uppy/status-bar": "^5.1.0", + "@uppy/tus": "^5.1.1", "@uppy/xhr-upload": "^5.1.1", "@vueuse/components": "^10.9.0", "@vueuse/core": "^10.9.0", @@ -864,6 +867,7 @@ "marked": "^12.0.2", "nanoid": "^5.0.7", "pinia": "^2.1.7", + "qrcode-generator": "^2.0.4", "tippy.js": "^6.3.7", "vue": "^3.4.21", "vue-codemirror": "^6.1.1", @@ -1348,36 +1352,6 @@ "node": ">=12" } }, - "frontend/node_modules/@uppy/companion-client": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-5.1.1.tgz", - "integrity": "sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q==", - "license": "MIT", - "dependencies": { - "@uppy/utils": "^7.1.1", - "namespace-emitter": "^2.0.1", - "p-retry": "^6.1.0" - }, - "peerDependencies": { - "@uppy/core": "^5.1.1" - } - }, - "frontend/node_modules/@uppy/core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@uppy/core/-/core-5.2.0.tgz", - "integrity": "sha512-uvfNyz4cnaplt7LYJmEZHuqOuav0tKp4a9WKJIaH6iIj7XiqYvS2J5SEByexAlUFlzefOAyjzj4Ja2dd/8aMrw==", - "license": "MIT", - "dependencies": { - "@transloadit/prettier-bytes": "^0.3.4", - "@uppy/store-default": "^5.0.0", - "@uppy/utils": "^7.1.4", - "lodash": "^4.17.21", - "mime-match": "^1.0.2", - "namespace-emitter": "^2.0.1", - "nanoid": "^5.0.9", - "preact": "^10.5.13" - } - }, "frontend/node_modules/@uppy/drop-target": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@uppy/drop-target/-/drop-target-4.1.0.tgz", @@ -1405,22 +1379,6 @@ "@uppy/core": "^5.2.0" } }, - "frontend/node_modules/@uppy/store-default": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-5.0.0.tgz", - "integrity": "sha512-hQtCSQ1yGiaval/wVYUWquYGDJ+bpQ7e4FhUUAsRQz1x1K+o7NBtjfp63O9I4Ks1WRoKunpkarZ+as09l02cPw==", - "license": "MIT" - }, - "frontend/node_modules/@uppy/utils": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-7.1.5.tgz", - "integrity": "sha512-Vz4WGTjef6WebECGur4clWjpkET4o3bdvPMj1m2sD5cL+dTt69m+FIE5h5JD3HBMLEPTXPVkrXGMIFcbOYC12Q==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21", - "preact": "^10.5.13" - } - }, "frontend/node_modules/@uppy/xhr-upload": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-5.1.1.tgz", @@ -5148,6 +5106,13 @@ "url": "https://github.com/sponsors/kazupon" } }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT", + "optional": true + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5964,6 +5929,31 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "optional": true, + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -7885,6 +7875,51 @@ "integrity": "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA==", "license": "MIT" }, + "node_modules/@tus/file-store": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@tus/file-store/-/file-store-2.1.1.tgz", + "integrity": "sha512-/uXV3Ibb4Upirrsj7lSrX+o08H7IKYEkPHV8TKAPPK6CYUcOMTilluOkwPrb9JZydLSYlIBQkhZZVS9sPEfDwQ==", + "license": "MIT", + "dependencies": { + "@tus/utils": "^0.7.1", + "debug": "^4.3.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@redis/client": "^5.0.0" + } + }, + "node_modules/@tus/server": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@tus/server/-/server-2.4.5.tgz", + "integrity": "sha512-DaQY5F1/JC1E+eDNY+Q5L08wgQ8NHFDNW/E6NclSx10BOHJk85BT4mSId49Ep890Utsk+6hEEuhNFb/LH+Og0w==", + "license": "MIT", + "dependencies": { + "@tus/utils": "^0.7.1", + "debug": "^4.3.4", + "lodash.throttle": "^4.1.1", + "set-cookie-parser": "^2.7.1", + "srvx": "~0.11.15" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@redis/client": "^5.0.0", + "ioredis": "^5.4.1" + } + }, + "node_modules/@tus/utils": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tus/utils/-/utils-0.7.1.tgz", + "integrity": "sha512-KXKK6mhsRto7cApRtJFoIovd6rAXJBUd1qR6S1ynVXApdd+jT1eBHprKP0D9oZtJwwD/B8+n3DLAm68DYPqfuQ==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -8090,6 +8125,66 @@ "dev": true, "license": "ISC" }, + "node_modules/@uppy/companion-client": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-5.1.1.tgz", + "integrity": "sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q==", + "license": "MIT", + "dependencies": { + "@uppy/utils": "^7.1.1", + "namespace-emitter": "^2.0.1", + "p-retry": "^6.1.0" + }, + "peerDependencies": { + "@uppy/core": "^5.1.1" + } + }, + "node_modules/@uppy/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@uppy/core/-/core-5.2.0.tgz", + "integrity": "sha512-uvfNyz4cnaplt7LYJmEZHuqOuav0tKp4a9WKJIaH6iIj7XiqYvS2J5SEByexAlUFlzefOAyjzj4Ja2dd/8aMrw==", + "license": "MIT", + "dependencies": { + "@transloadit/prettier-bytes": "^0.3.4", + "@uppy/store-default": "^5.0.0", + "@uppy/utils": "^7.1.4", + "lodash": "^4.17.21", + "mime-match": "^1.0.2", + "namespace-emitter": "^2.0.1", + "nanoid": "^5.0.9", + "preact": "^10.5.13" + } + }, + "node_modules/@uppy/store-default": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-5.0.0.tgz", + "integrity": "sha512-hQtCSQ1yGiaval/wVYUWquYGDJ+bpQ7e4FhUUAsRQz1x1K+o7NBtjfp63O9I4Ks1WRoKunpkarZ+as09l02cPw==", + "license": "MIT" + }, + "node_modules/@uppy/tus": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@uppy/tus/-/tus-5.1.1.tgz", + "integrity": "sha512-316kLQfO5H/uUJIMhBYhBrTpeN0Q+d6ykW3pomCvdTkFGCvg20rF3oH/owE3lf2UZZN7ZqBk+wHO0WlQePoklg==", + "license": "MIT", + "dependencies": { + "@uppy/companion-client": "^5.1.1", + "@uppy/utils": "^7.1.5", + "tus-js-client": "^4.2.3" + }, + "peerDependencies": { + "@uppy/core": "^5.2.0" + } + }, + "node_modules/@uppy/utils": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-7.2.0.tgz", + "integrity": "sha512-6lC246qszMv6bTyl/+QyHwrudgeguWkA94ME1wHn+a6uRAvmtAEaUManIfGqTJfoKvWAiCJqdJPl5xRJjhAloQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.23", + "preact": "^10.26.10" + } + }, "node_modules/@vicons/antd": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@vicons/antd/-/antd-0.12.0.tgz", @@ -9954,6 +10049,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -10005,6 +10110,15 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "license": "MIT" }, + "node_modules/combine-errors": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz", + "integrity": "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==", + "dependencies": { + "custom-error-instance": "2.1.1", + "lodash.uniqby": "4.5.0" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -10347,6 +10461,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/custom-error-instance": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz", + "integrity": "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==", + "license": "ISC" + }, "node_modules/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -10600,6 +10720,16 @@ "license": "MIT", "optional": true }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -12921,6 +13051,39 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -13625,6 +13788,12 @@ "node": ">=10" } }, + "node_modules/js-base64": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz", + "integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==", + "license": "BSD-3-Clause" + }, "node_modules/js-beautify": { "version": "1.15.4", "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", @@ -14293,6 +14462,52 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/lodash._baseiteratee": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz", + "integrity": "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==", + "license": "MIT", + "dependencies": { + "lodash._stringtopath": "~4.8.0" + } + }, + "node_modules/lodash._basetostring": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz", + "integrity": "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==", + "license": "MIT" + }, + "node_modules/lodash._baseuniq": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz", + "integrity": "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==", + "license": "MIT", + "dependencies": { + "lodash._createset": "~4.0.0", + "lodash._root": "~3.0.0" + } + }, + "node_modules/lodash._createset": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz", + "integrity": "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==", + "license": "MIT" + }, + "node_modules/lodash._root": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz", + "integrity": "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==", + "license": "MIT" + }, + "node_modules/lodash._stringtopath": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz", + "integrity": "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==", + "license": "MIT", + "dependencies": { + "lodash._basetostring": "~4.12.0" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -14342,6 +14557,22 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/lodash.uniqby": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz", + "integrity": "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==", + "license": "MIT", + "dependencies": { + "lodash._baseiteratee": "~4.7.0", + "lodash._baseuniq": "~4.6.0" + } + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -16120,6 +16351,32 @@ "node": ">= 4" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -16332,6 +16589,12 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -16351,7 +16614,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, "license": "MIT" }, "node_modules/queue-microtask": { @@ -16582,6 +16844,29 @@ "node": ">=8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "optional": true, + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -16655,7 +16940,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, "license": "MIT" }, "node_modules/resolve": { @@ -17103,6 +17387,12 @@ "license": "ISC", "optional": true }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -17533,6 +17823,18 @@ } } }, + "node_modules/srvx": { + "version": "0.11.22", + "resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.22.tgz", + "integrity": "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==", + "license": "MIT", + "bin": { + "srvx": "bin/srvx.mjs" + }, + "engines": { + "node": ">=20.16.0" + } + }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -17553,6 +17855,13 @@ "dev": true, "license": "MIT" }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT", + "optional": true + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -18198,6 +18507,36 @@ "node": "*" } }, + "node_modules/tus-js-client": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz", + "integrity": "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.1.2", + "combine-errors": "^3.0.3", + "is-stream": "^2.0.0", + "js-base64": "^3.7.2", + "lodash.throttle": "^4.1.1", + "proper-lockfile": "^4.1.2", + "url-parse": "^1.5.7" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tus-js-client/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -18557,7 +18896,6 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, "license": "MIT", "dependencies": { "querystringify": "^2.1.1",