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
7 changes: 5 additions & 2 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const { bootstrap } = require('./utils/bootstrap');
const { configureSession } = require('./middleware/session');
const logger = require('./utils/logger');
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
const { uploads } = require('./config');

/**
* Creates and configures the Express application.
Expand Down Expand Up @@ -49,8 +50,10 @@ const createApp = async (options = {}) => {
configureHttpLogging(app);

configureCors(app);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Large enough to carry back whatever the text editor was allowed to open;
// see the reasoning beside the two limits in the configuration.
app.use(express.json({ limit: uploads.maxJsonBodyBytes }));
app.use(express.urlencoded({ extended: true, limit: uploads.maxJsonBodyBytes }));
app.use(cookieParser());
app.use(requestContextMiddleware);
logger.debug('Mounted cookie parser middleware');
Expand Down
8 changes: 8 additions & 0 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ module.exports = {
ONLYOFFICE_SECRET: process.env.ONLYOFFICE_SECRET || null,
ONLYOFFICE_LANG: process.env.ONLYOFFICE_LANG?.trim() || 'en',
ONLYOFFICE_FORCE_SAVE: normalizeBoolean(process.env.ONLYOFFICE_FORCE_SAVE) || false,
ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS: Number(process.env.ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS) || 10000,
// 0 disables proactive writes to the external storage. A bounded interval
// keeps the Document Server's internal autosave from becoming a full document
// conversion on every edit.
ONLYOFFICE_AUTO_SAVE_INTERVAL_MS: (() => {
const value = Number(process.env.ONLYOFFICE_AUTO_SAVE_INTERVAL_MS);
return Number.isFinite(value) && value >= 0 ? value : 30000;
})(),
ONLYOFFICE_FILE_EXTENSIONS: process.env.ONLYOFFICE_FILE_EXTENSIONS || '',
ONLYOFFICE_DOWNLOAD_ORIGINS: process.env.ONLYOFFICE_DOWNLOAD_ORIGINS || '',

Expand Down
75 changes: 68 additions & 7 deletions backend/src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const env = require('./env');
const constants = require('./constants');
const loggingConfig = require('./logging');
const { parseByteSize } = require('../utils/env');
// logger reads config/logging, never this file — requiring it here makes no cycle.
const logger = require('../utils/logger');

const parseCommaOrSpaceList = (raw) => {
if (!raw) return [];
Expand Down Expand Up @@ -277,11 +279,75 @@ const searchMaxFileSizeBytes = (() => {
})();

// --- Uploads ---
// --- Editor ---
/**
* What the inline text editor opens, and what a JSON request body may weigh.
*
* They are one decision rather than two. The editor sends a file back through a
* JSON body when it saves it, so a body limit under the size the editor opens
* produces a file that opens and cannot be saved — answered with "request
* entity too large", which names neither setting. Express's own default is
* 100 kB, against an editor that opens two megabytes.
*
* Escaping is why the body has to be worth more than the file: in the worst
* case every character of the content is a quote, a backslash or a newline and
* becomes two, and the path travels in the same body. A file whose bytes would
* expand further than that is one the editor refuses to open anyway, as binary.
*/
const JSON_ESCAPE_WORST_CASE = 2;
const JSON_BODY_OVERHEAD_BYTES = 64 * 1024;
const DEFAULT_JSON_BODY_BYTES = 8 * 1024 * 1024;

const bodyNeededFor = (fileBytes) => fileBytes * JSON_ESCAPE_WORST_CASE + JSON_BODY_OVERHEAD_BYTES;
const fileAllowedBy = (bodyBytes) =>
Math.max(0, Math.floor((bodyBytes - JSON_BODY_OVERHEAD_BYTES) / JSON_ESCAPE_WORST_CASE));

const { editorMaxFileSizeBytes, maxJsonBodyBytes } = (() => {
const parsedEditor = parseByteSize(env.EDITOR_MAX_FILESIZE);
// Default: 2 MiB if not configured or invalid
const editorAsked =
Number.isFinite(parsedEditor) && parsedEditor > 0 ? parsedEditor : 2 * 1024 * 1024;

const parsedBody = parseByteSize(env.MAX_JSON_BODY_SIZE);
const bodyWasChosen = Number.isFinite(parsedBody) && parsedBody > 0;

// A body limit someone set is a ceiling they meant — it is a guard, not a
// detail — so it is never raised from here. The editor is what gives way, and
// it gives way by refusing to open what it could not save back.
if (bodyWasChosen) {
const allowed = fileAllowedBy(parsedBody);
if (editorAsked > allowed) {
logger.warn(
{ editorAsked, loweredTo: allowed, maxJsonBodyBytes: parsedBody },
'EDITOR_MAX_FILESIZE is larger than MAX_JSON_BODY_SIZE can carry back and has been ' +
'lowered to match; the editor would otherwise open files it could not save'
);
}
return { editorMaxFileSizeBytes: Math.min(editorAsked, allowed), maxJsonBodyBytes: parsedBody };
}

// Nobody chose the body limit, so the editor's size is the only wish there is
// to honour: the default body limit rises to carry it.
const needed = bodyNeededFor(editorAsked);
if (needed > DEFAULT_JSON_BODY_BYTES) {
logger.info(
{ editorMaxFileSizeBytes: editorAsked, maxJsonBodyBytes: needed },
'Raised the JSON body limit above its default so the text editor can save what it opens'
);
}

return {
editorMaxFileSizeBytes: editorAsked,
maxJsonBodyBytes: Math.max(DEFAULT_JSON_BODY_BYTES, needed),
};
})();

// Ceilings for direct (non-chunked) uploads. They exist so a single request
// cannot stream until the disk is full; they are generous on purpose, since
// large files are a normal use of a file manager. Chunked uploads have their
// own storage guard in the TUS service.
const uploads = {
maxJsonBodyBytes,
maxDirectUploadBytes: (() => {
const parsed = parseByteSize(env.MAX_DIRECT_UPLOAD_SIZE);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 64 * 1024 * 1024 * 1024;
Expand Down Expand Up @@ -320,6 +386,8 @@ const onlyoffice = {
secret: env.ONLYOFFICE_SECRET || deriveSecret('onlyoffice'),
lang: env.ONLYOFFICE_LANG,
forceSave: env.ONLYOFFICE_FORCE_SAVE,
forceSaveTimeoutMs: Math.min(30000, Math.max(7000, env.ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS)),
autoSaveIntervalMs: Math.min(300000, Math.max(0, env.ONLYOFFICE_AUTO_SAVE_INTERVAL_MS)),
extensions: env.ONLYOFFICE_FILE_EXTENSIONS.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
Expand Down Expand Up @@ -365,13 +433,6 @@ const collabora = {
.filter(Boolean),
};

// --- Editor ---
const editorMaxFileSizeBytes = (() => {
const parsed = parseByteSize(env.EDITOR_MAX_FILESIZE);
// Default: 2 MiB if not configured or invalid
return Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 1024 * 1024;
})();

const editor = {
extensions: parseExtensionList(env.EDITOR_EXTENSIONS),
maxFileSizeBytes: editorMaxFileSizeBytes,
Expand Down
40 changes: 40 additions & 0 deletions backend/src/routes/browse.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,42 @@ const { NotFoundError } = require('../errors/AppError');
const router = express.Router();
const { resolvePathWithAccess } = require('../services/accessManager');
const { listDirectoryItems } = require('../services/directoryListingService');
const versions = require('../services/versions');
const { rightsFrom: versionRights } = versions;

/**
* The mark that says a file has earlier versions, for a whole listing.
*
* Counted once for the folder rather than once per row, and only when somebody
* asked to see it — the preference is on by default, and turning it off takes
* the query away as well as the icon, so it costs nothing to somebody who does
* not want it.
*
* The right to see a history is the row's own and not the folder's: a share
* hands out histories only when its owner said so, and that is decided here
* from each child's access rather than from the folder's.
*/
const versionMarks = async (directoryPath, userSettings) => {
if (userSettings?.showVersionMarks === false) return null;

let marks;
try {
marks = await versions.marksForFolder(directoryPath);
} catch (error) {
// A listing is not worth failing over a count. Nothing is marked, and the
// history is still one right-click away.
logger.warn({ err: error, directoryPath }, 'File versions were not counted for a listing');
return null;
}
if (!marks || marks.size === 0) return null;

return ({ name, stats, access }) => {
if (!stats?.isFile()) return null;
const mark = marks.get(name);
if (!mark || !versionRights(access).see) return null;
return { versions: { count: mark.versions, bytes: mark.bytes, newest: mark.newest } };
};
};

router.get(
'/browse/{*splat}',
Expand Down Expand Up @@ -49,6 +85,7 @@ router.get(
excludeDownloadArtifacts: true,
includeHiddenFiles,
permissionRules: settings?.access?.rules || [],
itemExtras: await versionMarks(directoryPath, userSettings),
});

const response = {
Expand All @@ -60,6 +97,9 @@ router.get(
canDelete: accessInfo.canDelete,
canShare: accessInfo.canShare,
canDownload: accessInfo.canDownload,
// Whether the files here show their history, which a share hands out
// only when its owner said so.
canSeeVersions: versionRights(accessInfo).see,
},
current: {
isDirectory: true,
Expand Down
110 changes: 49 additions & 61 deletions backend/src/routes/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,29 @@ const express = require('express');
const path = require('path');
const fs = require('fs/promises');

const config = require('../config');
const { normalizeRelativePath } = require('../utils/pathUtils');
const { ensureDir } = require('../utils/fsUtils');
const { ACTIONS, authorizeAndResolve } = require('../services/authorizationService');
const versions = require('../services/versions/operations');
const asyncHandler = require('../utils/asyncHandler');
const { sendTextFile } = require('../utils/textFileResponse');
const { ValidationError, ForbiddenError, NotFoundError } = require('../errors/AppError');
const {
ValidationError,
ForbiddenError,
NotFoundError,
UnsupportedMediaTypeError,
} = require('../errors/AppError');
readFileEncoding,
encodeText,
MAX_EDITOR_FILE_SIZE,
} = require('../services/textEditorService');

const router = express.Router();

const MAX_EDITOR_FILE_SIZE = config.editor?.maxFileSizeBytes ?? 1 * 1024 * 1024;
const VIDEO_EXTENSIONS = Array.isArray(config.extensions?.videos) ? config.extensions.videos : [];

function isProbablyBinaryBuffer(buffer) {
const length = Math.min(buffer.length, 4096);
if (!length) return false;

let suspicious = 0;
for (let index = 0; index < length; index += 1) {
const byte = buffer[index];
if (byte === 0) {
return true;
}
if (byte < 7 || (byte > 13 && byte < 32)) {
suspicious += 1;
}
}

return suspicious / length > 0.3;
}

async function readTextFileBuffer(req, relative) {
async function resolveReadableFile(req, relative) {
if (typeof relative !== 'string' || !relative) {
throw new ValidationError('A valid file path is required.');
}

const relativePath = normalizeRelativePath(relative);
const context = { user: req.user, guestSession: req.guestSession };

let accessInfo;
let resolved;
try {
Expand All @@ -65,51 +45,43 @@ async function readTextFileBuffer(req, relative) {
throw new ForbiddenError(accessInfo?.denialReason || 'Access denied.');
}

const { absolutePath } = resolved;
const stats = await fs.stat(absolutePath);

if (stats.isDirectory()) {
throw new ValidationError('Cannot open a directory in the editor.');
}

if (typeof stats.size === 'number' && stats.size > MAX_EDITOR_FILE_SIZE) {
throw new ValidationError('This file is too large to open in the text editor.');
}

const ext = path.extname(absolutePath).slice(1).toLowerCase();
if (VIDEO_EXTENSIONS.includes(ext)) {
throw new UnsupportedMediaTypeError('This file type cannot be opened in the text editor.');
}
return resolved.absolutePath;
}

const buffer = await fs.readFile(absolutePath);
if (isProbablyBinaryBuffer(buffer)) {
throw new UnsupportedMediaTypeError(
'This file appears to be binary and cannot be opened in the text editor.'
);
}
/**
* The editor's read. By GET, which the browser keeps and revalidates, so the
* editor opened from the Markdown preview does not download the file again; by
* POST for the clients written against it, which nothing keeps.
*/
const sendEditorText = async (req, res, relative) => {
const absolutePath = await resolveReadableFile(req, relative);
await sendTextFile(req, res, { absolutePath, render: ({ text }) => ({ content: text }) });
};

return { buffer, absolutePath };
}
router.get(
'/editor',
asyncHandler(async (req, res) => {
await sendEditorText(req, res, req.query?.path);
})
);

router.post(
'/editor',
asyncHandler(async (req, res) => {
const { path: relative = '' } = req.body || {};
const { buffer } = await readTextFileBuffer(req, relative);
const data = buffer.toString('utf-8');
res.send({ content: data });
await sendEditorText(req, res, relative);
})
);

router.get(
'/raw',
asyncHandler(async (req, res) => {
const relative = req.query?.path;
const { buffer } = await readTextFileBuffer(req, relative);

res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.send(buffer.toString('utf-8'));
const absolutePath = await resolveReadableFile(req, req.query?.path);
await sendTextFile(req, res, {
absolutePath,
headers: { 'X-Content-Type-Options': 'nosniff' },
render: ({ text }) => text,
});
})
);

Expand Down Expand Up @@ -161,6 +133,22 @@ router.put(
const { absolutePath } = resolved;

await ensureDir(path.dirname(absolutePath));
const existed = await fs
.stat(absolutePath)
.then((stats) => stats.isFile())
.catch(() => false);

// Written back in the encoding it already had: a UTF-16 file saved as UTF-8
// reads perfectly well here and breaks whatever wrote it.
const payload = encodeText(content, existed ? await readFileEncoding(absolutePath) : undefined);
// Refused for the same reason the editor refuses to open it. Without this
// the editor wrote whatever it was given — paste two megabytes into a small
// file, save, and the next attempt to open it answered that the file is too
// large. Measured on the bytes actually written, which is what the size
// limit is about.
if (payload.length > MAX_EDITOR_FILE_SIZE) {
throw new ValidationError('This file is too large to save in the text editor.');
}

// Written beside the file and put in place once whole, with what it
// replaces kept as a version: a save used to go straight over the file, so
Expand All @@ -169,7 +157,7 @@ router.put(
// session here to group it with, as there is in the office editors.
await versions.saveFile(
absolutePath,
(temporaryPath) => fs.writeFile(temporaryPath, content, { encoding: 'utf-8', flag: 'wx' }),
(temporaryPath) => fs.writeFile(temporaryPath, payload, { flag: 'wx' }),
{
purpose: 'editor',
author: versions.authorOf({ user: req.user, guestSession: req.guestSession }),
Expand Down
Loading
Loading