From 02b08020a1b771da978cfc81be52ad72c6562c7a Mon Sep 17 00:00:00 2001 From: Benjy Date: Fri, 25 Sep 2026 11:11:51 +0200 Subject: [PATCH] Keep what a text save replaces, and let a history be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving from the text editor wrote straight over the file. A stop halfway through — a full disk, a killed process — left it truncated, and what the save replaced was gone in every case, even though the engine to keep it has been here since the versions landed and the office editors already feed it. The save goes through that engine now: the content is written beside the file and put in place once whole, and the state it replaces becomes a version. Somebody pressed Save, so it is marked as a state worth keeping — there is no editing session here to group it with, as there is in the office editors. A save carrying something that is not text is answered as a bad request rather than failing at the write, where it used to become a server error after the document had already been opened for writing. And a history can be read. `/api/versions` answers the versions of a file with who saved each one and where it came from, `/versions/:id/content` hands one over, `/restore` puts one back — keeping what it replaced, so going back loses nothing — `/copy` writes one beside the file under a name of its own, `/replace` writes it into another file, and a version can be labelled, pinned or deleted. A version is only ever reached through the file it belongs to, with that file's rights: asked for under another path, it does not exist. Left for the batches that bring them: reading a version as text (it wants the text service), the purge entry in the activity log, and telling an open ONLYOFFICE document that it was restored under a new key. Seven tests drive it from outside — save, save again, read what was kept, restore it, and ask for a version under the wrong file. Four of them fail when the save is put back the way it was. --- backend/src/routes/editor.js | 24 +- backend/src/routes/index.js | 2 + backend/src/routes/versions.js | 106 +++ backend/src/services/versions/index.js | 673 ++++++++++++++++++ backend/tests/routes/versions-history.test.js | 172 +++++ 5 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/versions.js create mode 100644 backend/src/services/versions/index.js create mode 100644 backend/tests/routes/versions-history.test.js diff --git a/backend/src/routes/editor.js b/backend/src/routes/editor.js index 470a88d3..1cbff088 100644 --- a/backend/src/routes/editor.js +++ b/backend/src/routes/editor.js @@ -6,6 +6,7 @@ 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 { ValidationError, @@ -119,6 +120,12 @@ router.put( if (typeof relative !== 'string' || !relative) { throw new ValidationError('A valid file path is required.'); } + // Answered rather than thrown at the write: `null` used to reach the file + // itself, where it failed as a server error after the document had already + // been opened for writing. + if (typeof content !== 'string') { + throw new ValidationError('The content to save must be text.'); + } const relativePath = normalizeRelativePath(relative); @@ -154,7 +161,22 @@ router.put( const { absolutePath } = resolved; await ensureDir(path.dirname(absolutePath)); - await fs.writeFile(absolutePath, content, { encoding: 'utf-8' }); + + // 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 + // a stop halfway through left it truncated and the state it replaced was + // gone. Somebody pressed Save, so it is a state worth keeping — there is no + // 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' }), + { + purpose: 'editor', + author: versions.authorOf({ user: req.user, guestSession: req.guestSession }), + source: 'editor', + explicit: true, + } + ); res.send({ success: true }); }) ); diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 025e368a..38680b70 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -22,6 +22,7 @@ const healthRoutes = require('./health'); const userVolumesRoutes = require('./userVolumes'); const folderSizeRoutes = require('./folderSize'); const trashRoutes = require('./trash'); +const versionsRoutes = require('./versions'); const { onlyoffice, collabora } = require('../config/index'); const registerRoutes = (app) => { @@ -45,6 +46,7 @@ const registerRoutes = (app) => { app.use('/api', zipRoutes); app.use('/api', folderSizeRoutes); app.use('/api', trashRoutes); + app.use('/api', versionsRoutes); // User volumes management (admin only, requires USER_VOLUMES feature) app.use('/api', userVolumesRoutes); // Share routes (supports guest sessions) diff --git a/backend/src/routes/versions.js b/backend/src/routes/versions.js new file mode 100644 index 00000000..de590e73 --- /dev/null +++ b/backend/src/routes/versions.js @@ -0,0 +1,106 @@ +const express = require('express'); +const fs = require('fs'); + +const asyncHandler = require('../utils/asyncHandler'); +const logger = require('../utils/logger'); +const { mimeTypes } = require('../config/index'); +const versions = require('../services/versions'); +const { encodeContentDisposition } = require('./files/utils'); + +/** + * A file's history, through the API. + * + * Every route names the file it is about by its path, as the rest of the API + * does, and the version by its id: a version is only ever reached through the + * file it belongs to, with that file's rights. + */ +const router = express.Router(); + +const contextOf = (req) => ({ user: req.user, guestSession: req.guestSession }); + +const mimeTypeOf = (name = '') => + mimeTypes[String(name).split('.').pop().toLowerCase()] || 'application/octet-stream'; + +router.get( + '/versions', + asyncHandler(async (req, res) => { + res.set('Cache-Control', 'no-store'); + res.json(await versions.listVersions(contextOf(req), req.query?.path)); + }) +); + +router.get( + '/versions/:id/content', + asyncHandler(async (req, res) => { + const located = await versions.downloadVersion(contextOf(req), req.query?.path, req.params.id); + res.writeHead(200, { + 'Content-Type': mimeTypeOf(located.name), + 'Content-Length': located.size, + 'Content-Disposition': encodeContentDisposition(located.downloadName, 'attachment'), + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + }); + const stream = fs.createReadStream(located.absolutePath); + stream.on('error', (error) => { + logger.warn({ err: error, versionId: req.params.id }, 'A version could not be streamed'); + res.destroy(error); + }); + stream.pipe(res); + }) +); + +router.post( + '/versions/:id/restore', + asyncHandler(async (req, res) => { + res.json(await versions.restoreVersion(contextOf(req), req.body?.path, req.params.id)); + }) +); + +router.post( + '/versions/:id/copy', + asyncHandler(async (req, res) => { + res.json( + await versions.copyVersionTo(contextOf(req), req.body?.path, req.params.id, { + destination: req.body?.destination, + name: req.body?.name, + }) + ); + }) +); + +router.post( + '/versions/:id/replace', + asyncHandler(async (req, res) => { + res.json( + await versions.replaceWithVersion(contextOf(req), req.body?.path, req.params.id, { + target: req.body?.target, + }) + ); + }) +); + +router.patch( + '/versions/:id', + asyncHandler(async (req, res) => { + res.json( + await versions.updateVersion(contextOf(req), req.body?.path, req.params.id, { + label: req.body?.label, + pinned: req.body?.pinned, + }) + ); + }) +); + +router.post( + '/versions/delete', + asyncHandler(async (req, res) => { + const outcome = await versions.deleteVersions(contextOf(req), req.body?.path, { + ids: req.body?.ids, + all: req.body?.all === true, + }); + + res.json(outcome); + }) +); + +module.exports = router; diff --git a/backend/src/services/versions/index.js b/backend/src/services/versions/index.js new file mode 100644 index 00000000..66250c8b --- /dev/null +++ b/backend/src/services/versions/index.js @@ -0,0 +1,673 @@ +/** + * File versions as the rest of the application sees them. + * + * The modules beside this one keep what saves replace and follow files around; + * this one decides who is asking and what they may do with a file's history: + * + * - seeing it follows the right to read the file — except through a share, + * whose owner decides whether its history is shown at all; + * - downloading a version, or copying it out, is taking a copy, so it also + * needs the right to download — and through a share, the owner's say-so; + * - restoring, naming or pinning changes the file, and needs the right to + * write it; + * - deleting versions needs the right to delete the file. + * + * Restoring never destroys anything: the content it replaces becomes a version, + * like any other save, and the restored version stays in the history. + */ +const fs = require('fs'); +const fsp = require('fs/promises'); +const path = require('path'); + +const { ZONE_DIRECTORY_NAME } = require('../../config/constants'); +const { + ConflictError, + ForbiddenError, + NotFoundError, + ValidationError, +} = require('../../errors/AppError'); +const logger = require('../../utils/logger'); +const { ensureValidName, normalizeRelativePath } = require('../../utils/pathUtils'); +const { ACTIONS, authorizeAndResolve, authorizePath } = require('../authorizationService'); +const { getDb } = require('../db'); +const clock = require('../trash/clock'); +const trashStore = require('../trash/store'); +const zones = require('../trash/zones'); +const operations = require('./operations'); +const { getVersionSettings } = require('./settings'); +const store = require('./store'); + +const MAX_IDS = 1000; +const MAX_LABEL_LENGTH = 200; + +/** + * What this person may do with a file's history, from what they may do with the + * file. A share hands out its history only when its owner turned it on. + */ +const rightsFrom = (accessInfo) => { + const shared = Boolean(accessInfo?.isShared); + const share = accessInfo?.share || null; + const see = Boolean(accessInfo?.canRead) && (!shared || share?.versionsVisible === true); + const download = + see && accessInfo?.canDownload !== false && (!shared || share?.versionsDownload === true); + const restore = see && Boolean(accessInfo?.canWrite); + return { see, download, restore, remove: see && Boolean(accessInfo?.canDelete) }; +}; + +const relativeFrom = (value, what = 'A file path') => { + const relative = typeof value === 'string' ? normalizeRelativePath(value) : ''; + if (!relative) throw new ValidationError(`${what} is required.`); + return relative; +}; + +/** Resolve and authorize a path, answering a path that is not one as not found. */ +const authorize = async (context, relative, action) => { + try { + return await authorizeAndResolve(context, relative, action); + } catch (error) { + if (error?.isOperational) throw error; + throw new NotFoundError('This file does not exist.'); + } +}; + +/** The file a request is about, with what this person may do with its history. */ +const resolveFile = async (context, relativePath) => { + const relative = relativeFrom(relativePath); + const { allowed, accessInfo, resolved } = await authorize(context, relative, ACTIONS.read); + if (!allowed || !resolved) { + throw new ForbiddenError(accessInfo?.denialReason || 'Access denied.'); + } + const stats = await fsp.stat(resolved.absolutePath).catch(() => null); + if (!stats) throw new NotFoundError('This file does not exist.'); + if (!stats.isFile()) throw new ValidationError('Only files have versions.'); + return { + relative, + absolutePath: resolved.absolutePath, + stats, + rights: rightsFrom(accessInfo), + }; +}; + +/** The history of the file at an absolute path, whichever zone row its root has. */ +const historyOf = async (db, absolutePath) => { + const located = await zones.locateZoneRoot(absolutePath); + if (!located.root) return null; + const relativePath = path.relative(located.root, absolutePath).split(path.sep).join('/'); + for (const zone of trashStore.listZones(db).filter((row) => row.root === located.root)) { + const file = store.findFileAt(db, zone.id, relativePath, ['live', 'orphaned']); + if (file) return file; + } + return null; +}; + +/** + * How many kept versions each file in a folder has, by file name. + * + * What the listing needs to put a mark on a row, and the reason it is a + * folder's worth at a time: a count per file would be one query per row, and + * a folder of three hundred files is an ordinary folder. + * + * The zone root is resolved once here. `locateZoneRoot` answers `zone-root` + * for the root itself rather than naming it — true for a file being deleted, + * which cannot go into its own volume's trash, and wrong for a listing, where + * the top of a volume is a folder like any other and its files have + * histories. So that answer is turned back into the zone it is about. + */ +const marksForFolder = async (absoluteDir) => { + const directory = path.resolve(absoluteDir); + const located = await zones.locateZoneRoot(directory); + if (!located.root && located.reason !== 'zone-root') return new Map(); + + const db = await getDb(); + const root = located.root || directory; + const relative = located.root ? path.relative(root, directory).split(path.sep).join('/') : ''; + // Outside the zone after all: nothing here belongs to it. + if (relative.startsWith('..')) return new Map(); + + const zoneIds = trashStore + .listZones(db) + .filter((row) => row.root === root) + .map((row) => row.id); + if (zoneIds.length === 0) return new Map(); + + const marks = new Map(); + for (const row of store.countKeptInFolder(db, zoneIds, relative)) { + const name = path.posix.basename(row.relativePath); + const existing = marks.get(name); + marks.set( + name, + existing + ? { + versions: existing.versions + row.versions, + bytes: existing.bytes + row.bytes, + newest: existing.newest > row.newest ? existing.newest : row.newest, + } + : { versions: row.versions, bytes: row.bytes, newest: row.newest } + ); + } + return marks; +}; + +/** The names accounts go by now, for the versions they wrote. */ +const accountLabels = (db, ids) => { + const wanted = [...new Set(ids.filter(Boolean))]; + if (wanted.length === 0) return new Map(); + try { + return new Map( + db + .prepare( + `SELECT id, display_name, username, email FROM users + WHERE id IN (${wanted.map(() => '?').join(', ')})` + ) + .all(...wanted) + .map((row) => [row.id, row.display_name || row.username || row.email || null]) + ); + } catch (error) { + logger.debug({ err: error }, 'Account names were not found for file versions'); + return new Map(); + } +}; + +const authorOf = (labels, id, storedLabel) => + id || storedLabel + ? { id: id || null, label: (id && labels.get(id)) || storedLabel || null } + : null; + +const presentVersion = (version, labels, available) => ({ + id: version.id, + size: version.size, + modifiedAt: version.modifiedAt, + capturedAt: version.capturedAt, + author: authorOf(labels, version.authorId, version.authorLabel), + source: version.source, + label: version.label, + pinned: version.pinned, + aside: version.aside, + available, +}); + +/** A file's history: its versions, newest first, and what this person may do with them. */ +const listVersions = async (context, relativePath) => { + const target = await resolveFile(context, relativePath); + if (!target.rights.see) throw new ForbiddenError('The history of this file is not shared.'); + + const settings = await getVersionSettings(); + const db = await getDb(); + const file = await historyOf(db, target.absolutePath); + const versions = file ? store.listVersionsOfFile(db, file.id) : []; + + const availability = new Map(); + for (const zoneId of new Set(versions.map((version) => version.zoneId))) { + const zone = trashStore.getZone(db, zoneId); + availability.set(zoneId, zone ? (await zones.inspectZone(zone)).available : false); + } + const labels = accountLabels(db, [ + ...versions.map((version) => version.authorId), + file?.currentAuthorId, + ]); + const currentKnown = + Boolean(file) && + file.currentSize === target.stats.size && + file.currentMtimeMs === target.stats.mtimeMs; + + return { + enabled: settings.enabled, + file: { + name: path.basename(target.absolutePath), + path: target.relative, + size: target.stats.size, + modifiedAt: target.stats.mtime.toISOString(), + author: currentKnown ? authorOf(labels, file.currentAuthorId, file.currentAuthorLabel) : null, + source: currentKnown ? file.currentSource : null, + }, + versions: versions.map((version) => + presentVersion(version, labels, availability.get(version.zoneId)) + ), + totalBytes: versions.reduce((total, version) => total + version.size, 0), + rights: target.rights, + }; +}; + +/** The default name of a version taken out of its history: the file's, with the version's date. */ +const nameForCopy = (fileName, version) => { + const extension = path.extname(fileName); + const base = extension ? fileName.slice(0, -extension.length) : fileName; + const stamp = new Date(version.modifiedAt).toISOString().slice(0, 16).replace('T', ' '); + return `${base} (version ${stamp.replace(':', '-')})${extension}`; +}; + +/** One version of the file a request is about, and where its content can be read. */ +const locateVersion = async (context, relativePath, versionId, { download }) => { + const target = await resolveFile(context, relativePath); + if (!target.rights.see) throw new ForbiddenError('The history of this file is not shared.'); + if (download && !target.rights.download) { + throw new ForbiddenError('Earlier versions of this file cannot be downloaded.'); + } + const db = await getDb(); + const file = await historyOf(db, target.absolutePath); + const version = + file && typeof versionId === 'string' && versionId ? store.getVersion(db, versionId) : null; + if (!version || version.fileId !== file.id || version.state !== 'kept') { + throw new NotFoundError('This version does not exist.'); + } + const located = await operations.locateVersion(version.id); + if (located.status === 'unavailable') { + throw new ConflictError('The volume this version is kept on is not available.'); + } + if (located.status !== 'found') throw new NotFoundError('This version does not exist.'); + const stats = await fsp.stat(located.absolutePath); + return { + target, + file, + version, + absolutePath: located.absolutePath, + size: stats.size, + name: path.basename(target.absolutePath), + }; +}; + +/** A version to download: its content, and the name it is offered under. */ +const downloadVersion = async (context, relativePath, versionId) => { + const located = await locateVersion(context, relativePath, versionId, { download: true }); + return { ...located, downloadName: nameForCopy(located.name, located.version) }; +}; + +/** + * 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. + */ +const markRestored = async (absolutePath) => { + try { + const db = await getDb(); + const file = await historyOf(db, absolutePath); + if (file) store.setRestoredAt(db, file.id, clock.nowIso()); + } catch (error) { + logger.warn({ err: error, absolutePath }, 'A restore could not be announced to open editors'); + } +}; + +/** Put a version's content into a file, the way every save does. */ +const writeVersionInto = async (located, destination, context) => { + const result = await operations.saveFile( + destination, + (temporaryPath) => + fsp.copyFile(located.absolutePath, temporaryPath, fs.constants.COPYFILE_FICLONE), + { + purpose: 'restore', + author: operations.authorOf(context), + source: 'restore', + explicit: true, + } + ); + return result; +}; + +/** Put the file back as a version had it. What it holds now becomes a version itself. */ +const restoreVersion = async (context, relativePath, versionId) => { + const located = await locateVersion(context, relativePath, versionId, { download: false }); + 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); + } + return { status: result.status, path: located.target.relative }; +}; + +/** A folder someone chose to put something in: one they can reach, and a folder. */ +const resolveFolder = async (context, value) => { + const relative = relativeFrom(value, 'A destination folder'); + const { allowed, accessInfo, resolved } = await authorize(context, relative, ACTIONS.read); + if (!allowed || !resolved) { + throw new ForbiddenError(accessInfo?.denialReason || 'This destination cannot be reached.'); + } + const stats = await fsp.stat(resolved.absolutePath).catch(() => null); + if (!stats?.isDirectory()) + throw new ValidationError('The destination must be an existing folder.'); + if (resolved.absolutePath.split(path.sep).includes(ZONE_DIRECTORY_NAME)) { + throw new ValidationError('The destination must be an existing folder.'); + } + return { relative, absolutePath: resolved.absolutePath }; +}; + +/** Take a version out of the history as a new file, in a folder someone chose. */ +const copyVersionTo = async (context, relativePath, versionId, { destination, name } = {}) => { + const located = await locateVersion(context, relativePath, versionId, { download: true }); + const folder = await resolveFolder(context, destination); + const { allowed } = await authorizePath(context, folder.relative, ACTIONS.createFile); + if (!allowed) throw new ForbiddenError('Files cannot be created in this folder.'); + + let wanted = nameForCopy(located.name, located.version); + if (name !== undefined && name !== null && name !== '') { + try { + wanted = ensureValidName(String(name)); + } catch (error) { + throw new ValidationError(error.message); + } + } + // A new file, never one already there: the copy lands under a name nothing + // holds, "(1)" when taken, even by a file that arrives while it is written. + // Putting it through the save of an existing file would have replaced such a + // file, and kept it as an earlier version of the copy. + const placed = await operations.saveNewFile( + folder.absolutePath, + wanted, + (temporaryPath) => + fsp.copyFile(located.absolutePath, temporaryPath, fs.constants.COPYFILE_FICLONE), + { purpose: 'restore' } + ); + const finalName = placed.name; + return { path: `${folder.relative}/${finalName}`, name: finalName }; +}; + +/** Put a version's content into another existing file, whose own content becomes a version. */ +const replaceWithVersion = async (context, relativePath, versionId, { target } = {}) => { + const located = await locateVersion(context, relativePath, versionId, { download: true }); + const other = relativeFrom(target, 'The file to replace'); + const { allowed, accessInfo, resolved } = await authorize(context, other, ACTIONS.write); + if (!allowed || !resolved) { + throw new ForbiddenError(accessInfo?.denialReason || 'This file cannot be changed.'); + } + const stats = await fsp.stat(resolved.absolutePath).catch(() => null); + 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); + return { status: result.status, path: other }; +}; + +/** Name a version, or pin it so that nothing but the space the zone has ever removes it. */ +const updateVersion = async (context, relativePath, versionId, { label, pinned } = {}) => { + const located = await locateVersion(context, relativePath, versionId, { download: false }); + if (!located.target.rights.restore) throw new ForbiddenError('This file cannot be changed.'); + if (label !== undefined && label !== null && typeof label !== 'string') { + throw new ValidationError('A version name is text.'); + } + if (typeof label === 'string' && label.trim().length > MAX_LABEL_LENGTH) { + throw new ValidationError(`A version name is at most ${MAX_LABEL_LENGTH} characters.`); + } + if (pinned !== undefined && typeof pinned !== 'boolean') { + throw new ValidationError('Pinned is true or false.'); + } + const db = await getDb(); + store.setVersionDetails(db, located.version.id, { + label: label === undefined ? undefined : String(label || '').trim() || null, + pinned, + }); + const updated = store.getVersion(db, located.version.id); + return presentVersion(updated, accountLabels(db, [updated.authorId]), true); +}; + +/** Delete versions of a file for good: some of them, or all. The file itself is untouched. */ +const deleteVersions = async (context, relativePath, { ids, all = false } = {}) => { + const target = await resolveFile(context, relativePath); + if (!target.rights.see) throw new ForbiddenError('The history of this file is not shared.'); + if (!target.rights.remove) throw new ForbiddenError('Versions of this file cannot be deleted.'); + + const db = await getDb(); + const file = await historyOf(db, target.absolutePath); + const kept = file ? store.listVersionsOfFile(db, file.id) : []; + let wanted; + if (all === true) { + wanted = kept.map((version) => version.id); + } else { + if (!Array.isArray(ids) || ids.length === 0) { + throw new ValidationError('At least one version is required.'); + } + if (ids.length > MAX_IDS) { + throw new ValidationError(`At most ${MAX_IDS} versions can be deleted at once.`); + } + if (!ids.every((id) => typeof id === 'string' && id)) { + throw new ValidationError('Version ids must be strings.'); + } + wanted = [...new Set(ids)]; + } + + const belongs = new Set(kept.map((version) => version.id)); + const items = []; + for (const id of wanted) { + if (!belongs.has(id)) { + items.push({ id, status: 'not-found' }); + continue; + } + try { + const outcome = await operations.purgeVersion(id); + items.push({ id, status: outcome.status === 'unavailable' ? 'pending' : outcome.status }); + } catch (error) { + logger.warn({ err: error, versionId: id }, 'A version could not be deleted'); + items.push({ id, status: 'failed' }); + } + } + return { + items, + deleted: items.filter((item) => item.status === 'purged' || item.status === 'pending').length, + }; +}; + +/** + * --------------------------------------------------------------------------- + * The whole installation's histories, for an administrator. + * + * Everything above answers about one file, and answers it with that file's + * own rights — which is the right shape for the person using the browser, and + * the wrong one for the question "what is taking the space, and where". That + * question has no path to hang on: a history whose file was deleted outside + * the application has no file left to be authorised against, and it is + * exactly the kind that nobody goes looking for. + * + * So these are addressed by the history's own id, and they are behind + * `ensureAdmin`. Two consequences worth stating rather than discovering: + * this lists paths from every space, personal folders included, which the + * browsing API never lets one account see of another; and it can delete a + * history that its owner would still want. It is an administrator's screen in + * the same sense as the trash's zones are. + * --------------------------------------------------------------------------- + */ + +/** Each zone by id, with the shape the screen shows it in. */ +const describeZones = (db) => + new Map( + trashStore.listZones(db).map((zone) => { + const described = zones.describeZoneRoot(zone.root); + return [ + zone.id, + { id: zone.id, root: zone.root, kind: described.kind, name: described.name }, + ]; + }) + ); + +/** + * The path a browser could open, when there is one. + * + * Only a volume has one: its logical path is its name and then the path + * inside it. A personal folder is addressed as `personal/…` by the one + * account it belongs to and by nobody else, so an administrator looking at + * somebody else's has no address to be given — and being handed a link that + * answers 404 is worse than being handed none. + */ +const logicalPathFor = (zone, relativePath) => + zone?.kind === 'volume' ? `${zone.name}/${relativePath}` : null; + +const ADMIN_PAGE_SIZE = 25; +const MAX_ADMIN_PAGE_SIZE = 200; + +const boundedInteger = (value, fallback, min, max) => { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(max, Math.max(min, Math.floor(number))); +}; + +/** A page of the files that have a history, and what the whole filter holds. */ +const listFilesWithVersions = async ({ + zoneId = null, + state = null, + query = '', + sort = 'bytes', + limit = ADMIN_PAGE_SIZE, + offset = 0, +} = {}) => { + if (state !== null && state !== undefined && state !== '' && !store.FILE_STATES.includes(state)) { + throw new ValidationError('That is not a state a history can be in.'); + } + if (sort && !Object.keys(store.ADMIN_SORTS).includes(sort)) { + throw new ValidationError('That is not an order this list can be read in.'); + } + if (typeof query !== 'string') throw new ValidationError('A search is text.'); + + const db = await getDb(); + const zoneMap = describeZones(db); + const filter = { + zoneId: zoneId || null, + state: state || null, + query: query.slice(0, 200), + }; + const page = { + ...filter, + sort: sort || 'bytes', + limit: boundedInteger(limit, ADMIN_PAGE_SIZE, 1, MAX_ADMIN_PAGE_SIZE), + offset: boundedInteger(offset, 0, 0, Number.MAX_SAFE_INTEGER), + }; + + const rows = store.listFilesWithVersions(db, page); + const totals = store.summariseFilesWithVersions(db, filter); + + return { + files: rows.map((row) => { + const zone = zoneMap.get(row.zoneId) || null; + return { + id: row.id, + name: path.posix.basename(row.relativePath), + relativePath: row.relativePath, + folder: + path.posix.dirname(row.relativePath) === '.' ? '' : path.posix.dirname(row.relativePath), + path: logicalPathFor(zone, row.relativePath), + state: row.state, + versions: row.versions, + bytes: row.bytes, + newest: row.newest, + zone: zone ? { id: zone.id, kind: zone.kind, name: zone.name } : null, + }; + }), + total: totals.files, + totalBytes: totals.bytes, + totalVersions: totals.versions, + limit: page.limit, + offset: page.offset, + zones: [...zoneMap.values()].map((zone) => ({ + id: zone.id, + kind: zone.kind, + name: zone.name, + })), + states: [...store.FILE_STATES], + sorts: Object.keys(store.ADMIN_SORTS), + }; +}; + +/** One history, with its versions — the same shape the panel shows, by id. */ +const readFileVersions = async (fileId) => { + const db = await getDb(); + const file = typeof fileId === 'string' && fileId ? store.getFile(db, fileId) : null; + if (!file) throw new NotFoundError('This history does not exist.'); + + const zone = describeZones(db).get(file.zoneId) || null; + const versions = store.listVersionsOfFile(db, file.id); + const availability = new Map(); + for (const zoneId of new Set(versions.map((version) => version.zoneId))) { + const row = trashStore.getZone(db, zoneId); + availability.set(zoneId, row ? (await zones.inspectZone(row)).available : false); + } + const labels = accountLabels( + db, + versions.map((version) => version.authorId) + ); + + return { + file: { + id: file.id, + name: path.posix.basename(file.relativePath), + relativePath: file.relativePath, + path: logicalPathFor(zone, file.relativePath), + state: file.state, + zone: zone ? { id: zone.id, kind: zone.kind, name: zone.name } : null, + }, + versions: versions.map((version) => + presentVersion(version, labels, availability.get(version.zoneId)) + ), + totalBytes: versions.reduce((total, version) => total + version.size, 0), + }; +}; + +/** + * Delete versions of one history, named by the history rather than by a path. + * + * `all` on a history whose file is gone takes the row with it: keeping an + * entry that leads to nothing would leave the list showing a file that has + * neither content nor versions. A live file keeps its row, because that row + * is also what the next save reads to tell an editing session from a change + * made behind its back. + */ +const deleteFileVersions = async (fileId, { ids, all = false } = {}) => { + const db = await getDb(); + const file = typeof fileId === 'string' && fileId ? store.getFile(db, fileId) : null; + if (!file) throw new NotFoundError('This history does not exist.'); + + const kept = store.listVersionsOfFile(db, file.id); + let wanted; + if (all === true) { + wanted = kept.map((version) => version.id); + } else { + if (!Array.isArray(ids) || ids.length === 0) { + throw new ValidationError('At least one version is required.'); + } + if (ids.length > MAX_IDS) { + throw new ValidationError(`At most ${MAX_IDS} versions can be deleted at once.`); + } + if (!ids.every((id) => typeof id === 'string' && id)) { + throw new ValidationError('Version ids must be strings.'); + } + wanted = [...new Set(ids)]; + } + + const belongs = new Set(kept.map((version) => version.id)); + const items = []; + for (const id of wanted) { + if (!belongs.has(id)) { + items.push({ id, status: 'not-found' }); + continue; + } + try { + const outcome = await operations.purgeVersion(id); + items.push({ id, status: outcome.status === 'unavailable' ? 'pending' : outcome.status }); + } catch (error) { + logger.warn({ err: error, versionId: id }, 'A version could not be deleted'); + items.push({ id, status: 'failed' }); + } + } + + const left = store.listVersionsOfFile(db, file.id, { + states: ['capturing', 'kept', 'purging'], + }); + if (left.length === 0 && file.state !== 'live') store.deleteFile(db, file.id); + + return { + items, + deleted: items.filter((item) => item.status === 'purged' || item.status === 'pending').length, + remaining: store.listVersionsOfFile(db, file.id).length, + }; +}; + +module.exports = { + rightsFrom, + marksForFolder, + listVersions, + locateVersion, + downloadVersion, + restoreVersion, + copyVersionTo, + replaceWithVersion, + updateVersion, + deleteVersions, + listFilesWithVersions, + readFileVersions, + deleteFileVersions, +}; diff --git a/backend/tests/routes/versions-history.test.js b/backend/tests/routes/versions-history.test.js new file mode 100644 index 00000000..eeec9aa1 --- /dev/null +++ b/backend/tests/routes/versions-history.test.js @@ -0,0 +1,172 @@ +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'; + +/** + * A file's history, from the outside. + * + * The engine that keeps what a save replaces has been here since the versions + * landed, and the office editors feed it. Two things were missing: the text + * editor wrote straight over the file — so a save left nothing behind, and a + * stop halfway through left the file truncated — and nothing could read a + * history back. + * + * These drive both through the API: save, save again, read what was kept, + * restore it, and check that restoring did not throw away what was there. + */ + +const DOCUMENT = 'Notes/journal.md'; + +let env; +let app; +let alice; + +const load = (relative) => require(modulePath(relative)); + +const absolute = () => path.join(env.volumeDir, ...DOCUMENT.split('/')); + +beforeEach(async () => { + env = await setupTestEnv({ tag: 'versions-history-' }); + + alice = await load('src/services/users').createLocalUser({ + email: 'alice@example.com', + username: 'alice', + displayName: 'Alice', + password: 'secret123', + roles: ['user'], + }); + + await fs.mkdir(path.dirname(absolute()), { recursive: true }); + + app = express(); + app.use(express.json()); + 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) => request(app).put('/api/editor').send({ path: DOCUMENT, content }); + +const history = () => request(app).get('/api/versions').query({ path: DOCUMENT }); + +const onDisk = () => fs.readFile(absolute(), 'utf8'); + +/** + * A version goes out as a download, with its own content type, which supertest + * does not buffer: the body is collected by hand or the test compares nothing. + */ +const collectBody = (res, callback) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => callback(null, Buffer.concat(chunks).toString('utf8'))); +}; + +const contentOf = (id, forPath = DOCUMENT) => + request(app) + .get(`/api/versions/${id}/content`) + .query({ path: forPath }) + .buffer(true) + .parse(collectBody); + +describe('saving a text file', () => { + it('keeps what it replaced, and says who saved it', async () => { + expect((await save('first')).status).toBe(200); + expect((await save('second')).status).toBe(200); + + expect(await onDisk()).toBe('second'); + + const listed = await history(); + expect(listed.status).toBe(200); + expect(listed.body.versions).toHaveLength(1); + expect(listed.body.file.source).toBe('editor'); + expect(listed.body.file.author?.label).toBe('Alice'); + }); + + /** The first save of a file that was not there creates it and keeps nothing. */ + it('keeps nothing for a file that did not exist', async () => { + await save('first'); + + expect(await onDisk()).toBe('first'); + expect((await history()).body.versions).toEqual([]); + }); + + it('never leaves the file as it was found halfway through', async () => { + await save('first'); + // The content the engine writes goes to a file of its own; the document is + // only replaced once it is whole. Saving something unwritable therefore + // leaves the document alone. + const refused = await request(app).put('/api/editor').send({ path: DOCUMENT, content: null }); + + expect(refused.status).toBe(400); + expect(await onDisk()).toBe('first'); + }); +}); + +describe('a history read back', () => { + it('hands over the content a version holds', async () => { + await save('first'); + await save('second'); + + const [version] = (await history()).body.versions; + const content = await contentOf(version.id); + + expect(content.status).toBe(200); + expect(content.body).toBe('first'); + }); + + it('puts a version back, and keeps what was there before it did', async () => { + await save('first'); + await save('second'); + + const [version] = (await history()).body.versions; + const restored = await request(app) + .post(`/api/versions/${version.id}/restore`) + .send({ path: DOCUMENT }); + + expect(restored.status).toBe(200); + expect(await onDisk()).toBe('first'); + + // What the restore replaced is a state of its own now: nothing is lost by + // going back. + const after = (await history()).body.versions; + expect(after).toHaveLength(2); + const contents = await Promise.all(after.map(async (each) => (await contentOf(each.id)).body)); + expect(contents.sort()).toEqual(['first', 'second']); + }); + + it('answers nothing for a file nobody has saved over', async () => { + await fs.writeFile(absolute(), 'written outside the application'); + + const listed = await history(); + + expect(listed.status).toBe(200); + expect(listed.body.versions).toEqual([]); + expect(listed.body.enabled).toBe(true); + }); + + it('refuses a version that does not belong to the file it is asked about', async () => { + await save('first'); + await save('second'); + const [version] = (await history()).body.versions; + + const other = 'Notes/other.md'; + await request(app).put('/api/editor').send({ path: other, content: 'elsewhere' }); + + const response = await contentOf(version.id, other); + + expect(response.status).toBe(404); + }); +});