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); + }); +});