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
1 change: 1 addition & 0 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ module.exports = {
ONLYOFFICE_LANG: process.env.ONLYOFFICE_LANG?.trim() || 'en',
ONLYOFFICE_FORCE_SAVE: normalizeBoolean(process.env.ONLYOFFICE_FORCE_SAVE) || false,
ONLYOFFICE_FILE_EXTENSIONS: process.env.ONLYOFFICE_FILE_EXTENSIONS || '',
ONLYOFFICE_DOWNLOAD_ORIGINS: process.env.ONLYOFFICE_DOWNLOAD_ORIGINS || '',

// Collabora (WOPI)
COLLABORA_URL: process.env.COLLABORA_URL?.trim() || null,
Expand Down
8 changes: 6 additions & 2 deletions backend/src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,15 +164,15 @@ if (env.PUBLIC_URL) {
// They are considered valid so accessing the app that way doesn't raise the
// public-URL mismatch warning, and they're accepted by CORS. PUBLIC_URL remains
// the canonical URL used to build absolute links (shares, OIDC callbacks, WOPI).
const parseOriginList = (value) =>
const parseOriginList = (value, variableName = 'INTERNAL_URL') =>
(typeof value === 'string' ? value.split(',') : [])
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
try {
return new URL(entry).origin;
} catch (err) {
console.warn(`[Config] Invalid INTERNAL_URL entry: ${entry}`);
console.warn(`[Config] Invalid ${variableName} entry: ${entry}`);
return null;
}
})
Expand Down Expand Up @@ -323,6 +323,10 @@ const onlyoffice = {
extensions: env.ONLYOFFICE_FILE_EXTENSIONS.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
// Where a saved document may be fetched from, beyond the Document Server's
// own address: it sometimes reports itself under another host than the one it
// is called on, behind a proxy or inside a container network.
downloadOrigins: parseOriginList(env.ONLYOFFICE_DOWNLOAD_ORIGINS, 'ONLYOFFICE_DOWNLOAD_ORIGINS'),
};

// Silent JWT mismatches surface to the user as "Document security token is not
Expand Down
46 changes: 34 additions & 12 deletions backend/src/routes/collabora.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@ const asyncHandler = require('../utils/asyncHandler');
const logger = require('../utils/logger');
const { getDiscoveryActionsByExt } = require('../services/collaboraDiscoveryService');
const lockService = require('../services/wopiLockService');
const versions = require('../services/versions/operations');
const { ValidationError, UnauthorizedError, ForbiddenError } = require('../errors/AppError');

/** A Collabora save header, under its current name or the one older servers still send. */
const wopiSaveHeader = (req, name) =>
String(req.headers[`x-cool-wopi-${name}`] ?? req.headers[`x-lool-wopi-${name}`] ?? '')
.trim()
.toLowerCase();

const router = express.Router();

const toExt = (filename = '') => {
Expand Down Expand Up @@ -282,18 +289,33 @@ router.post(
return res.status(409).end();
}

const dir = path.dirname(abs);
const tmp = path.join(dir, `.${path.basename(abs)}.wopi-tmp-${process.pid}-${Date.now()}`);

const writeStream = fs.createWriteStream(tmp);
await new Promise((resolve, reject) => {
req.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
req.on('error', reject);
});

await fsp.rename(tmp, abs);
await versions.saveFile(
abs,
(temporaryPath) =>
new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(temporaryPath, { flags: 'wx' });
req.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
req.on('error', reject);
}),
{
purpose: 'wopi',
author: { id: tokenPayload.userId || null, label: tokenPayload.userName || null },
source: 'collabora',
session: {
// One lock per open document, held by everyone editing it together:
// the session its saves belong to. Without one every save would stand
// as a state of its own.
key: requestLock ? `wopi:${requestLock}` : null,
startedAt: Number.isFinite(tokenPayload.iat) ? tokenPayload.iat * 1000 : null,
},
// Collabora saves on its own every few minutes; the ones somebody asked
// for, and the one made on closing the document, are states worth
// keeping.
explicit: wopiSaveHeader(req, 'isautosave') !== 'true',
}
);
const stat = await fsp.stat(abs);

res.setHeader('Cache-Control', 'no-store');
Expand Down
132 changes: 122 additions & 10 deletions backend/src/routes/onlyoffice.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ const path = require('path');
const fs = require('fs');
const fsp = require('fs/promises');
const crypto = require('crypto');
const { pipeline } = require('stream/promises');
const axios = require('axios');
const jwt = require('jsonwebtoken');

const { onlyoffice, public: publicConfig, mimeTypes } = require('../config/index');
const { normalizeRelativePath } = require('../utils/pathUtils');
const { ensureDir } = require('../utils/fsUtils');
const { resolvePathWithAccess } = require('../services/accessManager');
const versions = require('../services/versions/operations');
const logger = require('../utils/logger');
const asyncHandler = require('../utils/asyncHandler');
const { ValidationError, UnauthorizedError, ForbiddenError } = require('../errors/AppError');
Expand All @@ -33,6 +35,89 @@ const getDocumentType = (ext) => {

const resolveMime = (ext) => mimeTypes[ext] || 'application/octet-stream';

/**
* The addresses a saved document may be fetched from.
*
* The Document Server's own, and any declared beside it: behind a proxy or
* inside a container network it sometimes reports itself under a host other
* than the one it is called on.
*/
const buildAllowedDownloadOrigins = () => {
const origins = new Set();
const add = (value) => {
if (!value) return;
try {
origins.add(new URL(value).origin);
} catch {
// Ignore malformed configuration entries.
}
};
add(onlyoffice.serverUrl);
(onlyoffice.downloadOrigins || []).forEach(add);
return origins;
};

/**
* The callback says where to fetch the saved document from, and the server
* fetched whatever it was told to: an address on the machine itself, or inside
* the network the container sits in, reached by anyone who can reach the
* callback. It has to come from the Document Server we sent the document to.
*/
const ensureAllowedDownloadUrl = (rawUrl) => {
let parsed;
try {
parsed = new URL(String(rawUrl));
} catch {
throw new ValidationError('The document URL is not a valid URL.');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new ValidationError('The document URL must use HTTP or HTTPS.');
}
const allowed = buildAllowedDownloadOrigins();
if (!allowed.has(parsed.origin)) {
logger.warn(
{ origin: parsed.origin, allowed: Array.from(allowed) },
'ONLYOFFICE callback rejected: document URL origin is not allowed. Add it to ONLYOFFICE_DOWNLOAD_ORIGINS if the Document Server reports a different host.'
);
throw new ForbiddenError('The document URL does not come from the configured Document Server.');
}
return parsed.toString();
};

/**
* Pull the saved document into a file of its own, next to the document.
*
* Never into the document itself: it used to be truncated to nothing before
* the Document Server had answered, so a slow network, a restart or a refused
* download left an empty file where the work had been. The versions place the
* temporary file over the document once it is whole.
*/
const fetchDocumentInto = async (downloadUrl, temporaryPath, mode) => {
const response = await axios.get(downloadUrl, { responseType: 'stream', timeout: 30000 });
await pipeline(response.data, fs.createWriteStream(temporaryPath, { flags: 'wx', mode }));
// The write stream's mode is subject to the umask; this is not.
await fsp.chmod(temporaryPath, mode);
};

/**
* Who a callback is saving for.
*
* The Document Server names the people whose changes are in this save; the
* last of them is the one to credit. It says nothing when a save carries no
* history — the first one of a session — and then the account the editing
* 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.
*/
const authorFromCallback = (body, backendCtx) => {
const changes = Array.isArray(body?.history?.changes) ? body.history.changes : [];
const user = changes.length ? changes[changes.length - 1]?.user : null;
const id = user?.id ? String(user.id) : backendCtx?.userId || null;
if ((id && id.startsWith('guest_')) || (!id && backendCtx?.guestSessionId)) {
return { id: null, label: 'share-link' };
}
return { id, label: user?.name ? String(user.name) : null };
};

const getDsJwtFromReq = (req) => {
const auth = (req.headers['authorization'] || req.headers['authorizationjwt'] || '').toString();
if (auth.toLowerCase().startsWith('bearer ')) {
Expand Down Expand Up @@ -294,6 +379,8 @@ router.post(
const status = Number(body.status);
// 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;
Expand All @@ -308,16 +395,41 @@ router.post(
abs = resolved.absolutePath;
}
await ensureDir(path.dirname(abs));
// Download updated file from Document Server
const response = await axios.get(body.url, { responseType: 'stream' });
await fsp.writeFile(abs, Buffer.from([])); // ensure file exists / truncate
const writeStream = fs.createWriteStream(abs);
await new Promise((resolve, reject) => {
response.data.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
logger.debug({ path: relativePath }, 'ONLYOFFICE file updated');

// Keep the permissions the document already had; one the editor is
// creating starts private.
let mode = 0o600;
try {
const previous = await fsp.stat(abs);
if (previous.isFile()) mode = previous.mode & 0o777;
} catch {
// A document that is not there yet has nothing to keep.
}

// A save on purpose — the editor's own Save, or the last one made once
// everybody has left the document — is a state worth keeping. The
// automatic saves in between are not, beyond the checkpoint the
// versions take of a session that runs long.
const explicit = status === 2 || Number(body.forcesavetype) === 1;

await versions.saveFile(
abs,
(temporaryPath) => fetchDocumentInto(downloadUrl, temporaryPath, mode),
{
purpose: 'onlyoffice',
author: authorFromCallback(body, backendCtx),
source: 'onlyoffice',
session: {
// Everybody editing together shares the document key: it is the
// session, and its saves belong to it rather than each standing
// as a state of its own.
key: typeof body.key === 'string' && body.key ? body.key : null,
startedAt: Number.isFinite(backendCtx?.iat) ? backendCtx.iat * 1000 : null,
},
explicit,
}
);
logger.debug({ path: relativePath, status }, 'ONLYOFFICE file updated');
// MUST return {error:0} according to ONLYOFFICE spec
return res.json({ error: 0 });
}
Expand Down
Loading
Loading