From 7792c4a32c81c89bb34412c04b0c63de2efd1e3e Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 10:03:53 +0200 Subject: [PATCH 1/8] Show a file's history, and read text wherever it lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine that keeps what a save replaces, and the API that reads a history back, arrived with the batch before this one. Nothing showed them: no mark in a listing, no panel, no way for an administrator to see what histories cost, and no way to read an earlier version at all. This is that half. A file that has earlier versions carries a small mark in the listing, with how many; clicking it opens the panel, which lists what was kept, who saved it and where it came from, and offers reading a version, downloading it, putting it back, naming it, pinning it and deleting it. Putting one back keeps what it replaced, so nothing is lost by going back. The mark is counted once per folder rather than once per row, and a preference turns it off — which takes the query away as well as the icon. An administrator gets the list of every file that has a history, wherever it is, with what each one takes up, and can empty one. Those routes are 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, and no file to authorise against. They sit behind the administrator check for the same reason. Reading text is now one reader, used by the editor, by a version and by a file still in the trash — the two the batch before this one left out. That reader fixes what the editor used to do with it: - A UTF-16 file was answered "this file appears to be binary and cannot be opened". In UTF-16 every letter of English is accompanied by a zero byte, and a zero byte was exactly the test for binary. That is what `Out-File` wrote by default until PowerShell 6 and what Notepad still offers as "Unicode", so an export or a log from a Windows machine could not be opened. A mark is believed when there is one; otherwise the pairing of zeros decides, which is what tools that write UTF-16 without a mark leave behind. - A save wrote UTF-8 over whatever the file was. That reads perfectly well here and breaks whatever wrote it, so a save now writes back in the encoding the file already had. - The size limit was checked when opening and not when saving: a paste larger than the limit was written, and the file could then never be opened again. It is now checked on the bytes about to be written, which in UTF-16 are twice the characters. - 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 two are one decision now: the body limit is derived from what the editor may open, and a body limit somebody set is a ceiling that is never raised from here, so it is the editor that gives way. The editor's read is also a GET the browser may keep and revalidate, answered 304 while the file is unchanged, so opening the editor from the Markdown preview no longer downloads the same file twice. The identity is taken from the file's metadata, including 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. An earlier version and a file in the trash open in the editor as text to read: no save, no shortcut that saves, and an editor that does not take typing. Closing goes back where it came from — the folder with the history open again, or the trash, inside the deleted folder the file was read from. Found while wiring this up: `marksForFolder`, `listFilesWithVersions`, `readFileVersions` and `deleteFileVersions` all called store helpers that were never added. Nothing called them, so nothing noticed; all four threw. The queries they need are here, and every one of them now has a test. Left for later, and named here so it is not lost: taking a version out as a new file somewhere, and putting one over another file, both need the destination dialog, which is not here yet; reading a version of an office document needs the office editors to accept one, which comes with the batch that finishes them; and the entry an activity log would want for a purge waits for the log. Gates: lint and formatting on the changed files, `npm run build`, every backend module loads, and the whole backend suite — 1,949 pass, against the two that fail on `main` before any of this. Every claim above has a test that fails when the change behind it is put back; the detection of UTF-16 was mutated twice, once for the mark and once for the pairing, because they are two mechanisms. The screens were checked in the built image: two saves, the mark showing 2 in the listing, the panel opening from it, a version read in the read-only viewer, a version restored from the panel with the history growing to three, a file deleted and read from the trash by double-clicking it, and the administrator's list showing the file, its space and its history. That is also how the last defect here turned up: the dialog that confirms a restore teleports to the body and stacked below the panel's own overlay, so its buttons could not be clicked. No unit test would have seen it. --- backend/src/app.js | 7 +- backend/src/config/index.js | 73 +- backend/src/routes/browse.js | 40 + backend/src/routes/editor.js | 110 ++- backend/src/routes/index.js | 2 + backend/src/routes/trash.js | 16 + backend/src/routes/versions.js | 14 + backend/src/routes/versionsAdmin.js | 80 ++ backend/src/services/settingsService.js | 1 + backend/src/services/textEditorService.js | 331 +++++++++ backend/src/services/trash/index.js | 39 + backend/src/services/trash/operations.js | 33 + backend/src/services/versions/index.js | 14 + backend/src/services/versions/store.js | 164 ++++ backend/src/utils/compressedResponse.js | 208 ++++++ backend/src/utils/textFileResponse.js | 77 ++ backend/tests/routes/text-reading.test.js | 229 ++++++ backend/tests/routes/trash-text.test.js | 159 ++++ backend/tests/routes/version-marks.test.js | 126 ++++ backend/tests/routes/versions-admin.test.js | 190 +++++ frontend/src/api/index.js | 1 + frontend/src/api/trash.api.js | 12 + frontend/src/api/versions.api.js | 109 +++ .../src/components/ExplorerContextMenu.vue | 30 +- frontend/src/components/FileObject.vue | 92 ++- frontend/src/components/InfoPanel.vue | 31 + frontend/src/components/ModalDialog.vue | 12 +- frontend/src/components/VersionsPanel.vue | 700 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 125 +++- frontend/src/i18n/locales/en.json | 125 +++- frontend/src/i18n/locales/es.json | 125 +++- frontend/src/i18n/locales/fr.json | 125 +++- frontend/src/i18n/locales/hi.json | 125 +++- frontend/src/i18n/locales/it.json | 125 +++- frontend/src/i18n/locales/ko.json | 125 +++- frontend/src/i18n/locales/nl.json | 125 +++- frontend/src/i18n/locales/pl.json | 125 +++- frontend/src/i18n/locales/pt-BR.json | 125 +++- frontend/src/i18n/locales/ro.json | 125 +++- frontend/src/i18n/locales/ru.json | 125 +++- frontend/src/i18n/locales/sv.json | 125 +++- frontend/src/i18n/locales/zh-CN.json | 125 +++- frontend/src/i18n/locales/zh-TW.json | 125 +++- frontend/src/layouts/BrowserLayout.vue | 2 + frontend/src/router/index.js | 26 + frontend/src/stores/features.js | 6 + frontend/src/stores/versionsPanel.js | 48 ++ frontend/src/views/EditorView.vue | 155 +++- frontend/src/views/TrashView.vue | 39 +- .../views/settings/SettingsFileVersions.vue | 513 +++++++++++++ .../settings/SettingsUserPreferences.vue | 39 +- frontend/src/views/settings/SettingsView.vue | 7 + 52 files changed, 5445 insertions(+), 165 deletions(-) create mode 100644 backend/src/routes/versionsAdmin.js create mode 100644 backend/src/services/textEditorService.js create mode 100644 backend/src/utils/compressedResponse.js create mode 100644 backend/src/utils/textFileResponse.js create mode 100644 backend/tests/routes/text-reading.test.js create mode 100644 backend/tests/routes/trash-text.test.js create mode 100644 backend/tests/routes/version-marks.test.js create mode 100644 backend/tests/routes/versions-admin.test.js create mode 100644 frontend/src/api/versions.api.js create mode 100644 frontend/src/components/VersionsPanel.vue create mode 100644 frontend/src/stores/versionsPanel.js create mode 100644 frontend/src/views/settings/SettingsFileVersions.vue 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/index.js b/backend/src/config/index.js index 703e6f669..88cb5cb21 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,11 +279,75 @@ 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. const uploads = { + maxJsonBodyBytes, maxDirectUploadBytes: (() => { const parsed = parseByteSize(env.MAX_DIRECT_UPLOAD_SIZE); return Number.isFinite(parsed) && parsed > 0 ? parsed : 64 * 1024 * 1024 * 1024; @@ -365,13 +431,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/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/index.js b/backend/src/routes/index.js index 38680b704..a9d587b67 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -23,6 +23,7 @@ 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) => { @@ -47,6 +48,7 @@ const registerRoutes = (app) => { 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/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/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/services/settingsService.js b/backend/src/services/settingsService.js index 056553307..2a9204e68 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -308,6 +308,7 @@ const setUserSetting = async (userId, key, value) => { if ( key === 'showHiddenFiles' || key === 'showThumbnails' || + key === 'showVersionMarks' || key === 'showSidebarFavorites' || key === 'showSidebarShares' || key === 'showSidebarTools' 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/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/versions/index.js b/backend/src/services/versions/index.js index 66250c8b0..4bee2b464 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,6 +273,18 @@ 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 @@ -662,6 +675,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/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/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/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/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/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/frontend/src/api/index.js b/frontend/src/api/index.js index d3295f11a..717dbf740 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -15,3 +15,4 @@ export * from './collabora.api'; export * from './features.api'; export * from './terminal.api'; export * from './trash.api'; +export * from './versions.api'; 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/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..e5b64d42c 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 } 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,34 @@ const isCut = computed(() => const selected = computed(() => isSelected(props.item)); +/** + * 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 +292,19 @@ if (isTouchDevice.value) { > + @@ -316,7 +360,20 @@ if (isTouchDevice.value) { /> @@ -374,7 +431,20 @@ if (isTouchDevice.value) { />

@@ -448,12 +518,26 @@ 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, ref, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; +import { useRouter } from 'vue-router'; +import { useEventListener } from '@vueuse/core'; +import { + ArrowPathIcon, + EllipsisVerticalIcon, + MapPinIcon, + XMarkIcon, +} from '@heroicons/vue/24/outline'; +import ModalDialog from '@/components/ModalDialog.vue'; +import { useVersionsPanelStore } from '@/stores/versionsPanel'; +import { useNotificationsStore } from '@/stores/notifications'; +import { useFileStore } from '@/stores/fileStore'; +import { isEditableExtension } from '@/config/editor'; +import { formatBytes, formatLocalDateTime } from '@/utils'; +import { + deleteVersions, + getVersionDownloadUrl, + getVersions, + normalizePath, + restoreVersion, + updateVersion, +} from '@/api'; + +/** + * A file's history: the earlier versions its saves left, newest first, and + * what can be done with each — look at it, download it, put it back, take it + * out as a copy or over another file, name it, pin it, delete it. + * + * It sits above the preview layer, since an office editor opens it too, and its + * own dialogs sit above it. + */ + +const MAX_LABEL_LENGTH = 200; + +const store = useVersionsPanelStore(); +const notifications = useNotificationsStore(); +const fileStore = useFileStore(); +const router = useRouter(); +const { t } = useI18n(); + +const isOpen = computed(() => store.isOpen); +const filePath = computed(() => store.relativePath); +const fileName = computed(() => store.item?.name || ''); +const parentPath = computed(() => { + const segments = filePath.value.split('/').filter(Boolean); + segments.pop(); + return segments.join('/'); +}); + +const data = ref(null); +const loading = ref(false); +const loadError = ref(''); +const selected = ref([]); +const openMenu = ref(null); +const busy = ref(false); +const confirmation = ref(null); +const naming = ref(null); + +const versions = computed(() => data.value?.versions || []); +const rights = computed( + () => data.value?.rights || { see: false, download: false, restore: false, remove: false } +); +const allSelected = computed( + () => versions.value.length > 0 && selected.value.length === versions.value.length +); + +const load = async () => { + if (!isOpen.value || !filePath.value) return; + const requested = filePath.value; + loading.value = true; + loadError.value = ''; + try { + const response = await getVersions(requested); + if (requested !== filePath.value) return; + data.value = response; + const ids = new Set((response?.versions || []).map((version) => version.id)); + selected.value = selected.value.filter((id) => ids.has(id)); + } catch (error) { + if (requested !== filePath.value) return; + data.value = null; + loadError.value = + error?.statusCode === 403 + ? t('versions.notShared') + : error?.message || t('versions.loadFailed'); + } finally { + if (requested === filePath.value) loading.value = false; + } +}; + +watch( + [isOpen, filePath], + ([open]) => { + openMenu.value = null; + if (!open) return; + data.value = null; + selected.value = []; + void load(); + }, + { immediate: true } +); + +const close = () => store.close(); + +useEventListener(window, 'keydown', (event) => { + if (event.key !== 'Escape' || !isOpen.value) return; + if (confirmation.value || naming.value) return; + if (openMenu.value) { + openMenu.value = null; + return; + } + close(); +}); + +useEventListener(document, 'pointerdown', (event) => { + if (!openMenu.value) return; + if (event.target?.closest?.('[data-version-menu]')) return; + openMenu.value = null; +}); + +const SOURCES = { + editor: 'editor', + 'share-editor': 'shareEditor', + onlyoffice: 'onlyoffice', + collabora: 'collabora', + restore: 'restore', + external: 'external', +}; + +const sourceLabel = (source) => (SOURCES[source] ? t(`versions.source.${SOURCES[source]}`) : ''); + +/** Who wrote a content, as a person recognises it. */ +const authorLabel = (author) => { + if (!author) return t('versions.unknownAuthor'); + if (!author.id && author.label === 'share-link') return t('versions.shareLink'); + return author.label || t('versions.unknownAuthor'); +}; + +const describe = (entry) => + [authorLabel(entry.author), sourceLabel(entry.source), formatBytes(entry.size)] + .filter(Boolean) + .join(' · '); + +const extension = computed(() => { + const name = fileName.value; + const dot = name.lastIndexOf('.'); + return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''; +}); + +const actionsFor = (version) => { + const usable = version.available !== false; + const list = []; + // A text file opens in the editor, read only on that version. An office + // document has no reader for a version yet. + if (usable && isEditableExtension(extension.value)) { + list.push({ id: 'preview', label: t('versions.actions.preview') }); + } + if (usable && rights.value.download) { + list.push({ id: 'download', label: t('versions.actions.download') }); + } + if (usable && rights.value.restore) { + list.push({ id: 'restore', label: t('versions.actions.restore') }); + } + if (rights.value.restore) { + list.push({ id: 'rename', label: t('versions.actions.rename') }); + list.push({ + id: 'pin', + label: version.pinned ? t('versions.actions.unpin') : t('versions.actions.pin'), + }); + } + if (rights.value.remove) { + list.push({ id: 'delete', label: t('versions.actions.delete'), danger: true }); + } + return list; +}; + +const toggleMenu = (id) => { + openMenu.value = openMenu.value === id ? null : id; +}; + +const toggleSelected = (id) => { + selected.value = selected.value.includes(id) + ? selected.value.filter((candidate) => candidate !== id) + : [...selected.value, id]; +}; + +const toggleAll = () => { + selected.value = allSelected.value ? [] : versions.value.map((version) => version.id); +}; + +const notifyFailure = (error) => { + notifications.addNotification({ + type: 'error', + heading: t('versions.errors.action'), + body: error?.message || '', + }); +}; + +/** What changed on disk shows in the folder being browsed, if it is the one. */ +const refreshListing = async (folder) => { + try { + const current = normalizePath(fileStore.currentPath || ''); + if (typeof fileStore.fetchPathItems === 'function' && current === normalizePath(folder || '')) { + await fileStore.fetchPathItems(current); + } + } catch { + // The listing catches up on the next visit; the change itself succeeded. + } +}; + +const work = async (task) => { + busy.value = true; + try { + await task(); + } catch (error) { + notifyFailure(error); + } finally { + busy.value = false; + await load(); + } +}; + +const download = (version) => { + const link = document.createElement('a'); + link.href = getVersionDownloadUrl(filePath.value, version.id); + link.rel = 'noopener'; + link.download = ''; + document.body.appendChild(link); + link.click(); + link.remove(); +}; + +const preview = (version) => { + const path = filePath.value; + close(); + router.push({ name: 'VersionFileViewer', params: { versionId: version.id, path } }); +}; + +const runAction = (action, version) => { + openMenu.value = null; + switch (action.id) { + case 'preview': + preview(version); + break; + case 'download': + download(version); + break; + case 'restore': + confirmation.value = { kind: 'restore', version }; + break; + case 'rename': + naming.value = { version, value: version.label || '' }; + break; + case 'pin': + void work(async () => { + await updateVersion(filePath.value, version.id, { pinned: !version.pinned }); + notifications.addNotification({ + type: 'success', + heading: version.pinned ? t('versions.results.unpinned') : t('versions.results.pinned'), + durationMs: 3000, + }); + }); + break; + case 'delete': + confirmation.value = { kind: 'delete', ids: [version.id] }; + break; + default: + break; + } +}; + +const confirmationOpen = computed({ + get: () => Boolean(confirmation.value), + set: (value) => { + if (!value) confirmation.value = null; + }, +}); + +const confirmationTitle = computed(() => { + const request = confirmation.value; + if (!request) return ''; + if (request.kind === 'restore') return t('versions.confirm.restoreTitle'); + if (request.kind === 'deleteAll') return t('versions.confirm.deleteAllTitle'); + return t('versions.confirm.deleteTitle', { count: request.ids.length }, request.ids.length); +}); + +const confirmationMessage = computed(() => { + const request = confirmation.value; + if (!request) return ''; + if (request.kind === 'restore') { + return t('versions.confirm.restoreMessage', { + name: fileName.value, + date: formatLocalDateTime(request.version.modifiedAt), + }); + } + if (request.kind === 'deleteAll') { + return t('versions.confirm.deleteAllMessage', { name: fileName.value }); + } + return t('versions.confirm.deleteMessage', { count: request.ids.length }, request.ids.length); +}); + +const confirmationDanger = computed(() => + ['delete', 'deleteAll'].includes(confirmation.value?.kind) +); + +/** The button says what happens. */ +const confirmationButton = computed(() => { + if (confirmation.value?.kind === 'restore') return t('versions.actions.restore'); + return t('common.delete'); +}); + +const confirm = async () => { + const request = confirmation.value; + if (!request) return; + confirmation.value = null; + await work(async () => { + if (request.kind === 'restore') { + const result = await restoreVersion(filePath.value, request.version.id); + store.markRestored(); + notifications.addNotification({ + type: result?.status === 'unchanged' ? 'info' : 'success', + heading: + result?.status === 'unchanged' + ? t('versions.results.unchanged') + : t('versions.results.restored'), + durationMs: 4000, + }); + await refreshListing(parentPath.value); + } else { + const result = await deleteVersions( + filePath.value, + request.kind === 'deleteAll' ? { all: true } : { ids: request.ids } + ); + const count = Number(result?.deleted) || 0; + selected.value = []; + notifications.addNotification({ + type: 'success', + heading: t('versions.results.deleted', { count }, count), + durationMs: 4000, + }); + // The row behind the panel carries a mark saying how many versions the + // file has. Deleting them here and leaving that mark at its old number + // is the panel disagreeing with the listing it was opened from. + await refreshListing(parentPath.value); + } + }); +}; + +const namingOpen = computed({ + get: () => Boolean(naming.value), + set: (value) => { + if (!value) naming.value = null; + }, +}); + +const saveName = async () => { + const request = naming.value; + if (!request || request.value.trim().length > MAX_LABEL_LENGTH) return; + naming.value = null; + await work(async () => { + await updateVersion(filePath.value, request.version.id, { label: request.value.trim() }); + notifications.addNotification({ + type: 'success', + heading: t('versions.results.renamed'), + durationMs: 3000, + }); + }); +}; + + + + + diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 32413e1a7..45b62d3c4 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", @@ -319,7 +321,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…", @@ -392,7 +395,8 @@ "security": "Sicherheit", "accessControl": "Zugriffskontrolle", "adminUsers": "Benutzerverwaltung", - "trash": "Papierkorb und Versionen" + "trash": "Papierkorb und Versionen", + "fileVersions": "Dateiversionen" }, "about": { "subtitle": "Build-Informationen für diese Anwendung anzeigen.", @@ -442,7 +446,9 @@ "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." }, "thumbs": { "subtitle": "Vorschau-Miniaturen für Bilder und Videos anpassen.", @@ -615,6 +621,53 @@ "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" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8c6a72918..358f83989 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", @@ -319,7 +321,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…", @@ -392,7 +395,8 @@ "security": "Security", "accessControl": "Access Control", "adminUsers": "User Management", - "trash": "Trash and versions" + "trash": "Trash and versions", + "fileVersions": "File versions" }, "about": { "subtitle": "View build information for this application.", @@ -442,7 +446,9 @@ "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." }, "thumbs": { "subtitle": "Customize preview thumbnails for images and videos.", @@ -615,6 +621,53 @@ "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" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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" } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 768b8a041..68c6eb155 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", @@ -319,7 +321,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…", @@ -392,7 +395,8 @@ "security": "Seguridad", "accessControl": "Control de acceso", "adminUsers": "Gestión de usuarios", - "trash": "Papelera y versiones" + "trash": "Papelera y versiones", + "fileVersions": "Versiones de archivos" }, "about": { "subtitle": "Ver la información de compilación de esta aplicación.", @@ -442,7 +446,9 @@ "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." }, "thumbs": { "subtitle": "Personaliza las miniaturas de vista previa para imágenes y vídeos.", @@ -615,6 +621,53 @@ "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" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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" } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 6411cc892..19f40c755 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", @@ -319,7 +321,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…", @@ -392,7 +395,8 @@ "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" }, "about": { "subtitle": "Afficher les informations de build de cette application.", @@ -442,7 +446,9 @@ "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." }, "thumbs": { "subtitle": "Personnalisez les vignettes d'aperçu pour les images et les vidéos.", @@ -615,6 +621,53 @@ "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" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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" } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index cba1c2111..8f6614af0 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": "सफलतापूर्वक अपडेट किया गया", @@ -319,7 +321,8 @@ "dateTaken": "खिंचने की तिथि: {date}", "camera": "कैमरा: {makeModel}", "lens": "लेंस: {lens}", - "duration": "अवधि: {seconds}s" + "duration": "अवधि: {seconds}s", + "versions": "संस्करण" }, "auth": { "preparing": "आपका एक्सप्लोरर तैयार किया जा रहा है…", @@ -392,7 +395,8 @@ "security": "सुरक्षा", "accessControl": "एक्सेस कंट्रोल", "adminUsers": "उपयोगकर्ता प्रबंधन", - "trash": "रीसायकल बिन और संस्करण" + "trash": "रीसायकल बिन और संस्करण", + "fileVersions": "फ़ाइल संस्करण" }, "about": { "subtitle": "इस एप्लिकेशन के बिल्ड संबंधी जानकारी देखें।", @@ -442,7 +446,9 @@ "months": "महीने", "skipHome": "होम पेज छोड़ें", "skipHomeHelp": "होम पेज पर आने पर पहले वॉल्यूम पर स्वचालित रूप से रीडायरेक्ट करें। यदि सेट नहीं है, तो सर्वर कॉन्फ़िगरेशन का पालन करें।", - "useEnvSetting": "सर्वर सेटिंग का उपयोग करें" + "useEnvSetting": "सर्वर सेटिंग का उपयोग करें", + "showVersionMarks": "संस्करण वाली फ़ाइलों पर निशान लगाएँ", + "showVersionMarksHelp": "सूची में उन फ़ाइलों पर एक छोटा निशान जिनके पुराने संस्करण हैं, उनकी संख्या के साथ। इतिहास खोलने के लिए उस पर क्लिक करें।" }, "thumbs": { "subtitle": "छवियों और वीडियो के लिए पूर्वावलोकन थंबनेल अनुकूलित करें।", @@ -615,6 +621,53 @@ "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": "चुने गए संस्करण हटाएँ" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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} पुराने संस्करण" } } diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 3a3edcea3..3ea8b6312 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", @@ -319,7 +321,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…", @@ -392,7 +395,8 @@ "security": "Sicurezza", "accessControl": "Controllo accessi", "adminUsers": "Gestione utenti", - "trash": "Cestino e versioni" + "trash": "Cestino e versioni", + "fileVersions": "Versioni dei file" }, "about": { "subtitle": "Visualizza le informazioni di build per questa applicazione.", @@ -442,7 +446,9 @@ "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." }, "thumbs": { "subtitle": "Personalizza le miniature di anteprima per immagini e video.", @@ -615,6 +621,53 @@ "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" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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" } } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index a34348001..cc806969d 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": "업데이트 성공", @@ -319,7 +321,8 @@ "dateTaken": "촬영된 날짜: {date}", "camera": "카메라: {makeModel}", "lens": "렌즈: {lens}", - "duration": "길이: {seconds}초" + "duration": "길이: {seconds}초", + "versions": "버전" }, "auth": { "preparing": "탐색기를 준비하는 중입니다…", @@ -392,7 +395,8 @@ "security": "보안", "accessControl": "접근 제어", "adminUsers": "유저 관리", - "trash": "휴지통 및 버전" + "trash": "휴지통 및 버전", + "fileVersions": "파일 버전" }, "about": { "subtitle": "애플리케이션의 빌드 정보를 확인합니다.", @@ -442,7 +446,9 @@ "months": "개월", "skipHome": "홈페이지 건너뛰기", "skipHomeHelp": "접속했을 때, 자동으로 첫 번째 볼륨으로 이동합니다. 설정하지 않을 경우 서버 설정을 따릅니다.", - "useEnvSetting": "서버 설정 사용" + "useEnvSetting": "서버 설정 사용", + "showVersionMarks": "버전이 있는 파일 표시", + "showVersionMarksHelp": "이전 버전이 있는 파일에 개수와 함께 목록에서 작은 표시를 붙입니다. 누르면 기록이 열립니다." }, "thumbs": { "subtitle": "사진, 영상 파일의 미리보기 썸네일 관련 설정을 커스터마이징하세요.", @@ -615,6 +621,53 @@ "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": "선택한 버전 삭제" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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}개" } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 20fa3eb27..d3cb1c0a4 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", @@ -319,7 +321,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…", @@ -392,7 +395,8 @@ "security": "Beveiliging", "accessControl": "Toegangscontrole", "adminUsers": "Gebruikersbeheer", - "trash": "Prullenbak en versies" + "trash": "Prullenbak en versies", + "fileVersions": "Bestandsversies" }, "about": { "subtitle": "Versieinformatie voor deze applicatie.", @@ -442,7 +446,9 @@ "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." }, "thumbs": { "subtitle": "Voorbeeldminiaturen aanpassen voor afbeeldingen en video's.", @@ -615,6 +621,53 @@ "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" } }, "share": { @@ -801,5 +854,69 @@ "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" } } diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index f9f7a1b3b..c2ea89e87 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", @@ -319,7 +321,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…", @@ -392,7 +395,8 @@ "security": "Bezpieczeństwo", "accessControl": "Kontrola dostępu", "adminUsers": "Zarządzanie użytkownikami", - "trash": "Kosz i wersje" + "trash": "Kosz i wersje", + "fileVersions": "Wersje plików" }, "about": { "subtitle": "Wyświetl informacje o kompilacji tej aplikacji.", @@ -442,7 +446,9 @@ "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ę." }, "thumbs": { "subtitle": "Dostosuj miniatury podglądu dla obrazów i filmów.", @@ -615,6 +621,53 @@ "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" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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" } } diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 18ad20c04..0d80726cf 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", @@ -319,7 +321,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…", @@ -392,7 +395,8 @@ "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" }, "about": { "subtitle": "Veja as informações de compilação deste aplicativo.", @@ -442,7 +446,9 @@ "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." }, "thumbs": { "subtitle": "Personalize as pré-visualizações em miniatura de imagens e vídeos.", @@ -615,6 +621,53 @@ "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" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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" } } diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 00ff31fbf..1c588ef11 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", @@ -319,7 +321,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…", @@ -392,7 +395,8 @@ "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" }, "about": { "subtitle": "Vezi informațiile de build pentru această aplicație.", @@ -442,7 +446,9 @@ "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." }, "thumbs": { "subtitle": "Personalizează miniaturile de previzualizare pentru imagini și video.", @@ -615,6 +621,53 @@ "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" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 2c7df246c..a5d67553e 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": "Успешно обновлено", @@ -319,7 +321,8 @@ "dateTaken": "Дата съемки: {date}", "camera": "Камера: {makeModel}", "lens": "Объектив: {lens}", - "duration": "Длительность: {seconds}с" + "duration": "Длительность: {seconds}с", + "versions": "Версии" }, "auth": { "preparing": "Подготовка проводника…", @@ -392,7 +395,8 @@ "security": "Безопасность", "accessControl": "Контроль доступа", "adminUsers": "Управление пользователями", - "trash": "Корзина и версии" + "trash": "Корзина и версии", + "fileVersions": "Версии файлов" }, "about": { "subtitle": "Информация о сборке приложения.", @@ -442,7 +446,9 @@ "months": "Месяцев", "skipHome": "Пропустить главную", "skipHomeHelp": "Автоматически перенаправляет к первому тому при заходе на главную. Если не указано, используется настройка сервера.", - "useEnvSetting": "Использовать серверную настройку" + "useEnvSetting": "Использовать серверную настройку", + "showVersionMarks": "Отмечать файлы, у которых есть версии", + "showVersionMarksHelp": "Небольшая отметка в списке у файлов с предыдущими версиями и их числом. Нажмите на неё, чтобы открыть историю." }, "thumbs": { "subtitle": "Настройте миниатюры предпросмотра для изображений и видео.", @@ -615,6 +621,53 @@ "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": "Удалить отмеченные версии" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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} предыдущих версий" } } diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 1ebdc0011..c4c4d90c9 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", @@ -319,7 +321,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…", @@ -392,7 +395,8 @@ "security": "Säkerhet", "accessControl": "Åtkomstkontroll", "adminUsers": "Användarhantering", - "trash": "Papperskorg och versioner" + "trash": "Papperskorg och versioner", + "fileVersions": "Filversioner" }, "about": { "subtitle": "Visa bygginformation för denna applikation.", @@ -442,7 +446,9 @@ "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." }, "thumbs": { "subtitle": "Anpassa förhandsvisningsminiatyrer för bilder och videor.", @@ -615,6 +621,53 @@ "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" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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" } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 54329945c..6aedc5d5f 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": "更新成功", @@ -319,7 +321,8 @@ "dateTaken": "拍摄日期:{date}", "camera": "相机:{makeModel}", "lens": "镜头:{lens}", - "duration": "时长:{seconds}s" + "duration": "时长:{seconds}s", + "versions": "版本" }, "auth": { "preparing": "正在为您准备 Explorer…", @@ -392,7 +395,8 @@ "security": "安全", "accessControl": "访问控制", "adminUsers": "用户管理", - "trash": "回收站和版本" + "trash": "回收站和版本", + "fileVersions": "文件版本" }, "about": { "subtitle": "查看此应用的构建信息。", @@ -442,7 +446,9 @@ "months": "月", "skipHome": "跳过首页", "skipHomeHelp": "访问首页时自动重定向到第一个卷。未设置时遵循服务器配置。", - "useEnvSetting": "使用服务器设置" + "useEnvSetting": "使用服务器设置", + "showVersionMarks": "标记有版本的文件", + "showVersionMarksHelp": "在列表中为有历史版本的文件加一个小标记,并显示数量。点击即可打开历史记录。" }, "thumbs": { "subtitle": "自定义图片和视频的预览缩略图。", @@ -615,6 +621,53 @@ "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": "删除勾选的版本" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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} 个历史版本" } } diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 49fc439e3..516a5d467 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": "更新成功", @@ -319,7 +321,8 @@ "dateTaken": "拍攝日期:{date}", "camera": "相機:{makeModel}", "lens": "鏡頭:{lens}", - "duration": "長度:{seconds}s" + "duration": "長度:{seconds}s", + "versions": "版本" }, "auth": { "preparing": "正在為您準備 Explorer…", @@ -392,7 +395,8 @@ "security": "安全", "accessControl": "權限控制", "adminUsers": "使用者管理", - "trash": "資源回收筒和版本" + "trash": "資源回收筒和版本", + "fileVersions": "檔案版本" }, "about": { "subtitle": "查看此應用的構建資訊。", @@ -442,7 +446,9 @@ "months": "月", "skipHome": "跳過首頁", "skipHomeHelp": "進入首頁時自動導向到第一個儲存卷。未設定時會使用伺服器預設。", - "useEnvSetting": "使用伺服器預設" + "useEnvSetting": "使用伺服器預設", + "showVersionMarks": "標示有版本的檔案", + "showVersionMarksHelp": "在清單中為有舊版本的檔案加上一個小標記,並顯示數量。點一下即可開啟歷程記錄。" }, "thumbs": { "subtitle": "自訂圖片和影片的預覽縮圖。", @@ -615,6 +621,53 @@ "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": "刪除勾選的版本" } }, "mediaPreview": { @@ -801,5 +854,69 @@ "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} 個舊版本" } } diff --git a/frontend/src/layouts/BrowserLayout.vue b/frontend/src/layouts/BrowserLayout.vue index 636baecbf..fc3a0cc12 100644 --- a/frontend/src/layouts/BrowserLayout.vue +++ b/frontend/src/layouts/BrowserLayout.vue @@ -21,6 +21,7 @@ import { useAuthStore } from '@/stores/auth'; import { useAppSettings } from '@/stores/appSettings'; import { useFeaturesStore } from '@/stores/features'; import InfoPanel from '@/components/InfoPanel.vue'; +import VersionsPanel from '@/components/VersionsPanel.vue'; import { useFileUploader } from '@/composables/fileUploader'; import { useKeyboardShortcuts } from '@/composables/keyboardShortcuts'; import SpotlightSearch from '@/components/SpotlightSearch.vue'; @@ -225,6 +226,7 @@ const handleGuestLogin = () => { + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 6bd3fad74..a88e5fb08 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -14,6 +14,7 @@ import AdminUsers from '@/views/settings/AdminUsers.vue'; import SettingsPassword from '@/views/settings/SettingsPassword.vue'; import SettingsAbout from '@/views/settings/SettingsAbout.vue'; import SettingsTrash from '@/views/settings/SettingsTrash.vue'; +import SettingsFileVersions from '@/views/settings/SettingsFileVersions.vue'; import TrashView from '@/views/TrashView.vue'; import SettingsUserPreferences from '@/views/settings/SettingsUserPreferences.vue'; import AboutView from '@/views/AboutView.vue'; @@ -68,6 +69,11 @@ const router = createRouter({ component: SettingsTrash, meta: { requiresAdmin: true }, }, + { + path: 'file-versions', + component: SettingsFileVersions, + meta: { requiresAdmin: true }, + }, { path: 'admin-overview', component: SettingsComingSoon, @@ -159,6 +165,26 @@ const router = createRouter({ }, ], }, + { + // A file in the trash, shown in the editor to be read: nothing there can + // be saved. Its own path, so no volume name can ever collide with it. + path: '/trash/view', + component: EditorLayout, + meta: { requiresAuth: true }, + children: [ + { path: ':itemId/:entryPath(.*)*', name: 'TrashFileViewer', component: EditorView }, + ], + }, + { + // An earlier version of a file, shown in the editor to be read. The file + // is named by its path, a share path for a share's visitor. + path: '/versions/view', + component: EditorLayout, + meta: { requiresAuth: true, allowGuest: true }, + children: [ + { path: ':versionId/:path(.*)', name: 'VersionFileViewer', component: EditorView }, + ], + }, { path: '/about', component: AboutView, diff --git a/frontend/src/stores/features.js b/frontend/src/stores/features.js index 2156523f9..9ded5ea88 100644 --- a/frontend/src/stores/features.js +++ b/frontend/src/stores/features.js @@ -24,6 +24,10 @@ export const useFeaturesStore = defineStore('features', () => { // read it, so the way into the trash could not be shown or hidden. const trashEnabled = ref(false); const trashRetentionDays = ref(null); + // Whether a save keeps what it replaces as a version. The server already + // said so; nothing read it, so a file's history could not be offered or + // hidden. + const versionsEnabled = ref(false); const terminalExtensions = ref([]); const version = ref(''); const gitCommit = ref(''); @@ -88,6 +92,7 @@ export const useFeaturesStore = defineStore('features', () => { personalEnabled.value = Boolean(features?.personal?.enabled); trashEnabled.value = Boolean(features?.trash?.enabled); trashRetentionDays.value = features?.trash?.retentionDays ?? null; + versionsEnabled.value = Boolean(features?.versions?.enabled); // User volumes (per-user volume assignments) userVolumesEnabled.value = Boolean(features?.userVolumes?.enabled); @@ -162,6 +167,7 @@ export const useFeaturesStore = defineStore('features', () => { terminalEnabled, trashEnabled, trashRetentionDays, + versionsEnabled, terminalExtensions, version, gitCommit, diff --git a/frontend/src/stores/versionsPanel.js b/frontend/src/stores/versionsPanel.js new file mode 100644 index 000000000..74b7a0699 --- /dev/null +++ b/frontend/src/stores/versionsPanel.js @@ -0,0 +1,48 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { normalizePath } from '@/api'; + +/** + * Which file the Versions panel shows. + * + * Opened from the file browser with the item that was clicked, or from an + * editor with just the path of the document it has open. `restored` counts the + * restores made from the panel, so an editor showing the same file knows to + * reload what it shows. + */ +export const useVersionsPanelStore = defineStore('versionsPanel', () => { + const isOpen = ref(false); + const item = ref(null); + const restored = ref(0); + + const open = (target) => { + item.value = target || null; + isOpen.value = Boolean(target); + }; + + /** Open on a file known only by its path, as an editor knows it. */ + const openPath = (filePath) => { + const normalized = normalizePath(filePath || ''); + if (!normalized) return; + const segments = normalized.split('/'); + const name = segments.pop(); + open({ name, path: segments.join('/'), kind: 'file' }); + }; + + const close = () => { + isOpen.value = false; + }; + + const markRestored = () => { + restored.value += 1; + }; + + const relativePath = computed(() => { + const it = item.value; + if (!it || !it.name) return ''; + const parent = normalizePath(it.path || ''); + return normalizePath(parent ? `${parent}/${it.name}` : it.name); + }); + + return { isOpen, item, restored, open, openPath, close, markRestored, relativePath }; +}); diff --git a/frontend/src/views/EditorView.vue b/frontend/src/views/EditorView.vue index dafd340c7..f57f48a80 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..9d660661d 100644 --- a/frontend/src/views/settings/SettingsView.vue +++ b/frontend/src/views/settings/SettingsView.vue @@ -12,6 +12,7 @@ import { PhotoIcon, KeyIcon, TrashIcon, + ClockIcon, UsersIcon, UserCircleIcon, } from '@heroicons/vue/24/outline'; @@ -82,6 +83,12 @@ const adminCategories = [ name: 'Trash', icon: TrashIcon, }, + { + key: 'file-versions', + i18nKey: 'fileVersions', + name: 'File versions', + icon: ClockIcon, + }, { key: 'access-control', i18nKey: 'accessControl', From 81a45814b633ca0df68a0a7326580e96bf34a295 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 10:57:21 +0200 Subject: [PATCH 2/8] Let a preference the screen offers actually be saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which keys are preferences was decided twice: once in the settings service, which sanitises the value, and once in the settings route, which decides whether the key is written at all. They had drifted. The preference added here — the mark on files that have versions — reached the service's list and not the route's, so the toggle moved on screen, the save answered success, and nothing was stored. It would have read as "my setting does not stick", with two lists to find before anyone could say why. One list now, named and exported by the service; the route asks it. A test walks every key the service calls a preference and checks the route keeps it, so the two cannot drift again. --- backend/src/routes/settings.js | 14 ++-- backend/src/services/settingsService.js | 30 +++++-- .../tests/routes/settings-preferences.test.js | 83 +++++++++++++++++++ 3 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 backend/tests/routes/settings-preferences.test.js diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index 5bcc09193..e4ba073f0 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); } } diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 2a9204e68..0bb483bfe 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -292,6 +292,26 @@ const getSettingsForUser = async (user) => { 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', + 'showSidebarFavorites', + 'showSidebarShares', + 'showSidebarTools', +]); + +const USER_SETTING_KEYS = new Set([...USER_BOOLEAN_SETTINGS, 'defaultShareExpiration', 'skipHome']); + /** * Set a user setting */ @@ -305,14 +325,7 @@ const setUserSetting = async (userId, key, value) => { // Validate and sanitize value based on key let sanitizedValue = value; - if ( - key === 'showHiddenFiles' || - key === 'showThumbnails' || - key === 'showVersionMarks' || - 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 @@ -471,6 +484,7 @@ const updateSettings = async (updater) => { }; module.exports = { + USER_SETTING_KEYS, getPublicSettings, sanitizeTrash, sanitizeVersions, diff --git a/backend/tests/routes/settings-preferences.test.js b/backend/tests/routes/settings-preferences.test.js new file mode 100644 index 000000000..f8ab63b84 --- /dev/null +++ b/backend/tests/routes/settings-preferences.test.js @@ -0,0 +1,83 @@ +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', '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(); + }); +}); From 9f3b89ba255ef25f14aa9743186773de9699b876 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 10:20:13 +0200 Subject: [PATCH 3/8] Keep an open document's identity, and know where it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two people editing the same document only see each other when the Document Server was given the same key: a different key is a different document to it, and it 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 was recomputed on every open from the file's own modification time. So it changed under the people already editing — every save of theirs split the session — and it did not change once they had all left, which is the one moment it must, because the Document Server caches the prepared document under that key and serves the stale copy on the next open. The key is now kept: the same for as long as anyone has the document open, including across their saves, and dropped when the Document Server reports it has let go. Restoring a version drops it too, which is the piece the history batch could not carry: an editor reopened after a restore was getting the copy the restore had just replaced. The other half is where the document is. 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. There are now editing sessions, stored rather than held in memory — a restart mid-edit would otherwise forget the rename, and the next save would recreate the old name beside the new one, silently. Renaming from inside the editor moves the file through the ordinary rename, with the ordinary permission checks, and then moves the session, the presence record and the key with it. Presence starts when the client says the document is really open, not when the configuration was asked for: asking for a configuration says nothing about whether the document opened, and a file the editor then refused was shown to everybody as being edited until it expired. Four things were wrong in this path and are fixed here: - **A read-only folder handed out an editing session.** The decision looked at the requested mode and at whether the document came through a read-only share, and never at the location's own rights. The backend token now carries that decision, and the callback refuses a save made with a token that says read-only. - **The backend token was untyped and never expired.** It is signed with the same secret as the Document Server's own tokens, so one of those would have been accepted as ours. It now carries a type claim and a twelve-hour life. - **Every unknown extension was announced as a text document.** The Document Server validates documentType against the extension and refuses the config outright, answering "the file content does not match the file extension" — true, unhelpful, and several steps from the setting that caused it. .odg was one of them: the Document Server counts a drawing as a presentation. The mapping is now the one it ships, and an extension it has no editor for is refused here, naming the setting. - **Nothing ever expired.** Only a terminal callback released a key, so a browser closed on the editor left the row for good — one for every document ever opened. There is now an hourly sweep, and it takes with it the two cleanups that were already in the tree with no caller at all: `cleanupExpiredShares` and `cleanupExpiredSessions`, so an expired share no longer sits on disk indefinitely. The rename itself moved out of its route into a service, because two callers now need exactly those checks in exactly that order. One thing changed on the way: `ensureValidName` throws a plain Error, which the error handler could only read as a server fault, so a name with a separator in it was answered 500 and appeared in the logs as an unhandled failure. It is a bad request. Gates: lint and formatting on the changed files, `npm run build`, every backend module loads, and the whole backend suite — 1,972 pass, against the two that fail on `main` before any of this. Twenty-three new tests; every claim above fails when the change behind it is put back, mutated one at a time: the key back to its inline computation, the type claim removed, the write check removed from both ends, the save target back to the token, and the editor table back to its catch-all. Checked in the built image with a volume: the schema migrates, a document is handed a key and a session, the heartbeat reports it open, opening it again while it is open gives the same key, an unknown extension is refused by name, renaming from the session moves the file and the folder lists the new name, and ending the session is accepted. Second half — editing together as the file list shows it, Save as, mentions, force-save, the history inside the editor, and opening a document in a tab of its own — follows under the same number. --- backend/src/routes/files/rename.js | 98 +---- backend/src/routes/onlyoffice.js | 355 +++++++++++++-- backend/src/server.js | 35 ++ backend/src/services/db.js | 54 +++ .../src/services/onlyofficeActivityService.js | 232 ++++++++++ .../services/onlyofficeDocumentKeyService.js | 165 +++++++ .../onlyofficeEditorSessionService.js | 108 +++++ backend/src/services/renameService.js | 111 +++++ backend/src/services/versions/index.js | 16 +- backend/src/utils/onlyofficeDocumentTypes.js | 116 +++++ .../tests/routes/onlyoffice-session.test.js | 413 ++++++++++++++++++ frontend/src/api/onlyoffice.api.js | 74 +++- frontend/src/i18n/locales/de.json | 5 + frontend/src/i18n/locales/en.json | 5 + frontend/src/i18n/locales/es.json | 5 + frontend/src/i18n/locales/fr.json | 5 + frontend/src/i18n/locales/hi.json | 5 + frontend/src/i18n/locales/it.json | 5 + frontend/src/i18n/locales/ko.json | 5 + frontend/src/i18n/locales/nl.json | 5 + frontend/src/i18n/locales/pl.json | 5 + frontend/src/i18n/locales/pt-BR.json | 5 + frontend/src/i18n/locales/ro.json | 5 + frontend/src/i18n/locales/ru.json | 5 + frontend/src/i18n/locales/sv.json | 5 + frontend/src/i18n/locales/zh-CN.json | 5 + frontend/src/i18n/locales/zh-TW.json | 5 + .../plugins/onlyoffice/OnlyOfficePreview.vue | 130 +++++- 28 files changed, 1846 insertions(+), 136 deletions(-) create mode 100644 backend/src/services/onlyofficeActivityService.js create mode 100644 backend/src/services/onlyofficeDocumentKeyService.js create mode 100644 backend/src/services/onlyofficeEditorSessionService.js create mode 100644 backend/src/services/renameService.js create mode 100644 backend/src/utils/onlyofficeDocumentTypes.js create mode 100644 backend/tests/routes/onlyoffice-session.test.js 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/onlyoffice.js b/backend/src/routes/onlyoffice.js index 8c88b9dce..61623b2a5 100644 --- a/backend/src/routes/onlyoffice.js +++ b/backend/src/routes/onlyoffice.js @@ -11,28 +11,29 @@ const { onlyoffice, public: publicConfig, mimeTypes } = require('../config/index const { normalizeRelativePath } = require('../utils/pathUtils'); const { ensureDir } = require('../utils/fsUtils'); 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; +// Helpers const toExt = (filename = '') => String(filename).split('.').pop().toLowerCase(); -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 resolveMime = (ext) => mimeTypes[ext] || 'application/octet-stream'; /** @@ -118,6 +119,128 @@ 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'), + }; +}; + +/** + * 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 ')) { @@ -165,9 +288,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 +315,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', @@ -257,10 +409,116 @@ router.post( res.json({ documentServerUrl: onlyoffice.serverUrl, config, + editorSessionId, }); }) ); +/** + * 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.'); + await getEditorSession(req, sessionId, relativePath); + + onlyofficeActivity.close({ absolutePath: resolved.absolutePath, sessionId }); + await editorSessions.remove(sessionId); + res.json({ ended: true }); + }) +); + +/** + * 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 }); + }) +); + // GET /api/onlyoffice/file?path=... router.get( '/onlyoffice/file', @@ -360,30 +618,51 @@ 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); + 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); diff --git a/backend/src/server.js b/backend/src/server.js index c0b8b20cb..f7bb861ad 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -12,6 +12,10 @@ const searchIndexManager = require('./services/searchIndexManager'); const folderSizeManager = require('./services/folderSizeManager'); const { sweepInterrupted } = require('./services/inFlightFiles'); const trashMaintenance = require('./services/trash/maintenance'); +const { cleanupExpiredShares } = require('./services/sharesService'); +const { cleanupExpiredSessions } = require('./services/guestSessionService'); +const { purgeExpiredDocumentKeys } = require('./services/onlyofficeDocumentKeyService'); +const editorSessions = require('./services/onlyofficeEditorSessionService'); let server = null; @@ -60,10 +64,41 @@ const startServer = async () => { // then keeps each zone within its retention and budget. trashMaintenance.start(); + // 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); folderSizeManager.stop(); trashMaintenance.stop(); searchIndexManager.stop(); diff --git a/backend/src/services/db.js b/backend/src/services/db.js index f5aed25f8..9c764ca15 100644 --- a/backend/src/services/db.js +++ b/backend/src/services/db.js @@ -38,6 +38,38 @@ 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); +`; + const getDbPath = () => { const configDir = directories.config; // Generic app database for auth, shares, and user settings. @@ -450,6 +482,16 @@ 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; + } })(); }; @@ -690,6 +732,18 @@ 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(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/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/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/versions/index.js b/backend/src/services/versions/index.js index 4bee2b464..2a24cb858 100644 --- a/backend/src/services/versions/index.js +++ b/backend/src/services/versions/index.js @@ -287,14 +287,20 @@ const readVersionText = async (context, relativePath, versionId) => { /** * 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'); } @@ -322,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 }; }; @@ -385,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 }; }; 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/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/frontend/src/api/onlyoffice.api.js b/frontend/src/api/onlyoffice.api.js index b679de5de..bd0c5d608 100644 --- a/frontend/src/api/onlyoffice.api.js +++ b/frontend/src/api/onlyoffice.api.js @@ -1,6 +1,6 @@ // /api/onlyoffice.api.js -import { requestJson, normalizePath } from './http'; +import { buildUrl, requestJson, normalizePath } from './http'; export async function fetchOnlyOfficeConfig(path, mode = 'edit') { const normalizedPath = normalizePath(path || ''); @@ -11,3 +11,75 @@ export async function fetchOnlyOfficeConfig(path, mode = 'edit') { body: JSON.stringify({ path: normalizedPath, mode }), }); } + +/** + * 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 }), + }); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 45b62d3c4..f4610a914 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -918,5 +918,10 @@ "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" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 358f83989..d8ac90463 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -918,5 +918,10 @@ "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}" } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 68c6eb155..217386951 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -918,5 +918,10 @@ "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}" } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 19f40c755..68800d101 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -918,5 +918,10 @@ "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}" } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 8f6614af0..1bd8701bf 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -918,5 +918,10 @@ "action": "इस संस्करण पर क्रिया विफल रही" }, "mark": "{count} पुराना संस्करण | {count} पुराने संस्करण" + }, + "onlyoffice": { + "renamedHeading": "नाम बदला गया", + "renamedBody": "दस्तावेज़ का नाम अब {name} है।", + "renameFailed": "{name} में नाम नहीं बदला जा सका" } } diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 3ea8b6312..cd5c8fd43 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -918,5 +918,10 @@ "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}" } } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index cc806969d..cf169e4a3 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -918,5 +918,10 @@ "action": "이 버전에 대한 작업이 실패했습니다" }, "mark": "이전 버전 {count}개" + }, + "onlyoffice": { + "renamedHeading": "이름 변경됨", + "renamedBody": "문서 이름이 {name}(으)로 바뀌었습니다.", + "renameFailed": "{name}(으)로 이름을 바꾸지 못했습니다" } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index d3cb1c0a4..50da957a2 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -918,5 +918,10 @@ "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" } } diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index c2ea89e87..547bc6f87 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -918,5 +918,10 @@ "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}" } } diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 0d80726cf..d4f3fff8d 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -918,5 +918,10 @@ "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}" } } diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 1c588ef11..5e4c93478 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -918,5 +918,10 @@ "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}" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index a5d67553e..1f7f89fb4 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -918,5 +918,10 @@ "action": "Не удалось выполнить действие с версией" }, "mark": "{count} предыдущая версия | {count} предыдущие версии | {count} предыдущих версий" + }, + "onlyoffice": { + "renamedHeading": "Переименовано", + "renamedBody": "Документ теперь называется {name}.", + "renameFailed": "Не удалось переименовать в {name}" } } diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index c4c4d90c9..58a565035 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -918,5 +918,10 @@ "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}" } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 6aedc5d5f..304f9ce2d 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -918,5 +918,10 @@ "action": "对此版本的操作失败" }, "mark": "{count} 个历史版本" + }, + "onlyoffice": { + "renamedHeading": "已重命名", + "renamedBody": "文档现在名为 {name}。", + "renameFailed": "无法重命名为 {name}" } } diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 516a5d467..0faee8964 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -918,5 +918,10 @@ "action": "對此版本的動作失敗" }, "mark": "{count} 個舊版本" + }, + "onlyoffice": { + "renamedHeading": "已重新命名", + "renamedBody": "文件現在名為 {name}。", + "renameFailed": "無法重新命名為 {name}" } } diff --git a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue index 126223f8f..d4f335276 100644 --- a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue +++ b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue @@ -25,9 +25,17 @@ + + 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/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f4610a914..8ff47632c 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Umbenannt", "renamedBody": "Das Dokument heißt jetzt {name}.", - "renameFailed": "Umbenennen in {name} nicht möglich" + "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" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d8ac90463..7cddde2b2 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Renamed", "renamedBody": "The document is now called {name}.", - "renameFailed": "Could not rename to {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" } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 217386951..28e9684fe 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Renombrado", "renamedBody": "El documento se llama ahora {name}.", - "renameFailed": "No se ha podido renombrar a {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" } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 68800d101..11a36f2e2 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Renommé", "renamedBody": "Le document s’appelle maintenant {name}.", - "renameFailed": "Impossible de renommer en {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" } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 1bd8701bf..40fed6c68 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "नाम बदला गया", "renamedBody": "दस्तावेज़ का नाम अब {name} है।", - "renameFailed": "{name} में नाम नहीं बदला जा सका" + "renameFailed": "{name} में नाम नहीं बदला जा सका", + "andOthers": "और {count} अन्य", + "transferHeading": "फ़ाइल संपादित की जा रही है", + "transferBody": "यह फ़ाइल OnlyOffice में खुली है। जारी रखने से चल रही सेव प्रक्रिया बाधित हो सकती है। क्या आप जारी रखना चाहते हैं?", + "transferCancel": "रद्द करें", + "transferConfirm": "जारी रखें", + "editingBy": "ONLYOFFICE में संपादित किया जा रहा है: {names}", + "editingNow": "ONLYOFFICE में संपादित किया जा रहा है" } } diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index cd5c8fd43..898abb4ef 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Rinominato", "renamedBody": "Il documento si chiama ora {name}.", - "renameFailed": "Impossibile rinominare in {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" } } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index cf169e4a3..1370deada 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "이름 변경됨", "renamedBody": "문서 이름이 {name}(으)로 바뀌었습니다.", - "renameFailed": "{name}(으)로 이름을 바꾸지 못했습니다" + "renameFailed": "{name}(으)로 이름을 바꾸지 못했습니다", + "andOthers": "외 {count}개", + "transferHeading": "파일 편집 중", + "transferBody": "이 파일은 OnlyOffice에서 열려 있습니다. 계속하면 진행 중인 저장 작업에 문제가 생길 수 있습니다. 계속하시겠습니까?", + "transferCancel": "취소", + "transferConfirm": "계속", + "editingBy": "ONLYOFFICE에서 편집 중: {names}", + "editingNow": "ONLYOFFICE에서 편집 중" } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 50da957a2..64e270dc4 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Hernoemd", "renamedBody": "Het document heet nu {name}.", - "renameFailed": "Hernoemen naar {name} is mislukt" + "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" } } diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 547bc6f87..6fb8aa6f6 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Zmieniono nazwę", "renamedBody": "Dokument nazywa się teraz {name}.", - "renameFailed": "Nie można zmienić nazwy na {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" } } diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index d4f3fff8d..0f3b4d8fb 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Renomeado", "renamedBody": "O documento agora se chama {name}.", - "renameFailed": "Não foi possível renomear para {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" } } diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 5e4c93478..f4d7ccfd3 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Redenumit", "renamedBody": "Documentul se numește acum {name}.", - "renameFailed": "Nu s-a putut redenumi în {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" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 1f7f89fb4..c06cba1d6 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Переименовано", "renamedBody": "Документ теперь называется {name}.", - "renameFailed": "Не удалось переименовать в {name}" + "renameFailed": "Не удалось переименовать в {name}", + "andOthers": "и еще {count}", + "transferHeading": "Файл редактируется", + "transferBody": "Этот файл открыт в OnlyOffice. Продолжение может нарушить текущее сохранение. Хотите продолжить?", + "transferCancel": "Отмена", + "transferConfirm": "Продолжить", + "editingBy": "Редактируется в ONLYOFFICE: {names}", + "editingNow": "Редактируется в ONLYOFFICE" } } diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 58a565035..a76b8871a 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "Namnet ändrat", "renamedBody": "Dokumentet heter nu {name}.", - "renameFailed": "Det gick inte att byta namn till {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" } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 304f9ce2d..aa614e8b9 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "已重命名", "renamedBody": "文档现在名为 {name}。", - "renameFailed": "无法重命名为 {name}" + "renameFailed": "无法重命名为 {name}", + "andOthers": "以及其他 {count} 项", + "transferHeading": "文件正在编辑中", + "transferBody": "此文件已在 OnlyOffice 中打开。继续可能会干扰正在进行的保存。是否继续?", + "transferCancel": "取消", + "transferConfirm": "继续", + "editingBy": "正在 ONLYOFFICE 中编辑:{names}", + "editingNow": "正在 ONLYOFFICE 中编辑" } } diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 0faee8964..3b0b94d01 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -922,6 +922,13 @@ "onlyoffice": { "renamedHeading": "已重新命名", "renamedBody": "文件現在名為 {name}。", - "renameFailed": "無法重新命名為 {name}" + "renameFailed": "無法重新命名為 {name}", + "andOthers": "以及其他 {count} 項", + "transferHeading": "檔案正在編輯中", + "transferBody": "此檔案已在 OnlyOffice 中開啟。繼續可能會干擾正在進行的儲存。是否繼續?", + "transferCancel": "取消", + "transferConfirm": "繼續", + "editingBy": "正在 ONLYOFFICE 中編輯:{names}", + "editingNow": "正在 ONLYOFFICE 中編輯" } } diff --git a/frontend/src/layouts/BrowserLayout.vue b/frontend/src/layouts/BrowserLayout.vue index fc3a0cc12..e7ffab019 100644 --- a/frontend/src/layouts/BrowserLayout.vue +++ b/frontend/src/layouts/BrowserLayout.vue @@ -22,6 +22,7 @@ import { useAppSettings } from '@/stores/appSettings'; import { useFeaturesStore } from '@/stores/features'; 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'; @@ -227,6 +228,7 @@ const handleGuestLogin = () => { + diff --git a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue index d4f335276..3272507c8 100644 --- a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue +++ b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue @@ -33,6 +33,7 @@ import { fetchOnlyOfficeConfig, heartbeatOnlyOfficeSession, renameOnlyOfficeDocument, + requestOnlyOfficeForceSave, } from '@/api'; import { useFileStore } from '@/stores/fileStore'; import { useNotificationsStore } from '@/stores/notifications'; @@ -59,6 +60,9 @@ const error = ref(null); const documentPath = ref(''); const sessionId = ref(null); let heartbeatTimer = null; +let autoSaveTimer = null; +let autoSaveIntervalMs = 0; +let hasUnsavedChanges = false; let disposed = false; /** @@ -83,9 +87,42 @@ const stopHeartbeat = () => { heartbeatTimer = null; }; +/** + * Write what the editor is holding, without waiting for it to decide. + * + * The Document Server writes the document through its own callback, which it + * sends when it considers the document finished with — seconds after the last + * keystroke, and long after the folder behind the editor has been listed again + * with the old content. Asking for it explicitly is what makes an edit visible + * where it was made. + */ +const forceSave = (reason) => { + if (!documentPath.value || !sessionId.value) return Promise.resolve({ queued: false }); + return requestOnlyOfficeForceSave(documentPath.value, { + sessionId: sessionId.value, + reason, + }).catch(() => ({ queued: false })); +}; + +const startAutoSave = () => { + if (autoSaveTimer) clearInterval(autoSaveTimer); + if (disposed || !autoSaveIntervalMs || !sessionId.value) return; + autoSaveTimer = setInterval(() => { + // Only when there is something to write: the editor tells us, and asking + // for a save of an unchanged document costs a full conversion. + if (hasUnsavedChanges) void forceSave('auto'); + }, autoSaveIntervalMs); +}; + +const stopAutoSave = () => { + if (autoSaveTimer) clearInterval(autoSaveTimer); + autoSaveTimer = null; +}; + /** End the session, so the document stops being reported as open by whoever left. */ const endSession = ({ beacon = false } = {}) => { stopHeartbeat(); + stopAutoSave(); const current = sessionId.value; if (!current || !documentPath.value) return; sessionId.value = null; @@ -159,8 +196,11 @@ const load = async () => { documentServerUrl, config: cfg, editorSessionId, + autoSaveIntervalMs: configuredAutoSaveIntervalMs, } = await fetchOnlyOfficeConfig(path, 'edit'); sessionId.value = editorSessionId || null; + autoSaveIntervalMs = Number(configuredAutoSaveIntervalMs) || 0; + hasUnsavedChanges = false; serverUrl.value = documentServerUrl; logger.debug('ONLYOFFICE config', cfg); cfg.events = { @@ -168,6 +208,12 @@ const load = async () => { onDocumentReady() { logger.debug('ONLYOFFICE document ready', { path: documentPath.value }); startHeartbeat(); + startAutoSave(); + }, + // The editor says whether it is holding anything unwritten, which is what + // decides whether a periodic save is worth a document conversion. + onDocumentStateChange(event) { + if (typeof event?.data === 'boolean') hasUnsavedChanges = event.data; }, onRequestRename(event) { void renameDocument(event?.data); diff --git a/frontend/src/stores/fileStore.js b/frontend/src/stores/fileStore.js index 66ba1c84c..bd167957c 100644 --- a/frontend/src/stores/fileStore.js +++ b/frontend/src/stores/fileStore.js @@ -433,6 +433,10 @@ export const useFileStore = defineStore('fileStore', () => { currentPath.value = normalizePath(path); } + // Fields this store puts on an item itself, which no listing ever sends and + // which must survive a refresh. + const LOCAL_ONLY_ITEM_FIELDS = new Set(['thumbnail']); + async function fetchPathItems(path) { const previousItems = Array.isArray(currentPathItems.value) ? currentPathItems.value : []; @@ -478,6 +482,15 @@ export const useFileStore = defineStore('fileStore', () => { // Preserve any locally-added thumbnail if the backend // does not send one, but refresh all other metadata. const prevThumbnail = existing.thumbnail; + // A field the listing has stopped sending has to go, not merely be + // overwritten: `Object.assign` cannot remove anything, so a mark the + // row no longer carries — a document nobody has open any more, a + // history that was just emptied — stayed for as long as the folder + // was on screen. `supportsThumbnail` was patched for exactly this, + // one field at a time; this is the same rule for all of them. + for (const key of Object.keys(existing)) { + if (!(key in incoming) && !LOCAL_ONLY_ITEM_FIELDS.has(key)) delete existing[key]; + } Object.assign(existing, incoming); if (!incoming.thumbnail && prevThumbnail) { existing.thumbnail = prevThumbnail; diff --git a/frontend/src/views/FolderView.vue b/frontend/src/views/FolderView.vue index 23980b742..3232ef81e 100644 --- a/frontend/src/views/FolderView.vue +++ b/frontend/src/views/FolderView.vue @@ -7,6 +7,7 @@ import FileObject from '@/components/FileObject.vue'; import { useFileStore } from '@/stores/fileStore'; import { useFolderSizeStore } from '@/stores/folderSize'; import { useFeaturesStore } from '@/stores/features'; +import { useOnlyOfficeActivity } from '@/composables/useOnlyOfficeActivity'; import LoadingIcon from '@/icons/LoadingIcon.vue'; import { useSelection } from '@/composables/itemSelection'; import { useExplorerContextMenu } from '@/composables/contextMenu'; @@ -261,7 +262,17 @@ const refreshFolderSizes = () => { watch(() => fileStore.getCurrentPathItems, refreshFolderSizes, { immediate: true }); -onMounted(loadFiles); +// Whoever has a document open in an editor, kept current while this folder is +// on screen: the server holds the request until somebody joins or leaves. +const onlyofficeActivity = useOnlyOfficeActivity({ + featuresStore, + refresh: () => fileStore.fetchPathItems(fileStore.currentPath).catch(() => {}), +}); + +onMounted(() => { + void loadFiles(); + void onlyofficeActivity.start(); +}); watch(hasMoreItems, () => { setupLoadMoreObserver(); @@ -394,6 +405,10 @@ useEventListener(window, 'pointercancel', stopResize); useEventListener(window, 'resize', updateScrollState); useEventListener(window, 'scroll', updateScrollState, { passive: true }); +onBeforeUnmount(() => { + onlyofficeActivity.stop(); +}); + onBeforeUnmount(() => { stopResize(); disconnectLoadMoreObserver(); From b4425b229ccf1457e332b91bdc8e6a0cb6029f9f Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 10:45:16 +0200 Subject: [PATCH 5/8] Answer what the editor asks: a copy, a mention, and a history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ONLYOFFICE hides the menu entries it has no answer for. Three of them were hidden because nothing here answered. **Saving a copy.** The editor does not write anything itself: it converts the document and hands the integration a URL to fetch the result from. With nothing to receive it, Download was the only way out of a document — through the browser, into the person's downloads rather than their volume. Now the server fetches it and writes the copy beside the original, never over anything: a name already taken gets the same "(1)" treatment as everywhere else, and the answer says the name actually used. A title carrying a path separator is refused rather than trimmed to its last segment — reinterpreting it would turn "../invoice.pdf" into a silent success in a folder nobody named. Saving a copy is not a change to the original, so a reader may do it; what it needs is the right to read the document and to write into the folder, exactly as an upload would. **Mentioning somebody in a comment.** The editor asks for the whole list and filters it itself as the name is typed, and it waits for an answer even when the list is empty. Only a signed-in account gets it: a visitor through a share link has no business being handed the user directory. The comment itself is already written by the editor; the separate "tell them" step has no channel to deliver on, so the mention is recorded and the answer says plainly that nothing was sent, rather than leaving the editor waiting on a handler that does nothing. **The document's own history.** 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. The two are reconciled here rather than in the editor. Restore is offered only to somebody who may change the document. That same piece closes what the history panel could not do: an earlier version of an office document now opens in the office editor, read only — which is what the versions panel was missing, since it could only put text in front of anybody on its own. A version gets a key of its own, so it never touches the one the people editing the document share, and it is served with no callback at all: nothing is saved from a version, and the document's own callback would release that shared key. Left out on purpose: handing the editor a file from the volume to insert or compare against. It is a short-lived read-only token over the same route, and it is useless without somewhere to choose the file — so it travels with the batch that brings the destination dialog. Gates: lint and formatting on the changed files, `npm run build`, every backend module loads, and the whole backend suite — 2,001 pass, against the two that fail on `main` before any of this. Fifteen new tests; every claim above fails when the change behind it is put back, mutated one at a time: the document URL no longer checked against the Document Server, the title trimmed instead of refused, the copy written straight over the name, the mention list handed to anybody, and a version config given a callback. Checked in the built image: a document with two versions answers a history of three entries ending in itself, one entry hands back a signed location whose token says it may never be written, and opening that version answers a viewer with its own key and no editing session. --- backend/src/routes/onlyoffice.js | 376 +++++++++++++++++- backend/src/services/userSearchService.js | 31 ++ .../routes/onlyoffice-from-inside.test.js | 291 ++++++++++++++ frontend/src/api/onlyoffice.api.js | 78 +++- frontend/src/components/VersionsPanel.vue | 34 +- frontend/src/i18n/locales/de.json | 5 +- frontend/src/i18n/locales/en.json | 5 +- frontend/src/i18n/locales/es.json | 5 +- frontend/src/i18n/locales/fr.json | 5 +- frontend/src/i18n/locales/hi.json | 5 +- frontend/src/i18n/locales/it.json | 5 +- frontend/src/i18n/locales/ko.json | 5 +- frontend/src/i18n/locales/nl.json | 5 +- frontend/src/i18n/locales/pl.json | 5 +- frontend/src/i18n/locales/pt-BR.json | 5 +- frontend/src/i18n/locales/ro.json | 5 +- frontend/src/i18n/locales/ru.json | 5 +- frontend/src/i18n/locales/sv.json | 5 +- frontend/src/i18n/locales/zh-CN.json | 5 +- frontend/src/i18n/locales/zh-TW.json | 5 +- .../plugins/onlyoffice/OnlyOfficePreview.vue | 181 ++++++++- .../plugins/onlyoffice/onlyofficePreview.js | 2 + 22 files changed, 1046 insertions(+), 22 deletions(-) create mode 100644 backend/tests/routes/onlyoffice-from-inside.test.js diff --git a/backend/src/routes/onlyoffice.js b/backend/src/routes/onlyoffice.js index ff4fd7b44..1dc26d506 100644 --- a/backend/src/routes/onlyoffice.js +++ b/backend/src/routes/onlyoffice.js @@ -8,8 +8,14 @@ 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'); @@ -116,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; @@ -393,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); @@ -655,6 +693,342 @@ router.post( }) ); +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. * 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/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/frontend/src/api/onlyoffice.api.js b/frontend/src/api/onlyoffice.api.js index 8851a55b1..24abf599b 100644 --- a/frontend/src/api/onlyoffice.api.js +++ b/frontend/src/api/onlyoffice.api.js @@ -2,13 +2,17 @@ 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 } : {}), + }), }); } @@ -114,3 +118,73 @@ export async function waitForOnlyOfficeActivityVersion(since, options = {}) { ...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/components/VersionsPanel.vue b/frontend/src/components/VersionsPanel.vue index 1ac4f30e4..17b54bb6d 100644 --- a/frontend/src/components/VersionsPanel.vue +++ b/frontend/src/components/VersionsPanel.vue @@ -14,6 +14,7 @@ import { useVersionsPanelStore } from '@/stores/versionsPanel'; import { useNotificationsStore } from '@/stores/notifications'; import { useFileStore } from '@/stores/fileStore'; import { isEditableExtension } from '@/config/editor'; +import { usePreviewManager } from '@/plugins/preview/manager'; import { formatBytes, formatLocalDateTime } from '@/utils'; import { deleteVersions, @@ -149,12 +150,28 @@ const extension = computed(() => { return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''; }); +const previewManager = usePreviewManager(); + +/** + * Whether an office editor opens this document, and can open one of its + * versions to be read. A text file goes to the text editor instead. + */ +const officeViewer = computed(() => { + if (isEditableExtension(extension.value) || !fileName.value) return null; + const match = previewManager.findPlugin?.({ + name: fileName.value, + path: parentPath.value, + kind: extension.value, + }); + return match?.plugin?.supportsVersions ? match.plugin : null; +}); + const actionsFor = (version) => { const usable = version.available !== false; const list = []; - // A text file opens in the editor, read only on that version. An office - // document has no reader for a version yet. - if (usable && isEditableExtension(extension.value)) { + // A text file opens in the text editor, read only on that version; an office + // document opens in its own, the same way. + if (usable && (isEditableExtension(extension.value) || officeViewer.value)) { list.push({ id: 'preview', label: t('versions.actions.preview') }); } if (usable && rights.value.download) { @@ -235,6 +252,17 @@ const download = (version) => { const preview = (version) => { const path = filePath.value; close(); + // A document opens in its office editor, read only on that version; the + // editor is closed like any preview, and the history reopened from there. + if (officeViewer.value) { + previewManager.open({ + name: fileName.value, + path: parentPath.value, + kind: extension.value, + versionId: version.id, + }); + return; + } router.push({ name: 'VersionFileViewer', params: { versionId: version.id, path } }); }; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 8ff47632c..7f242379b 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -929,6 +929,9 @@ "transferCancel": "Abbrechen", "transferConfirm": "Fortfahren", "editingBy": "Wird in ONLYOFFICE bearbeitet von: {names}", - "editingNow": "Wird gerade in ONLYOFFICE bearbeitet" + "editingNow": "Wird gerade in ONLYOFFICE bearbeitet", + "savedAsHeading": "In diesem Ordner gespeichert", + "savedAsBody": "{name} wurde neben dem Original abgelegt.", + "saveAsFailed": "{name} konnte nicht gespeichert werden" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7cddde2b2..05128a78a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -929,6 +929,9 @@ "transferCancel": "Cancel", "transferConfirm": "Continue", "editingBy": "Being edited in ONLYOFFICE by {names}", - "editingNow": "Being edited in ONLYOFFICE" + "editingNow": "Being edited in ONLYOFFICE", + "savedAsHeading": "Saved to this folder", + "savedAsBody": "{name} was added next to the original.", + "saveAsFailed": "Could not save {name}" } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 28e9684fe..3ca94222f 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -929,6 +929,9 @@ "transferCancel": "Cancelar", "transferConfirm": "Continuar", "editingBy": "Se está editando en ONLYOFFICE: {names}", - "editingNow": "Se está editando en ONLYOFFICE" + "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}" } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 11a36f2e2..e693185d4 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -929,6 +929,9 @@ "transferCancel": "Annuler", "transferConfirm": "Continuer", "editingBy": "Édition en cours dans ONLYOFFICE : {names}", - "editingNow": "Édition en cours dans ONLYOFFICE" + "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}" } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 40fed6c68..fd6035423 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -929,6 +929,9 @@ "transferCancel": "रद्द करें", "transferConfirm": "जारी रखें", "editingBy": "ONLYOFFICE में संपादित किया जा रहा है: {names}", - "editingNow": "ONLYOFFICE में संपादित किया जा रहा है" + "editingNow": "ONLYOFFICE में संपादित किया जा रहा है", + "savedAsHeading": "इस फ़ोल्डर में सहेजा गया", + "savedAsBody": "{name} को मूल फ़ाइल के बगल में जोड़ा गया।", + "saveAsFailed": "{name} को सहेजा नहीं जा सका" } } diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 898abb4ef..d396f1121 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -929,6 +929,9 @@ "transferCancel": "Annulla", "transferConfirm": "Continua", "editingBy": "In modifica in ONLYOFFICE da {names}", - "editingNow": "In modifica in ONLYOFFICE" + "editingNow": "In modifica in ONLYOFFICE", + "savedAsHeading": "Salvato in questa cartella", + "savedAsBody": "{name} è stato aggiunto accanto all’originale.", + "saveAsFailed": "Impossibile salvare {name}" } } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 1370deada..17b149c0f 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -929,6 +929,9 @@ "transferCancel": "취소", "transferConfirm": "계속", "editingBy": "ONLYOFFICE에서 편집 중: {names}", - "editingNow": "ONLYOFFICE에서 편집 중" + "editingNow": "ONLYOFFICE에서 편집 중", + "savedAsHeading": "이 폴더에 저장됨", + "savedAsBody": "{name}이(가) 원본 옆에 추가되었습니다.", + "saveAsFailed": "{name}을(를) 저장하지 못했습니다" } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 64e270dc4..eefe5def6 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -929,6 +929,9 @@ "transferCancel": "Annuleren", "transferConfirm": "Doorgaan", "editingBy": "Wordt bewerkt in ONLYOFFICE door {names}", - "editingNow": "Wordt bewerkt in ONLYOFFICE" + "editingNow": "Wordt bewerkt in ONLYOFFICE", + "savedAsHeading": "Opgeslagen in deze map", + "savedAsBody": "{name} is naast het origineel toegevoegd.", + "saveAsFailed": "{name} kon niet worden opgeslagen" } } diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 6fb8aa6f6..230869506 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -929,6 +929,9 @@ "transferCancel": "Anuluj", "transferConfirm": "Kontynuuj", "editingBy": "Edytowane w ONLYOFFICE przez: {names}", - "editingNow": "Edytowane w ONLYOFFICE" + "editingNow": "Edytowane w ONLYOFFICE", + "savedAsHeading": "Zapisano w tym folderze", + "savedAsBody": "{name} został dodany obok oryginału.", + "saveAsFailed": "Nie można zapisać {name}" } } diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 0f3b4d8fb..7d57a84e8 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -929,6 +929,9 @@ "transferCancel": "Cancelar", "transferConfirm": "Continuar", "editingBy": "Sendo editado no ONLYOFFICE por {names}", - "editingNow": "Sendo editado no ONLYOFFICE" + "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}" } } diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index f4d7ccfd3..d17e01dd6 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -929,6 +929,9 @@ "transferCancel": "Anulează", "transferConfirm": "Continuă", "editingBy": "Se editează în ONLYOFFICE de către {names}", - "editingNow": "Se editează în ONLYOFFICE" + "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" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index c06cba1d6..ba9a24aa3 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -929,6 +929,9 @@ "transferCancel": "Отмена", "transferConfirm": "Продолжить", "editingBy": "Редактируется в ONLYOFFICE: {names}", - "editingNow": "Редактируется в ONLYOFFICE" + "editingNow": "Редактируется в ONLYOFFICE", + "savedAsHeading": "Сохранено в эту папку", + "savedAsBody": "{name} добавлен рядом с оригиналом.", + "saveAsFailed": "Не удалось сохранить {name}" } } diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index a76b8871a..9f0505736 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -929,6 +929,9 @@ "transferCancel": "Avbryt", "transferConfirm": "Fortsätt", "editingBy": "Redigeras i ONLYOFFICE av {names}", - "editingNow": "Redigeras i ONLYOFFICE" + "editingNow": "Redigeras i ONLYOFFICE", + "savedAsHeading": "Sparad i den här mappen", + "savedAsBody": "{name} lades till bredvid originalet.", + "saveAsFailed": "Det gick inte att spara {name}" } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index aa614e8b9..471869cb6 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -929,6 +929,9 @@ "transferCancel": "取消", "transferConfirm": "继续", "editingBy": "正在 ONLYOFFICE 中编辑:{names}", - "editingNow": "正在 ONLYOFFICE 中编辑" + "editingNow": "正在 ONLYOFFICE 中编辑", + "savedAsHeading": "已保存到此文件夹", + "savedAsBody": "{name} 已添加到原文件旁边。", + "saveAsFailed": "无法保存 {name}" } } diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 3b0b94d01..5998ccd0e 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -929,6 +929,9 @@ "transferCancel": "取消", "transferConfirm": "繼續", "editingBy": "正在 ONLYOFFICE 中編輯:{names}", - "editingNow": "正在 ONLYOFFICE 中編輯" + "editingNow": "正在 ONLYOFFICE 中編輯", + "savedAsHeading": "已儲存到此資料夾", + "savedAsBody": "{name} 已新增至原檔案旁。", + "saveAsFailed": "無法儲存 {name}" } } diff --git a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue index 3272507c8..884a49696 100644 --- a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue +++ b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue @@ -31,10 +31,19 @@ import { DocumentEditor } from '@onlyoffice/document-editor-vue'; import { endOnlyOfficeSession, fetchOnlyOfficeConfig, + fetchOnlyOfficeHistory, + fetchOnlyOfficeHistoryData, + fetchOnlyOfficeMentionUsers, heartbeatOnlyOfficeSession, + notifyOnlyOfficeMention, renameOnlyOfficeDocument, requestOnlyOfficeForceSave, + restoreVersion, + saveOnlyOfficeDocumentAs, } from '@/api'; +import { useFeaturesStore } from '@/stores/features'; +import { useVersionsPanelStore } from '@/stores/versionsPanel'; +import { formatLocalDateTime } from '@/utils'; import { useFileStore } from '@/stores/fileStore'; import { useNotificationsStore } from '@/stores/notifications'; import logger from '@/utils/logger'; @@ -50,6 +59,13 @@ const props = defineProps({ const { t } = useI18n(); const fileStore = useFileStore(); const notifications = useNotificationsStore(); +const featuresStore = useFeaturesStore(); +const versionsPanel = useVersionsPanelStore(); + +/** Opened on an earlier version: a viewer, with nothing to save and no session. */ +const viewedVersionId = computed(() => + typeof props.item?.versionId === 'string' ? props.item.versionId : '' +); const serverUrl = ref(null); const config = ref(null); @@ -183,6 +199,137 @@ const editorId = computed(() => { ); }); +/** The live editor, which is how ONLYOFFICE takes answers to what it asked. */ +const editorInstance = () => window.DocEditor?.instances?.[editorId.value]; + +/** Which of our version ids each number in the editor's history stands for. */ +let historyVersionIds = new Map(); + +const showHistory = async () => { + const editor = editorInstance(); + if (!editor?.refreshHistory) return; + try { + const result = await fetchOnlyOfficeHistory(documentPath.value); + historyVersionIds = new Map(result.history.map((entry) => [entry.version, entry.versionId])); + editor.refreshHistory({ + currentVersion: result.currentVersion, + history: result.history.map((entry) => ({ + version: entry.version, + key: entry.key, + created: formatLocalDateTime(entry.created), + user: entry.user, + })), + }); + } catch (historyError) { + logger.error('ONLYOFFICE history unavailable', historyError); + editor.refreshHistory({ error: historyError?.message || '' }); + } +}; + +const showHistoryEntry = async (version) => { + const editor = editorInstance(); + if (!editor?.setHistoryData) return; + try { + editor.setHistoryData( + await fetchOnlyOfficeHistoryData(documentPath.value, { + version, + versionId: historyVersionIds.get(version) || undefined, + }) + ); + } catch (dataError) { + editor.setHistoryData({ version, error: dataError?.message || t('versions.loadFailed') }); + } +}; + +const restoreFromHistory = async (version) => { + const versionId = historyVersionIds.get(version); + // The current state is already the document. + if (!versionId) return; + try { + const result = await restoreVersion(documentPath.value, versionId); + const unchanged = result?.status === 'unchanged'; + notifications.addNotification({ + type: unchanged ? 'info' : 'success', + heading: unchanged ? t('versions.results.unchanged') : t('versions.results.restored'), + durationMs: 4000, + }); + versionsPanel.markRestored(); + await fileStore.fetchPathItems(fileStore.currentPath).catch(() => {}); + // The editor is still showing the history, over what the restore replaced. + await load(); + } catch (restoreError) { + notifications.addNotification({ + type: 'error', + heading: t('versions.errors.action'), + body: restoreError?.message || '', + }); + } +}; + +/** + * The editor's own history panel, where the document has a history to show. + * + * Left out entirely when a version is what is being read: a history inside a + * history is a way to lose track of which document is on screen. + */ +const historyEvents = (cfg) => { + if (viewedVersionId.value || !featuresStore.versionsEnabled) return {}; + const events = { + onRequestHistory() { + void showHistory(); + }, + onRequestHistoryData(event) { + void showHistoryEntry(Number(event?.data)); + }, + onRequestHistoryClose() { + void load(); + }, + }; + // Restore is only offered to somebody who may change the document. + if (cfg?.document?.permissions?.edit) { + events.onRequestRestore = (event) => { + void restoreFromHistory(Number(event?.data?.version)); + }; + } + return events; +}; + +/** + * "Save as" from the editor's menu. + * + * ONLYOFFICE converts the document and hands over a URL; the server fetches it + * and writes the copy beside the original. Without this the entry is hidden and + * Download is the only way out — through the browser, into the person's + * downloads rather than their volume. + */ +const saveDocumentAs = async (data) => { + const title = data?.title; + const url = data?.url; + if (!documentPath.value || !title || !url) { + logger.warn('ONLYOFFICE save-as request was incomplete', { title: title || null }); + return; + } + + try { + const saved = await saveOnlyOfficeDocumentAs(documentPath.value, { url, title }); + notifications.addNotification({ + type: 'success', + heading: t('onlyoffice.savedAsHeading'), + body: t('onlyoffice.savedAsBody', { name: saved?.name || title }), + }); + // The file landed in the folder being browsed, so show it without waiting + // for the next navigation. + await fileStore.fetchPathItems(fileStore.currentPath).catch(() => {}); + } catch (e) { + logger.error('ONLYOFFICE save-as failed', { path: documentPath.value, err: e }); + notifications.addNotification({ + type: 'error', + heading: t('onlyoffice.saveAsFailed', { name: title }), + body: e?.message || '', + }); + } +}; + const load = async () => { endSession(); error.value = null; @@ -197,7 +344,9 @@ const load = async () => { config: cfg, editorSessionId, autoSaveIntervalMs: configuredAutoSaveIntervalMs, - } = await fetchOnlyOfficeConfig(path, 'edit'); + } = await fetchOnlyOfficeConfig(path, viewedVersionId.value ? 'view' : 'edit', { + ...(viewedVersionId.value ? { versionId: viewedVersionId.value } : {}), + }); sessionId.value = editorSessionId || null; autoSaveIntervalMs = Number(configuredAutoSaveIntervalMs) || 0; hasUnsavedChanges = false; @@ -205,6 +354,36 @@ const load = async () => { logger.debug('ONLYOFFICE config', cfg); cfg.events = { ...cfg.events, + ...historyEvents(cfg), + // Saving a copy from the editor's menu, which is the only way out of the + // editor that lands in the volume rather than in the browser. + onRequestSaveAs(event) { + void saveDocumentAs(event?.data); + }, + // A comment was started with @. The editor takes the whole list and + // filters it itself as the name is typed, so there is nothing to search + // on; it also expects an answer even when the list is empty, or the + // mention popup waits for ever. + onRequestUsers(event) { + const editor = editorInstance(); + if (!editor?.setUsers) return; + void fetchOnlyOfficeMentionUsers() + .then((result) => editor.setUsers({ c: event?.data?.c, users: result?.users || [] })) + .catch((usersError) => { + logger.debug('ONLYOFFICE mention list unavailable', usersError); + editor.setUsers({ c: event?.data?.c, users: [] }); + }); + }, + // The comment is already in the document; this is the separate "tell + // them" step, which ONLYOFFICE leaves to the integration. + onRequestSendNotify(event) { + const data = event?.data || {}; + void notifyOnlyOfficeMention(documentPath.value, { + emails: data.emails, + actionLink: data.actionLink, + comment: data.message, + }).catch((notifyError) => logger.debug('ONLYOFFICE mention not sent', notifyError)); + }, onDocumentReady() { logger.debug('ONLYOFFICE document ready', { path: documentPath.value }); startHeartbeat(); diff --git a/frontend/src/plugins/onlyoffice/onlyofficePreview.js b/frontend/src/plugins/onlyoffice/onlyofficePreview.js index 01b8828a2..28db598ca 100644 --- a/frontend/src/plugins/onlyoffice/onlyofficePreview.js +++ b/frontend/src/plugins/onlyoffice/onlyofficePreview.js @@ -21,6 +21,8 @@ export const onlyofficePreviewPlugin = (extensions) => ({ priority: 50, // Render with minimal chrome in the overlay host minimalHeader: true, + // Can open an earlier version of a document, to be read (`item.versionId`). + supportsVersions: true, match: (context) => { const ext = String(context.extension || '').toLowerCase(); From d6483e0c57397414b1a57494baac8a22bbdf1c34 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 11:00:27 +0200 Subject: [PATCH 6/8] Give a document an address of its own, and every tab a name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document had no address. Opening one filled a panel over the folder it was in, driven by a store, and the browser knew nothing about it: nothing could be linked to, nothing kept as a bookmark, the back button did not close it, and two documents could not be open at once. `/open/` is that address. Everything the page does the folder listing also does — the same preview, the same plugins, the same component. What it adds is the one thing a panel over a folder cannot have: a URL. The browser then provides the tabs. Two things about leaving had to be right. Closing the document lands back in the folder it is in, with the document selected, which is where closing the panel leaves you — except in a tab opened for this document alone, where the thing the close button should close is the tab. Closing the tab has to tell the server what closing the panel tells it: a tab gives one synchronous moment on its way out, so the plugin is told it is unloading and sends what it has to send in one beacon. Without it, a document closed by closing its tab would go on being reported as open by somebody who had left. Whether a document opens in a tab of its own is an account's preference, off by default. 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. And the tab has a name. It was the last segment of the address whatever the page was, so every settings section, the search and both share lists read "Volumes"; a document opened directly read "Explorer". Now each page says what it is and which instance it belongs to — the name set in Branding, read from the settings every page already loads, so renaming the instance reaches every open tab without a reload. Four tabs all reading "Explorer" are four tabs nobody can tell apart, which is exactly what a page whose point is to be opened several times at once cannot afford. Gates: lint and formatting on the changed files, `npm run build`, every backend module loads, and the whole backend suite — 2,007 pass, against the two that fail on `main` before any of this. Checked in the built image: the folder tab reads "Projects | Explorer", the settings tab "Preferences | Explorer", the trash tab "Trash | Explorer" and the document tab "notes.md | Explorer"; `/open/Work/Projects/notes.md` shows the document; a file nothing opens says so and offers the way back; and with the preference on, a double click in the listing opens a second tab at that address. --- backend/src/services/settingsService.js | 1 + .../tests/routes/settings-preferences.test.js | 21 +- frontend/src/composables/navigation.js | 45 +++- frontend/src/composables/usePageTitle.js | 21 ++ frontend/src/i18n/locales/de.json | 8 +- frontend/src/i18n/locales/en.json | 8 +- frontend/src/i18n/locales/es.json | 8 +- frontend/src/i18n/locales/fr.json | 8 +- frontend/src/i18n/locales/hi.json | 8 +- frontend/src/i18n/locales/it.json | 8 +- frontend/src/i18n/locales/ko.json | 8 +- frontend/src/i18n/locales/nl.json | 8 +- frontend/src/i18n/locales/pl.json | 8 +- frontend/src/i18n/locales/pt-BR.json | 8 +- frontend/src/i18n/locales/ro.json | 8 +- frontend/src/i18n/locales/ru.json | 8 +- frontend/src/i18n/locales/sv.json | 8 +- frontend/src/i18n/locales/zh-CN.json | 8 +- frontend/src/i18n/locales/zh-TW.json | 8 +- frontend/src/layouts/AuthLayout.vue | 3 + frontend/src/layouts/BrowserLayout.vue | 19 +- frontend/src/plugins/index.js | 27 ++- .../plugins/onlyoffice/OnlyOfficePreview.vue | 16 ++ .../plugins/onlyoffice/onlyofficePreview.js | 27 +++ frontend/src/plugins/preview/manager.js | 27 ++- frontend/src/router/index.js | 9 + frontend/src/utils/documentRoute.js | 22 ++ frontend/src/utils/pageTitle.js | 70 ++++++ frontend/src/views/DocumentView.vue | 222 ++++++++++++++++++ frontend/src/views/EditorView.vue | 6 + .../settings/SettingsUserPreferences.vue | 19 ++ 31 files changed, 629 insertions(+), 46 deletions(-) create mode 100644 frontend/src/composables/usePageTitle.js create mode 100644 frontend/src/utils/documentRoute.js create mode 100644 frontend/src/utils/pageTitle.js create mode 100644 frontend/src/views/DocumentView.vue diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 0bb483bfe..8f72f194c 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -305,6 +305,7 @@ const USER_BOOLEAN_SETTINGS = new Set([ 'showHiddenFiles', 'showThumbnails', 'showVersionMarks', + 'documentsOpenInNewTab', 'showSidebarFavorites', 'showSidebarShares', 'showSidebarTools', diff --git a/backend/tests/routes/settings-preferences.test.js b/backend/tests/routes/settings-preferences.test.js index f8ab63b84..614011242 100644 --- a/backend/tests/routes/settings-preferences.test.js +++ b/backend/tests/routes/settings-preferences.test.js @@ -49,16 +49,19 @@ 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', 'showSidebarFavorites'])( - 'is written when %s is saved', - async (key) => { - const response = await save({ [key]: true }); + 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); - } - ); + 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: 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/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 7f242379b..98fd7cb88 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Leitet beim Besuch der Startseite automatisch zur ersten Volume weiter. Wenn nicht gesetzt, wird die Serverkonfiguration verwendet.", "useEnvSetting": "Servereinstellung verwenden", "showVersionMarks": "Dateien mit Versionen kennzeichnen", - "showVersionMarksHelp": "Ein kleines Zeichen in der Liste bei Dateien mit früheren Versionen, mit deren Anzahl. Ein Klick öffnet den Verlauf." + "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.", @@ -933,5 +935,9 @@ "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" } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 05128a78a..5508c978e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Automatically redirect to the first volume when visiting the home page. If not set, follows the server configuration.", "useEnvSetting": "Use server setting", "showVersionMarks": "Mark files that have versions", - "showVersionMarksHelp": "A small mark in the listing on any file with earlier versions, with how many. Click it to open the history." + "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.", @@ -933,5 +935,9 @@ "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" } } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 3ca94222f..f29c84026 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Redirige automáticamente a la primera unidad al visitar la pantalla de inicio. Si no se establece, usa la configuración del servidor.", "useEnvSetting": "Usar configuración del servidor", "showVersionMarks": "Marcar los archivos con versiones", - "showVersionMarksHelp": "Una pequeña marca en la lista sobre los archivos con versiones anteriores, con su número. Haga clic para abrir el historial." + "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.", @@ -933,5 +935,9 @@ "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" } } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index e693185d4..6fc3f966d 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Redirige automatiquement vers le premier volume depuis l’accueil. Si non défini, utilise la configuration serveur.", "useEnvSetting": "Utiliser la configuration serveur", "showVersionMarks": "Signaler les fichiers qui ont des versions", - "showVersionMarksHelp": "Une petite marque dans la liste sur les fichiers qui ont des versions antérieures, avec leur nombre. Cliquez dessus pour ouvrir l’historique." + "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.", @@ -933,5 +935,9 @@ "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" } } diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index fd6035423..3120387e3 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -448,7 +448,9 @@ "skipHomeHelp": "होम पेज पर आने पर पहले वॉल्यूम पर स्वचालित रूप से रीडायरेक्ट करें। यदि सेट नहीं है, तो सर्वर कॉन्फ़िगरेशन का पालन करें।", "useEnvSetting": "सर्वर सेटिंग का उपयोग करें", "showVersionMarks": "संस्करण वाली फ़ाइलों पर निशान लगाएँ", - "showVersionMarksHelp": "सूची में उन फ़ाइलों पर एक छोटा निशान जिनके पुराने संस्करण हैं, उनकी संख्या के साथ। इतिहास खोलने के लिए उस पर क्लिक करें।" + "showVersionMarksHelp": "सूची में उन फ़ाइलों पर एक छोटा निशान जिनके पुराने संस्करण हैं, उनकी संख्या के साथ। इतिहास खोलने के लिए उस पर क्लिक करें।", + "documentsOpenInNewTab": "दस्तावेज़ नए टैब में खोलें", + "documentsOpenInNewTabHelp": "कोई फ़ाइल खोलने पर उसे अपना ब्राउज़र टैब मिलता है, इसलिए ब्राउज़ करते हुए कई खुले रह सकते हैं। बंद होने पर वह आज की तरह फ़ोल्डर के ऊपर खुलती है।" }, "thumbs": { "subtitle": "छवियों और वीडियो के लिए पूर्वावलोकन थंबनेल अनुकूलित करें।", @@ -933,5 +935,9 @@ "savedAsHeading": "इस फ़ोल्डर में सहेजा गया", "savedAsBody": "{name} को मूल फ़ाइल के बगल में जोड़ा गया।", "saveAsFailed": "{name} को सहेजा नहीं जा सका" + }, + "preview": { + "nothingOpensIt": "यहाँ कुछ भी {name} को नहीं खोल सकता।", + "backToFolder": "फ़ोल्डर पर वापस" } } diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index d396f1121..3329d97c7 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Reindirizza automaticamente al primo volume quando si visita la home. Se non impostato, usa la configurazione del server.", "useEnvSetting": "Usa impostazione del server", "showVersionMarks": "Segnalare i file con versioni", - "showVersionMarksHelp": "Un piccolo segno nell’elenco sui file con versioni precedenti, con il loro numero. Un clic apre la cronologia." + "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.", @@ -933,5 +935,9 @@ "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" } } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 17b149c0f..fe410ca6a 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -448,7 +448,9 @@ "skipHomeHelp": "접속했을 때, 자동으로 첫 번째 볼륨으로 이동합니다. 설정하지 않을 경우 서버 설정을 따릅니다.", "useEnvSetting": "서버 설정 사용", "showVersionMarks": "버전이 있는 파일 표시", - "showVersionMarksHelp": "이전 버전이 있는 파일에 개수와 함께 목록에서 작은 표시를 붙입니다. 누르면 기록이 열립니다." + "showVersionMarksHelp": "이전 버전이 있는 파일에 개수와 함께 목록에서 작은 표시를 붙입니다. 누르면 기록이 열립니다.", + "documentsOpenInNewTab": "문서를 새 탭에서 열기", + "documentsOpenInNewTabHelp": "파일을 열면 브라우저 탭이 따로 생기므로, 계속 탐색하는 동안 여러 개를 열어 둘 수 있습니다. 끄면 지금처럼 폴더 위에서 열립니다." }, "thumbs": { "subtitle": "사진, 영상 파일의 미리보기 썸네일 관련 설정을 커스터마이징하세요.", @@ -933,5 +935,9 @@ "savedAsHeading": "이 폴더에 저장됨", "savedAsBody": "{name}이(가) 원본 옆에 추가되었습니다.", "saveAsFailed": "{name}을(를) 저장하지 못했습니다" + }, + "preview": { + "nothingOpensIt": "여기에서 {name}을(를) 열 수 있는 것이 없습니다.", + "backToFolder": "폴더로 돌아가기" } } diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index eefe5def6..1e4ccacc4 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Automatisch doorsturen naar het eerste volume bij het bezoeken van de startpagina. Als dit niet is ingesteld, wordt de serverconfiguratie gevolgd.", "useEnvSetting": "Serverconfiguratie gebruiken", "showVersionMarks": "Bestanden met versies markeren", - "showVersionMarksHelp": "Een klein teken in de lijst bij bestanden met eerdere versies, met het aantal. Klik erop om de geschiedenis te openen." + "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.", @@ -933,5 +935,9 @@ "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" } } diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 230869506..06aa509f3 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Automatycznie przekierowuje do pierwszego wolumenu przy otwieraniu strony głównej. Jeśli nie ustawiono, stosuje konfigurację serwera.", "useEnvSetting": "Użyj ustawień serwera", "showVersionMarks": "Oznaczaj pliki, które mają wersje", - "showVersionMarksHelp": "Mały znak na liście przy plikach z wcześniejszymi wersjami, wraz z ich liczbą. Kliknięcie otwiera historię." + "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.", @@ -933,5 +935,9 @@ "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" } } diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 7d57a84e8..9d9a33092 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Redirecionar automaticamente para o primeiro volume ao visitar a página inicial. Se não definido, segue a configuração do servidor.", "useEnvSetting": "Usar configuração do servidor", "showVersionMarks": "Marcar arquivos que têm versões", - "showVersionMarksHelp": "Uma pequena marca na lista nos arquivos com versões anteriores, com a quantidade. Clique nela para abrir o histórico." + "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.", @@ -933,5 +935,9 @@ "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" } } diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index d17e01dd6..1f80c5b4c 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Redirectează automat către primul volum la deschiderea paginii principale. Dacă nu este setat, se folosește configurația serverului.", "useEnvSetting": "Folosește setarea serverului", "showVersionMarks": "Marchează fișierele care au versiuni", - "showVersionMarksHelp": "Un semn discret în listă pe fișierele cu versiuni anterioare, cu numărul lor. Un clic deschide istoricul." + "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.", @@ -933,5 +935,9 @@ "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" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index ba9a24aa3..cec52d0a6 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Автоматически перенаправляет к первому тому при заходе на главную. Если не указано, используется настройка сервера.", "useEnvSetting": "Использовать серверную настройку", "showVersionMarks": "Отмечать файлы, у которых есть версии", - "showVersionMarksHelp": "Небольшая отметка в списке у файлов с предыдущими версиями и их числом. Нажмите на неё, чтобы открыть историю." + "showVersionMarksHelp": "Небольшая отметка в списке у файлов с предыдущими версиями и их числом. Нажмите на неё, чтобы открыть историю.", + "documentsOpenInNewTab": "Открывать документы в новой вкладке", + "documentsOpenInNewTabHelp": "Открытый файл получает собственную вкладку браузера, так что несколько остаются открытыми, пока вы продолжаете просмотр. Выключено — открывается поверх папки, как сейчас." }, "thumbs": { "subtitle": "Настройте миниатюры предпросмотра для изображений и видео.", @@ -933,5 +935,9 @@ "savedAsHeading": "Сохранено в эту папку", "savedAsBody": "{name} добавлен рядом с оригиналом.", "saveAsFailed": "Не удалось сохранить {name}" + }, + "preview": { + "nothingOpensIt": "Здесь нечем открыть {name}.", + "backToFolder": "Назад к папке" } } diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 9f0505736..08af25f21 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -448,7 +448,9 @@ "skipHomeHelp": "Omdirigerar automatiskt till den första volymen när startsidan öppnas. Om inget anges används serverinställningen.", "useEnvSetting": "Använd serverinställning", "showVersionMarks": "Märk filer som har versioner", - "showVersionMarksHelp": "Ett litet märke i listan på filer med tidigare versioner, med antalet. Klicka på det för att öppna historiken." + "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.", @@ -933,5 +935,9 @@ "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" } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 471869cb6..cfa1fbc39 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -448,7 +448,9 @@ "skipHomeHelp": "访问首页时自动重定向到第一个卷。未设置时遵循服务器配置。", "useEnvSetting": "使用服务器设置", "showVersionMarks": "标记有版本的文件", - "showVersionMarksHelp": "在列表中为有历史版本的文件加一个小标记,并显示数量。点击即可打开历史记录。" + "showVersionMarksHelp": "在列表中为有历史版本的文件加一个小标记,并显示数量。点击即可打开历史记录。", + "documentsOpenInNewTab": "在新标签页中打开文档", + "documentsOpenInNewTabHelp": "打开文件时给它一个独立的浏览器标签页,这样你继续浏览时可以同时开着好几个。关闭后,它像现在一样覆盖在文件夹上打开。" }, "thumbs": { "subtitle": "自定义图片和视频的预览缩略图。", @@ -933,5 +935,9 @@ "savedAsHeading": "已保存到此文件夹", "savedAsBody": "{name} 已添加到原文件旁边。", "saveAsFailed": "无法保存 {name}" + }, + "preview": { + "nothingOpensIt": "这里没有什么能打开 {name}。", + "backToFolder": "返回文件夹" } } diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 5998ccd0e..cebb28b46 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -448,7 +448,9 @@ "skipHomeHelp": "進入首頁時自動導向到第一個儲存卷。未設定時會使用伺服器預設。", "useEnvSetting": "使用伺服器預設", "showVersionMarks": "標示有版本的檔案", - "showVersionMarksHelp": "在清單中為有舊版本的檔案加上一個小標記,並顯示數量。點一下即可開啟歷程記錄。" + "showVersionMarksHelp": "在清單中為有舊版本的檔案加上一個小標記,並顯示數量。點一下即可開啟歷程記錄。", + "documentsOpenInNewTab": "在新分頁中開啟文件", + "documentsOpenInNewTabHelp": "開啟檔案時給它一個獨立的瀏覽器分頁,這樣你繼續瀏覽時可以同時開著好幾個。關閉後,它像現在一樣覆蓋在資料夾上開啟。" }, "thumbs": { "subtitle": "自訂圖片和影片的預覽縮圖。", @@ -933,5 +935,9 @@ "savedAsHeading": "已儲存到此資料夾", "savedAsBody": "{name} 已新增至原檔案旁。", "saveAsFailed": "無法儲存 {name}" + }, + "preview": { + "nothingOpensIt": "這裡沒有什麼能開啟 {name}。", + "backToFolder": "返回資料夾" } } 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 e7ffab019..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,6 +20,10 @@ 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'; @@ -36,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(); @@ -106,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( diff --git a/frontend/src/plugins/index.js b/frontend/src/plugins/index.js index bf7ce64cb..013872bb7 100644 --- a/frontend/src/plugins/index.js +++ b/frontend/src/plugins/index.js @@ -8,6 +8,19 @@ 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 +44,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); }; /** diff --git a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue index 884a49696..09aa96d25 100644 --- a/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue +++ b/frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue @@ -54,8 +54,16 @@ const props = defineProps({ filePath: { type: String, required: true }, previewUrl: { type: String, required: true }, api: { type: Object, required: true }, + // Shared with the plugin's own close hook, which runs after this component + // has gone — including the one synchronous moment a closing tab gives. + previewState: { type: Object, default: () => ({}) }, }); +// The object the manager created for this preview and hands to the plugin's +// own hooks, which run after this component is gone. Writing into it is the +// point of it: it is shared state, not this component's own. +const shared = props.previewState; + const { t } = useI18n(); const fileStore = useFileStore(); const notifications = useNotificationsStore(); @@ -142,6 +150,7 @@ const endSession = ({ beacon = false } = {}) => { const current = sessionId.value; if (!current || !documentPath.value) return; sessionId.value = null; + shared.editorSessionId = null; void endOnlyOfficeSession(documentPath.value, { sessionId: current, beacon }).catch(() => {}); }; @@ -172,6 +181,9 @@ const renameDocument = async (data) => { newName, }); documentPath.value = renamed?.path || documentPath.value; + // The plugin's close hook names the file it is closing, and after a rename + // the context it was opened with is the wrong name. + shared.documentPath = documentPath.value; notifications.addNotification({ type: 'success', heading: t('onlyoffice.renamedHeading'), @@ -350,6 +362,10 @@ const load = async () => { sessionId.value = editorSessionId || null; autoSaveIntervalMs = Number(configuredAutoSaveIntervalMs) || 0; hasUnsavedChanges = false; + // Told to the plugin, which is what closes the session when this component + // is already gone — a tab on its way out, for instance. + shared.editorSessionId = sessionId.value; + shared.documentPath = documentPath.value; serverUrl.value = documentServerUrl; logger.debug('ONLYOFFICE config', cfg); cfg.events = { diff --git a/frontend/src/plugins/onlyoffice/onlyofficePreview.js b/frontend/src/plugins/onlyoffice/onlyofficePreview.js index 28db598ca..09c31ae11 100644 --- a/frontend/src/plugins/onlyoffice/onlyofficePreview.js +++ b/frontend/src/plugins/onlyoffice/onlyofficePreview.js @@ -1,5 +1,6 @@ import { useFeaturesStore } from '@/stores/features'; import { useSettingsStore } from '@/stores/settings'; +import { endOnlyOfficeSession } from '@/api'; const DEFAULT_EXTS = [ 'docx', @@ -43,6 +44,32 @@ export const onlyofficePreviewPlugin = (extensions) => ({ component: () => import('./OnlyOfficePreview.vue'), + /** + * Leaving the document: end the editing session, which flushes what the + * editor holds on the way. + * + * `unloading` is the same closing, from a tab that is going away. There is + * one synchronous moment and no way to wait between two requests, so the + * order lives on the server: one beacon to `/session-end`, which flushes and + * then closes. Either way the server is left holding the same thing — the + * session gone, a last save queued, and the advisory "being edited" standing + * until the Document Server says the document was let go. + */ + onBeforeClose: async (context, { unloading = false } = {}) => { + const sessionId = context?.previewState?.editorSessionId; + // The document may have been renamed from the editor's title bar, in which + // case the context still names the file the preview was opened on. + const filePath = context?.previewState?.documentPath || context?.filePath; + if (!filePath || !sessionId) return; + + if (unloading) { + void endOnlyOfficeSession(filePath, { sessionId, beacon: true }); + return; + } + + await endOnlyOfficeSession(filePath, { sessionId }).catch(() => {}); + }, + actions: (context) => [ { id: 'download', diff --git a/frontend/src/plugins/preview/manager.js b/frontend/src/plugins/preview/manager.js index 98bbf99a1..4257442df 100644 --- a/frontend/src/plugins/preview/manager.js +++ b/frontend/src/plugins/preview/manager.js @@ -146,6 +146,30 @@ export const usePreviewManager = defineStore('preview-manager', () => { return true; }; + /** + * The page is going away, and there is one synchronous moment left. + * + * Nothing can be awaited here, so the plugin is told outright what is + * happening, and one that has something to tell the server sends it in one + * breath — see the ONLYOFFICE plugin, which ends its editing session with a + * single beacon rather than the two calls it makes when there is time. + * + * Nothing here touches the state: whatever this store holds is about to stop + * existing anyway, and clearing it would only risk cancelling the very + * request being sent. + */ + const endForUnload = () => { + const plugin = activePlugin.value; + const item = activeItem.value; + if (!plugin || !item) return false; + try { + plugin.onBeforeClose?.(item, { unloading: true }); + } catch (error) { + console.warn(`Plugin ${plugin.id} onBeforeClose error while unloading:`, error); + } + return true; + }; + const close = () => { if (pendingClose) return pendingClose; if (!activePlugin.value || !activeItem.value) return; @@ -153,7 +177,7 @@ export const usePreviewManager = defineStore('preview-manager', () => { const plugin = activePlugin.value; const item = activeItem.value; - pendingClose = Promise.resolve(plugin.onBeforeClose?.(item)) + pendingClose = Promise.resolve(plugin.onBeforeClose?.(item, { unloading: false })) .catch((error) => { // A preview must always remain closable. Plugins can use this hook for // best-effort cleanup such as asking ONLYOFFICE to flush changes. @@ -192,6 +216,7 @@ export const usePreviewManager = defineStore('preview-manager', () => { unregister, open, close, + endForUnload, findPlugin, }; }); diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index a88e5fb08..d673226a9 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import FolderView from '@/views/FolderView.vue'; import HomeView from '@/views/HomeView.vue'; import EditorView from '@/views/EditorView.vue'; +import DocumentView from '@/views/DocumentView.vue'; import BrowserLayout from '@/layouts/BrowserLayout.vue'; import EditorLayout from '@/layouts/EditorLayout.vue'; import SearchResultsView from '@/views/SearchResultsView.vue'; @@ -185,6 +186,14 @@ const router = createRouter({ { path: ':versionId/:path(.*)', name: 'VersionFileViewer', component: EditorView }, ], }, + { + // One document, at an address of its own — see views/DocumentView.vue. + // Outside the browser layout on purpose: a document opened in its own tab + // is the document, and nothing else. + path: '/open/:path(.*)', + component: DocumentView, + meta: { requiresAuth: true }, + }, { path: '/about', component: AboutView, diff --git a/frontend/src/utils/documentRoute.js b/frontend/src/utils/documentRoute.js new file mode 100644 index 000000000..7b8715d04 --- /dev/null +++ b/frontend/src/utils/documentRoute.js @@ -0,0 +1,22 @@ +import { encodeFolderPath } from './folderRoute'; + +/** + * Where a document is, as an address of its own. + * + * Until this existed, a document had no address at all: opening one filled a + * panel over the folder it was in, driven by a store, and the browser knew + * nothing about it. Nothing could be linked to, nothing could be kept as a + * bookmark, the back button did not close it, and two documents could not be + * open at once — which is the whole of what somebody replacing Synology Drive + * or Google Drive asks for (nxzai/NextExplorer#303). + * + * Giving it an address is what answers all of that at once, and it is the + * browser rather than this application that then provides the tabs. + * + * Encoded the same way a folder is — each segment on its own, so the slashes + * between them survive and no address comes back full of `%2F`. + */ +export const documentRoute = (path) => { + const encoded = encodeFolderPath(path); + return { path: encoded ? `/open/${encoded}` : '/browse/' }; +}; diff --git a/frontend/src/utils/pageTitle.js b/frontend/src/utils/pageTitle.js new file mode 100644 index 000000000..a6e22d816 --- /dev/null +++ b/frontend/src/utils/pageTitle.js @@ -0,0 +1,70 @@ +/** + * The browser tab's title: what the page is, then the name the instance was + * given in Settings → Branding. + * + * It used to be the page alone — a folder's name, with nothing to say which + * application, let alone which instance, the tab belonged to among the others + * (nxzai/NextExplorer discussion #395). And "the page" was the last segment of + * the path whatever the page was, so every settings section, the search and + * both share lists read "Volumes", in English, as the home page did. + */ + +/** Narrow in the tab bar, where every character of the page name counts. */ +export const TITLE_SEPARATOR = ' | '; + +/** The whole title, from the page's own name and the instance's. */ +export const composeTitle = (page, appName) => { + const part = String(page ?? '').trim(); + const name = String(appName ?? '').trim(); + if (part && name) return `${part}${TITLE_SEPARATOR}${name}`; + return part || name || 'Explorer'; +}; + +const camelCase = (key) => key.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); + +const segmentsOf = (route) => { + const path = route?.params?.path; + const joined = Array.isArray(path) ? path.join('/') : path || ''; + return joined.split('/').filter(Boolean); +}; + +/** + * What a page of the explorer is called, before the instance's name. + * + * @param {object} route + * @param {Function} t the translator + * @param {object} [extras] + * @param {Function} [extras.te] whether a translation exists, to name a + * settings section only when it has a name of its own + * @param {string} [extras.shareName] what the share being browsed is called: + * at its top the address holds only its token, which names nothing + */ +export const pageTitleFor = (route, t, { te = () => false, shareName = '' } = {}) => { + if (route?.name === 'Trash') return t('trash.title'); + if (route?.name === 'SharedWithMe') return t('share.sharedWithMe'); + if (route?.name === 'SharedByMe') return t('share.sharedByMe'); + + const address = String(route?.path || ''); + if (address.startsWith('/settings')) { + const section = address.split('/').filter(Boolean)[1] || ''; + const key = section ? `settings.categories.${camelCase(section)}` : ''; + return key && te(key) ? t(key) : t('titles.settings'); + } + if (address.startsWith('/search')) { + const term = String(route?.query?.q || '').trim(); + return term ? t('search.resultsFor', { q: term }) : t('actions.search'); + } + + const segments = segmentsOf(route); + if (segments.length === 0) return t('titles.volumes'); + if (segments.length === 1 && segments[0] === 'personal') return t('drives.myfiles'); + if (segments.length === 2 && segments[0] === 'share') return shareName || t('titles.share'); + return segments[segments.length - 1]; +}; + +/** The name of a file from its path, for the pages that show one file. */ +export const fileTitleFor = (path) => + String(path || '') + .split('/') + .filter(Boolean) + .pop() || ''; diff --git a/frontend/src/views/DocumentView.vue b/frontend/src/views/DocumentView.vue new file mode 100644 index 000000000..09f209e8e --- /dev/null +++ b/frontend/src/views/DocumentView.vue @@ -0,0 +1,222 @@ + + + diff --git a/frontend/src/views/EditorView.vue b/frontend/src/views/EditorView.vue index f57f48a80..04265167b 100644 --- a/frontend/src/views/EditorView.vue +++ b/frontend/src/views/EditorView.vue @@ -181,6 +181,8 @@ import { import { Save20Regular, Color20Regular } from '@vicons/fluent'; import { onClickOutside, onKeyStroke, useLocalStorage } from '@vueuse/core'; import { useVersionsPanelStore } from '@/stores/versionsPanel'; +import { usePageTitle } from '@/composables/usePageTitle'; +import { fileTitleFor } from '@/utils/pageTitle'; const route = useRoute(); const router = useRouter(); @@ -299,6 +301,10 @@ const displayPath = computed(() => { return normalizedPath.value; }); +// The file's name in the tab, as a folder's is. It had none: opened directly +// the tab read "Explorer", and opened from a folder it kept that folder's name. +usePageTitle(computed(() => fileTitleFor(displayPath.value))); + const hasUnsavedChanges = computed( () => !isViewerOnly.value && fileContent.value !== originalContent.value ); diff --git a/frontend/src/views/settings/SettingsUserPreferences.vue b/frontend/src/views/settings/SettingsUserPreferences.vue index 5b05ac55f..d8125d32e 100644 --- a/frontend/src/views/settings/SettingsUserPreferences.vue +++ b/frontend/src/views/settings/SettingsUserPreferences.vue @@ -13,6 +13,7 @@ const local = reactive({ showHiddenFiles: false, showThumbnails: true, showVersionMarks: true, + documentsOpenInNewTab: false, showSidebarFavorites: true, showSidebarShares: true, showSidebarTools: true, @@ -33,6 +34,7 @@ const dirty = computed(() => { local.showHiddenFiles !== orig.showHiddenFiles || local.showThumbnails !== orig.showThumbnails || local.showVersionMarks !== (orig.showVersionMarks ?? true) || + local.documentsOpenInNewTab !== (orig.documentsOpenInNewTab ?? false) || local.showSidebarFavorites !== (orig.showSidebarFavorites ?? true) || local.showSidebarShares !== (orig.showSidebarShares ?? true) || local.showSidebarTools !== (orig.showSidebarTools ?? true) || @@ -74,6 +76,8 @@ watch( local.showHiddenFiles = userSettings.showHiddenFiles ?? false; local.showThumbnails = userSettings.showThumbnails ?? true; local.showVersionMarks = userSettings.showVersionMarks ?? true; + local.documentsOpenInNewTab = userSettings.documentsOpenInNewTab ?? false; + local.documentsOpenInNewTab = userSettings.documentsOpenInNewTab ?? false; local.showSidebarFavorites = userSettings.showSidebarFavorites ?? true; local.showSidebarShares = userSettings.showSidebarShares ?? true; local.showSidebarTools = userSettings.showSidebarTools ?? true; @@ -123,6 +127,7 @@ const save = async () => { showHiddenFiles: local.showHiddenFiles, showThumbnails: local.showThumbnails, showVersionMarks: local.showVersionMarks, + documentsOpenInNewTab: local.documentsOpenInNewTab, showSidebarFavorites: local.showSidebarFavorites, showSidebarShares: local.showSidebarShares, showSidebarTools: local.showSidebarTools, @@ -217,6 +222,20 @@ const save = async () => {
+
+
+
+ {{ t('settings.userPreferences.documentsOpenInNewTab') }} +
+
+ {{ t('settings.userPreferences.documentsOpenInNewTabHelp') }} +
+
+ +
+
Date: Sat, 26 Sep 2026 11:16:52 +0200 Subject: [PATCH 7/8] Let an upload survive a proxy and a dropped connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An upload was one request. A reverse proxy refuses it outright once the body passes whatever limit it enforces — the error names a size and never the setting — and a connection that drops loses the whole thing, however far it had got. Both are what somebody sending a film or a disk image over a home connection meets first, and there was nothing to be done about either. This adds the tus protocol beside the direct upload. The transfer becomes 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. Nothing lands in the folder until the file is whole, and when it does it takes a free name rather than replacing anything, as every other way a file arrives here does. Which one is used is an administrator's decision, in a screen of its own, and the default is unchanged: nothing about an upload moves until somebody asks. The chunk size is theirs too, bounded by a ceiling the server states rather than discovered by having a number refused — a chunk is held whole in memory at each end, so an unbounded one is a way to run a server out of it. The parts live in the cache rather than beside the destination: a part file is not a file anybody asked for, and a volume should never show one. They are swept once they are past their time, which covers the two ways they are left — a client that never came back, and a data file whose record never reached the disk. Three things in the direct upload were wrong and are fixed here, since this is the batch that owns it: - **A request that announced no size skipped the space check.** The guard takes a number and was handed nothing, so a chunked request — which is what an API client streaming a body sends — could fill a volume 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 the reserve is still held free. - **One request can reach several folders, and only the first was checked.** Each file carries its own relative path, so on a machine with more than one disk that is several disks; the second had its free space never measured and its dead uploads never swept. - **A refusal left the folders it had invented behind.** No space, a file over the limit, a client that went away: empty folders an upload created, in somebody's tree, with nothing to say where they came from. What lies between the topmost folder `mkdir` had to create and the destination is exactly what that file added, and it is removed deepest first, each one only while it is still empty — another file of the same request may have landed in it. And one about permissions: the destination is authorized, but the file also carries a client-supplied relative path, so the folder it actually lands in is authorized too. Without that, a subfolder an administrator had made read-only still accepted uploads. Left for later, and named here: the client learning a chunk size for itself after a direct upload stalls, which needs the watchdog that notices the stall. The setting for it is not here either — a setting whose half is missing is worse than none. Two dependencies on the server (`@tus/server`, `@tus/file-store`) and one in the browser (`@uppy/tus`). The store and the protocol server are built on first use rather than when the module is required: `FileStore` creates its directory in its constructor, which would turn requiring the file into a filesystem write — one that fails wherever the cache is not there yet, including the check that every module loads. Gates: lint and formatting on the changed files, `npm run build`, every backend module loads, and the whole backend suite — 2,020 pass, against the two that fail on `main` before any of this. Twelve new tests; every claim above fails when the change behind it is put back: the finished file written straight over the name, a metadata field of the literal "undefined" believed, the root allowed, and the sweep stopped. Checked in the built image: an upload of 300 kB was created, a third of it sent, then stopped; the server answered that it held 100,000 bytes; the folder showed nothing; the rest was sent from that offset and the file landed whole, at its full size. The settings screen offers the switch and the chunk size. --- backend/package.json | 4 +- backend/src/config/env.js | 7 + backend/src/config/index.js | 30 + backend/src/middleware/multipartRefusals.js | 36 + backend/src/middleware/responseEndCompat.js | 64 ++ backend/src/routes/features.js | 7 + backend/src/routes/settings.js | 19 + backend/src/routes/upload.js | 76 +- backend/src/server.js | 5 + backend/src/services/settingsService.js | 70 ++ backend/src/services/tusUploadService.js | 933 ++++++++++++++++++ .../src/services/uploadFolderTargetService.js | 178 ++++ backend/src/services/uploadRemnants.js | 83 ++ backend/src/services/uploadService.js | 236 ++++- .../tests/routes/resumable-uploads.test.js | 231 +++++ frontend/package.json | 1 + frontend/src/composables/fileUploader.js | 61 +- frontend/src/i18n/locales/de.json | 12 +- frontend/src/i18n/locales/en.json | 12 +- frontend/src/i18n/locales/es.json | 12 +- frontend/src/i18n/locales/fr.json | 12 +- frontend/src/i18n/locales/hi.json | 12 +- frontend/src/i18n/locales/it.json | 12 +- frontend/src/i18n/locales/ko.json | 12 +- frontend/src/i18n/locales/nl.json | 12 +- frontend/src/i18n/locales/pl.json | 12 +- frontend/src/i18n/locales/pt-BR.json | 12 +- frontend/src/i18n/locales/ro.json | 12 +- frontend/src/i18n/locales/ru.json | 12 +- frontend/src/i18n/locales/sv.json | 12 +- frontend/src/i18n/locales/zh-CN.json | 12 +- frontend/src/i18n/locales/zh-TW.json | 12 +- frontend/src/router/index.js | 6 + frontend/src/stores/appSettings.js | 4 + frontend/src/stores/features.js | 6 + frontend/src/utils/uploadMode.js | 33 + .../src/views/settings/SettingsUploads.vue | 163 +++ frontend/src/views/settings/SettingsView.vue | 7 + package-lock.json | 437 +++++++- 39 files changed, 2755 insertions(+), 122 deletions(-) create mode 100644 backend/src/middleware/multipartRefusals.js create mode 100644 backend/src/middleware/responseEndCompat.js create mode 100644 backend/src/services/tusUploadService.js create mode 100644 backend/src/services/uploadFolderTargetService.js create mode 100644 backend/src/services/uploadRemnants.js create mode 100644 backend/tests/routes/resumable-uploads.test.js create mode 100644 frontend/src/utils/uploadMode.js create mode 100644 frontend/src/views/settings/SettingsUploads.vue 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/config/env.js b/backend/src/config/env.js index 92465c9cc..42576ae9e 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -147,6 +147,13 @@ 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, + 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 b20ab688f..c14c4a657 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -346,6 +346,32 @@ const { editorMaxFileSizeBytes, maxJsonBodyBytes } = (() => { // 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: (() => { @@ -353,6 +379,10 @@ const uploads = { 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. 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/features.js b/backend/src/routes/features.js index c0d0c5fd7..3f200c8d7 100644 --- a/backend/src/routes/features.js +++ b/backend/src/routes/features.js @@ -11,6 +11,7 @@ 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 packageJson = require('../../package.json'); const router = express.Router(); @@ -54,6 +55,12 @@ router.get('/features', async (_req, res) => { (settings) => ({ enabled: settings.enabled }), () => ({ enabled: false }) ), + 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/settings.js b/backend/src/routes/settings.js index e4ba073f0..555ffb7e0 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -231,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/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/server.js b/backend/src/server.js index f7bb861ad..5d563c8bf 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -12,6 +12,7 @@ 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'); @@ -63,6 +64,9 @@ 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, @@ -99,6 +103,7 @@ const startServer = async () => { logger.info('Shutting down server...'); terminalService.cleanup(); clearInterval(expirySweep); + tusUploads.stopCacheSweep(); folderSizeManager.stop(); trashMaintenance.stop(); searchIndexManager.stop(); diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index 8f72f194c..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,6 +352,7 @@ const getSettingsForUser = async (user) => { result.access = systemSettings.access; result.trash = systemSettings.trash; result.versions = systemSettings.versions; + result.uploads = systemSettings.uploads; } } @@ -395,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); @@ -486,6 +555,7 @@ const updateSettings = async (updater) => { module.exports = { USER_SETTING_KEYS, + MAX_UPLOAD_CHUNK_SIZE_BYTES, getPublicSettings, sanitizeTrash, sanitizeVersions, 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/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/frontend/package.json b/frontend/package.json index dd645486c..a40984ed2 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", 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/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 98fd7cb88..30862f044 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -396,7 +396,8 @@ "accessControl": "Zugriffskontrolle", "adminUsers": "Benutzerverwaltung", "trash": "Papierkorb und Versionen", - "fileVersions": "Dateiversionen" + "fileVersions": "Dateiversionen", + "uploads": "Uploads" }, "about": { "subtitle": "Build-Informationen für diese Anwendung anzeigen.", @@ -670,6 +671,15 @@ "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." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 5508c978e..fda14d580 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -396,7 +396,8 @@ "accessControl": "Access Control", "adminUsers": "User Management", "trash": "Trash and versions", - "fileVersions": "File versions" + "fileVersions": "File versions", + "uploads": "Uploads" }, "about": { "subtitle": "View build information for this application.", @@ -670,6 +671,15 @@ "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." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index f29c84026..a71859cae 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -396,7 +396,8 @@ "accessControl": "Control de acceso", "adminUsers": "Gestión de usuarios", "trash": "Papelera y versiones", - "fileVersions": "Versiones de archivos" + "fileVersions": "Versiones de archivos", + "uploads": "Subidas" }, "about": { "subtitle": "Ver la información de compilación de esta aplicación.", @@ -670,6 +671,15 @@ "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." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 6fc3f966d..159373b71 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -396,7 +396,8 @@ "accessControl": "Contrôle d'accès", "adminUsers": "Gestion des utilisateurs", "trash": "Corbeille et versions", - "fileVersions": "Versions de fichiers" + "fileVersions": "Versions de fichiers", + "uploads": "Téléversements" }, "about": { "subtitle": "Afficher les informations de build de cette application.", @@ -670,6 +671,15 @@ "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." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 3120387e3..4ba114081 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -396,7 +396,8 @@ "accessControl": "एक्सेस कंट्रोल", "adminUsers": "उपयोगकर्ता प्रबंधन", "trash": "रीसायकल बिन और संस्करण", - "fileVersions": "फ़ाइल संस्करण" + "fileVersions": "फ़ाइल संस्करण", + "uploads": "अपलोड" }, "about": { "subtitle": "इस एप्लिकेशन के बिल्ड संबंधी जानकारी देखें।", @@ -670,6 +671,15 @@ "next": "अगला", "range": "{total} में से {from} से {to}", "deleteSelectedNone": "चुने गए संस्करण हटाएँ" + }, + "uploads": { + "title": "अपलोड", + "chunkSize": "चंक आकार", + "chunkSizeHelp": "प्रत्येक अपलोड अनुरोध का अधिकतम आकार। इसे अपनी रिवर्स प्रॉक्सी सीमा से कम रखें। छोटे चंक (8–32 MiB) अधिक सहज प्रगति देते हैं; बहुत बड़े चंक दृश्यमान चरण दिखाते हैं (सर्वर अगले से पहले प्रत्येक चंक लिखता है)।", + "subtitle": "कॉन्फ़िगर करें कि फ़ाइलें सर्वर पर कैसे भेजी जाती हैं।", + "chunkedEnable": "चंक्ड अपलोड सक्षम करें", + "chunkedEnableHelp": "बड़ी फ़ाइलों को कई छोटे अनुरोधों के माध्यम से भेजने के लिए TUS का उपयोग करें।", + "chunkSizeInvalid": "1 से {max} MiB के बीच चंक आकार दर्ज करें।" } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 3329d97c7..c16d1760c 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -396,7 +396,8 @@ "accessControl": "Controllo accessi", "adminUsers": "Gestione utenti", "trash": "Cestino e versioni", - "fileVersions": "Versioni dei file" + "fileVersions": "Versioni dei file", + "uploads": "Caricamenti" }, "about": { "subtitle": "Visualizza le informazioni di build per questa applicazione.", @@ -670,6 +671,15 @@ "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." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index fe410ca6a..c23868e6d 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -396,7 +396,8 @@ "accessControl": "접근 제어", "adminUsers": "유저 관리", "trash": "휴지통 및 버전", - "fileVersions": "파일 버전" + "fileVersions": "파일 버전", + "uploads": "업로드" }, "about": { "subtitle": "애플리케이션의 빌드 정보를 확인합니다.", @@ -670,6 +671,15 @@ "next": "다음", "range": "{total}개 중 {from}–{to}", "deleteSelectedNone": "선택한 버전 삭제" + }, + "uploads": { + "title": "업로드", + "chunkSize": "청크 크기", + "chunkSizeHelp": "각 업로드 요청의 최대 크기입니다. 리버스 프록시 제한보다 낮게 유지하세요. 청크가 작을수록(8–32 MiB) 진행률이 더 부드럽게 표시되고, 청크가 매우 크면 눈에 띄는 단계가 나타납니다(서버가 다음 청크를 처리하기 전에 각 청크를 기록합니다).", + "subtitle": "파일을 서버로 전송하는 방식을 설정합니다.", + "chunkedEnable": "청크 업로드 활성화", + "chunkedEnableHelp": "TUS를 사용하여 대용량 파일을 여러 개의 작은 요청으로 나누어 전송합니다.", + "chunkSizeInvalid": "청크 크기를 1~{max} MiB 사이로 입력하세요." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 1e4ccacc4..687b38954 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -396,7 +396,8 @@ "accessControl": "Toegangscontrole", "adminUsers": "Gebruikersbeheer", "trash": "Prullenbak en versies", - "fileVersions": "Bestandsversies" + "fileVersions": "Bestandsversies", + "uploads": "Uploads" }, "about": { "subtitle": "Versieinformatie voor deze applicatie.", @@ -670,6 +671,15 @@ "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." } }, "share": { diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 06aa509f3..26ec0728e 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -396,7 +396,8 @@ "accessControl": "Kontrola dostępu", "adminUsers": "Zarządzanie użytkownikami", "trash": "Kosz i wersje", - "fileVersions": "Wersje plików" + "fileVersions": "Wersje plików", + "uploads": "Przesyłanie" }, "about": { "subtitle": "Wyświetl informacje o kompilacji tej aplikacji.", @@ -670,6 +671,15 @@ "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." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 9d9a33092..1f3dc8200 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -396,7 +396,8 @@ "accessControl": "Controle de Acesso", "adminUsers": "Gerenciamento de Usuários", "trash": "Lixeira e versões", - "fileVersions": "Versões de arquivos" + "fileVersions": "Versões de arquivos", + "uploads": "Envios" }, "about": { "subtitle": "Veja as informações de compilação deste aplicativo.", @@ -670,6 +671,15 @@ "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." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 1f80c5b4c..a93aa4212 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -396,7 +396,8 @@ "accessControl": "Control acces", "adminUsers": "Gestionare utilizatori", "trash": "Coș de gunoi și versiuni", - "fileVersions": "Versiuni de fișiere" + "fileVersions": "Versiuni de fișiere", + "uploads": "Încărcări" }, "about": { "subtitle": "Vezi informațiile de build pentru această aplicație.", @@ -670,6 +671,15 @@ "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." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index cec52d0a6..25d3a6523 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -396,7 +396,8 @@ "accessControl": "Контроль доступа", "adminUsers": "Управление пользователями", "trash": "Корзина и версии", - "fileVersions": "Версии файлов" + "fileVersions": "Версии файлов", + "uploads": "Загрузки" }, "about": { "subtitle": "Информация о сборке приложения.", @@ -670,6 +671,15 @@ "next": "Вперёд", "range": "С {from} по {to} из {total}", "deleteSelectedNone": "Удалить отмеченные версии" + }, + "uploads": { + "title": "Загрузки", + "chunkSize": "Размер части", + "chunkSizeHelp": "Максимальный размер каждого запроса загрузки. Держите его ниже лимита вашего обратного прокси. Меньшие части (8–32 MiB) дают более плавный прогресс; очень большие части показывают заметные скачки (сервер записывает каждую часть перед следующей).", + "subtitle": "Настройте способ отправки файлов на сервер.", + "chunkedEnable": "Включить загрузку по частям", + "chunkedEnableHelp": "Использует TUS для отправки больших файлов несколькими меньшими запросами.", + "chunkSizeInvalid": "Укажите размер части от 1 до {max} MiB." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 08af25f21..92b820234 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -396,7 +396,8 @@ "accessControl": "Åtkomstkontroll", "adminUsers": "Användarhantering", "trash": "Papperskorg och versioner", - "fileVersions": "Filversioner" + "fileVersions": "Filversioner", + "uploads": "Uppladdningar" }, "about": { "subtitle": "Visa bygginformation för denna applikation.", @@ -670,6 +671,15 @@ "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." } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index cfa1fbc39..d73a51677 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -396,7 +396,8 @@ "accessControl": "访问控制", "adminUsers": "用户管理", "trash": "回收站和版本", - "fileVersions": "文件版本" + "fileVersions": "文件版本", + "uploads": "上传" }, "about": { "subtitle": "查看此应用的构建信息。", @@ -670,6 +671,15 @@ "next": "下一页", "range": "第 {from}–{to} 项,共 {total} 项", "deleteSelectedNone": "删除勾选的版本" + }, + "uploads": { + "title": "上传", + "chunkSize": "分块大小", + "chunkSizeHelp": "每个上传请求的最大大小。请将其保持在反向代理限制以下。较小的分块(8–32 MiB)进度更平滑;非常大的分块会出现明显的阶段(服务器会先写入每个分块,再处理下一个)。", + "subtitle": "配置文件发送到服务器的方式。", + "chunkedEnable": "启用分块上传", + "chunkedEnableHelp": "使用 TUS 通过多个较小的请求发送大文件。", + "chunkSizeInvalid": "请输入 1 至 {max} MiB 之间的分块大小。" } }, "mediaPreview": { diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index cebb28b46..b9e350151 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -396,7 +396,8 @@ "accessControl": "權限控制", "adminUsers": "使用者管理", "trash": "資源回收筒和版本", - "fileVersions": "檔案版本" + "fileVersions": "檔案版本", + "uploads": "上傳" }, "about": { "subtitle": "查看此應用的構建資訊。", @@ -670,6 +671,15 @@ "next": "下一頁", "range": "第 {from}–{to} 項,共 {total} 項", "deleteSelectedNone": "刪除勾選的版本" + }, + "uploads": { + "title": "上傳", + "chunkSize": "分塊大小", + "chunkSizeHelp": "每個上傳請求的最大大小。請保持在反向代理的限制以下。較小的分塊(8–32 MiB)能提供更流暢的進度;非常大的分塊會顯示明顯的階段變化(伺服器會在寫入下一個分塊之前先寫入每個分塊)。", + "subtitle": "設定檔案傳送到伺服器的方式。", + "chunkedEnable": "啓用分塊上傳", + "chunkedEnableHelp": "使用 TUS 透過多個較小的請求傳送大型檔案。", + "chunkSizeInvalid": "請輸入 1 至 {max} MiB 之間的分塊大小。" } }, "mediaPreview": { diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index d673226a9..96aa2ca1d 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -16,6 +16,7 @@ import SettingsPassword from '@/views/settings/SettingsPassword.vue'; import SettingsAbout from '@/views/settings/SettingsAbout.vue'; import SettingsTrash from '@/views/settings/SettingsTrash.vue'; import SettingsFileVersions from '@/views/settings/SettingsFileVersions.vue'; +import SettingsUploads from '@/views/settings/SettingsUploads.vue'; import TrashView from '@/views/TrashView.vue'; import SettingsUserPreferences from '@/views/settings/SettingsUserPreferences.vue'; import AboutView from '@/views/AboutView.vue'; @@ -75,6 +76,11 @@ const router = createRouter({ component: SettingsFileVersions, meta: { requiresAdmin: true }, }, + { + path: 'uploads', + component: SettingsUploads, + meta: { requiresAdmin: true }, + }, { path: 'admin-overview', component: SettingsComingSoon, diff --git a/frontend/src/stores/appSettings.js b/frontend/src/stores/appSettings.js index a18826b34..84026c61e 100644 --- a/frontend/src/stores/appSettings.js +++ b/frontend/src/stores/appSettings.js @@ -36,6 +36,7 @@ export const useAppSettings = defineStore('appSettings', () => { // is the one that decides what a value may be. trash: null, versions: null, + uploads: null, }); // Computed state that combines all settings (for backward compatibility) @@ -46,6 +47,7 @@ export const useAppSettings = defineStore('appSettings', () => { access: systemSettings.value.access, trash: systemSettings.value.trash, versions: systemSettings.value.versions, + uploads: systemSettings.value.uploads, })); // Whether thumbnails should be shown/requested for the current session. @@ -128,6 +130,7 @@ export const useAppSettings = defineStore('appSettings', () => { } if (s?.trash) systemSettings.value.trash = { ...s.trash }; if (s?.versions) systemSettings.value.versions = { ...s.versions }; + if (s?.uploads) systemSettings.value.uploads = { ...s.uploads }; if (s?.access) { systemSettings.value.access = { rules: Array.isArray(s.access.rules) ? s.access.rules : [], @@ -188,6 +191,7 @@ export const useAppSettings = defineStore('appSettings', () => { if (updated?.trash) systemSettings.value.trash = { ...updated.trash }; if (updated?.versions) systemSettings.value.versions = { ...updated.versions }; + if (updated?.uploads) systemSettings.value.uploads = { ...updated.uploads }; if (updated?.thumbnails) { systemSettings.value.thumbnails = { enabled: true, diff --git a/frontend/src/stores/features.js b/frontend/src/stores/features.js index 9ded5ea88..f055c014e 100644 --- a/frontend/src/stores/features.js +++ b/frontend/src/stores/features.js @@ -7,6 +7,8 @@ export const useFeaturesStore = defineStore('features', () => { const publicOrigin = ref(''); // Every origin the app may legitimately be reached from (public + internal). const publicOrigins = ref([]); + // The ceiling an administrator may raise the upload chunk size to. + const maxUploadChunkSizeBytes = ref(0); const editorExtensions = ref([]); const hiddenFilePatterns = ref(['.']); const onlyofficeEnabled = ref(false); @@ -93,6 +95,9 @@ export const useFeaturesStore = defineStore('features', () => { trashEnabled.value = Boolean(features?.trash?.enabled); trashRetentionDays.value = features?.trash?.retentionDays ?? null; versionsEnabled.value = Boolean(features?.versions?.enabled); + maxUploadChunkSizeBytes.value = Number.isFinite(features?.uploads?.maxChunkSizeBytes) + ? features.uploads.maxChunkSizeBytes + : 0; // User volumes (per-user volume assignments) userVolumesEnabled.value = Boolean(features?.userVolumes?.enabled); @@ -168,6 +173,7 @@ export const useFeaturesStore = defineStore('features', () => { trashEnabled, trashRetentionDays, versionsEnabled, + maxUploadChunkSizeBytes, terminalExtensions, version, gitCommit, diff --git a/frontend/src/utils/uploadMode.js b/frontend/src/utils/uploadMode.js new file mode 100644 index 000000000..212c99a82 --- /dev/null +++ b/frontend/src/utils/uploadMode.js @@ -0,0 +1,33 @@ +const MIB_BYTES = 1024 * 1024; + +/** Default chunk size when the administrator has not set one. */ +export const DEFAULT_CHUNK_BYTES = 8 * MIB_BYTES; + +/** + * 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. + * + * Which one is used is the administrator's decision, and the default is + * unchanged — so nothing about an upload moves until somebody asks. + * + * Kept here rather than inside the uploader because this is the part worth + * testing, and reaching it through the composable means building Uppy, several + * stores and a file dialog first. + * + * @param {object} uploads the `uploads` section of the application settings + */ +export const resolveUploadMode = (uploads = {}) => { + const chunkSizeBytes = Number.isFinite(uploads?.chunkSizeBytes) + ? uploads.chunkSizeBytes + : DEFAULT_CHUNK_BYTES; + + return { + mode: uploads?.chunkedEnabled ? 'chunked' : 'direct', + chunkedEnabled: Boolean(uploads?.chunkedEnabled), + chunkSizeBytes, + }; +}; diff --git a/frontend/src/views/settings/SettingsUploads.vue b/frontend/src/views/settings/SettingsUploads.vue new file mode 100644 index 000000000..1301bffd5 --- /dev/null +++ b/frontend/src/views/settings/SettingsUploads.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/views/settings/SettingsView.vue b/frontend/src/views/settings/SettingsView.vue index 9d660661d..094af16bc 100644 --- a/frontend/src/views/settings/SettingsView.vue +++ b/frontend/src/views/settings/SettingsView.vue @@ -13,6 +13,7 @@ import { KeyIcon, TrashIcon, ClockIcon, + ArrowUpTrayIcon, UsersIcon, UserCircleIcon, } from '@heroicons/vue/24/outline'; @@ -89,6 +90,12 @@ const adminCategories = [ 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..a9acde327 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", @@ -1348,36 +1351,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 +1378,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 +5105,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 +5928,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 +7874,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 +8124,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 +10048,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 +10109,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 +10460,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 +10719,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 +13050,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 +13787,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 +14461,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 +14556,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 +16350,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", @@ -16351,7 +16607,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 +16837,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 +16933,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 +17380,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 +17816,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 +17848,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 +18500,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 +18889,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", From b845247a167d089c5fd497110263c66feac160d4 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 11:27:53 +0200 Subject: [PATCH 8/8] Make a thumbnail outside the request that asked for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thumbnail was 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, so the tile somebody was looking at waited behind the one they had 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: a thumbnail somebody is waiting for goes ahead of one being fetched in advance, and a prefetch is admitted only while nothing interactive is running. The browser asks again, with increasing delays, rather than holding a connection open per tile. Making them is the other half. Video thumbnails go through ffmpeg with their own concurrency, their own seek point, their own thread count and a generous ceiling — a thumbnail killed early is a thumbnail that never appears. RAW files hand over the full-size preview their own metadata carries, which is far cheaper than decoding the raw sensor data and is what every other viewer shows. Both are bounded: sharp's cache is held to a size, the external processes are counted and can be given a lower priority, and a job that takes too long says so in the log rather than disappearing into it. What they leave behind is bounded too. The cache has a file count, an age and a cleanup that walks it in batches, shared with the RAW previews so the two are held to one reading of the same settings rather than two copies that drift. A temporary file an interrupted write left behind is recognised and removed, under both the name this version writes and the ones earlier versions did — and a file this process is still writing is never one of them. A failure is remembered for a while, so a file that cannot be made into a thumbnail is not attempted again on every listing; and the cache key carries a version, so a change in how thumbnails are made invalidates what is there rather than serving it for ever. The settings screen was saving something other than what it showed: a value outside the bounds the server holds it to was sent as typed and brought within them there, and an emptied field was dropped. The bounds are now the page's as well, and it will not offer to save what the server would change. It also offered to save before the settings had loaded, and saving then wrote its own defaults over what was stored. Gates: lint and formatting on the changed files, `npm run build`, every backend module loads, and the whole backend suite — 2,026 pass, against the two that fail on `main` before any of this. Six new tests, and the thumbnail-token test from #411 now waits for the picture the way the browser does. Every claim fails when the change behind it is put back: the request held again, and thumbnails that cannot be switched off. One claim was dropped rather than left passing for the wrong reason — which of the two short-circuits answers a second ask cannot be told apart from outside, so nothing here says it can. Checked in the built image: the first ask answered 202 and said so, the picture appeared on a later ask, the next ask answered at once, a prefetch was taken, the picture opened with the proof on its URL and was refused 401 without it, and the folder drew all three. --- backend/src/config/env.js | 70 + backend/src/routes/thumbnails.js | 46 +- backend/src/services/rawPreviewService.js | 326 +++- backend/src/services/thumbnailService.js | 1342 ++++++++++++++--- backend/src/utils/cacheCleanup.js | 87 ++ backend/tests/routes/thumbnail-queue.test.js | 141 ++ backend/tests/routes/thumbnails-token.test.js | 16 +- frontend/src/stores/fileStore.js | 30 +- .../settings/SettingsFilesThumbnails.vue | 30 +- 9 files changed, 1838 insertions(+), 250 deletions(-) create mode 100644 backend/src/utils/cacheCleanup.js create mode 100644 backend/tests/routes/thumbnail-queue.test.js diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 42576ae9e..841449b7b 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -151,6 +151,76 @@ module.exports = { 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, 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/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/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/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/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/frontend/src/stores/fileStore.js b/frontend/src/stores/fileStore.js index bd167957c..1f6533e4a 100644 --- a/frontend/src/stores/fileStore.js +++ b/frontend/src/stores/fileStore.js @@ -340,6 +340,12 @@ export const useFileStore = defineStore('fileStore', () => { return itemKey(item) === renameState.value.key; }; + // How long the client waits for a thumbnail being made, and how it spaces the + // asking. Increasing, because the first one is usually ready straight away + // and the ones that are not are the slow kind. + const THUMBNAIL_RETRY_DELAYS_MS = [400, 900, 1800, 3000, 5000]; + const THUMBNAIL_RETRIES = THUMBNAIL_RETRY_DELAYS_MS.length; + const ensureItemThumbnail = async (item) => { if (!item || !item.name) { return null; @@ -383,15 +389,25 @@ export const useFileStore = defineStore('fileStore', () => { pending = (async () => { try { - const response = await fetchThumbnailApi(relativePath); - const thumbnail = response?.thumbnail || ''; - if (thumbnail) { - const target = findItemByKey(key); - if (target) { - target.thumbnail = thumbnail; + // The server answers at once with a thumbnail it already has, and + // otherwise queues one and says so. A long video on a slow disk takes + // seconds, so the answer is asked for again rather than the request + // held open — a held request costs a connection for every tile on + // screen, and a folder of five hundred has five hundred tiles. + for (let attempt = 0; attempt <= THUMBNAIL_RETRIES; attempt += 1) { + const response = await fetchThumbnailApi(relativePath); + const thumbnail = response?.thumbnail || ''; + if (thumbnail) { + const target = findItemByKey(key); + if (target) { + target.thumbnail = thumbnail; + } + return thumbnail; } + if (!response?.pending || attempt === THUMBNAIL_RETRIES) return null; + await new Promise((resolve) => setTimeout(resolve, THUMBNAIL_RETRY_DELAYS_MS[attempt])); } - return thumbnail || null; + return null; } catch (error) { console.error(`Failed to fetch thumbnail for ${relativePath}`, error); return null; diff --git a/frontend/src/views/settings/SettingsFilesThumbnails.vue b/frontend/src/views/settings/SettingsFilesThumbnails.vue index e0e0ba96c..7a794abad 100644 --- a/frontend/src/views/settings/SettingsFilesThumbnails.vue +++ b/frontend/src/views/settings/SettingsFilesThumbnails.vue @@ -22,7 +22,10 @@ const dirty = computed( local.enabled !== original.value.enabled || local.quality !== original.value.quality || local.size !== original.value.size || - local.concurrency !== original.value.concurrency + // A stored section without a concurrency means the default the field shows, + // not a change: read as one, the page offered to save before the settings + // had even loaded, and saving then wrote its defaults over what was stored. + local.concurrency !== (original.value.concurrency ?? 10) ); watch( @@ -38,6 +41,21 @@ watch( { immediate: true } ); +// The bounds the server holds these to. A value outside them was sent as typed +// and brought within them there, and an emptied field was dropped: what was +// saved was not what the page showed. +const BOUNDS = { + quality: [1, 100], + size: [64, 1024], + concurrency: [1, 50], +}; + +const invalid = computed(() => + Object.entries(BOUNDS).some( + ([key, [min, max]]) => !(Number.isInteger(local[key]) && local[key] >= min && local[key] <= max) + ) +); + const reset = () => { const t = appSettings.systemSettings?.thumbnails || appSettings.state.thumbnails; if (t) { @@ -49,6 +67,7 @@ const reset = () => { }; const save = async () => { + if (invalid.value) return; await appSettings.save({ thumbnails: { enabled: local.enabled, @@ -69,7 +88,10 @@ const save = async () => {
{{ t('common.unsavedChanges') }}