Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion backend/src/routes/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 });
})
);
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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)
Expand Down
106 changes: 106 additions & 0 deletions backend/src/routes/versions.js
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading