Compare commits

..
24 Commits
Author SHA1 Message Date
yu-i-i e824377bea Minor changes in README.md 2025-04-09 15:31:39 +02:00
yu-i-i 2c934c5bec Track changes / comments: update backend to support frontend changes 2025-04-09 15:31:39 +02:00
yu-i-i 97892dd3ac Allow EXTERNAL_AUTH to be undefined, fixes #26 2025-04-09 15:31:39 +02:00
yu-i-i 9cf606d061 Clarify OIDC redirect URI in README.md (closes #28) 2025-04-09 15:31:39 +02:00
yu-i-i b45e1a0c17 Symbol palette: switch to 'OL' UI components and apply minor cosmetic changes 2025-04-09 15:31:39 +02:00
yu-i-i 4f6e15b306 Clarify OVERLEAF_OIDC_USER_ID_FIELD parameter usage (closes #24) 2025-04-09 15:31:39 +02:00
yu-i-i 683c225c4a Make OVERLEAF_OIDC_USER_ID_FIELD support 'email' as a value 2025-04-09 15:31:38 +02:00
yu-i-i 6f06e85065 See upstream commit 42ee56e 2025-04-09 15:31:38 +02:00
yu-i-i c4f69e4d24 Fix glitches in symbol palette after switching to Bootstrap 5 2025-04-09 15:31:38 +02:00
yu-i-i 40c014800a Whitelist /oidc/login endpoint, fixes #21 2025-04-09 15:31:38 +02:00
yu-i-i d65bd08f6a Update README.md (add ENV variables to control SAML signature validation) 2025-04-09 15:31:38 +02:00
yu-i-i 4af7208d5e Add ENV variables to control SAML signature validation 2025-04-09 15:31:38 +02:00
yu-i-i c6747d2985 Update README.md 2025-04-09 15:31:38 +02:00
yu-i-i e615d96b90 Re-export doLogout (was removed from exports in commit b9fb636). 2025-04-09 15:31:37 +02:00
yu-i-i 3b9dc301a1 Refactor authentication code; add OIDC support 2025-04-09 15:31:37 +02:00
yu-i-i 559abbf34f Allow adding extra flags to LaTeX compiler through environment variable 2025-04-09 15:31:37 +02:00
yu-i-i 8e1c78eb88 Update README.md 2025-04-09 15:31:36 +02:00
yu-i-i 178555ad4f Enable LDAP and SAML authentication support 2025-04-09 15:31:36 +02:00
yu-i-i 2a21801e8f Enable Symbol Palette 2025-04-09 15:31:36 +02:00
yu-i-i c683551e65 Allow selecting a TeX Live image for a project 2025-04-09 15:31:36 +02:00
Sam Van den Vonder 4cfec830b3 Enable Sandboxed Compiles feature 2025-04-09 15:31:36 +02:00
yu-i-i f51f49ec7a Enable autocomplete of reference keys feature 2025-04-09 15:31:35 +02:00
yu-i-i 7e86c8a56a Enable track changes and comments feature 2025-04-09 15:31:35 +02:00
yu-i-i 2ef7738a56 Redirect non-existing links to Overleaf page 2025-04-09 15:31:35 +02:00
6085 changed files with 365484 additions and 466207 deletions
+44
View File
@@ -0,0 +1,44 @@
<!--
Note: If you are using www.overleaf.com and have a problem,
or if you would like to request a new feature please contact
the support team at support@overleaf.com
This form should only be used to report bugs in the
Community Edition release of Overleaf.
-->
<!-- BUG REPORT TEMPLATE -->
## Steps to Reproduce
<!-- Describe the steps leading up to when / where you found the bug. -->
<!-- Screenshots may be helpful here. -->
1.
2.
3.
## Expected Behaviour
<!-- What should have happened when you completed the steps above? -->
## Observed Behaviour
<!-- What actually happened when you completed the steps above? -->
<!-- Screenshots may be helpful here. -->
## Context
<!-- How has this issue affected you? What were you trying to accomplish? -->
## Technical Info
<!-- Provide any technical details that may be applicable (or N/A if not applicable). -->
* URL:
* Browser Name and version:
* Operating System and version (desktop or mobile):
* Signed in as:
* Project and/or file:
## Analysis
<!--- Optionally, document investigation of / suggest a fix for the bug, e.g. 'comes from this line / commit' -->
-56
View File
@@ -1,56 +0,0 @@
---
name: Bug report
about: Report a bug
title: ''
labels: type:bug
assignees: ''
---
<!--
Note: If you are using www.overleaf.com and have a problem,
or if you would like to request a new feature please contact
the support team at support@overleaf.com
This form should only be used to report bugs in the
Community Edition release of Overleaf.
-->
<!-- BUG REPORT TEMPLATE -->
## Steps to Reproduce
<!-- Describe the steps leading up to when / where you found the bug. -->
<!-- Screenshots may be helpful here. -->
1.
2.
3.
## Expected Behaviour
<!-- What should have happened when you completed the steps above? -->
## Observed Behaviour
<!-- What actually happened when you completed the steps above? -->
<!-- Screenshots may be helpful here. -->
## Context
<!-- How has this issue affected you? What were you trying to accomplish? -->
## Technical Info
<!-- Provide any technical details that may be applicable (or N/A if not applicable). -->
- URL:
- Browser Name and version:
- Operating System and version (desktop or mobile):
- Signed in as:
- Project and/or file:
## Analysis
<!--- Optionally, document investigation of / suggest a fix for the bug, e.g. 'comes from this line / commit' -->
+3 -3
View File
@@ -1,11 +1,11 @@
## Description
<!-- Goal of the pull request -->
## Related issues / Pull Requests
## Related issues / Pull Requests
<!-- Fixes #xyz, Contributes to #xyz, Related to #xyz-->
## Contributor Agreement
- [ ] I confirm I have signed the [Contributor License Agreement](https://github.com/overleaf/overleaf/blob/main/CONTRIBUTING.md#contributor-license-agreement)
- [ ] I confirm I have signed the [Contributor License Agreement](https://github.com/overleaf/overleaf/blob/master/CONTRIBUTING.md#contributor-license-agreement)
-58
View File
@@ -1,58 +0,0 @@
// Resolve ESM imports through Yarn PnP.
//
// Yarn ships .pnp.loader.mjs for use with module.register(), which runs hooks on a dedicated
// thread — a second V8 isolate costing 50-110MB in every node process, whether or not the
// loader does any work. registerHooks() runs synchronously in-thread instead, and PnP's own
// resolveRequest already applies "exports" conditions, so the hook is just a lookup.
import { registerHooks, isBuiltin, createRequire } from 'node:module'
import { pathToFileURL, fileURLToPath } from 'node:url'
import path from 'node:path'
const pnp = createRequire(import.meta.url)('pnpapi')
registerHooks({
resolve(specifier, context, nextResolve) {
// require() is patched by .pnp.cjs, and builtins never reach the filesystem.
if (context.conditions.includes('require') || isBuiltin(specifier)) {
return nextResolve(specifier, context)
}
// Only bare specifiers need PnP. The entrypoint arrives as a path, and mocha imports test
// files by file:// URL; both are already resolved.
let url
if (specifier.startsWith('file://')) {
url = specifier
} else if (!context.parentURL) {
url = pathToFileURL(specifier).href
} else {
// Tools installed outside the project — npm, corepack — bring their own node_modules.
// PnP does not own those paths and refuses to resolve from them.
const issuer = path.dirname(fileURLToPath(context.parentURL)) + '/'
if (!pnp.findPackageLocator(issuer)) {
return nextResolve(specifier, context)
}
try {
url = pathToFileURL(
pnp.resolveRequest(specifier, issuer, {
conditions: new Set(context.conditions),
})
).href
} catch (err) {
// PnP reports a missing module with the CommonJS code. require() returned above, so this is an ESM import and node's own resolver would raise the ESM code, which is what callers of an optional import catch.
if (err.code === 'MODULE_NOT_FOUND') err.code = 'ERR_MODULE_NOT_FOUND'
throw err
}
}
try {
// Keep the resolved path in the chain so loaders registered later can still mock it.
return nextResolve(url, context)
} catch (err) {
// Node cannot look inside PnP's zips, nor at its __virtual__ paths, so PnP's answer is
// final for those. Everything else — a genuinely missing file, an error raised by
// another loader — is a real failure.
if (url.includes('/__virtual__/') || url.includes('.zip/')) {
return { url, shortCircuit: true }
}
throw err
}
},
})
@@ -1,16 +0,0 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index b781835b37a1262510bdd66d8d3b399a076e9d68..312c314186d85bb3bc2ab2620e99bca259ef7167 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -44,10 +44,9 @@ import { z as z2 } from "zod/v4";
// src/tool/types.ts
import { z } from "zod/v4";
-var LATEST_PROTOCOL_VERSION = "2025-11-25";
+var LATEST_PROTOCOL_VERSION = "2025-06-18";
var SUPPORTED_PROTOCOL_VERSIONS = [
LATEST_PROTOCOL_VERSION,
- "2025-06-18",
"2025-03-26",
"2024-11-05"
];
@@ -1,831 +0,0 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 39215ae..b44cb76 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -187,16 +187,23 @@ Helper function that returns a transaction spec which inserts a
completion's text in the main selection range, and any other
selection range that has the same text in front of it.
*/
-function insertCompletionText(state$1, text, from, to) {
+function insertCompletionText(state$1, text, from, to, extend) {
let { main } = state$1.selection, fromOff = from - main.from, toOff = to - main.from;
return Object.assign(Object.assign({}, state$1.changeByRange(range => {
if (range != main && from != to &&
state$1.sliceDoc(range.from + fromOff, range.from + toOff) != state$1.sliceDoc(from, to))
return { range };
- let lines = state$1.toText(text);
+ let change = {
+ from: range.from + fromOff,
+ to: to == main.from ? range.to : range.from + toOff,
+ insert: text instanceof state.Text ? text : state$1.toText(text),
+ };
+ if (extend) {
+ extend(state$1, change);
+ }
return {
- changes: { from: range.from + fromOff, to: to == main.from ? range.to : range.from + toOff, insert: lines },
- range: state.EditorSelection.cursor(range.from + fromOff + lines.length)
+ changes: change,
+ range: state.EditorSelection.cursor(change.from + change.insert.length)
};
})), { scrollIntoView: true, userEvent: "input.complete" });
}
@@ -389,7 +396,9 @@ const completionConfig = state.Facet.define({
filterStrict: false,
compareCompletions: (a, b) => a.label.localeCompare(b.label),
interactionDelay: 75,
- updateSyncTime: 100
+ updateSyncTime: 100,
+ // overleaf: default to at top which is default CM6 behaviour
+ unfilteredResultsAtEnd: false
}, {
defaultKeymap: (a, b) => a && b,
closeOnBlur: (a, b) => a && b,
@@ -744,6 +753,7 @@ function score(option) {
(option.type ? 1 : 0);
}
function sortOptions(active, state) {
+ var _a;
let options = [];
let sections = null;
let addOption = (option) => {
@@ -763,7 +773,8 @@ function sortOptions(active, state) {
let getMatch = a.result.getMatch;
if (a.result.filter === false) {
for (let option of a.result.options) {
- addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], 1e9 - options.length));
+ let defaultScore = conf.unfilteredResultsAtEnd ? -1e9 : 1e9;
+ addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], defaultScore - options.length));
}
}
else {
@@ -790,15 +801,42 @@ function sortOptions(active, state) {
}
}
let result = [], prev = null;
+ const priorityIndices = new Map();
let compare = conf.compareCompletions;
for (let opt of options.sort((a, b) => (b.score - a.score) || compare(a.completion, b.completion))) {
+ // overleaf: Deduplicate results with dedup options
+ // The goal is to keep only the highest priority option, in the
+ // highest scoring position.
+ const key = (_a = opt.completion.deduplicate) === null || _a === void 0 ? void 0 : _a.key;
+ if (key) {
+ // Handle merging specifically for deduplicated items item
+ const currentOptionIndex = priorityIndices.get(key);
+ if (currentOptionIndex === undefined) {
+ priorityIndices.set(key, result.length);
+ result.push(opt);
+ prev = opt.completion;
+ }
+ else {
+ if (result[currentOptionIndex].completion.deduplicate.priority < opt.completion.deduplicate.priority) {
+ result[currentOptionIndex] = opt;
+ if (currentOptionIndex === result.length - 1) {
+ prev = opt.completion;
+ }
+ }
+ }
+ continue;
+ }
+ // overleaf: end
let cur = opt.completion;
- if (!prev || prev.label != cur.label || prev.detail != cur.detail ||
- (prev.type != null && cur.type != null && prev.type != cur.type) ||
- prev.apply != cur.apply || prev.boost != cur.boost)
+ if (!prev || prev.label != cur.label)
+ result.push(opt);
+ // overleaf: we're already handling deduplication, so skip extra merges
+ else if (prev.deduplicate)
result.push(opt);
else if (score(opt.completion) > score(prev))
result[result.length - 1] = opt;
+ else if (opt.completion.info)
+ result[result.length - 1] = opt;
prev = opt.completion;
}
return result;
@@ -817,8 +855,9 @@ class CompletionDialog {
: new CompletionDialog(this.options, makeAttrs(id, selected), this.tooltip, this.timestamp, selected, this.disabled);
}
static build(active, state, id, prev, conf, didSetActive) {
- if (prev && !didSetActive && active.some(s => s.isPending))
- return prev.setDisabled();
+ // Overleaf: avoid setting the previous completion state to disabled while completion sources are pending
+ // if (prev && !didSetActive && active.some(s => s.isPending))
+ // return prev.setDisabled()
let options = sortOptions(active, state);
if (!options.length)
return prev && active.some(a => a.isPending) ? prev.setDisabled() : null;
@@ -1017,13 +1056,14 @@ const completionState = state.StateField.define({
view.EditorView.contentAttributes.from(f, state => state.attrs)
]
});
+const getCompletionTooltip = (state) => { var _a; return (_a = state.field(completionState, false)) === null || _a === void 0 ? void 0 : _a.tooltip; };
function applyCompletion(view, option) {
const apply = option.completion.apply || option.completion.label;
let result = view.state.field(completionState).active.find(a => a.source == option.source);
if (!(result instanceof ActiveResult))
return false;
if (typeof apply == "string")
- view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to)), { annotations: pickedCompletion.of(option.completion) }));
+ view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to, option.completion.extend)), { annotations: pickedCompletion.of(option.completion) }));
else
apply(view, option.completion, result.from, result.to);
return true;
@@ -1559,20 +1599,42 @@ interpreted as indicating a placeholder.
function snippet(template) {
let snippet = Snippet.parse(template);
return (editor, completion, from, to) => {
- let { text, ranges } = snippet.instantiate(editor.state, from);
- let { main } = editor.state.selection;
- let spec = {
- changes: { from, to: to == main.from ? main.to : to, insert: state.Text.of(text) },
- scrollIntoView: true,
- annotations: completion ? [pickedCompletion.of(completion), state.Transaction.userEvent.of("input.complete")] : undefined
- };
+ let { main } = editor.state.selection, fromOff = from - main.from, toOff = to - main.from;
+ let ranges = [];
+ let totalOffset = 0;
+ let spec = Object.assign(Object.assign({}, editor.state.changeByRange(range => {
+ if (range != main && from != to &&
+ editor.state.sliceDoc(range.from + fromOff, range.from + toOff) != editor.state.sliceDoc(from, to))
+ return { range };
+ let { text, ranges: fieldRanges } = snippet.instantiate(editor.state, range.from + fromOff);
+ let change = {
+ from: range.from + fromOff,
+ to: range.from + toOff,
+ insert: state.Text.of(text)
+ };
+ let originalTo = change.to;
+ let offset = change.insert.length + fromOff;
+ if (completion.extend) {
+ completion.extend(editor.state, change);
+ offset += originalTo - change.to;
+ }
+ for (const fieldRange of fieldRanges) {
+ ranges.push(new FieldRange(fieldRange.field, fieldRange.from + totalOffset, fieldRange.to + totalOffset));
+ }
+ totalOffset += offset;
+ return {
+ changes: change,
+ range: state.EditorSelection.cursor(change.from + change.insert.length)
+ };
+ })), { scrollIntoView: true, annotations: completion ? [pickedCompletion.of(completion), state.Transaction.userEvent.of("input.complete")] : undefined, effects: [] });
if (ranges.length)
spec.selection = fieldSelection(ranges, 0);
if (ranges.some(r => r.field > 0)) {
let active = new ActiveSnippet(ranges, 0);
- let effects = spec.effects = [setActive.of(active)];
- if (editor.state.field(snippetState, false) === undefined)
- effects.push(state.StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
+ spec.effects.push(setActive.of(active));
+ if (editor.state.field(snippetState, false) === undefined) {
+ spec.effects.push(state.StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
+ }
}
editor.dispatch(editor.state.update(spec));
};
@@ -1746,7 +1808,8 @@ const completeAnyWord = context => {
const defaults = {
brackets: ["(", "[", "{", "'", '"'],
before: ")]}:;>",
- stringPrefixes: []
+ stringPrefixes: [],
+ buildInsert: (state, range, open, close) => open + close,
};
const closeBracketEffect = state.StateEffect.define({
map(value, mapping) {
@@ -1854,8 +1917,8 @@ function insertBracket(state$1, bracket) {
for (let tok of tokens) {
let closed = closing(state.codePointAt(tok, 0));
if (bracket == tok)
- return closed == tok ? handleSame(state$1, tok, tokens.indexOf(tok + tok + tok) > -1, conf)
- : handleOpen(state$1, tok, closed, conf.before || defaults.before);
+ return closed == tok ? handleSame(state$1, tok, tokens.indexOf(tok + tok) > -1, tokens.indexOf(tok + tok + tok) > -1, conf)
+ : handleOpen(state$1, tok, closed, conf.before || defaults.before, conf);
if (bracket == closed && closedBracketAt(state$1, state$1.selection.main.from))
return handleClose(state$1, tok, closed);
}
@@ -1877,17 +1940,21 @@ function prevChar(doc, pos) {
let prev = doc.sliceString(pos - 2, pos);
return state.codePointSize(state.codePointAt(prev, 0)) == prev.length ? prev : prev.slice(1);
}
-function handleOpen(state$1, open, close, closeBefore) {
+function handleOpen(state$1, open, close, closeBefore, config) {
+ let buildInsert = config.buildInsert || defaults.buildInsert;
let dont = null, changes = state$1.changeByRange(range => {
+ var _a;
if (!range.empty)
return { changes: [{ insert: open, from: range.from }, { insert: close, from: range.to }],
effects: closeBracketEffect.of(range.to + open.length),
range: state.EditorSelection.range(range.anchor + open.length, range.head + open.length) };
let next = nextChar(state$1.doc, range.head);
- if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1)
- return { changes: { insert: open + close, from: range.head },
- effects: closeBracketEffect.of(range.head + open.length),
+ if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
+ const insert = (_a = buildInsert(state$1, range, open, close)) !== null && _a !== void 0 ? _a : open + close;
+ return { changes: { insert, from: range.head },
+ effects: insert === open ? [] : closeBracketEffect.of(range.head + open.length),
range: state.EditorSelection.cursor(range.head + open.length) };
+ }
return { range: dont = range };
});
return dont ? null : state$1.update(changes, {
@@ -1909,18 +1976,36 @@ function handleClose(state$1, _open, close) {
}
// Handles cases where the open and close token are the same, and
// possibly triple quotes (as in `"""abc"""`-style quoting).
-function handleSame(state$1, token, allowTriple, config) {
+function handleSame(state$1, token, allowDouble, allowTriple, config) {
let stringPrefixes = config.stringPrefixes || defaults.stringPrefixes;
+ let buildInsert = config.buildInsert || defaults.buildInsert;
let dont = null, changes = state$1.changeByRange(range => {
+ var _a, _b, _c;
if (!range.empty)
return { changes: [{ insert: token, from: range.from }, { insert: token, from: range.to }],
effects: closeBracketEffect.of(range.to + token.length),
range: state.EditorSelection.range(range.anchor + token.length, range.head + token.length) };
let pos = range.head, next = nextChar(state$1.doc, pos), start;
- if (next == token) {
+ if (allowTriple && state$1.sliceDoc(pos - 2 * token.length, pos) == token + token &&
+ (start = canStartStringAt(state$1, pos - 2 * token.length, stringPrefixes)) > -1 &&
+ nodeStart(state$1, start)) {
+ return { changes: { insert: token + token + token + token, from: pos },
+ effects: closeBracketEffect.of(pos + token.length),
+ range: state.EditorSelection.cursor(pos + token.length) };
+ }
+ else if (allowDouble && state$1.sliceDoc(pos - token.length, pos) == token &&
+ (start = canStartStringAt(state$1, pos - token.length, stringPrefixes)) > -1 &&
+ nodeStart(state$1, start)) {
+ let insert = (_a = buildInsert(state$1, range, token, token)) !== null && _a !== void 0 ? _a : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
+ range: state.EditorSelection.cursor(pos + token.length) };
+ }
+ else if (next == token) {
if (nodeStart(state$1, pos)) {
- return { changes: { insert: token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
+ let insert = (_b = buildInsert(state$1, range, token, token)) !== null && _b !== void 0 ? _b : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
range: state.EditorSelection.cursor(pos + token.length) };
}
else if (closedBracketAt(state$1, pos)) {
@@ -1930,18 +2015,13 @@ function handleSame(state$1, token, allowTriple, config) {
range: state.EditorSelection.cursor(pos + content.length) };
}
}
- else if (allowTriple && state$1.sliceDoc(pos - 2 * token.length, pos) == token + token &&
- (start = canStartStringAt(state$1, pos - 2 * token.length, stringPrefixes)) > -1 &&
- nodeStart(state$1, start)) {
- return { changes: { insert: token + token + token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
- range: state.EditorSelection.cursor(pos + token.length) };
- }
else if (state$1.charCategorizer(pos)(next) != state.CharCategory.Word) {
- if (canStartStringAt(state$1, pos, stringPrefixes) > -1 && !probablyInString(state$1, pos, token, stringPrefixes))
- return { changes: { insert: token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
+ if (canStartStringAt(state$1, pos, stringPrefixes) > -1 && !probablyInString(state$1, pos, token, stringPrefixes)) {
+ const insert = (_c = buildInsert(state$1, range, token, token)) !== null && _c !== void 0 ? _c : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
range: state.EditorSelection.cursor(pos + token.length) };
+ }
}
return { range: dont = range };
});
@@ -2086,6 +2166,7 @@ exports.completionKeymap = completionKeymap;
exports.completionStatus = completionStatus;
exports.currentCompletions = currentCompletions;
exports.deleteBracketPair = deleteBracketPair;
+exports.getCompletionTooltip = getCompletionTooltip;
exports.hasNextSnippetField = hasNextSnippetField;
exports.hasPrevSnippetField = hasPrevSnippetField;
exports.ifIn = ifIn;
@@ -2093,8 +2174,10 @@ exports.ifNotIn = ifNotIn;
exports.insertBracket = insertBracket;
exports.insertCompletionText = insertCompletionText;
exports.moveCompletionSelection = moveCompletionSelection;
+exports.nextChar = nextChar;
exports.nextSnippetField = nextSnippetField;
exports.pickedCompletion = pickedCompletion;
+exports.prevChar = prevChar;
exports.prevSnippetField = prevSnippetField;
exports.selectedCompletion = selectedCompletion;
exports.selectedCompletionIndex = selectedCompletionIndex;
diff --git a/dist/index.d.cts b/dist/index.d.cts
index b57b8f6..fce47ab 100644
--- a/dist/index.d.cts
+++ b/dist/index.d.cts
@@ -1,6 +1,6 @@
import * as _codemirror_state from '@codemirror/state';
-import { EditorState, ChangeDesc, TransactionSpec, Transaction, StateCommand, Facet, Extension, StateEffect } from '@codemirror/state';
-import { EditorView, Rect, KeyBinding, Command } from '@codemirror/view';
+import { EditorState, Text, ChangeDesc, TransactionSpec, StateCommand, Transaction, Facet, SelectionRange, Extension, StateEffect } from '@codemirror/state';
+import { EditorView, Rect, KeyBinding, Tooltip, Command } from '@codemirror/view';
import * as _lezer_common from '@lezer/common';
/**
@@ -73,6 +73,19 @@ interface Completion {
a `{name}` object.
*/
section?: string | CompletionSection;
+ /**
+ Can be used to alter the change created when the completion is applied
+ */
+ extend?: ExtendCompletion;
+ /**
+ If multiple sources return the same result, use this field to specifiy a
+ deduplication key as well as a priority. For each unique key, only the
+ completion with the highest priority will be shown.
+ */
+ deduplicate?: {
+ key: string;
+ priority: number;
+ };
}
/**
The type returned from
@@ -306,12 +319,17 @@ This annotation is added to transactions that are produced by
picking a completion.
*/
declare const pickedCompletion: _codemirror_state.AnnotationType<Completion>;
+type ExtendCompletion = (state: EditorState, change: {
+ from: number;
+ to: number;
+ insert: string | Text;
+}) => void;
/**
Helper function that returns a transaction spec which inserts a
completion's text in the main selection range, and any other
selection range that has the same text in front of it.
*/
-declare function insertCompletionText(state: EditorState, text: string, from: number, to: number): TransactionSpec;
+declare function insertCompletionText(state: EditorState, text: string | Text, from: number, to: number, extend?: ExtendCompletion): TransactionSpec;
interface CompletionConfig {
/**
@@ -441,6 +459,10 @@ interface CompletionConfig {
milliseconds.
*/
updateSyncTime?: number;
+ /**
+ overleaf: Move unfiltered results after the filtered ones
+ */
+ unfilteredResultsAtEnd?: boolean;
}
/**
@@ -514,6 +536,8 @@ applies the snippet.
*/
declare function snippetCompletion(template: string, completion: Completion): Completion;
+declare const getCompletionTooltip: (state: EditorState) => Tooltip | undefined | null;
+
/**
Returns a command that moves the completion selection forward or
backward by the given amount.
@@ -562,6 +586,11 @@ interface CloseBracketConfig {
these prefixes before the opening quote.
*/
stringPrefixes?: string[];
+ /**
+ An optional callback for overriding the content that's inserted
+ based on surrounding characters
+ */
+ buildInsert?: (state: EditorState, range: SelectionRange, open: string, close: string) => string;
}
/**
Extension to enable bracket-closing behavior. When a closeable
@@ -593,6 +622,8 @@ to programmatically insert brackets—the
take care of running this for user input.)
*/
declare function insertBracket(state: EditorState, bracket: string): Transaction | null;
+declare function nextChar(doc: Text, pos: number): string;
+declare function prevChar(doc: Text, pos: number): string;
/**
Returns an extension that enables autocompletion.
@@ -636,4 +667,5 @@ the currently selected completion.
*/
declare function setSelectedCompletion(index: number): StateEffect<unknown>;
-export { type CloseBracketConfig, type Completion, CompletionContext, type CompletionInfo, type CompletionResult, type CompletionSection, type CompletionSource, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
+export type { CloseBracketConfig, Completion, CompletionInfo, CompletionResult, CompletionSection, CompletionSource };
diff --git a/dist/index.d.ts b/dist/index.d.ts
index b57b8f6..fce47ab 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -1,6 +1,6 @@
import * as _codemirror_state from '@codemirror/state';
-import { EditorState, ChangeDesc, TransactionSpec, Transaction, StateCommand, Facet, Extension, StateEffect } from '@codemirror/state';
-import { EditorView, Rect, KeyBinding, Command } from '@codemirror/view';
+import { EditorState, Text, ChangeDesc, TransactionSpec, StateCommand, Transaction, Facet, SelectionRange, Extension, StateEffect } from '@codemirror/state';
+import { EditorView, Rect, KeyBinding, Tooltip, Command } from '@codemirror/view';
import * as _lezer_common from '@lezer/common';
/**
@@ -73,6 +73,19 @@ interface Completion {
a `{name}` object.
*/
section?: string | CompletionSection;
+ /**
+ Can be used to alter the change created when the completion is applied
+ */
+ extend?: ExtendCompletion;
+ /**
+ If multiple sources return the same result, use this field to specifiy a
+ deduplication key as well as a priority. For each unique key, only the
+ completion with the highest priority will be shown.
+ */
+ deduplicate?: {
+ key: string;
+ priority: number;
+ };
}
/**
The type returned from
@@ -306,12 +319,17 @@ This annotation is added to transactions that are produced by
picking a completion.
*/
declare const pickedCompletion: _codemirror_state.AnnotationType<Completion>;
+type ExtendCompletion = (state: EditorState, change: {
+ from: number;
+ to: number;
+ insert: string | Text;
+}) => void;
/**
Helper function that returns a transaction spec which inserts a
completion's text in the main selection range, and any other
selection range that has the same text in front of it.
*/
-declare function insertCompletionText(state: EditorState, text: string, from: number, to: number): TransactionSpec;
+declare function insertCompletionText(state: EditorState, text: string | Text, from: number, to: number, extend?: ExtendCompletion): TransactionSpec;
interface CompletionConfig {
/**
@@ -441,6 +459,10 @@ interface CompletionConfig {
milliseconds.
*/
updateSyncTime?: number;
+ /**
+ overleaf: Move unfiltered results after the filtered ones
+ */
+ unfilteredResultsAtEnd?: boolean;
}
/**
@@ -514,6 +536,8 @@ applies the snippet.
*/
declare function snippetCompletion(template: string, completion: Completion): Completion;
+declare const getCompletionTooltip: (state: EditorState) => Tooltip | undefined | null;
+
/**
Returns a command that moves the completion selection forward or
backward by the given amount.
@@ -562,6 +586,11 @@ interface CloseBracketConfig {
these prefixes before the opening quote.
*/
stringPrefixes?: string[];
+ /**
+ An optional callback for overriding the content that's inserted
+ based on surrounding characters
+ */
+ buildInsert?: (state: EditorState, range: SelectionRange, open: string, close: string) => string;
}
/**
Extension to enable bracket-closing behavior. When a closeable
@@ -593,6 +622,8 @@ to programmatically insert brackets—the
take care of running this for user input.)
*/
declare function insertBracket(state: EditorState, bracket: string): Transaction | null;
+declare function nextChar(doc: Text, pos: number): string;
+declare function prevChar(doc: Text, pos: number): string;
/**
Returns an extension that enables autocompletion.
@@ -636,4 +667,5 @@ the currently selected completion.
*/
declare function setSelectedCompletion(index: number): StateEffect<unknown>;
-export { type CloseBracketConfig, type Completion, CompletionContext, type CompletionInfo, type CompletionResult, type CompletionSection, type CompletionSource, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
+export type { CloseBracketConfig, Completion, CompletionInfo, CompletionResult, CompletionSection, CompletionSource };
diff --git a/dist/index.js b/dist/index.js
index 4729223..9361a53 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1,4 +1,4 @@
-import { Annotation, StateEffect, EditorSelection, codePointAt, codePointSize, fromCodePoint, Facet, combineConfig, StateField, Prec, Text, Transaction, MapMode, RangeValue, RangeSet, CharCategory } from '@codemirror/state';
+import { Annotation, StateEffect, Text, EditorSelection, codePointAt, codePointSize, fromCodePoint, Facet, combineConfig, StateField, Prec, Transaction, MapMode, RangeValue, RangeSet, CharCategory } from '@codemirror/state';
import { Direction, logException, showTooltip, EditorView, ViewPlugin, getTooltip, Decoration, WidgetType, keymap } from '@codemirror/view';
import { syntaxTree, indentUnit } from '@codemirror/language';
@@ -185,16 +185,23 @@ Helper function that returns a transaction spec which inserts a
completion's text in the main selection range, and any other
selection range that has the same text in front of it.
*/
-function insertCompletionText(state, text, from, to) {
+function insertCompletionText(state, text, from, to, extend) {
let { main } = state.selection, fromOff = from - main.from, toOff = to - main.from;
return Object.assign(Object.assign({}, state.changeByRange(range => {
if (range != main && from != to &&
state.sliceDoc(range.from + fromOff, range.from + toOff) != state.sliceDoc(from, to))
return { range };
- let lines = state.toText(text);
+ let change = {
+ from: range.from + fromOff,
+ to: to == main.from ? range.to : range.from + toOff,
+ insert: text instanceof Text ? text : state.toText(text),
+ };
+ if (extend) {
+ extend(state, change);
+ }
return {
- changes: { from: range.from + fromOff, to: to == main.from ? range.to : range.from + toOff, insert: lines },
- range: EditorSelection.cursor(range.from + fromOff + lines.length)
+ changes: change,
+ range: EditorSelection.cursor(change.from + change.insert.length)
};
})), { scrollIntoView: true, userEvent: "input.complete" });
}
@@ -387,7 +394,9 @@ const completionConfig = /*@__PURE__*/Facet.define({
filterStrict: false,
compareCompletions: (a, b) => a.label.localeCompare(b.label),
interactionDelay: 75,
- updateSyncTime: 100
+ updateSyncTime: 100,
+ // overleaf: default to at top which is default CM6 behaviour
+ unfilteredResultsAtEnd: false
}, {
defaultKeymap: (a, b) => a && b,
closeOnBlur: (a, b) => a && b,
@@ -742,6 +751,7 @@ function score(option) {
(option.type ? 1 : 0);
}
function sortOptions(active, state) {
+ var _a;
let options = [];
let sections = null;
let addOption = (option) => {
@@ -761,7 +771,8 @@ function sortOptions(active, state) {
let getMatch = a.result.getMatch;
if (a.result.filter === false) {
for (let option of a.result.options) {
- addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], 1e9 - options.length));
+ let defaultScore = conf.unfilteredResultsAtEnd ? -1e9 : 1e9;
+ addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], defaultScore - options.length));
}
}
else {
@@ -788,15 +799,42 @@ function sortOptions(active, state) {
}
}
let result = [], prev = null;
+ const priorityIndices = new Map();
let compare = conf.compareCompletions;
for (let opt of options.sort((a, b) => (b.score - a.score) || compare(a.completion, b.completion))) {
+ // overleaf: Deduplicate results with dedup options
+ // The goal is to keep only the highest priority option, in the
+ // highest scoring position.
+ const key = (_a = opt.completion.deduplicate) === null || _a === void 0 ? void 0 : _a.key;
+ if (key) {
+ // Handle merging specifically for deduplicated items item
+ const currentOptionIndex = priorityIndices.get(key);
+ if (currentOptionIndex === undefined) {
+ priorityIndices.set(key, result.length);
+ result.push(opt);
+ prev = opt.completion;
+ }
+ else {
+ if (result[currentOptionIndex].completion.deduplicate.priority < opt.completion.deduplicate.priority) {
+ result[currentOptionIndex] = opt;
+ if (currentOptionIndex === result.length - 1) {
+ prev = opt.completion;
+ }
+ }
+ }
+ continue;
+ }
+ // overleaf: end
let cur = opt.completion;
- if (!prev || prev.label != cur.label || prev.detail != cur.detail ||
- (prev.type != null && cur.type != null && prev.type != cur.type) ||
- prev.apply != cur.apply || prev.boost != cur.boost)
+ if (!prev || prev.label != cur.label)
+ result.push(opt);
+ // overleaf: we're already handling deduplication, so skip extra merges
+ else if (prev.deduplicate)
result.push(opt);
else if (score(opt.completion) > score(prev))
result[result.length - 1] = opt;
+ else if (opt.completion.info)
+ result[result.length - 1] = opt;
prev = opt.completion;
}
return result;
@@ -815,8 +853,9 @@ class CompletionDialog {
: new CompletionDialog(this.options, makeAttrs(id, selected), this.tooltip, this.timestamp, selected, this.disabled);
}
static build(active, state, id, prev, conf, didSetActive) {
- if (prev && !didSetActive && active.some(s => s.isPending))
- return prev.setDisabled();
+ // Overleaf: avoid setting the previous completion state to disabled while completion sources are pending
+ // if (prev && !didSetActive && active.some(s => s.isPending))
+ // return prev.setDisabled()
let options = sortOptions(active, state);
if (!options.length)
return prev && active.some(a => a.isPending) ? prev.setDisabled() : null;
@@ -1015,13 +1054,14 @@ const completionState = /*@__PURE__*/StateField.define({
EditorView.contentAttributes.from(f, state => state.attrs)
]
});
+const getCompletionTooltip = (state) => { var _a; return (_a = state.field(completionState, false)) === null || _a === void 0 ? void 0 : _a.tooltip; };
function applyCompletion(view, option) {
const apply = option.completion.apply || option.completion.label;
let result = view.state.field(completionState).active.find(a => a.source == option.source);
if (!(result instanceof ActiveResult))
return false;
if (typeof apply == "string")
- view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to)), { annotations: pickedCompletion.of(option.completion) }));
+ view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to, option.completion.extend)), { annotations: pickedCompletion.of(option.completion) }));
else
apply(view, option.completion, result.from, result.to);
return true;
@@ -1557,20 +1597,42 @@ interpreted as indicating a placeholder.
function snippet(template) {
let snippet = Snippet.parse(template);
return (editor, completion, from, to) => {
- let { text, ranges } = snippet.instantiate(editor.state, from);
- let { main } = editor.state.selection;
- let spec = {
- changes: { from, to: to == main.from ? main.to : to, insert: Text.of(text) },
- scrollIntoView: true,
- annotations: completion ? [pickedCompletion.of(completion), Transaction.userEvent.of("input.complete")] : undefined
- };
+ let { main } = editor.state.selection, fromOff = from - main.from, toOff = to - main.from;
+ let ranges = [];
+ let totalOffset = 0;
+ let spec = Object.assign(Object.assign({}, editor.state.changeByRange(range => {
+ if (range != main && from != to &&
+ editor.state.sliceDoc(range.from + fromOff, range.from + toOff) != editor.state.sliceDoc(from, to))
+ return { range };
+ let { text, ranges: fieldRanges } = snippet.instantiate(editor.state, range.from + fromOff);
+ let change = {
+ from: range.from + fromOff,
+ to: range.from + toOff,
+ insert: Text.of(text)
+ };
+ let originalTo = change.to;
+ let offset = change.insert.length + fromOff;
+ if (completion.extend) {
+ completion.extend(editor.state, change);
+ offset += originalTo - change.to;
+ }
+ for (const fieldRange of fieldRanges) {
+ ranges.push(new FieldRange(fieldRange.field, fieldRange.from + totalOffset, fieldRange.to + totalOffset));
+ }
+ totalOffset += offset;
+ return {
+ changes: change,
+ range: EditorSelection.cursor(change.from + change.insert.length)
+ };
+ })), { scrollIntoView: true, annotations: completion ? [pickedCompletion.of(completion), Transaction.userEvent.of("input.complete")] : undefined, effects: [] });
if (ranges.length)
spec.selection = fieldSelection(ranges, 0);
if (ranges.some(r => r.field > 0)) {
let active = new ActiveSnippet(ranges, 0);
- let effects = spec.effects = [setActive.of(active)];
- if (editor.state.field(snippetState, false) === undefined)
- effects.push(StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
+ spec.effects.push(setActive.of(active));
+ if (editor.state.field(snippetState, false) === undefined) {
+ spec.effects.push(StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
+ }
}
editor.dispatch(editor.state.update(spec));
};
@@ -1744,7 +1806,8 @@ const completeAnyWord = context => {
const defaults = {
brackets: ["(", "[", "{", "'", '"'],
before: ")]}:;>",
- stringPrefixes: []
+ stringPrefixes: [],
+ buildInsert: (state, range, open, close) => open + close,
};
const closeBracketEffect = /*@__PURE__*/StateEffect.define({
map(value, mapping) {
@@ -1852,8 +1915,8 @@ function insertBracket(state, bracket) {
for (let tok of tokens) {
let closed = closing(codePointAt(tok, 0));
if (bracket == tok)
- return closed == tok ? handleSame(state, tok, tokens.indexOf(tok + tok + tok) > -1, conf)
- : handleOpen(state, tok, closed, conf.before || defaults.before);
+ return closed == tok ? handleSame(state, tok, tokens.indexOf(tok + tok) > -1, tokens.indexOf(tok + tok + tok) > -1, conf)
+ : handleOpen(state, tok, closed, conf.before || defaults.before, conf);
if (bracket == closed && closedBracketAt(state, state.selection.main.from))
return handleClose(state, tok, closed);
}
@@ -1875,17 +1938,21 @@ function prevChar(doc, pos) {
let prev = doc.sliceString(pos - 2, pos);
return codePointSize(codePointAt(prev, 0)) == prev.length ? prev : prev.slice(1);
}
-function handleOpen(state, open, close, closeBefore) {
+function handleOpen(state, open, close, closeBefore, config) {
+ let buildInsert = config.buildInsert || defaults.buildInsert;
let dont = null, changes = state.changeByRange(range => {
+ var _a;
if (!range.empty)
return { changes: [{ insert: open, from: range.from }, { insert: close, from: range.to }],
effects: closeBracketEffect.of(range.to + open.length),
range: EditorSelection.range(range.anchor + open.length, range.head + open.length) };
let next = nextChar(state.doc, range.head);
- if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1)
- return { changes: { insert: open + close, from: range.head },
- effects: closeBracketEffect.of(range.head + open.length),
+ if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
+ const insert = (_a = buildInsert(state, range, open, close)) !== null && _a !== void 0 ? _a : open + close;
+ return { changes: { insert, from: range.head },
+ effects: insert === open ? [] : closeBracketEffect.of(range.head + open.length),
range: EditorSelection.cursor(range.head + open.length) };
+ }
return { range: dont = range };
});
return dont ? null : state.update(changes, {
@@ -1907,18 +1974,36 @@ function handleClose(state, _open, close) {
}
// Handles cases where the open and close token are the same, and
// possibly triple quotes (as in `"""abc"""`-style quoting).
-function handleSame(state, token, allowTriple, config) {
+function handleSame(state, token, allowDouble, allowTriple, config) {
let stringPrefixes = config.stringPrefixes || defaults.stringPrefixes;
+ let buildInsert = config.buildInsert || defaults.buildInsert;
let dont = null, changes = state.changeByRange(range => {
+ var _a, _b, _c;
if (!range.empty)
return { changes: [{ insert: token, from: range.from }, { insert: token, from: range.to }],
effects: closeBracketEffect.of(range.to + token.length),
range: EditorSelection.range(range.anchor + token.length, range.head + token.length) };
let pos = range.head, next = nextChar(state.doc, pos), start;
- if (next == token) {
+ if (allowTriple && state.sliceDoc(pos - 2 * token.length, pos) == token + token &&
+ (start = canStartStringAt(state, pos - 2 * token.length, stringPrefixes)) > -1 &&
+ nodeStart(state, start)) {
+ return { changes: { insert: token + token + token + token, from: pos },
+ effects: closeBracketEffect.of(pos + token.length),
+ range: EditorSelection.cursor(pos + token.length) };
+ }
+ else if (allowDouble && state.sliceDoc(pos - token.length, pos) == token &&
+ (start = canStartStringAt(state, pos - token.length, stringPrefixes)) > -1 &&
+ nodeStart(state, start)) {
+ let insert = (_a = buildInsert(state, range, token, token)) !== null && _a !== void 0 ? _a : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
+ range: EditorSelection.cursor(pos + token.length) };
+ }
+ else if (next == token) {
if (nodeStart(state, pos)) {
- return { changes: { insert: token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
+ let insert = (_b = buildInsert(state, range, token, token)) !== null && _b !== void 0 ? _b : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
range: EditorSelection.cursor(pos + token.length) };
}
else if (closedBracketAt(state, pos)) {
@@ -1928,18 +2013,13 @@ function handleSame(state, token, allowTriple, config) {
range: EditorSelection.cursor(pos + content.length) };
}
}
- else if (allowTriple && state.sliceDoc(pos - 2 * token.length, pos) == token + token &&
- (start = canStartStringAt(state, pos - 2 * token.length, stringPrefixes)) > -1 &&
- nodeStart(state, start)) {
- return { changes: { insert: token + token + token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
- range: EditorSelection.cursor(pos + token.length) };
- }
else if (state.charCategorizer(pos)(next) != CharCategory.Word) {
- if (canStartStringAt(state, pos, stringPrefixes) > -1 && !probablyInString(state, pos, token, stringPrefixes))
- return { changes: { insert: token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
+ if (canStartStringAt(state, pos, stringPrefixes) > -1 && !probablyInString(state, pos, token, stringPrefixes)) {
+ const insert = (_c = buildInsert(state, range, token, token)) !== null && _c !== void 0 ? _c : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
range: EditorSelection.cursor(pos + token.length) };
+ }
}
return { range: dont = range };
});
@@ -2071,4 +2151,4 @@ function setSelectedCompletion(index) {
return setSelectedEffect.of(index);
}
-export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
@@ -1,381 +0,0 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 46231ae..fb0f9aa 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -592,6 +592,7 @@ class SearchQuery {
this.valid = !!this.search && (!this.regexp || validRegExp(this.search));
this.unquoted = this.unquote(this.search);
this.wholeWord = !!config.wholeWord;
+ this.scope = config.scope;
}
/**
@internal
@@ -606,7 +607,7 @@ class SearchQuery {
eq(other) {
return this.search == other.search && this.replace == other.replace &&
this.caseSensitive == other.caseSensitive && this.regexp == other.regexp &&
- this.wholeWord == other.wholeWord;
+ this.wholeWord == other.wholeWord && this.scope == other.scope;
}
/**
@internal
@@ -631,7 +632,12 @@ class QueryType {
}
}
function stringCursor(spec, state, from, to) {
- return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined);
+ const test = spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined;
+ const testWithinScope = (from, to, buffer, bufferPos) => {
+ return (!test || test(from, to, buffer, bufferPos))
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
+ };
+ return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), testWithinScope);
}
function stringWordTest(doc, categorizer) {
return (from, to, buf, bufPos) => {
@@ -695,9 +701,14 @@ class StringQuery extends QueryType {
}
}
function regexpCursor(spec, state, from, to) {
+ const test = spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined;
+ const testWithinScope = (from, to, match) => {
+ return (!test || test(from, to, match))
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
+ };
return new RegExpCursor(state.doc, spec.search, {
ignoreCase: !spec.caseSensitive,
- test: spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined
+ test: testWithinScope,
}, from, to);
}
function charBefore(str, index) {
@@ -737,10 +748,18 @@ class RegExpQuery extends QueryType {
this.prevMatchInRange(state, curTo, state.doc.length);
}
getReplacement(result) {
- return this.spec.unquote(this.spec.replace).replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$"
- : i == "&" ? result.match[0]
- : i != "0" && +i < result.match.length ? result.match[i]
- : m);
+ return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g, (m, i) => {
+ if (i == "&")
+ return result.match[0];
+ if (i == "$")
+ return "$";
+ for (let l = i.length; l > 0; l--) {
+ let n = +i.slice(0, l);
+ if (n > 0 && n < result.match.length)
+ return result.match[n] + i.slice(l);
+ }
+ return m;
+ });
}
matchAll(state, limit) {
let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = [];
@@ -1227,7 +1246,9 @@ const searchExtensions = [
exports.RegExpCursor = RegExpCursor;
exports.SearchCursor = SearchCursor;
exports.SearchQuery = SearchQuery;
+exports.StringQuery = StringQuery;
exports.closeSearchPanel = closeSearchPanel;
+exports.createSearchPanel = createSearchPanel;
exports.findNext = findNext;
exports.findPrevious = findPrevious;
exports.getSearchQuery = getSearchQuery;
@@ -1242,4 +1263,6 @@ exports.searchPanelOpen = searchPanelOpen;
exports.selectMatches = selectMatches;
exports.selectNextOccurrence = selectNextOccurrence;
exports.selectSelectionMatches = selectSelectionMatches;
+exports.selectWord = selectWord;
exports.setSearchQuery = setSearchQuery;
+exports.togglePanel = togglePanel;
diff --git a/dist/index.d.cts b/dist/index.d.cts
index 08f5696..663d192 100644
--- a/dist/index.d.cts
+++ b/dist/index.d.cts
@@ -1,6 +1,6 @@
import * as _codemirror_state from '@codemirror/state';
import { Text, Extension, StateCommand, EditorState, SelectionRange, StateEffect } from '@codemirror/state';
-import { Command, KeyBinding, EditorView, Panel } from '@codemirror/view';
+import { Command, EditorView, Panel, KeyBinding } from '@codemirror/view';
/**
A search cursor provides an iterator over text matches in a
@@ -161,6 +161,7 @@ the `"cm-selectionMatch"` class for the highlighting. When
itself will be highlighted with `"cm-selectionMatch-main"`.
*/
declare function highlightSelectionMatches(options?: HighlightOptions): Extension;
+declare const selectWord: StateCommand;
/**
Select next occurrence of the current selection. Expand selection
to the surrounding word when the selection is empty.
@@ -264,6 +265,13 @@ declare class SearchQuery {
*/
readonly wholeWord: boolean;
/**
+ When set, only include search matches within these ranges
+ */
+ readonly scope?: Readonly<{
+ from: number;
+ to: number;
+ }[]>;
+ /**
Create a query object.
*/
constructor(config: {
@@ -293,6 +301,13 @@ declare class SearchQuery {
Enable whole-word matching.
*/
wholeWord?: boolean;
+ /**
+ The ranges to match within
+ */
+ scope?: Readonly<{
+ from: number;
+ to: number;
+ }[]>;
});
/**
Compare this query to another query.
@@ -307,6 +322,34 @@ declare class SearchQuery {
to: number;
}>;
}
+type SearchResult = typeof SearchCursor.prototype.value;
+declare abstract class QueryType<Result extends SearchResult = SearchResult> {
+ readonly spec: SearchQuery;
+ constructor(spec: SearchQuery);
+ abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
+ abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
+ abstract getReplacement(result: Result): string;
+ abstract matchAll(state: EditorState, limit: number): readonly Result[] | null;
+ abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
+}
+declare class StringQuery extends QueryType<SearchResult> {
+ constructor(spec: SearchQuery);
+ nextMatch(state: EditorState, curFrom: number, curTo: number): {
+ from: number;
+ to: number;
+ } | null;
+ private prevMatchInRange;
+ prevMatch(state: EditorState, curFrom: number, curTo: number): {
+ from: number;
+ to: number;
+ } | null;
+ getReplacement(_result: SearchResult): string;
+ matchAll(state: EditorState, limit: number): {
+ from: number;
+ to: number;
+ }[] | null;
+ highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
+}
/**
A state effect that updates the current search query. Note that
this only has an effect if the search state has been initialized
@@ -315,6 +358,7 @@ by running [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSea
once).
*/
declare const setSearchQuery: _codemirror_state.StateEffectType<SearchQuery>;
+declare const togglePanel: _codemirror_state.StateEffectType<boolean>;
/**
Get the current search query from an editor state.
*/
@@ -353,6 +397,7 @@ Replace all instances of the search query with the given
replacement.
*/
declare const replaceAll: Command;
+declare function createSearchPanel(view: EditorView): Panel;
/**
Make sure the search panel is open and focused.
*/
@@ -372,4 +417,4 @@ Default search-related key bindings.
*/
declare const searchKeymap: readonly KeyBinding[];
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 08f5696..663d192 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -1,6 +1,6 @@
import * as _codemirror_state from '@codemirror/state';
import { Text, Extension, StateCommand, EditorState, SelectionRange, StateEffect } from '@codemirror/state';
-import { Command, KeyBinding, EditorView, Panel } from '@codemirror/view';
+import { Command, EditorView, Panel, KeyBinding } from '@codemirror/view';
/**
A search cursor provides an iterator over text matches in a
@@ -161,6 +161,7 @@ the `"cm-selectionMatch"` class for the highlighting. When
itself will be highlighted with `"cm-selectionMatch-main"`.
*/
declare function highlightSelectionMatches(options?: HighlightOptions): Extension;
+declare const selectWord: StateCommand;
/**
Select next occurrence of the current selection. Expand selection
to the surrounding word when the selection is empty.
@@ -264,6 +265,13 @@ declare class SearchQuery {
*/
readonly wholeWord: boolean;
/**
+ When set, only include search matches within these ranges
+ */
+ readonly scope?: Readonly<{
+ from: number;
+ to: number;
+ }[]>;
+ /**
Create a query object.
*/
constructor(config: {
@@ -293,6 +301,13 @@ declare class SearchQuery {
Enable whole-word matching.
*/
wholeWord?: boolean;
+ /**
+ The ranges to match within
+ */
+ scope?: Readonly<{
+ from: number;
+ to: number;
+ }[]>;
});
/**
Compare this query to another query.
@@ -307,6 +322,34 @@ declare class SearchQuery {
to: number;
}>;
}
+type SearchResult = typeof SearchCursor.prototype.value;
+declare abstract class QueryType<Result extends SearchResult = SearchResult> {
+ readonly spec: SearchQuery;
+ constructor(spec: SearchQuery);
+ abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
+ abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
+ abstract getReplacement(result: Result): string;
+ abstract matchAll(state: EditorState, limit: number): readonly Result[] | null;
+ abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
+}
+declare class StringQuery extends QueryType<SearchResult> {
+ constructor(spec: SearchQuery);
+ nextMatch(state: EditorState, curFrom: number, curTo: number): {
+ from: number;
+ to: number;
+ } | null;
+ private prevMatchInRange;
+ prevMatch(state: EditorState, curFrom: number, curTo: number): {
+ from: number;
+ to: number;
+ } | null;
+ getReplacement(_result: SearchResult): string;
+ matchAll(state: EditorState, limit: number): {
+ from: number;
+ to: number;
+ }[] | null;
+ highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
+}
/**
A state effect that updates the current search query. Note that
this only has an effect if the search state has been initialized
@@ -315,6 +358,7 @@ by running [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSea
once).
*/
declare const setSearchQuery: _codemirror_state.StateEffectType<SearchQuery>;
+declare const togglePanel: _codemirror_state.StateEffectType<boolean>;
/**
Get the current search query from an editor state.
*/
@@ -353,6 +397,7 @@ Replace all instances of the search query with the given
replacement.
*/
declare const replaceAll: Command;
+declare function createSearchPanel(view: EditorView): Panel;
/**
Make sure the search panel is open and focused.
*/
@@ -372,4 +417,4 @@ Default search-related key bindings.
*/
declare const searchKeymap: readonly KeyBinding[];
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
diff --git a/dist/index.js b/dist/index.js
index 22172ef..08a9974 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -590,6 +590,7 @@ class SearchQuery {
this.valid = !!this.search && (!this.regexp || validRegExp(this.search));
this.unquoted = this.unquote(this.search);
this.wholeWord = !!config.wholeWord;
+ this.scope = config.scope;
}
/**
@internal
@@ -604,7 +605,7 @@ class SearchQuery {
eq(other) {
return this.search == other.search && this.replace == other.replace &&
this.caseSensitive == other.caseSensitive && this.regexp == other.regexp &&
- this.wholeWord == other.wholeWord;
+ this.wholeWord == other.wholeWord && this.scope == other.scope;
}
/**
@internal
@@ -629,7 +630,12 @@ class QueryType {
}
}
function stringCursor(spec, state, from, to) {
- return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined);
+ const test = spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined;
+ const testWithinScope = (from, to, buffer, bufferPos) => {
+ return (!test || test(from, to, buffer, bufferPos))
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
+ };
+ return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), testWithinScope);
}
function stringWordTest(doc, categorizer) {
return (from, to, buf, bufPos) => {
@@ -693,9 +699,14 @@ class StringQuery extends QueryType {
}
}
function regexpCursor(spec, state, from, to) {
+ const test = spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined;
+ const testWithinScope = (from, to, match) => {
+ return (!test || test(from, to, match))
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
+ };
return new RegExpCursor(state.doc, spec.search, {
ignoreCase: !spec.caseSensitive,
- test: spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined
+ test: testWithinScope,
}, from, to);
}
function charBefore(str, index) {
@@ -735,10 +746,18 @@ class RegExpQuery extends QueryType {
this.prevMatchInRange(state, curTo, state.doc.length);
}
getReplacement(result) {
- return this.spec.unquote(this.spec.replace).replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$"
- : i == "&" ? result.match[0]
- : i != "0" && +i < result.match.length ? result.match[i]
- : m);
+ return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g, (m, i) => {
+ if (i == "&")
+ return result.match[0];
+ if (i == "$")
+ return "$";
+ for (let l = i.length; l > 0; l--) {
+ let n = +i.slice(0, l);
+ if (n > 0 && n < result.match.length)
+ return result.match[n] + i.slice(l);
+ }
+ return m;
+ });
}
matchAll(state, limit) {
let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = [];
@@ -1222,4 +1241,4 @@ const searchExtensions = [
baseTheme
];
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
@@ -1,67 +0,0 @@
diff --git a/lib/request.js b/lib/request.js
index f42960ca2b94b784497392cfb85ef31e4e63a1ac..7feea2ed29873e519aee0ba5e1f5b36e09fe865d 100644
--- a/lib/request.js
+++ b/lib/request.js
@@ -6,6 +6,30 @@
const InvalidArgumentError = require('./errors/invalid-argument-error');
/* type-is: https://github.com/jshttp/type-is */
const typeis = require('type-is');
+// --- Overleaf patch: request-input lockdown compatibility ---
+// The constructor below destructures query/body directly off the raw
+// framework request object it's handed (see AuthenticationController.mjs's
+// `new Oauth2Server.Request(req)`), which is otherwise indistinguishable
+// from unvalidated application code reading raw request input under
+// Overleaf's request-input lockdown (see libraries/validation-tools and the
+// patched express in .yarn/patches/express-npm-*.patch). The lockdown
+// captures the original parsed values in symbol-keyed fields, registered in
+// the global symbol registry (the same ones @overleaf/validation-tools'
+// lockdown.js reads), and only turns the *public* req.query/req.body
+// properties into throwing accessors -- reading through these raw fields
+// instead sidesteps that, and is identical to plain req.query/req.body when
+// the lockdown isn't installed (i.e. for every consumer of this package
+// outside this monorepo).
+const __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed');
+const __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery');
+const __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody');
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+function __overleafRawBody(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body;
+}
+// --- end Overleaf patch ---
/**
* Wrapper for webserver's request.
@@ -29,7 +53,12 @@ class Request {
* @param otherOptions {...object} any other properties that should be assigned to the request by your webserver
* @throws {InvalidArgumentError} if one of headers, method or query are missing.
*/
- constructor({ headers, method, query, body, ...otherOptions } = {}) {
+ constructor(req = {}) {
+ const headers = req.headers;
+ const method = req.method;
+ const query = __overleafRawQuery(req);
+ const body = __overleafRawBody(req);
+
if (!headers) {
throw new InvalidArgumentError('Missing parameter: `headers`');
}
@@ -52,7 +81,16 @@ class Request {
this.headers[header.toLowerCase()] = value;
});
- // Store additional properties of the request object passed in
+ // Store additional properties of the request object passed in (mirrors
+ // the original `...otherOptions` rest destructuring, but built off
+ // Object.assign so query/body -- already handled above via the raw
+ // lockdown fields -- aren't read a second time through the throwing
+ // accessors).
+ const otherOptions = Object.assign({}, req);
+ delete otherOptions.headers;
+ delete otherOptions.method;
+ delete otherOptions.query;
+ delete otherOptions.body;
Object.entries(otherOptions)
.filter(([property]) => !this[property])
.forEach(([property, value]) => {
@@ -1,99 +0,0 @@
diff --git a/lib/strategy.js b/lib/strategy.js
index 4de162f7e92a20b1ccd4d34dd9bcc0916f4c8c43..c67bb4e0b0287d615eac4c01b84c83d2bac91446 100644
--- a/lib/strategy.js
+++ b/lib/strategy.js
@@ -5,6 +5,29 @@ const passport_strategy_1 = require("passport-strategy");
const assert_1 = require("assert");
const url = require("url");
const node_saml_1 = require("@node-saml/node-saml");
+// --- Overleaf patch: request-input lockdown compatibility ---
+// This library reads req.query/req.body directly to parse the SAML protocol
+// fields (SAMLResponse, SAMLRequest, RelayState) it's built around, which is
+// otherwise indistinguishable from unvalidated application code reading raw
+// request input under Overleaf's request-input lockdown (see
+// libraries/validation-tools and the patched express in
+// .yarn/patches/express-npm-*.patch). The lockdown captures the original
+// parsed values in symbol-keyed fields, registered in the global symbol
+// registry (the same ones @overleaf/validation-tools' lockdown.js reads), and
+// only turns the *public* req.query/req.body properties into throwing
+// accessors -- reading through these raw fields instead sidesteps that, and
+// is identical to plain req.query/req.body when the lockdown isn't installed
+// (i.e. for every consumer of this package outside this monorepo).
+const __overleafLockdownInstalled = Symbol.for("overleaf.lockdown.installed");
+const __overleafLockdownRawQuery = Symbol.for("overleaf.lockdown.rawQuery");
+const __overleafLockdownRawBody = Symbol.for("overleaf.lockdown.rawBody");
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+function __overleafRawBody(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body;
+}
+// --- end Overleaf patch ---
class AbstractStrategy extends passport_strategy_1.Strategy {
constructor(options, signonVerify, logoutVerify) {
super();
@@ -52,7 +75,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy {
catch (_c) {
userMatch = false;
}
- const RelayState = ((_a = req.query) === null || _a === void 0 ? void 0 : _a.RelayState) || ((_b = req.body) === null || _b === void 0 ? void 0 : _b.RelayState);
+ const RelayState = ((_a = __overleafRawQuery(req)) === null || _a === void 0 ? void 0 : _a.RelayState) || ((_b = __overleafRawBody(req)) === null || _b === void 0 ? void 0 : _b.RelayState);
if (this._saml == null) {
return this.error(new Error("Can't get logout response URL without a SAML provider defined."));
}
@@ -125,22 +148,22 @@ class AbstractStrategy extends passport_strategy_1.Strategy {
this.redirect(url);
}
};
- if (((_a = req.query) === null || _a === void 0 ? void 0 : _a.SAMLResponse) || ((_b = req.query) === null || _b === void 0 ? void 0 : _b.SAMLRequest)) {
+ if (((_a = __overleafRawQuery(req)) === null || _a === void 0 ? void 0 : _a.SAMLResponse) || ((_b = __overleafRawQuery(req)) === null || _b === void 0 ? void 0 : _b.SAMLRequest)) {
const originalQuery = (_c = url.parse(req.url).query) !== null && _c !== void 0 ? _c : "";
this._saml
- .validateRedirectAsync(req.query, originalQuery)
+ .validateRedirectAsync(__overleafRawQuery(req), originalQuery)
.then(validateCallback)
.catch((err) => this.error(err));
}
- else if ((_d = req.body) === null || _d === void 0 ? void 0 : _d.SAMLResponse) {
+ else if ((_d = __overleafRawBody(req)) === null || _d === void 0 ? void 0 : _d.SAMLResponse) {
this._saml
- .validatePostResponseAsync(req.body)
+ .validatePostResponseAsync(__overleafRawBody(req))
.then(validateCallback)
.catch((err) => this.error(err));
}
- else if ((_e = req.body) === null || _e === void 0 ? void 0 : _e.SAMLRequest) {
+ else if ((_e = __overleafRawBody(req)) === null || _e === void 0 ? void 0 : _e.SAMLRequest) {
this._saml
- .validatePostRequestAsync(req.body)
+ .validatePostRequestAsync(__overleafRawBody(req))
.then(validateCallback)
.catch((err) => this.error(err));
}
@@ -151,7 +174,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy {
if (this._saml == null) {
throw new Error("Can't process login request without a SAML provider defined.");
}
- const RelayState = (req.query && req.query.RelayState) || (req.body && req.body.RelayState);
+ const RelayState = (__overleafRawQuery(req) && __overleafRawQuery(req).RelayState) || (__overleafRawBody(req) && __overleafRawBody(req).RelayState);
const host = req.headers && req.headers.host;
if (this._saml.options.authnRequestBinding === "HTTP-POST") {
const data = await this._saml.getAuthorizeFormAsync(RelayState, host);
@@ -172,7 +195,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy {
throw new Error("Can't process logout request without a SAML provider defined.");
}
try {
- const RelayState = (req.query && req.query.RelayState) || (req.body && req.body.RelayState);
+ const RelayState = (__overleafRawQuery(req) && __overleafRawQuery(req).RelayState) || (__overleafRawBody(req) && __overleafRawBody(req).RelayState);
// Defaults to HTTP-Redirect
this.redirect(await this._saml.getLogoutUrlAsync(req.user, RelayState, options));
}
@@ -188,7 +211,7 @@ class AbstractStrategy extends passport_strategy_1.Strategy {
if (this._saml == null) {
throw new Error("Can't logout without a SAML provider defined.");
}
- const RelayState = (req.query && req.query.RelayState) || (req.body && req.body.RelayState);
+ const RelayState = (__overleafRawQuery(req) && __overleafRawQuery(req).RelayState) || (__overleafRawBody(req) && __overleafRawBody(req).RelayState);
this._saml
.getLogoutUrlAsync(req.user, RelayState, {})
.then((url) => callback(null, url))
@@ -1,132 +0,0 @@
diff --git a/lib/read.js b/lib/read.js
index fce6283f50961e68c2f576031ed5e3d4fdc39984..6131c31fb8ba8d413e0a3bf887ebd87f1c3a4007 100644
--- a/lib/read.js
+++ b/lib/read.js
@@ -18,7 +18,7 @@ var iconv = require('iconv-lite')
var onFinished = require('on-finished')
var unpipe = require('unpipe')
var zlib = require('zlib')
-
+var Stream = require('stream')
/**
* Module exports.
*/
@@ -166,25 +166,25 @@ function contentstream (req, debug, inflate) {
case 'deflate':
stream = zlib.createInflate()
debug('inflate body')
- req.pipe(stream)
+ // req.pipe(stream)
break
case 'gzip':
stream = zlib.createGunzip()
debug('gunzip body')
- req.pipe(stream)
+ // req.pipe(stream)
break
case 'identity':
stream = req
stream.length = length
- break
+ return req
default:
throw createError(415, 'unsupported content encoding "' + encoding + '"', {
encoding: encoding,
type: 'encoding.unsupported'
})
}
-
- return stream
+ var pass = new Stream.PassThrough(); Stream.pipeline(req, stream, pass, () => {})
+ return pass
}
/**
diff --git a/lib/types/json.js b/lib/types/json.js
index 59f3f7e28f2416cac957b5e35dc78839a5019e0d..f23e52eca828a726f12e915f8ea52698d3761d68 100644
--- a/lib/types/json.js
+++ b/lib/types/json.js
@@ -17,6 +17,8 @@ var contentType = require('content-type')
var createError = require('http-errors')
var debug = require('debug')('body-parser:json')
var read = require('../read')
+var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed')
+var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
var typeis = require('type-is')
/**
@@ -105,7 +107,7 @@ function json (options) {
return
}
- req.body = req.body || {}
+ req.body = (req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body) || {}
// skip requests without bodies
if (!typeis.hasBody(req)) {
diff --git a/lib/types/raw.js b/lib/types/raw.js
index f5d1b67475405284e3dac312f92ade101571329f..251780a624b09340b8a821974565fa330f9792b7 100644
--- a/lib/types/raw.js
+++ b/lib/types/raw.js
@@ -13,6 +13,8 @@
var bytes = require('bytes')
var debug = require('debug')('body-parser:raw')
var read = require('../read')
+var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed')
+var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
var typeis = require('type-is')
/**
@@ -59,7 +61,7 @@ function raw (options) {
return
}
- req.body = req.body || {}
+ req.body = (req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body) || {}
// skip requests without bodies
if (!typeis.hasBody(req)) {
diff --git a/lib/types/text.js b/lib/types/text.js
index 083a00908a6299a8ef72f477983359f5675f82ef..4ea3ee88191bf02ecedf6c975d20144d4d7f2f47 100644
--- a/lib/types/text.js
+++ b/lib/types/text.js
@@ -14,6 +14,8 @@ var bytes = require('bytes')
var contentType = require('content-type')
var debug = require('debug')('body-parser:text')
var read = require('../read')
+var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed')
+var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
var typeis = require('type-is')
/**
@@ -61,7 +63,7 @@ function text (options) {
return
}
- req.body = req.body || {}
+ req.body = (req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body) || {}
// skip requests without bodies
if (!typeis.hasBody(req)) {
diff --git a/lib/types/urlencoded.js b/lib/types/urlencoded.js
index 832992c2c3e0324c9bcb612a01f2fe2fb3e19414..4da13852cd5a89c97d8d7be2b81ba8fd62551f9e 100644
--- a/lib/types/urlencoded.js
+++ b/lib/types/urlencoded.js
@@ -18,6 +18,8 @@ var createError = require('http-errors')
var debug = require('debug')('body-parser:urlencoded')
var deprecate = require('depd')('body-parser')
var read = require('../read')
+var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed')
+var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
var typeis = require('type-is')
/**
@@ -83,7 +85,7 @@ function urlencoded (options) {
return
}
- req.body = req.body || {}
+ req.body = (req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body) || {}
// skip requests without bodies
if (!typeis.hasBody(req)) {
@@ -1,46 +0,0 @@
diff --git a/index.js b/index.js
index 367bdbafbc71b61daf3907913c3861d155140f07..b31ff2d98186ac29446718edfccc03a3562e796a 100644
--- a/index.js
+++ b/index.js
@@ -17,6 +17,30 @@ var Cookie = require('cookie')
var createError = require('http-errors')
var sign = require('cookie-signature').sign
var Tokens = require('csrf')
+// --- Overleaf patch: request-input lockdown compatibility ---
+// The default value getter reads req.body/req.query directly to find the
+// CSRF token submitted alongside a mutating request -- otherwise
+// indistinguishable from unvalidated application code reading raw request
+// input under Overleaf's request-input lockdown (see
+// libraries/validation-tools and the patched express in
+// .yarn/patches/express-npm-*.patch). The lockdown captures the original
+// parsed values in symbol-keyed fields, registered in the global symbol
+// registry (the same ones @overleaf/validation-tools' lockdown.js reads),
+// and only turns the *public* req.query/req.body properties into throwing
+// accessors -- reading through these raw fields instead sidesteps that, and
+// is identical to plain req.query/req.body when the lockdown isn't
+// installed (i.e. for every consumer of this package outside this
+// monorepo).
+var __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed')
+var __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery')
+var __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody')
+function __overleafRawQuery (req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query
+}
+function __overleafRawBody (req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body
+}
+// --- end Overleaf patch ---
/**
* Module exports.
@@ -128,8 +152,8 @@ function csurf (options) {
*/
function defaultValue (req) {
- return (req.body && req.body._csrf) ||
- (req.query && req.query._csrf) ||
+ return (__overleafRawBody(req) && __overleafRawBody(req)._csrf) ||
+ (__overleafRawQuery(req) && __overleafRawQuery(req)._csrf) ||
(req.headers['csrf-token']) ||
(req.headers['xsrf-token']) ||
(req.headers['x-csrf-token']) ||
@@ -1,13 +0,0 @@
diff --git a/lib/MultiReporters.js b/lib/MultiReporters.js
index 98dc4ef..b2a97bf 100644
--- a/lib/MultiReporters.js
+++ b/lib/MultiReporters.js
@@ -160,7 +160,7 @@ MultiReporters.prototype.getCustomOptions = function (options) {
debug('options file (custom)', customOptionsFile);
try {
- if ('.js' === path.extname(customOptionsFile)) {
+ if (['.js', '.cjs'].includes(path.extname(customOptionsFile))) {
customOptions = require(customOptionsFile);
}
else {
File diff suppressed because one or more lines are too long
@@ -1,55 +0,0 @@
diff --git a/index.js b/index.js
index 49f181005f74cf11db3bd5b0ecf512520163ec71..d47ad4270883b90766152342c99495a07cb952e0 100644
--- a/index.js
+++ b/index.js
@@ -1,5 +1,29 @@
const parseCookie = require('cookie').parse;
const decodeCookie = require('cookie-parser').signedCookie;
+// --- Overleaf patch: request-input lockdown compatibility ---
+// This middleware is mounted globally, before any router split, and reads
+// req.query/req.body directly to look for a bearer token -- otherwise
+// indistinguishable from unvalidated application code reading raw request
+// input under Overleaf's request-input lockdown (see
+// libraries/validation-tools and the patched express in
+// .yarn/patches/express-npm-*.patch). The lockdown captures the original
+// parsed values in symbol-keyed fields, registered in the global symbol
+// registry (the same ones @overleaf/validation-tools' lockdown.js reads),
+// and only turns the *public* req.query/req.body properties into throwing
+// accessors -- reading through these raw fields instead sidesteps that, and
+// is identical to plain req.query/req.body when the lockdown isn't
+// installed (i.e. for every consumer of this package outside this
+// monorepo).
+const __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed');
+const __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery');
+const __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody');
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+function __overleafRawBody(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body;
+}
+// --- end Overleaf patch ---
const getCookie = (serialized_cookies, key) => parseCookie(serialized_cookies)[key] || false;
@@ -32,16 +56,16 @@ module.exports = opts => {
let error;
// query
- if (req.query && req.query[queryKey]) {
- token = req.query[queryKey];
+ if (__overleafRawQuery(req) && __overleafRawQuery(req)[queryKey]) {
+ token = __overleafRawQuery(req)[queryKey];
}
// body
- if (req.body && req.body[bodyKey]) {
+ if (__overleafRawBody(req) && __overleafRawBody(req)[bodyKey]) {
if (token) {
error = true;
}
- token = req.body[bodyKey];
+ token = __overleafRawBody(req)[bodyKey];
}
// headers
@@ -1,287 +0,0 @@
diff --git a/lib/lockdown.js b/lib/lockdown.js
new file mode 100644
index 0000000000000000000000000000000000000000..7acc55484421812ddb66edf015cdf0e1125b1cab
--- /dev/null
+++ b/lib/lockdown.js
@@ -0,0 +1,157 @@
+/*!
+ * Overleaf request-input lockdown.
+ *
+ * Captures parsed request input in symbol-keyed fields and, when
+ * REQ_LOCKDOWN_MODE is 'warn' or 'throw', turns the public req.body,
+ * req.query and req.params properties into lockdown accessors:
+ *
+ * - reads log a warning ('warn' mode) or throw ('throw' mode); request
+ * input must be read through parseReq() from @overleaf/validation-tools,
+ * which validates it against a zod schema, or through getRawReqInput()
+ * for allowlisted middleware
+ * - writes always work and are stored in the symbol-keyed fields, so body
+ * parsers and middleware that assign req.body keep functioning unchanged
+ *
+ * With REQ_LOCKDOWN_MODE unset (or 'off') nothing is installed and express
+ * behaves exactly like the unpatched package.
+ *
+ * The symbols are registered in the global symbol registry so that
+ * @overleaf/validation-tools can read the raw fields without importing
+ * express internals. Express' own internals (router param processing, the
+ * query middleware, res.jsonp) go through the helpers below instead of the
+ * public properties so that only application code trips the lockdown.
+ *
+ * Note: unlike the stock properties, the locked accessors are defined
+ * non-enumerable so that iterating/serializing a request (e.g. in logging)
+ * does not trip the lockdown.
+ */
+
+'use strict';
+
+var RAW_BODY = Symbol.for('overleaf.lockdown.rawBody');
+var RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery');
+var RAW_PARAMS = Symbol.for('overleaf.lockdown.rawParams');
+var INSTALLED = Symbol.for('overleaf.lockdown.installed');
+
+var VALID_MODES = ['off', 'warn', 'throw'];
+var cachedMode = null;
+
+function mode() {
+ if (cachedMode === null) {
+ var m = process.env.REQ_LOCKDOWN_MODE || 'off';
+ cachedMode = VALID_MODES.indexOf(m) === -1 ? 'off' : m;
+ }
+ return cachedMode;
+}
+
+/**
+ * Override the mode for tests. Pass null to re-read REQ_LOCKDOWN_MODE on the
+ * next access. Only affects requests that have not been handled yet.
+ */
+function setModeForTests(m) {
+ if (m === null) {
+ cachedMode = null;
+ } else {
+ cachedMode = VALID_MODES.indexOf(m) === -1 ? 'off' : m;
+ }
+}
+
+var warned = new Set();
+
+function report(req, field) {
+ var route = req.route && req.route.path;
+ var location =
+ (req.method || 'UNKNOWN') +
+ ' ' +
+ (route || req.originalUrl || req.url || 'unknown');
+ if (mode() === 'warn') {
+ var key = location + ' ' + field;
+ if (!warned.has(key)) {
+ warned.add(key);
+ var stack = new Error('trace').stack.split('\n').slice(2, 7).join('\n');
+ console.warn(
+ 'req-lockdown: raw request input read: req.' +
+ field +
+ ' at ' +
+ location +
+ '\n' +
+ stack
+ );
+ }
+ return;
+ }
+ var err = new Error(
+ 'raw request input is forbidden — use parseReq() (read req.' +
+ field +
+ ' at ' +
+ location +
+ ')'
+ );
+ err.code = 'ERR_RAW_REQ_INPUT';
+ throw err;
+}
+
+/**
+ * Install the lockdown accessors on a request. Idempotent, no-op in 'off'
+ * mode. Called from the router, the query middleware and expressInit so that
+ * every request is covered no matter how the app is assembled.
+ */
+function install(req) {
+ if (mode() === 'off') return;
+ if (req[INSTALLED]) return;
+ req[INSTALLED] = true;
+ lockField(req, 'body', RAW_BODY);
+ lockField(req, 'query', RAW_QUERY);
+ lockField(req, 'params', RAW_PARAMS);
+}
+
+function lockField(req, field, symbol) {
+ // preserve a value assigned before lockdown was installed
+ if (Object.prototype.hasOwnProperty.call(req, field)) {
+ req[symbol] = req[field];
+ delete req[field];
+ }
+ Object.defineProperty(req, field, {
+ configurable: true,
+ enumerable: false,
+ get: function () {
+ report(this, field);
+ return this[symbol]; // only reached in 'warn' mode
+ },
+ set: function (value) {
+ this[symbol] = value;
+ },
+ });
+}
+
+/**
+ * Effective params/query for express internals: the raw fields once lockdown
+ * is installed, the plain properties otherwise.
+ */
+function getParams(req) {
+ return req[INSTALLED] ? req[RAW_PARAMS] : req.params;
+}
+
+function getQuery(req) {
+ return req[INSTALLED] ? req[RAW_QUERY] : req.query;
+}
+
+/**
+ * Property key under which the router saves/restores params on the request.
+ */
+function paramsProp(req) {
+ return req[INSTALLED] ? RAW_PARAMS : 'params';
+}
+
+module.exports = {
+ RAW_BODY: RAW_BODY,
+ RAW_QUERY: RAW_QUERY,
+ RAW_PARAMS: RAW_PARAMS,
+ INSTALLED: INSTALLED,
+ mode: mode,
+ setModeForTests: setModeForTests,
+ install: install,
+ getParams: getParams,
+ getQuery: getQuery,
+ paramsProp: paramsProp,
+};
diff --git a/lib/middleware/init.js b/lib/middleware/init.js
index dfd042747bdb971d9760e9575241ddaa1b776cd8..162ec6da452bde5f5384d3d5bd79d5727742ed8f 100644
--- a/lib/middleware/init.js
+++ b/lib/middleware/init.js
@@ -14,6 +14,7 @@
*/
var setPrototypeOf = require('setprototypeof')
+var lockdown = require('../lockdown')
/**
* Initialization middleware, exposing the
@@ -27,6 +28,7 @@ var setPrototypeOf = require('setprototypeof')
exports.init = function(app){
return function expressInit(req, res, next){
+ lockdown.install(req);
if (app.enabled('x-powered-by')) res.setHeader('X-Powered-By', 'Express');
req.res = res;
res.req = req;
diff --git a/lib/middleware/query.js b/lib/middleware/query.js
index 7e9166947aff3be10f7ffec3771c92581ce211da..aad897b7ecd43477c1768e135ac06211a2424d36 100644
--- a/lib/middleware/query.js
+++ b/lib/middleware/query.js
@@ -15,6 +15,7 @@
var merge = require('utils-merge')
var parseUrl = require('parseurl');
var qs = require('qs');
+var lockdown = require('../lockdown');
/**
* @param {Object} options
@@ -37,7 +38,9 @@ module.exports = function query(options) {
}
return function query(req, res, next){
- if (!req.query) {
+ lockdown.install(req);
+
+ if (!lockdown.getQuery(req)) {
var val = parseUrl(req).query;
req.query = queryparse(val, opts);
}
diff --git a/lib/response.js b/lib/response.js
index 2b654f4c6629c821bbeaaeee970b199a420fd8b3..103df49d3d94abf017022cdcf4611e62cad84a74 100644
--- a/lib/response.js
+++ b/lib/response.js
@@ -29,6 +29,7 @@ var normalizeType = require('./utils').normalizeType;
var normalizeTypes = require('./utils').normalizeTypes;
var setCharset = require('./utils').setCharset;
var cookie = require('cookie');
+var lockdown = require('./lockdown');
var send = require('send');
var extname = path.extname;
var mime = send.mime;
@@ -312,7 +313,7 @@ res.jsonp = function jsonp(obj) {
var replacer = app.get('json replacer');
var spaces = app.get('json spaces');
var body = stringify(val, replacer, spaces, escape)
- var callback = this.req.query[app.get('jsonp callback name')];
+ var callback = lockdown.getQuery(this.req)[app.get('jsonp callback name')];
// content-type
if (!this.get('Content-Type')) {
diff --git a/lib/router/index.js b/lib/router/index.js
index abb3a6f589e834c5e105f2a96907ed2eb6fc0185..66fef252003098bcc268ef2ebcef0d20b3cce62b 100644
--- a/lib/router/index.js
+++ b/lib/router/index.js
@@ -22,6 +22,7 @@ var deprecate = require('depd')('express');
var flatten = require('array-flatten');
var parseUrl = require('parseurl');
var setPrototypeOf = require('setprototypeof')
+var lockdown = require('../lockdown');
/**
* Module variables.
@@ -136,6 +137,8 @@ proto.param = function param(name, fn) {
proto.handle = function handle(req, res, out) {
var self = this;
+ lockdown.install(req);
+
debug('dispatching %s %s', req.method, req.url);
var idx = 0;
@@ -153,9 +156,9 @@ proto.handle = function handle(req, res, out) {
var stack = self.stack;
// manage inter-router variables
- var parentParams = req.params;
+ var parentParams = lockdown.getParams(req);
var parentUrl = req.baseUrl || '';
- var done = restore(out, req, 'baseUrl', 'next', 'params');
+ var done = restore(out, req, 'baseUrl', 'next', lockdown.paramsProp(req));
// setup next layer
req.next = next;
@@ -368,7 +371,7 @@ proto.process_params = function process_params(layer, called, req, res, done) {
paramIndex = 0;
key = keys[i++];
name = key.name;
- paramVal = req.params[name];
+ paramVal = lockdown.getParams(req)[name];
paramCallbacks = params[name];
paramCalled = called[name];
@@ -380,7 +383,7 @@ proto.process_params = function process_params(layer, called, req, res, done) {
if (paramCalled && (paramCalled.match === paramVal
|| (paramCalled.error && paramCalled.error !== 'route'))) {
// restore value
- req.params[name] = paramCalled.value;
+ lockdown.getParams(req)[name] = paramCalled.value;
// next param
return param(paramCalled.error);
@@ -400,7 +403,7 @@ proto.process_params = function process_params(layer, called, req, res, done) {
var fn = paramCallbacks[paramIndex++];
// store updated value
- paramCalled.value = req.params[key.name];
+ paramCalled.value = lockdown.getParams(req)[key.name];
if (err) {
// store error
-13
View File
@@ -1,13 +0,0 @@
diff --git a/index.js b/index.js
index b2b6bdd..75e6254 100644
--- a/index.js
+++ b/index.js
@@ -46,7 +46,7 @@ function forwarded (req) {
function getSocketAddr (req) {
return req.socket
? req.socket.remoteAddress
- : req.connection.remoteAddress
+ : req.connection && req.connection.remoteAddress
}
/**
@@ -1,13 +0,0 @@
diff --git a/lib/MultiReporters.js b/lib/MultiReporters.js
index d61e019711d5ac7f82c0fb90548bb0eb41ebbb85..e7a9515e05287621301b831191884a84d72ad0a1 100644
--- a/lib/MultiReporters.js
+++ b/lib/MultiReporters.js
@@ -153,7 +153,7 @@ MultiReporters.prototype.getCustomOptions = function (options) {
debug('options file (custom)', customOptionsFile);
try {
- if ('.js' === path.extname(customOptionsFile)) {
+ if (['.js', '.cjs'].includes(path.extname(customOptionsFile))) {
customOptions = require(customOptionsFile);
}
else {
@@ -1,34 +0,0 @@
diff --git a/lib/make-middleware.js b/lib/make-middleware.js
index 2c15f248d4f4d221bd9e2b2e1092b8d5c46d2bdd..859f55f56729f0a3874cfdac53722497cfc3c461 100644
--- a/lib/make-middleware.js
+++ b/lib/make-middleware.js
@@ -26,7 +26,10 @@ function makeMiddleware (setup) {
var preservePath = options.preservePath
var defParamCharset = options.defParamCharset
- req.body = Object.create(null)
+ // keep a local reference: with the Overleaf request-input lockdown
+ // installed, writing req.body is allowed but reading it warns/throws
+ var body = Object.create(null)
+ req.body = body
var busboy
var appender = null
@@ -157,7 +160,7 @@ function makeMiddleware (setup) {
if (fieldname.split('[').length - 1 > limits.fieldNestingDepth) return abortWithCode('LIMIT_FIELD_NESTING', fieldname)
}
- appendField(req.body, fieldname, value)
+ appendField(body, fieldname, value)
})
// handle files
@@ -174,7 +177,7 @@ function makeMiddleware (setup) {
if (fieldname == null) return abortWithCode('MISSING_FIELD_NAME')
// don't attach to the files object, if there is no file
- if (!filename) return fileStream.resume()
+ if (!filename) filename = 'undefined'
// Work around bug in Busboy (https://github.com/mscdex/busboy/issues/6)
if (limits && Object.prototype.hasOwnProperty.call(limits, 'fieldNameSize')) {
-97
View File
@@ -1,97 +0,0 @@
diff --git a/lib/index.js b/lib/index.js
index 567ff5d..a7a38dd 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -545,8 +545,8 @@ function clone(instance) {
// tee instance body
p1 = new PassThrough();
p2 = new PassThrough();
- body.pipe(p1);
- body.pipe(p2);
+ Stream.pipeline(body, p1, () => {});
+ Stream.pipeline(body, p2, () => {});
// set instance body to teed body and return the other teed body
instance[INTERNALS].body = p1;
body = p2;
@@ -648,14 +648,14 @@ function writeToStream(dest, instance) {
// body is null
dest.end();
} else if (isBlob(body)) {
- body.stream().pipe(dest);
+ Stream.pipeline(body.stream(), dest, () => {});
} else if (Buffer.isBuffer(body)) {
// body is buffer
dest.write(body);
dest.end();
} else {
// body is stream
- body.pipe(dest);
+ Stream.pipeline(body, dest, () => {});
}
}
@@ -1638,7 +1638,7 @@ function fetch(url, opts) {
res.once('end', function () {
if (signal) signal.removeEventListener('abort', abortAndFinalize);
});
- let body = res.pipe(new PassThrough$1());
+ let body = Stream.pipeline(res, new PassThrough(), error => { if (error) reject(error); });
const response_options = {
url: request.url,
@@ -1679,7 +1679,7 @@ function fetch(url, opts) {
// for gzip
if (codings == 'gzip' || codings == 'x-gzip') {
- body = body.pipe(zlib.createGunzip(zlibOptions));
+ body = Stream.pipeline(body, zlib.createGunzip(zlibOptions), error => { if (error) reject(error); });
response = new Response(body, response_options);
resolve(response);
return;
@@ -1689,13 +1689,13 @@ function fetch(url, opts) {
if (codings == 'deflate' || codings == 'x-deflate') {
// handle the infamous raw deflate response from old servers
// a hack for old IIS and Apache servers
- const raw = res.pipe(new PassThrough$1());
+ const raw = Stream.pipeline(res, new PassThrough(), error => { if (error) reject(error); });
raw.once('data', function (chunk) {
// see http://stackoverflow.com/questions/37519828
if ((chunk[0] & 0x0F) === 0x08) {
- body = body.pipe(zlib.createInflate());
+ body = Stream.pipeline(body, zlib.createInflate(), error => { if (error) reject(error); });
} else {
- body = body.pipe(zlib.createInflateRaw());
+ body = Stream.pipeline(body, zlib.createInflateRaw(), error => { if (error) reject(error); });
}
response = new Response(body, response_options);
resolve(response);
@@ -1712,7 +1712,7 @@ function fetch(url, opts) {
// for br
if (codings == 'br' && typeof zlib.createBrotliDecompress === 'function') {
- body = body.pipe(zlib.createBrotliDecompress());
+ body = Stream.pipeline(body, zlib.createBrotliDecompress(), error => { if (error) reject(error); });
response = new Response(body, response_options);
resolve(response);
return;
@@ -1738,13 +1738,13 @@ function fixResponseChunkedTransferBadEnding(request, errorCallback) {
if (headers['transfer-encoding'] === 'chunked' && !headers['content-length']) {
response.once('close', function (hadError) {
- // tests for socket presence, as in some situations the
- // the 'socket' event is not triggered for the request
- // (happens in deno), avoids `TypeError`
- // if a data listener is still present we didn't end cleanly
- const hasDataListener = socket && socket.listenerCount('data') > 0;
-
- if (hasDataListener && !hadError) {
+ // A kept-alive socket (Node >= 24.16) still has its 'data'
+ // listener attached when the response 'close' event fires, so
+ // the old socket.listenerCount('data') check reported a false
+ // "Premature close". response.complete is true only once the
+ // terminating zero-length chunk has been received, so it tells
+ // a clean end apart from a genuine truncation.
+ if (!response.complete && !hadError) {
const err = new Error('Premature close');
err.code = 'ERR_STREAM_PREMATURE_CLOSE';
errorCallback(err);
@@ -1,25 +0,0 @@
diff --git a/lib/strategy.js b/lib/strategy.js
index e54e92a86c8e3d40338ed8c6e57e8729393d85a3..cad017ef2027ad676782515a9803835138efa6b3 100644
--- a/lib/strategy.js
+++ b/lib/strategy.js
@@ -66,10 +66,18 @@ util.inherits(Strategy, passport.Strategy);
* @param {Object} req
* @api protected
*/
+var LOCKDOWN_INSTALLED = Symbol.for('overleaf.lockdown.installed');
+var LOCKDOWN_RAW_BODY = Symbol.for('overleaf.lockdown.rawBody');
+var LOCKDOWN_RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery');
+
Strategy.prototype.authenticate = function(req, options) {
options = options || {};
- var username = lookup(req.body, this._usernameField) || lookup(req.query, this._usernameField);
- var password = lookup(req.body, this._passwordField) || lookup(req.query, this._passwordField);
+ // with the Overleaf request-input lockdown installed, read the raw
+ // symbol-keyed fields; the public req.body/req.query accessors warn/throw
+ var body = req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_BODY] : req.body;
+ var query = req[LOCKDOWN_INSTALLED] ? req[LOCKDOWN_RAW_QUERY] : req.query;
+ var username = lookup(body, this._usernameField) || lookup(query, this._usernameField);
+ var password = lookup(body, this._passwordField) || lookup(query, this._passwordField);
if (!username || !password) {
return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400);
@@ -1,85 +0,0 @@
diff --git a/lib/strategy.js b/lib/strategy.js
index b05aacce9a16fd32743a989b68f179bb35b20545..de1293a75c82452089eea37ab1031d4bbe42849c 100644
--- a/lib/strategy.js
+++ b/lib/strategy.js
@@ -14,6 +14,25 @@ var passport = require('passport-strategy')
, AuthorizationError = require('./errors/authorizationerror')
, TokenError = require('./errors/tokenerror')
, InternalOAuthError = require('./errors/internaloautherror');
+// --- Overleaf patch: request-input lockdown compatibility ---
+// This strategy reads req.query directly to parse the OAuth2 protocol
+// fields (error/error_description/error_uri, code, state) it's built
+// around, which is otherwise indistinguishable from unvalidated application
+// code reading raw request input under Overleaf's request-input lockdown
+// (see libraries/validation-tools and the patched express in
+// .yarn/patches/express-npm-*.patch). The lockdown captures the original
+// parsed values in symbol-keyed fields, registered in the global symbol
+// registry (the same ones @overleaf/validation-tools' lockdown.js reads),
+// and only turns the *public* req.query property into a throwing accessor
+// -- reading through this raw field instead sidesteps that, and is
+// identical to plain req.query when the lockdown isn't installed (i.e. for
+// every consumer of this package outside this monorepo).
+var __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed');
+var __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery');
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+// --- end Overleaf patch ---
/**
@@ -131,11 +150,11 @@ OAuth2Strategy.prototype.authenticate = function(req, options) {
options = options || {};
var self = this;
- if (req.query && req.query.error) {
- if (req.query.error == 'access_denied') {
- return this.fail({ message: req.query.error_description });
+ if (__overleafRawQuery(req) && __overleafRawQuery(req).error) {
+ if (__overleafRawQuery(req).error == 'access_denied') {
+ return this.fail({ message: __overleafRawQuery(req).error_description });
} else {
- return this.error(new AuthorizationError(req.query.error_description, req.query.error, req.query.error_uri));
+ return this.error(new AuthorizationError(__overleafRawQuery(req).error_description, __overleafRawQuery(req).error, __overleafRawQuery(req).error_uri));
}
}
@@ -156,14 +175,14 @@ OAuth2Strategy.prototype.authenticate = function(req, options) {
callbackURL: callbackURL
}
- if (req.query && req.query.code) {
+ if (__overleafRawQuery(req) && __overleafRawQuery(req).code) {
function loaded(err, ok, state) {
if (err) { return self.error(err); }
if (!ok) {
return self.fail(state, 403);
}
- var code = req.query.code;
+ var code = __overleafRawQuery(req).code;
var params = self.tokenParams(options);
params.grant_type = 'authorization_code';
@@ -213,7 +232,7 @@ OAuth2Strategy.prototype.authenticate = function(req, options) {
);
}
- var state = req.query.state;
+ var state = __overleafRawQuery(req).state;
try {
var arity = this._stateStore.verify.length;
if (arity == 4) {
diff --git a/lib/utils.js b/lib/utils.js
index 486f9e1..4584507 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -24,7 +24,7 @@ exports.originalURL = function(req, options) {
var trustProxy = options.proxy;
var proto = (req.headers['x-forwarded-proto'] || '').toLowerCase()
- , tls = req.connection.encrypted || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0])
+ , tls = (req.connection && req.connection.encrypted) || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0])
, host = (trustProxy && req.headers['x-forwarded-host']) || req.headers.host
, protocol = tls ? 'https' : 'http'
, path = req.url || '';
-22
View File
@@ -1,22 +0,0 @@
diff --git a/build/pdf.worker.mjs b/build/pdf.worker.mjs
index 6c5c6f1..bb6b7d1 100644
--- a/build/pdf.worker.mjs
+++ b/build/pdf.worker.mjs
@@ -1830,7 +1830,7 @@ async function __wbg_init(module_or_path) {
}
}
if (typeof module_or_path === 'undefined') {
- module_or_path = new URL('qcms_bg.wasm', import.meta.url);
+ module_or_path = new URL(/* webpackIgnore: true */ 'qcms_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || typeof Request === 'function' && module_or_path instanceof Request || typeof URL === 'function' && module_or_path instanceof URL) {
@@ -5358,7 +5358,7 @@ var OpenJPEG = (() => {
if (Module["locateFile"]) {
return locateFile("openjpeg.wasm");
}
- return new URL("openjpeg.wasm", import.meta.url).href;
+ return new URL(/* webpackIgnore: true */ "openjpeg.wasm", import.meta.url).href;
}
function getBinarySync(file) {
if (file == wasmBinaryFile && wasmBinary) {
-14
View File
@@ -1,14 +0,0 @@
diff --git a/internal/legacy-cli.mjs b/internal/legacy-cli.mjs
index a76a536..f4d95ad 100644
--- a/internal/legacy-cli.mjs
+++ b/internal/legacy-cli.mjs
@@ -5832,6 +5832,9 @@ async function formatFiles(context) {
}
output = result.formatted;
} catch (error2) {
+ // invalidate cache on format error
+ formatResultsCache?.removeFormatResultsCache(filename);
+
const errorIsIgnored = handleError(
context,
fileNameToDisplay,
File diff suppressed because it is too large Load Diff
@@ -1,30 +0,0 @@
diff --git a/index.js b/index.js
index 2fae107d03b30aff9320d135ec79c049c51f298a..32ec707ddbf8937e3e130c3deac1060c308bf439 100644
--- a/index.js
+++ b/index.js
@@ -1,6 +1,6 @@
'use strict';
-const {PassThrough} = require('stream');
+const {PassThrough, pipeline} = require('stream');
const extend = require('extend');
let debug = () => {};
@@ -185,7 +185,7 @@ function retryRequest(requestOpts, opts, callback) {
.on('complete', (...params) => handleFinish(params))
.on('finish', (...params) => handleFinish(params));
- requestStream.pipe(delayStream);
+ pipeline(requestStream, delayStream, () => {});
} else {
activeRequest = opts.request(requestOpts, onResponse);
}
@@ -251,7 +251,7 @@ function retryRequest(requestOpts, opts, callback) {
// No more attempts need to be made, just continue on.
if (streamMode) {
retryStream.emit('response', response);
- delayStream.pipe(retryStream);
+ pipeline(delayStream, retryStream, () => {});
requestStream.on('error', err => {
retryStream.destroy(err);
});
@@ -1,30 +0,0 @@
diff --git a/index.js b/index.js
index 298a351097d70a7fb005b6961f4d58247e391d8f..6a809ace0349d40cb2b6732ad66fd8ad208698ca 100644
--- a/index.js
+++ b/index.js
@@ -1,6 +1,6 @@
'use strict';
-const {PassThrough} = require('stream');
+const {PassThrough, pipeline} = require('stream');
const extend = require('extend');
let debug = () => {};
@@ -185,7 +185,7 @@ function retryRequest(requestOpts, opts, callback) {
.on('complete', (...params) => handleFinish(params))
.on('finish', (...params) => handleFinish(params));
- requestStream.pipe(delayStream);
+ pipeline(requestStream, delayStream, () => {});
} else {
activeRequest = opts.request(requestOpts, onResponse);
}
@@ -251,7 +251,7 @@ function retryRequest(requestOpts, opts, callback) {
// No more attempts need to be made, just continue on.
if (streamMode) {
retryStream.emit('response', response);
- delayStream.pipe(retryStream);
+ pipeline(delayStream, retryStream, () => {});
requestStream.on('error', err => {
retryStream.destroy(err);
});
@@ -1,251 +0,0 @@
diff --git a/lib/logout.js b/lib/logout.js
index 77c417213851ffbe4a29f1b501027b8727931d8f..03d1bd8452ce80964f187f541ff47986931d3b73 100644
--- a/lib/logout.js
+++ b/lib/logout.js
@@ -10,6 +10,20 @@ var zlib = require('zlib');
var qs = require('querystring');
var xtend = require('xtend');
var constants = require('./constants');
+// --- Overleaf patch: request-input lockdown compatibility ---
+// See lib/samlp.js for the full rationale; the IdP-initiated and
+// SP-initiated logout flows read SAMLRequest/SAMLResponse/RelayState off
+// req.query/req.body directly, same as the login flow there.
+var __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed');
+var __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery');
+var __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody');
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+function __overleafRawBody(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body;
+}
+// --- end Overleaf patch ---
var BINDINGS = constants.BINDINGS;
var STATUS = constants.STATUS;
@@ -87,7 +101,7 @@ module.exports.logout = function (options) {
}
function validateSamlResponse(req, sessionParticipant, cb) {
- var SAMLResponse = req.query.SAMLResponse || req.body.SAMLResponse;
+ var SAMLResponse = __overleafRawQuery(req).SAMLResponse || __overleafRawBody(req).SAMLResponse;
function parseAndValidate(err, buffer) {
if (err) { return cb(err); }
@@ -141,7 +155,7 @@ module.exports.logout = function (options) {
cb(null, parsedResponse);
}
- if (req.body.SAMLResponse || !options.deflate) {
+ if (__overleafRawBody(req).SAMLResponse || !options.deflate) {
// HTTP-POST or HTTP-Redirect without deflate encoding
return parseAndValidate(null, new Buffer(SAMLResponse, 'base64'));
}
@@ -153,7 +167,7 @@ module.exports.logout = function (options) {
return function (req, res, next) {
try {
// SP Initated flow.
- if (req.query.SAMLRequest || req.body.SAMLRequest) {
+ if (__overleafRawQuery(req).SAMLRequest || __overleafRawBody(req).SAMLRequest) {
const opts = {
getCredentials: function getCredentials(issuer, sessionIndices, nameId, cb) {
options.sessionParticipants.get(issuer, sessionIndices, nameId, function (err, session) {
@@ -168,7 +182,7 @@ module.exports.logout = function (options) {
});
}
};
- parseIncomingLogoutRequest(req, req.query.SAMLRequest || req.body.SAMLRequest, opts, function (err, requestData) {
+ parseIncomingLogoutRequest(req, __overleafRawQuery(req).SAMLRequest || __overleafRawBody(req).SAMLRequest, opts, function (err, requestData) {
if (err) { return next(err); }
if (!requestData.issuer) {
@@ -185,7 +199,7 @@ module.exports.logout = function (options) {
id: requestData.id,
serviceProviderLogoutURL: (session|| {}).serviceProviderLogoutURL || options.destination
},
- relayState: req.query.RelayState || (req.body && req.body.RelayState)
+ relayState: __overleafRawQuery(req).RelayState || (__overleafRawBody(req) && __overleafRawBody(req).RelayState)
};
options.store.save(req, spData, function (err, transactionId) {
@@ -206,7 +220,7 @@ module.exports.logout = function (options) {
});
// Logout flow in progress, incoming SAMLResponse from SP. (Could be SP initiated or IdP initiated)
- } else if (req.query.SAMLResponse || req.body.SAMLResponse) {
+ } else if (__overleafRawQuery(req).SAMLResponse || __overleafRawBody(req).SAMLResponse) {
function process(state, transactionId) {
// LogoutResponse was OK, we remove the session participant from the IdP
options.sessionParticipants.remove(state.issuer, state.sessionIndex, state.nameId, function (err) {
@@ -218,7 +232,7 @@ module.exports.logout = function (options) {
}
// Verify that the state sent to the SP matches the one returned
- var h = req.query.RelayState || (req.body && req.body.RelayState);
+ var h = __overleafRawQuery(req).RelayState || (__overleafRawBody(req) && __overleafRawBody(req).RelayState);
options.store.load(req, h, {destroy: true}, function (err, state) {
if (err) { return next(err); }
if (!state) { return next(new Error('Invalid RelayState')); }
@@ -379,7 +393,7 @@ module.exports.sendLogoutError = function(options){
// Signature location
options.reference = constants.ELEMENTS.LOGOUT_RESPONSE.SIGNATURE_LOCATION_PATH;
- options.relayState = options.relayState || req.query.RelayState || (req.body || {}).RelayState;
+ options.relayState = options.relayState || __overleafRawQuery(req).RelayState || (__overleafRawBody(req) || {}).RelayState;
return prepareAndSendToken(req, res, 'LOGOUT_RESPONSE', logoutResponse, options, next);
};
diff --git a/lib/samlp.js b/lib/samlp.js
index 19a13328d60124417e9aa844c6d54745f7121166..08bef8456e1dcbadceb0460588bb78fff1e3f122 100644
--- a/lib/samlp.js
+++ b/lib/samlp.js
@@ -7,6 +7,30 @@ var templates = require('./templates');
var encoders = require('./encoders');
var PassportProfileMapper = require('./claims/PassportProfileMapper');
var constants = require('./constants');
+// --- Overleaf patch: request-input lockdown compatibility ---
+// This library reads req.query/req.body directly to parse the SAML protocol
+// fields (Signature, SigAlg, RelayState, SAMLRequest/SAMLResponse) it is
+// built around, which is otherwise indistinguishable from unvalidated
+// application code reading raw request input under Overleaf's
+// request-input lockdown (see libraries/validation-tools and the patched
+// express in .yarn/patches/express-npm-*.patch). The lockdown captures the
+// original parsed values in symbol-keyed fields, registered in the global
+// symbol registry (the same ones @overleaf/validation-tools' lockdown.js
+// reads), and only turns the *public* req.query/req.body properties into
+// throwing accessors -- reading through these raw fields instead sidesteps
+// that, and is identical to plain req.query/req.body when the lockdown
+// isn't installed (i.e. for every consumer of this package outside this
+// monorepo).
+var __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed');
+var __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery');
+var __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody');
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+function __overleafRawBody(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body;
+}
+// --- end Overleaf patch ---
function buildSamlResponse(options) {
var SAMLResponse = templates.samlresponse({
@@ -153,10 +177,10 @@ module.exports.auth = function (options) {
return function (req, res, next) {
var opts = xtend({}, options || {}); // clone options
- if (req.method === 'GET' && req.query.Signature) {
- opts.signature = req.query.Signature;
- opts.sigAlg = req.query.SigAlg;
- opts.relayState = opts.RelayState || req.query.RelayState;
+ if (req.method === 'GET' && __overleafRawQuery(req).Signature) {
+ opts.signature = __overleafRawQuery(req).Signature;
+ opts.sigAlg = __overleafRawQuery(req).SigAlg;
+ opts.relayState = opts.RelayState || __overleafRawQuery(req).RelayState;
}
function execute(postUrl, audience, req, res, next) {
@@ -178,14 +202,14 @@ module.exports.auth = function (options) {
res.send(templates.form({
type: 'SAMLResponse',
callback: postUrl,
- RelayState: opts.RelayState || (req.query || {}).RelayState || (req.body || {}).RelayState || '',
+ RelayState: opts.RelayState || (__overleafRawQuery(req) || {}).RelayState || (__overleafRawBody(req) || {}).RelayState || '',
token: response.toString('base64')
}));
}
});
}
- utils.parseSamlRequest(req, (req.query || {}).SAMLRequest || (req.body || {}).SAMLRequest, "AUTHN_REQUEST", opts, function (err, samlRequestDom) {
+ utils.parseSamlRequest(req, (__overleafRawQuery(req) || {}).SAMLRequest || (__overleafRawBody(req) || {}).SAMLRequest, "AUTHN_REQUEST", opts, function (err, samlRequestDom) {
if (err) return next(err);
var audience = opts.audience;
@@ -216,7 +240,7 @@ module.exports.parseRequest = function (req, options, callback) {
options = {};
}
- var samlRequest = (req.query || {}).SAMLRequest || (req.body || {}).SAMLRequest;
+ var samlRequest = (__overleafRawQuery(req) || {}).SAMLRequest || (__overleafRawBody(req) || {}).SAMLRequest;
if (!samlRequest)
return callback();
diff --git a/lib/utils.js b/lib/utils.js
index c091ac335be08b2bf55907b269e8e1ef79b686bb..23940db43725664367329341a713f2a1077ad071 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -6,6 +6,21 @@ const url = require('url');
const xtend = require('xtend');
const signers = require('./signers');
const constants = require('./constants');
+// --- Overleaf patch: request-input lockdown compatibility ---
+// See lib/samlp.js for the full rationale; this module validates SAML
+// signatures carried on req.query/req.body (Signature, SigAlg, RelayState,
+// SAMLRequest/SAMLResponse) for both the login (samlp.js) and logout
+// (logout.js) flows.
+const __overleafLockdownInstalled = Symbol.for('overleaf.lockdown.installed');
+const __overleafLockdownRawQuery = Symbol.for('overleaf.lockdown.rawQuery');
+const __overleafLockdownRawBody = Symbol.for('overleaf.lockdown.rawBody');
+function __overleafRawQuery(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawQuery] : req.query;
+}
+function __overleafRawBody(req) {
+ return req[__overleafLockdownInstalled] ? req[__overleafLockdownRawBody] : req.body;
+}
+// --- end Overleaf patch ---
const IdGenerator = require('auth0-id-generator');
const generator = new IdGenerator({len: 20, alphabet:"abcdef0123456789"});
@@ -20,7 +35,7 @@ module.exports.parseSamlRequest = function(req, samlRequest, type, options, call
const opts = {
signingCert: options.signingCert,
thumbprints: options.thumbprints,
- deflate: !!req.query.Signature,
+ deflate: !!__overleafRawQuery(req).Signature,
relayState: options.relayState
};
@@ -186,10 +201,10 @@ function validateSignature(req, element_type, xml, options) {
const type = constants.ELEMENTS[element_type].PROP;
const isRequestSigned = !options.deflate ?
- xpath.select(options.signaturePath || constants.ELEMENTS[element_type].SIGNATURE_VALIDATION_PATH, xml).length > 0 : !!req.query.Signature;
+ xpath.select(options.signaturePath || constants.ELEMENTS[element_type].SIGNATURE_VALIDATION_PATH, xml).length > 0 : !!__overleafRawQuery(req).Signature;
if (isRequestSigned) {
- if ((req.body && req.body[type]) || !options.deflate) {
+ if ((__overleafRawBody(req) && __overleafRawBody(req)[type]) || !options.deflate) {
// HTTP-POST or HTTP-Redirect without deflate encoding
const validationErrors = signers.validateXmlEmbeddedSignature(xml, options);
if (validationErrors && validationErrors.length > 0) {
@@ -199,9 +214,9 @@ function validateSignature(req, element_type, xml, options) {
else {
// HTTP-Redirect with deflate encoding
let signedContent = {};
- signedContent[type] = req.query[type];
- signedContent.RelayState = req.query.RelayState || options.relayState;
- signedContent.SigAlg = req.query.SigAlg;
+ signedContent[type] = __overleafRawQuery(req)[type];
+ signedContent.RelayState = __overleafRawQuery(req).RelayState || options.relayState;
+ signedContent.SigAlg = __overleafRawQuery(req).SigAlg;
if (!signedContent.RelayState && !options.relayState) {
delete signedContent.RelayState;
@@ -215,13 +230,13 @@ function validateSignature(req, element_type, xml, options) {
throw new Error('Invalid signature algorithm. Supported algorithms are http://www.w3.org/2001/04/xmldsig-more#rsa-sha1 and http://www.w3.org/2001/04/xmldsig-more#rsa-sha256');
}
- const valid = signers.isValidContentAndSignature(qs.stringify(signedContent), req.query.Signature, {
+ const valid = signers.isValidContentAndSignature(qs.stringify(signedContent), __overleafRawQuery(req).Signature, {
signingCert: options.signingCert,
- signatureAlgorithm: req.query.SigAlg
+ signatureAlgorithm: __overleafRawQuery(req).SigAlg
});
if (!valid) {
- throw new Error('Signature check errors: The signature provided (' + req.query.Signature + ') does not match the one calculated');
+ throw new Error('Signature check errors: The signature provided (' + __overleafRawQuery(req).Signature + ') does not match the one calculated');
}
}
} else if (options.signingCert) {
@@ -1,57 +0,0 @@
diff --git a/lib/sandboxed_module.js b/lib/sandboxed_module.js
index 1cd6743..4718b97 100644
--- a/lib/sandboxed_module.js
+++ b/lib/sandboxed_module.js
@@ -4,7 +4,7 @@ var Module = require('module');
var fs = require('fs');
var vm = require('vm');
var path = require('path');
-var builtinModules = require('./builtin_modules.json');
+var builtinModules = Module.builtinModules || require('./builtin_modules.json');
var parent = module.parent;
var globalOptions = {};
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;
-57
View File
@@ -1,57 +0,0 @@
diff --git a/index.js b/index.js
index 768f8ca..a882f4d 100644
--- a/index.js
+++ b/index.js
@@ -788,29 +788,29 @@ SendStream.prototype.stream = function stream (path, options) {
// pipe
var stream = fs.createReadStream(path, options)
this.emit('stream', stream)
- stream.pipe(res)
-
- // cleanup
- function cleanup () {
- destroy(stream, true)
- }
-
- // response finished, cleanup
- onFinished(res, cleanup)
-
- // error handling
- stream.on('error', function onerror (err) {
- // clean up stream early
- cleanup()
-
- // error
- self.onStatError(err)
- })
-
- // end
- stream.on('end', function onend () {
- self.emit('end')
- })
+ Stream.pipeline(stream, res, err => { if (err) { self.onStatError(err) } else { self.emit('end') } })
+
+ // // cleanup
+ // function cleanup () {
+ // destroy(stream, true)
+ // }
+ //
+ // // response finished, cleanup
+ // onFinished(res, cleanup)
+ //
+ // // error handling
+ // stream.on('error', function onerror (err) {
+ // // clean up stream early
+ // cleanup()
+ //
+ // // error
+ // self.onStatError(err)
+ // })
+ //
+ // // end
+ // stream.on('end', function onend () {
+ // self.emit('end')
+ // })
}
/**
@@ -1,57 +0,0 @@
diff --git a/build/src/index.js b/build/src/index.js
index a101736..a87f6b9 100644
--- a/build/src/index.js
+++ b/build/src/index.js
@@ -130,6 +130,9 @@ function createMultipartStream(boundary, multipart) {
}
else {
part.body.pipe(stream, { end: false });
+ part.body.on('error', (err) => {
+ stream.destroy(err);
+ });
part.body.on('end', () => {
stream.write('\r\n');
stream.write(finale);
@@ -184,25 +187,25 @@ function teenyRequest(reqOpts, callback) {
// Stream mode
const requestStream = streamEvents(new stream_1.PassThrough());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- let responseStream;
- requestStream.once('reading', () => {
- if (responseStream) {
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });
- }
- else {
- requestStream.once('response', () => {
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });
- });
- }
- });
+ // let responseStream;
+ // requestStream.once('reading', () => {
+ // if (responseStream) {
+ // (0, stream_1.pipeline)(responseStream, requestStream, () => { });
+ // }
+ // else {
+ // requestStream.once('response', () => {
+ // (0, stream_1.pipeline)(responseStream, requestStream, () => { });
+ // });
+ // }
+ // });
options.compress = false;
teenyRequest.stats.requestStarting();
- fetch(uri, options).then(res => {
+ (0, node_fetch_1.default)(uri, options).then(res => {
- teenyRequest.stats.requestFinished();
- responseStream = res.body;
- responseStream.on('error', (err) => {
- requestStream.emit('error', err);
- });
+ teenyRequest.stats.requestFinished(); (0, stream_1.pipeline)(res.body, requestStream, () => {});
+ // responseStream = res.body;
+ // responseStream.on('error', (err) => {
+ // requestStream.emit('error', err);
+ // });
const response = fetchToRequestResponse(options, res);
requestStream.emit('response', response);
}, err => {
@@ -1,58 +0,0 @@
diff --git a/build/src/index.js b/build/src/index.js
index af5d15e260e2a47588c7c536447fe84bd3f86136..2b63d0c0b1eb6595c7a0bb314c1df792454c1a72 100644
--- a/build/src/index.js
+++ b/build/src/index.js
@@ -115,6 +115,9 @@ function createMultipartStream(boundary, multipart) {
}
else {
part.body.pipe(stream, { end: false });
+ part.body.on('error', (err) => {
+ stream.destroy(err);
+ });
part.body.on('end', () => {
stream.write('\r\n');
stream.write(finale);
@@ -168,25 +171,27 @@ function teenyRequest(reqOpts, callback) {
// Stream mode
const requestStream = streamEvents(new stream_1.PassThrough());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- let responseStream;
- requestStream.once('reading', () => {
- if (responseStream) {
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });
- }
- else {
- requestStream.once('response', () => {
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });
- });
- }
- });
+ // let responseStream;
+ // requestStream.once('reading', () => {
+ // if (responseStream) {
+ // (0, stream_1.pipeline)(responseStream, requestStream, () => { });
+ // }
+ // else {
+ // requestStream.once('response', () => {
+ // (0, stream_1.pipeline)(responseStream, requestStream, () => { });
+ // });
+ // }
+ // });
+
+
options.compress = false;
teenyRequest.stats.requestStarting();
(0, node_fetch_1.default)(uri, options).then(res => {
- teenyRequest.stats.requestFinished();
- responseStream = res.body;
- responseStream.on('error', (err) => {
- requestStream.emit('error', err);
- });
+ teenyRequest.stats.requestFinished(); stream_1.pipeline(res.body, requestStream, () => {});
+ // responseStream = res.body;
+ // responseStream.on('error', (err) => {
+ // requestStream.emit('error', err);
+ // });
const response = fetchToRequestResponse(options, res);
requestStream.emit('response', response);
}, err => {
@@ -1,81 +0,0 @@
diff --git a/dist/WorkerPool.js b/dist/WorkerPool.js
index 4145779f08eefafd0c18394806b6409c595ac5bb..aa16dd6a0f463804455164493a1e75e80cdf656a 100644
--- a/dist/WorkerPool.js
+++ b/dist/WorkerPool.js
@@ -258,6 +258,19 @@ class PoolWorker {
finalCallback();
break;
}
+ case 'logMessage':
+ {
+ const {
+ data: { loggerName, methodName, args }
+ } = message;
+ const {
+ data: jobData
+ } = this.jobs[id];
+ const logger = jobData.getLogger(loggerName);
+ logger[methodName].apply(logger, args);
+ finalCallback();
+ break;
+ }
case 'emitWarning':
{
const {
diff --git a/dist/index.js b/dist/index.js
index 75cd30fb63dc864057c1afc866f43fc7cc0a8020..d834af6ce1e2cd4473c4ae1b325d63eb1190c17e 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -43,6 +43,7 @@ function pitch() {
sourceMap: this.sourceMap,
emitError: this.emitError,
emitWarning: this.emitWarning,
+ getLogger: this.getLogger,
loadModule: this.loadModule,
resolve: this.resolve,
getResolve: this.getResolve,
diff --git a/dist/worker.js b/dist/worker.js
index 8e67959e4b7c9fd0db116509b1459636ba13a097..aca94f1442906baf179ada8e6a56501bbf71c480 100644
--- a/dist/worker.js
+++ b/dist/worker.js
@@ -90,6 +90,22 @@ function writeJson(data) {
writePipeWrite(lengthBuffer);
writePipeWrite(messageBuffer);
}
+const LOGGER_METHODS = ['error', 'warn', 'info', 'log', 'debug', 'trace', 'group', 'groupEnd', 'groupCollapsed', 'status', 'clear', 'profile', 'profileEnd'];
+class Logger {
+ constructor(id, loggerName) {
+ this.id = id
+ this.loggerName = loggerName
+ for (const methodName of LOGGER_METHODS) {
+ this[methodName] = (...args) => {
+ writeJson({
+ type: 'logMessage',
+ id: this.id,
+ data: { loggerName, methodName, args }
+ })
+ }
+ }
+ }
+}
const queue = (0, _queue.default)(({
id,
data
@@ -190,6 +206,7 @@ const queue = (0, _queue.default)(({
}
return options;
},
+ getLogger: (name) => new Logger(id, name),
emitWarning: warning => {
writeJson({
type: 'emitWarning',
@@ -211,6 +228,9 @@ const queue = (0, _queue.default)(({
module._compile(code, filename); // eslint-disable-line no-underscore-dangle
return module.exports;
},
+ addDependency: filename => {
+ buildDependencies.push(filename);
+ },
addBuildDependency: filename => {
buildDependencies.push(filename);
},
-109
View File
@@ -1,109 +0,0 @@
approvedGitRepositories:
- "**"
enableGlobalCache: false
enableScripts: true
enableTelemetry: false
nodeLinker: pnp
npmMinimalAgeGate: 3d
packageExtensions:
"bootstrap@*":
dependencies:
"@popperjs/core": "*"
jquery: "^3"
"@overleaf/settings@*":
peerDependencies:
"@overleaf/logger": "*"
"mongodb@*":
peerDependencies:
aws4: "*"
"bunyan@*":
peerDependencies:
source-map-support: "*"
"mocha-multi-reporters@*":
peerDependencies:
mocha-junit-reporter: "*"
"mongodb-legacy@*":
peerDependencies:
mongodb: "*"
"knex@*":
dependencies:
pg-query-stream: "*"
"zod-validation-error@*":
peerDependencies:
zod: "*"
"@vitejs/plugin-react@*":
peerDependencies:
vite: "*"
"@babel/preset-env@*":
peerDependencies:
core-js: "*"
"express@^4.0.0":
dependencies:
pug: "*"
"@prettier/plugin-pug@*":
dependencies:
pug: "*"
pug-filters: "*"
pug-lexer: "*"
pug-linker: "*"
pug-load: "*"
pug-parser: "*"
pug-runtime: "*"
pug-strip-comments: "*"
pug-walk: "*"
"postcss-loader@*":
peerDependencies:
autoprefixer: "*"
tailwindcss: "*"
"@ai-sdk/gateway@*":
peerDependencies:
zod: "*"
"@ai-sdk/provider-utils@*":
peerDependencies:
zod: "*"
"ai@*":
peerDependencies:
zod: "*"
"@uppy/react@*":
peerDependencies:
"@uppy/status-bar": "*"
"inversify@*":
dependencies:
reflect-metadata: "*"
# Must match services/web's pinned mongoose version exactly (not a range):
# sinon-mongoose checks `instanceof mongoose.Model` against its own resolved
# copy, so any version drift from web's copy breaks that check silently.
"sinon-mongoose@*":
dependencies:
mongoose: "8.24.3"
"react-linkify@*":
peerDependencies:
react: "*"
"socket.io-client@*":
dependencies:
ws: "*"
"debug@*":
peerDependenciesMeta:
supports-color:
optional: true
pnpZipBackend: js
supportedArchitectures:
cpu:
- current
- arm64
- x64
libc:
- current
- glibc
os:
- current
- darwin
- linux
+10 -5
View File
@@ -1,9 +1,11 @@
# Contributing to Overleaf
Contributing to Overleaf
==========================
Thank you for reading this! If you'd like to report a bug or join in the development
of Overleaf, then here are some notes on how to do that.
## Reporting bugs and opening issues
Reporting bugs and opening issues
---------------------------------
If you'd like to report a bug or open an issue, please **[check if there is an existing issue](https://github.com/overleaf/overleaf/issues).**
If there is then please add any more information that you have, or give it a 👍.
@@ -14,7 +16,8 @@ If you can include a screenshot for front end issues that is very helpful.
**Note**: If you are using [www.overleaf.com](www.overleaf.com) and have a problem, or if you would like to request a new feature, please contact the Support team at support@overleaf.com. Raise an issue here only to report bugs in the Community Edition release of Overleaf.
## Pull Requests
Pull Requests
-------------
See [our wiki](https://github.com/overleaf/overleaf/wiki)
for how to manage the Overleaf development environment and for our developer guidelines.
@@ -25,11 +28,13 @@ to get your changes merged into Overleaf.
If you're looking for something to work on, have a look at the [open issues](https://github.com/overleaf/overleaf/issues).
## Security
Security
--------
Please see [our security policy](https://github.com/overleaf/overleaf/security/policy) if you would like to report a potential security vulnerability.
## Contributor License Agreement
Contributor License Agreement
-----------------------------
Before we can accept any contributions of code, we need you to agree to our
[Contributor License Agreement](https://docs.google.com/forms/d/e/1FAIpQLSef79XH3mb7yIiMzZw-yALEegS-wyFetvjTiNBfZvf_IHD2KA/viewform?usp=sf_link).
+853 -18
View File
@@ -14,35 +14,872 @@
<a href="#license">License</a>
</p>
<img src="doc/screenshot.png" alt="A screenshot of a project being edited in Overleaf Community Edition">
<img src="doc/screenshot.png" alt="A screenshot of a project being edited in Overleaf Extended Community Edition">
<p align="center">
Figure 1: A screenshot of a project being edited in Overleaf Community Edition.
Figure 1: A screenshot of a project being edited in Overleaf Extended Community Edition.
</p>
## Community Edition
[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. We run a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf.
[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. Overleaf runs a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf.
> [!CAUTION]
> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles.
## Extended Community Edition
For more information on Sandbox Compiles check out our [documentation](https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles).
The present "extended" version of Overleaf CE includes:
- Sandboxed Compiles with TeX Live image selection
- LDAP authentication
- SAML authentication
- OpenID Connect authentication
- Real-time track changes and comments
- Autocomplete of reference keys
- Symbol Palette
## Enterprise
If you want help installing and maintaining Overleaf in your lab or workplace, we offer an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises). It also includes more features for security (SSO with LDAP or SAML), administration and collaboration (e.g. tracked changes). [Find out more!](https://www.overleaf.com/for/enterprises)
## Keeping up to date
Sign up to the [mailing list](https://mailchi.mp/overleaf.com/community-edition-and-server-pro) to get updates on Overleaf releases and development.
If you want help installing and maintaining Overleaf in your lab or workplace, Overleaf offers an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises).
## Installation
We have detailed installation instructions in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
Detailed installation instructions can be found in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
To run a custom image, add a file named docker-compose.override.yml with the following or similar content into the `overleaf-toolkit/config directory`:
## Upgrading
```yml
---
services:
sharelatex:
image: sharelatex/sharelatex:ext-ce
volumes:
- ../config/certs:/overleaf/certs
```
Here, the attached volume provides convenient access for the container to the certificates needed for SAML or LDAP authentication.
If you are upgrading from a previous version of Overleaf, please see the [Release Notes section on the Wiki](https://github.com/overleaf/overleaf/wiki#release-notes) for all of the versions between your current version and the version you are upgrading to.
If you want to build a Docker image of the extended CE based on the upstream v5.4.0 codebase, you can check out the corresponding tag by running:
```
git checkout v5.4.0-ext-v1
```
After building the image, switch to the latest state of the repository and check the `server-ce/hotfix` directory. If a subdirectory matching your version (e.g., `5.4.0`) exists, build a patched image.
Alternatively, you can download a prebuilt image from Docker Hub:
```
docker pull overleafcep/sharelatex:5.4.0-ext-v1
```
Make sure to update the image name in overleaf-toolkit/config/docker-compose.override.yml accordingly.
## Sandboxed Compiles
To enable sandboxed compiles (also known as "Sibling containers"), set the following configuration options in `overleaf-toolkit/config/overleaf.rc`:
```
SERVER_PRO=true
SIBLING_CONTAINERS_ENABLED=true
```
The following environment variables are used to specify which TeX Live images to use for sandboxed compiles:
- `ALL_TEX_LIVE_DOCKER_IMAGES` **(required)**
* A comma-separated list of TeX Live images to use. These images will be downloaded or updated.
To skip downloading the images, set `SIBLING_CONTAINERS_PULL=false` in `config/overleaf.rc`.
- `ALL_TEX_LIVE_DOCKER_IMAGE_NAMES`
* A comma-separated list of friendly names for the images. If omitted, the version name will be used (e.g., `latest-full`).
- `TEX_LIVE_DOCKER_IMAGE` **(required)**
* The default TeX Live image that will be used for compiling new projects. The environment variable `ALL_TEX_LIVE_DOCKER_IMAGES` must include this image.
Users can select the image for their project in the project menu.
Here is an example where the default TeX Live image is `latest-full` from Docker Hub, but the `TL2024-historic` image can be used for older projects:
```
ALL_TEX_LIVE_DOCKER_IMAGES=texlive/texlive:latest-full, texlive/texlive:TL2024-historic
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES=TeXLive 2025, TeXLive 2024
TEX_LIVE_DOCKER_IMAGE=texlive/texlive:latest-full
```
For additional details refer to
[Server Pro: Sandboxed Compiles](https://github.com/overleaf/overleaf/wiki/Server-Pro:-Sandboxed-Compiles) and
[Toolkit: Sandboxed Compiles](https://github.com/overleaf/toolkit/blob/master/doc/sandboxed-compiles.md).
When the compilation takes place in a dedicated container, it is relatively safe to permit running external commands from inside the TeX
file during compilation. This is required for packages like `minted`. For this purpose, the following environment variable can be used:
- `TEX_COMPILER_EXTRA_FLAGS`
* A list of extra flags for TeX compiler. Example: `-shell-escape -file-line-error`
<details>
<summary><h4>Sample variables.env file</h4></summary>
```
OVERLEAF_APP_NAME="Our Overleaf Instance"
ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
# Enables Thumbnail generation using ImageMagick
ENABLE_CONVERSIONS=true
# Disables email confirmation requirement
EMAIL_CONFIRMATION_DISABLED=true
## Nginx
# NGINX_WORKER_PROCESSES=4
# NGINX_WORKER_CONNECTIONS=768
## Set for TLS via nginx-proxy
# OVERLEAF_BEHIND_PROXY=true
# OVERLEAF_SECURE_COOKIE=true
OVERLEAF_SITE_URL=http://my-overleaf-instance.com
OVERLEAF_NAV_TITLE=Our Overleaf Instance
# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
OVERLEAF_ADMIN_EMAIL=support@example.com
OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
OVERLEAF_EMAIL_SMTP_PORT=587
OVERLEAF_EMAIL_SMTP_SECURE=false
# OVERLEAF_EMAIL_SMTP_USER=
# OVERLEAF_EMAIL_SMTP_PASS=
# OVERLEAF_EMAIL_SMTP_NAME=
OVERLEAF_EMAIL_SMTP_LOGGER=false
OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
OVERLEAF_PROXY_LEARN=true
NAV_HIDE_POWERED_BY=true
########################
## Sandboxed Compiles ##
########################
ALL_TEX_LIVE_DOCKER_IMAGES=texlive/texlive:latest-full, texlive/texlive:TL2023-historic
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES=TeXLive 2024, TeXLive 2023
TEX_LIVE_DOCKER_IMAGE=texlive/texlive:latest-full
TEX_COMPILER_EXTRA_FLAGS=-shell-escape
```
</details>
## Authentication Methods
The following authentication methods are supported: local authentication, LDAP authentication, SAML authentication,
and OpenID Connect (OIDC) authentication. Local authentication is always active. The environment variable `EXTERNAL_AUTH`
specifies which external authentication methods are activated. The value of this variable is a list. If the list includes `ldap`, `saml`, or `oidc`,
then LDAP authentication, SAML authentication, and OIDC authentication will be activated, respectively.
For example: `EXTERNAL_AUTH=ldap saml oidc`
This configuration activates all available authentication methods, although this is rarely necessary.
<details>
<summary><h3>Local Authentication</h3></summary>
Password of local users stored in the MongoDB database. An admin user can create a new local user. For details, visit the
[wiki of Overleaf project](https://github.com/overleaf/overleaf/wiki/Creating-and-managing-users).
It is possible to enforce password restrictions on local users:
* `OVERLEAF_PASSWORD_VALIDATION_MIN_LENGTH`: The minimum length required
* `OVERLEAF_PASSWORD_VALIDATION_MAX_LENGTH`: The maximum length allowed
* `OVERLEAF_PASSWORD_VALIDATION_PATTERN`: is used to validate password strength
- `abc123` – password requires 3 letters and 3 numbers and be at least 6 characters long
- `aA` – password requires lower and uppercase letters and be at least 2 characters long
- `ab$3` – it must contain letters, digits and symbols and be at least 4 characters long
- There are 4 groups of characters: letters, UPPERcase letters, digits, symbols. Anything that is neither a letter nor a digit is considered to be a symbol.
</details>
<details>
<summary><h3>LDAP Authentication</h3></summary>
Internally, Overleaf LDAP uses the [passport-ldapauth](https://github.com/vesse/passport-ldapauth) library. Most of these configuration options are passed through to the `server` config object which is used to configure `passport-ldapauth`. If you are having issues configuring LDAP, it is worth reading the README for `passport-ldapauth` to understand the configuration it expects.
When using Local and LDAP authentication methods, a user enters a `username` and `password` in the login form. If LDAP authentication is enabled, it is attempted first:
1. An LDAP user is searched for in the LDAP directory using the filter defined by `OVERLEAF_LDAP_SEARCH_FILTER` and authenticated.
2. If authentication is successful, the Overleaf users database is checked for a user with the primary email address that matches the email address of the authenticated LDAP user:
- If a matching user is found, the `hashedPassword` field for this user is deleted (if it exists). This ensures that the user can only log in via LDAP authentication in the future.
- If no matching user is found, a new Overleaf user is created using the email, first name, and last name retrieved from the LDAP server.
3. If LDAP authentication fails or is unsuccessful, local authentication is attempted.
#### Environment Variables
- `OVERLEAF_LDAP_URL` **(required)**
* URL of the LDAP server.
- Example: `ldaps://ldap.example.com:636` (LDAP over SSL)
- Example: `ldap://ldap.example.com:389` (unencrypted or STARTTLS, if configured).
- `OVERLEAF_LDAP_EMAIL_ATT`
* The email attribute returned by the LDAP server, default `mail`. Each LDAP user must have at least one email address.
If multiple addresses are provided, only the first one will be used.
- `OVERLEAF_LDAP_FIRST_NAME_ATT`
* The property name holding the first name of the user which is used in the application, usually `givenName`.
- `OVERLEAF_LDAP_LAST_NAME_ATT`
* The property name holding the family name of the user which is used in the application, usually `sn`.
- `OVERLEAF_LDAP_NAME_ATT`
* The property name holding the full name of the user, usually `cn`. If either of the two previous variables is not defined,
the first and/or last name of the user is extracted from this variable. Otherwise, it is not used.
- `OVERLEAF_LDAP_PLACEHOLDER`
* The placeholder for the login form, defaults to `Username`.
- `OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN`
* If set to `true`, updates the LDAP user `first_name` and `last_name` field on login, and turn off the user details form on the `/user/settings`
page for LDAP users. Otherwise, details will be fetched only on first login.
- `OVERLEAF_LDAP_BIND_DN`
* The distinguished name of the LDAP user that should be used for the LDAP connection
(this user should be able to search/list accounts on the LDAP server),
e.g., `cn=ldap_reader,dc=example,dc=com`. If not defined, anonymous binding is used.
- `OVERLEAF_LDAP_BIND_CREDENTIALS`
* Password for `OVERLEAF_LDAP_BIND_DN`.
- `OVERLEAF_LDAP_BIND_PROPERTY`
* Property of the user to bind against the client, defaults to `dn`.
- `OVERLEAF_LDAP_SEARCH_BASE` **(required)**
* The base DN from which to search for users. E.g., `ou=people,dc=example,dc=com`.
- `OVERLEAF_LDAP_SEARCH_FILTER`
* LDAP search filter with which to find a user. Use the literal '{{username}}' to have the given username be interpolated in for the LDAP search.
- Example: `(|(uid={{username}})(mail={{username}}))` (user can login with email or with login name).
- Example: `(sAMAccountName={{username}})` (Active Directory).
- `OVERLEAF_LDAP_SEARCH_SCOPE`
* The scope of the search can be `base`, `one`, or `sub` (default).
- `OVERLEAF_LDAP_SEARCH_ATTRIBUTES`
* JSON array of attributes to fetch from the LDAP server, e.g., `["uid", "mail", "givenName", "sn"]`.
By default, all attributes are fetched.
- `OVERLEAF_LDAP_STARTTLS`
* If `true`, LDAP over TLS is used.
- `OVERLEAF_LDAP_TLS_OPTS_CA_PATH`
* Path to the file containing the CA certificate used to verify the LDAP server's SSL/TLS certificate. If there are multiple certificates, then
it can be a JSON array of paths to the certificates. The files must be accessible to the docker container.
- Example (one certificate): `/overleaf/certs/ldap_ca_cert.pem`
- Example (multiple certificates): `["/overleaf/certs/ldap_ca_cert1.pem", "/overleaf/certs/ldap_ca_cert2.pem"]`
- `OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH`
* If `true`, the server certificate is verified against the list of supplied CAs.
- `OVERLEAF_LDAP_CACHE`
* If `true`, then up to 100 credentials at a time will be cached for 5 minutes.
- `OVERLEAF_LDAP_TIMEOUT`
* How long the client should let operations live for before timing out, ms (Default: Infinity).
- `OVERLEAF_LDAP_CONNECT_TIMEOUT`
* How long the client should wait before timing out on TCP connections, ms (Default: OS default).
- `OVERLEAF_LDAP_IS_ADMIN_ATT` and `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE`
* When both environment variables are set, the login process updates `user.isAdmin = true` if the LDAP profile contains the attribute specified by
`OVERLEAF_LDAP_IS_ADMIN_ATT` and its value either matches `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE` or is an array containing `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE`,
otherwise `user.isAdmin` is set to `false`. If either of these variables is not set, then the admin status is only set to `true` during admin user
creation in Launchpad.
The following five variables are used to configure how user contacts are retrieved from the LDAP server.
- `OVERLEAF_LDAP_CONTACTS_FILTER`
* The filter used to search for users in the LDAP server to be loaded into contacts. The placeholder '{{userProperty}}' within the filter is replaced with the value of
the property specified by `OVERLEAF_LDAP_CONTACTS_PROPERTY` from the LDAP user initiating the search. If not defined, no users are retrieved from the LDAP server into contacts.
- `OVERLEAF_LDAP_CONTACTS_SEARCH_BASE`
* Specifies the base DN from which to start searching for the contacts. Defaults to `OVERLEAF_LDAP_SEARCH_BASE`.
- `OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE`
* The scope of the search can be `base`, `one`, or `sub` (default).
- `OVERLEAF_LDAP_CONTACTS_PROPERTY`
* Specifies the property of the user object that will replace the '{{userProperty}}' placeholder in the `OVERLEAF_LDAP_CONTACTS_FILTER`.
- `OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE`
* Specifies the value of the `OVERLEAF_LDAP_CONTACTS_PROPERTY` if the search is initiated by a non-LDAP user. If this variable is not defined, the resulting filter
will match nothing. The value `*` can be used as a wildcard.
<details>
<summary><h5>Example</h5></summary>
OVERLEAF_LDAP_CONTACTS_FILTER=(gidNumber={{userProperty}})
OVERLEAF_LDAP_CONTACTS_PROPERTY=gidNumber
OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE=1000
The above example results in loading into the contacts of the current LDAP user all LDAP users who have the same UNIX `gid`. Non-LDAP users will have all LDAP users with UNIX `gid=1000` in their contacts.
</details>
<details>
<summary><h4>Sample variables.env file</h4></summary>
```
OVERLEAF_APP_NAME="Our Overleaf Instance"
ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
# Enables Thumbnail generation using ImageMagick
ENABLE_CONVERSIONS=true
# Disables email confirmation requirement
EMAIL_CONFIRMATION_DISABLED=true
## Nginx
# NGINX_WORKER_PROCESSES=4
# NGINX_WORKER_CONNECTIONS=768
## Set for TLS via nginx-proxy
# OVERLEAF_BEHIND_PROXY=true
# OVERLEAF_SECURE_COOKIE=true
OVERLEAF_SITE_URL=http://my-overleaf-instance.com
OVERLEAF_NAV_TITLE=Our Overleaf Instance
# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
OVERLEAF_ADMIN_EMAIL=support@example.com
OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
OVERLEAF_EMAIL_SMTP_PORT=587
OVERLEAF_EMAIL_SMTP_SECURE=false
# OVERLEAF_EMAIL_SMTP_USER=
# OVERLEAF_EMAIL_SMTP_PASS=
# OVERLEAF_EMAIL_SMTP_NAME=
OVERLEAF_EMAIL_SMTP_LOGGER=false
OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
OVERLEAF_PROXY_LEARN=true
NAV_HIDE_POWERED_BY=true
#################
## LDAP for CE ##
#################
EXTERNAL_AUTH=ldap
OVERLEAF_LDAP_URL=ldap://ldap.example.com:389
OVERLEAF_LDAP_STARTTLS=true
OVERLEAF_LDAP_TLS_OPTS_CA_PATH=/overleaf/certs/ldap_ca_cert.pem
OVERLEAF_LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
OVERLEAF_LDAP_SEARCH_FILTER=(|(uid={{username}})(mail={{username}}))
OVERLEAF_LDAP_BIND_DN=cn=ldap_reader,dc=example,dc=com
OVERLEAF_LDAP_BIND_CREDENTIALS=GoodNewsEveryone
OVERLEAF_LDAP_EMAIL_ATT=mail
OVERLEAF_LDAP_FIRST_NAME_ATT=givenName
OVERLEAF_LDAP_LAST_NAME_ATT=sn
# OVERLEAF_LDAP_NAME_ATT=cn
OVERLEAF_LDAP_SEARCH_ATTRIBUTES=["uid", "sn", "givenName", "mail"]
OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN=true
OVERLEAF_LDAP_PLACEHOLDER='Username or email address'
OVERLEAF_LDAP_IS_ADMIN_ATT=mail
OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE=admin@example.com
OVERLEAF_LDAP_CONTACTS_FILTER=(gidNumber={{userProperty}})
OVERLEAF_LDAP_CONTACTS_PROPERTY=gidNumber
OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE='*'
```
</details>
<details>
<summary><i>Deprecated variables</i></summary>
**These variables will be removed soon**, use `OVERLEAF_LDAP_IS_ADMIN_ATT` and `OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE` instead.
The following variables are used to determine if the user has admin rights.
Please note: the user gains admin status if the search result is not empty, not when the user is explicitly included in the search results.
- `OVERLEAF_LDAP_ADMIN_SEARCH_BASE`
* Specifies the base DN from which to start searching for the admin group. If this variable is defined,
`OVERLEAF_LDAP_ADMIN_SEARCH_FILTER` must also be defined for the search to function properly.
- `OVERLEAF_LDAP_ADMIN_SEARCH_FILTER`
* Defines the LDAP search filter used to identify the admin group. The placeholder `{{dn}}` within the filter
is replaced with the value of the property specified by `OVERLEAF_LDAP_ADMIN_DN_PROPERTY`. The placeholder `{{username}}` is also supported.
- `OVERLEAF_LDAP_ADMIN_DN_PROPERTY`
* Specifies the property of the user object that will replace the '{{dn}}' placeholder
in the `OVERLEAF_LDAP_ADMIN_SEARCH_FILTER`, defaults to `dn`.
- `OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE`
* The scope of the LDAP search can be `base`, `one`, or `sub` (default)
<details>
<summary><h5>Example</h5></summary>
In the following example admins are members of a group `admins`, the objectClass of the entry `admins` is `groupOfNames`:
OVERLEAF_LDAP_ADMIN_SEARCH_BASE='cn=admins,ou=group,dc=example,dc=com'
OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(member={{dn}})'
In the following example admins are members of a group 'admins', the objectClass of the entry `admins` is `posixGroup`:
OVERLEAF_LDAP_ADMIN_SEARCH_BASE='cn=admins,ou=group,dc=example,dc=com'
OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(memberUid={{username}})'
In the following example admins are users with UNIX gid=1234:
OVERLEAF_LDAP_ADMIN_SEARCH_BASE='ou=people,dc=example,dc=com'
OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(&(gidNumber=1234)(uid={{username}}))'
In the following example admin is the user with `uid=someuser`:
OVERLEAF_LDAP_ADMIN_SEARCH_BASE='ou=people,dc=example,dc=com'
OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(&(uid=someuser)(uid={{username}}))'
The filter
OVERLEAF_LDAP_ADMIN_SEARCH_FILTER='(uid=someuser)'
where `someuser` is the uid of an existing user, will always produce a non-empty search result.
As a result, **every user will be granted admin rights**, not just `someuser`, as one might expect.
</details>
</details>
</details>
<details>
<summary><h3>SAML Authentication</h3></summary>
Internally, Overleaf SAML module uses the [passport-saml](https://github.com/node-saml/passport-saml) library, most of the following
configuration options are passed through to `passport-saml`. If you are having issues configuring SAML, it is worth reading the README
for `passport-saml` to get a feel for the configuration it expects.
When using the SAML authentication method, a user is redirected to the Identity Provider (IdP) authentication site.
If the IdP successfully authenticates the user, the Overleaf users database is checked for a record containing a `samlIdentifiers` field structured as follows:
```
samlIdentifiers: [
{
externalUserId: "...",
providerId: "1",
userIdAttribute: "..."
}
]
```
The `externalUserId` must match the value of the property specified by `userIdAttribute` in the user profile returned by the IdP server.
If no matching record is found, the database is searched for a user with the primary email address matching the email in the IdP user profile:
- If such a user is found, the `hashedPassword` field is deleted to disable local authentication, and the `samlIdentifiers` field is added.
- If no matching user is found, a new user is created with the email address and `samlIdentifiers` from the IdP profile.
**Note:** Currently, only one SAML IdP is supported. The `providerId` field in `samlIdentifiers` is fixed to `'1'`.
#### Environment Variables
- `OVERLEAF_SAML_IDENTITY_SERVICE_NAME`
* Display name for the identity service, used on the login page (default: `Log in with SAML IdP`).
- `OVERLEAF_SAML_USER_ID_FIELD`
* The value of this attribute will be used by Overleaf as the external user ID, defaults to `nameID`.
- `OVERLEAF_SAML_EMAIL_FIELD`
* Name of the Email field in user profile, defaults to `nameID`.
- `OVERLEAF_SAML_FIRST_NAME_FIELD`
* Name of the firstName field in user profile, defaults to `givenName`.
- `OVERLEAF_SAML_LAST_NAME_FIELD`
* Name of the lastName field in user profile, defaults to `lastName`
- `OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN`
* If set to `true`, updates the user `first_name` and `last_name` field on login,
and turn off the user details form on `/user/settings` page.
- `OVERLEAF_SAML_ENTRYPOINT` **(required)**
* Entrypoint URL for the SAML identity service.
- Example: `https://idp.example.com/simplesaml/saml2/idp/SSOService.php`
- Azure Example: `https://login.microsoftonline.com/8b26b46a-6dd3-45c7-a104-f883f4db1f6b/saml2`
- `OVERLEAF_SAML_ISSUER` **(required)**
* The Issuer name.
- `OVERLEAF_SAML_AUDIENCE`
* Expected saml response Audience, defaults to value of `OVERLEAF_SAML_ISSUER`.
- `OVERLEAF_SAML_IDP_CERT` **(required)**
* Path to a file containing the Identity Provider's public certificate, used to validate the signatures of incoming SAML responses. If the Identity Provider has multiple valid signing certificates, then
it can be a JSON array of paths to the certificates.
- Example (one certificate): `/overleaf/certs/idp_cert.pem`
- Example (multiple certificates): `["/overleaf/certs/idp_cert.pem", "/overleaf/certs/idp_cert_old.pem"]`
- `OVERLEAF_SAML_PUBLIC_CERT`
* Path to a file containing public signing certificate used to embed in auth requests in order for the IdP to validate the signatures of the incoming SAML Request. It's required when setting up the [metadata endpoint](#metadata-for-the-identity-provider)
when the strategy is configured with a `OVERLEAF_SAML_PRIVATE_KEY`. A JSON array of paths to certificates can be provided to support certificate rotation. When supplying an array of certificates, the first entry in the array should match the
current `OVERLEAF_SAML_PRIVATE_KEY`. Additional entries in the array can be used to publish upcoming certificates to IdPs before changing the `OVERLEAF_SAML_PRIVATE_KEY`.
- `OVERLEAF_SAML_PRIVATE_KEY`
* Path to a file containing a PEM-formatted private key matching the `OVERLEAF_SAML_PUBLIC_CERT` used to sign auth requests sent by passport-saml.
- `OVERLEAF_SAML_DECRYPTION_CERT`
* Path to a file containing public certificate, used for the [metadata endpoint](#metadata-for-the-identity-provider).
- `OVERLEAF_SAML_DECRYPTION_PVK`
* Path to a file containing private key matching the `OVERLEAF_SAML_DECRYPTION_CERT` that will be used to attempt to decrypt any encrypted assertions that are received.
- `OVERLEAF_SAML_SIGNATURE_ALGORITHM`
* Optionally set the signature algorithm for signing requests,
valid values are 'sha1' (default), 'sha256' (prefered), 'sha512' (most secure, check if your IdP supports it).
- `OVERLEAF_SAML_ADDITIONAL_PARAMS`
* JSON dictionary of additional query params to add to all requests.
- `OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS`
* JSON dictionary of additional query params to add to 'authorize' requests.
- Example: `{"some_key": "some_value"}`
- `OVERLEAF_SAML_IDENTIFIER_FORMAT`
* Name identifier format to request from the identity provider (default: `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`).
If using `urn:oasis:names:tc:SAML:2.0:nameid-format:persistent`, ensure the `OVERLEAF_SAML_EMAIL_FIELD` envirionment variable is defined.
If `urn:oasis:names:tc:SAML:2.0:nameid-format:transient` is required, you must also define the `OVERLEAF_SAML_ID_FIELD` environment variable,
which can, for example, be set to the user's email address.
- `OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS`
* Time in milliseconds of skew that is acceptable between client and server when checking OnBefore and NotOnOrAfter assertion
condition validity timestamps. Setting to -1 will disable checking these conditions entirely. Default is 0.
- `OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX`
* `AttributeConsumingServiceIndex` attribute to add to AuthnRequest to instruct the IdP which attribute set to attach
to the response ([link](http://blog.aniljohn.com/2014/01/data-minimization-front-channel-saml-attribute-requests.html)).
- `OVERLEAF_SAML_AUTHN_CONTEXT`
* JSON array of name identifier format values to request auth context. Default: `["urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"]`.
- `OVERLEAF_SAML_FORCE_AUTHN`
* If `true`, the initial SAML request from the service provider specifies that the IdP should force re-authentication of the user,
even if they possess a valid session.
- `OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT`
* If `true`, do not request a specific auth context. For example, you can this this to `true` to allow additional contexts such as password-less logins (`urn:oasis:names:tc:SAML:2.0:ac:classes:X509`). Support for additional contexts is dependant on your IdP.
- `OVERLEAF_SAML_AUTHN_REQUEST_BINDING`
* If set to `HTTP-POST`, will request authentication from IdP via HTTP POST binding, otherwise defaults to HTTP-Redirect.
- `OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO`
* If `always`, then InResponseTo will be validated from incoming SAML responses.
* If `never`, then InResponseTo won't be validated (default).
* If `ifPresent`, then InResponseTo will only be validated if present in the incoming SAML response.
- `OVERLEAF_SAML_WANT_ASSERTIONS_SIGNED` and `OVERLEAF_SAML_WANT_AUTHN_RESPONSE_SIGNED`
* When set to `true` (default), Overleaf expects the SAML Assertions, respectively the entire SAML Authentication Response, to be signed by the IdP.
When both options are `false`, at least one of the assertions or the response must be signed.
- `OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS`
* Defines the expiration time when a Request ID generated for a SAML request will not be valid if seen
in a SAML response in the `InResponseTo` field. Default: 28800000 (8 hours).
- `OVERLEAF_SAML_LOGOUT_URL`
* base address to call with logout requests (default: `entryPoint`).
- Example: `https://idp.example.com/simplesaml/saml2/idp/SingleLogoutService.php`
- `OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS`
* JSON dictionary of additional query params to add to 'logout' requests.
- `OVERLEAF_SAML_IS_ADMIN_FIELD` and `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE`
* When both environment variables are set, the login process updates `user.isAdmin = true` if the profile returned by the SAML IdP contains the attribute specified by
`OVERLEAF_SAML_IS_ADMIN_FIELD` and its value either matches `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE` or is an array containing `OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE`,
otherwise `user.isAdmin` is set to `false`. If either of these variables is not set, then the admin status is only set to `true` during admin user
creation in Launchpad.
#### Metadata for the Identity Provider
The current version of Overleaf CE includes and endpoint to retrieve Service Provider Metadata: `http://my-overleaf-instance.com/saml/meta`
The Identity Provider will need to be configured to recognize the Overleaf server as a "Service Provider". Consult the documentation for your SAML server for instructions on how to do this.
Below is an example of appropriate Service Provider metadata:
<details>
<summary><h5>ol-meta.xml</h5></summary>
```
<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="MyOverleaf"
ID="_b508c83b7dda452f5b269383fb391107116f8f57">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol" AuthnRequestsSigned="true" WantAssertionsSigned="true">
<KeyDescriptor use="signing">
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>MII...
[skipped]
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</KeyDescriptor>
<KeyDescriptor use="encryption">
<ds:KeyInfo>
<ds:X509Data>
<ds:X509Certificate>MII...
[skipped]
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes256-gcm"/>
<EncryptionMethod Algorithm="http://www.w3.org/2009/xmlenc11#aes128-gcm"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
</KeyDescriptor>
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://my-overleaf-instance.com/saml/logout/callback"/>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
<AssertionConsumerService index="1"
isDefault="true"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://my-overleaf-instance.com/saml/login/callback"/>
</SPSSODescriptor>
</EntityDescriptor>
```
</details>
Note the certificates, `AssertionConsumerService.Location`, `SingleLogoutService.Location` and `EntityDescriptor.entityID`
and set as appropriate in your IdP configuration, or send the metadata file to the IdP admin.
<details>
<summary><h4>Sample variables.env file</h4></summary>
```
OVERLEAF_APP_NAME="Our Overleaf Instance"
ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
# Enables Thumbnail generation using ImageMagick
ENABLE_CONVERSIONS=true
# Disables email confirmation requirement
EMAIL_CONFIRMATION_DISABLED=true
## Nginx
# NGINX_WORKER_PROCESSES=4
# NGINX_WORKER_CONNECTIONS=768
## Set for TLS via nginx-proxy
# OVERLEAF_BEHIND_PROXY=true
# OVERLEAF_SECURE_COOKIE=true
OVERLEAF_SITE_URL=http://my-overleaf-instance.com
OVERLEAF_NAV_TITLE=Our Overleaf Instance
# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
OVERLEAF_ADMIN_EMAIL=support@example.com
OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
OVERLEAF_EMAIL_SMTP_PORT=587
OVERLEAF_EMAIL_SMTP_SECURE=false
# OVERLEAF_EMAIL_SMTP_USER=
# OVERLEAF_EMAIL_SMTP_PASS=
# OVERLEAF_EMAIL_SMTP_NAME=
OVERLEAF_EMAIL_SMTP_LOGGER=false
OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
OVERLEAF_PROXY_LEARN=true
NAV_HIDE_POWERED_BY=true
#################
## SAML for CE ##
#################
EXTERNAL_AUTH=saml
OVERLEAF_SAML_IDENTITY_SERVICE_NAME='Log in with My IdP'
OVERLEAF_SAML_EMAIL_FIELD=mail
OVERLEAF_SAML_FIRST_NAME_FIELD=givenName
OVERLEAF_SAML_LAST_NAME_FIELD=sn
OVERLEAF_SAML_ENTRYPOINT=https://idp.example.com/simplesamlphp/saml2/idp/SSOService.php
OVERLEAF_SAML_CALLBACK_URL=https://my-overleaf-instance.com/saml/login/callback
OVERLEAF_SAML_LOGOUT_URL=https://idp.example.com/simplesamlphp/saml2/idp/SingleLogoutService.php
OVERLEAF_SAML_LOGOUT_CALLBACK_URL=https://my-overleaf-instance.com/saml/logout/callback
OVERLEAF_SAML_ISSUER=MyOverleaf
OVERLEAF_SAML_IDP_CERT=/overleaf/certs/idp_cert.pem
OVERLEAF_SAML_PUBLIC_CERT=/overleaf/certs/myol_cert.pem
OVERLEAF_SAML_PRIVATE_KEY=/overleaf/certs/myol_key.pem
OVERLEAF_SAML_DECRYPTION_CERT=/overleaf/certs/myol_decr_cert.pem
OVERLEAF_SAML_DECRYPTION_PVK=/overleaf/certs/myol_decr_key.pem
OVERLEAF_SAML_IS_ADMIN_FIELD=mail
OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE=overleaf.admin@example.com
```
</details>
</details>
<details>
<summary><h3>OIDC Authentication</h3></summary>
Internally, Overleaf OIDC module uses the [passport-openidconnect](https://github.com/jaredhanson/passport-openidconnect) library.
If you are having issues configuring OpenID Connect, it is worth reading the README for `passport-openidconnect` to get a feel for the configuration it expects.
When using the OIDC authentication method, a user is redirected to the Identity Provider (IdP) authentication site.
If the IdP successfully authenticates the user, the Overleaf users database is checked for a record containing a `thirdPartyIdentifiers` field structured as follows:
```
thirdPartyIdentifiers: [
{
externalUserId: "...",
externalData: null,
providerId: "..."
}
]
```
The `externalUserId` must match the user ID in the profile returned by the IdP server (see the `OVERLEAF_OIDC_USER_ID_FIELD` environment variable), and `providerId`
must match the ID of the OIDC provider (see the `OVERLEAF_OIDC_PROVIDER_ID`).
If no matching record is found, the database is searched for a user with the primary email address matching the email in the IdP user profile:
- If such a user is found, the `thirdPartyIdentifiers` field is updated.
- If no matching user is found, a new user is created with the email address and `thirdPartyIdentifiers` from the IdP profile.
In both cases, the user is said to be 'linked' to the external OIDC user. The user can be unlinked from the OIDC provider on the `/user/settings` page.
#### Environment Variables
The values of the following five required variables can be found using `.well-known/openid-configuration` endpoint of your OpenID Provider (OP).
- `OVERLEAF_OIDC_ISSUER` **(required)**
- `OVERLEAF_OIDC_AUTHORIZATION_URL` **(required)**
- `OVERLEAF_OIDC_TOKEN_URL` **(required)**
- `OVERLEAF_OIDC_USER_INFO_URL` **(required)**
- `OVERLEAF_OIDC_LOGOUT_URL` **(required)**
The values of the following two required variables will be provided by the admin of your OP
- `OVERLEAF_OIDC_CLIENT_ID` **(required)**
- `OVERLEAF_OIDC_CLIENT_SECRET` **(required)**
- `OVERLEAF_OIDC_SCOPE`
* Default: `openid profile email`
- `OVERLEAF_OIDC_PROVIDER_ID`
* Arbitrary ID of the OP, defaults to `oidc`.
- `OVERLEAF_OIDC_PROVIDER_NAME`
* The name of the OP, used in the `Linked Accounts` section of the `/user/settings` page, defaults to `OIDC Provider`.
- `OVERLEAF_OIDC_IDENTITY_SERVICE_NAME`
* Display name for the identity service, used on the login page (default: `Log in with $OVERLEAF_OIDC_PROVIDER_NAME`).
- `OVERLEAF_OIDC_PROVIDER_DESCRIPTION`
* Description of OP, used in the `Linked Accounts` section (default: `Log in with $OVERLEAF_OIDC_PROVIDER_NAME`).
- `OVERLEAF_OIDC_PROVIDER_INFO_LINK`
* `Learn more` URL in the OP description, default: no `Learn more` link in the description.
- `OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED`
* Do not show OP on the `/user/settings` page, if the user's account is not linked with the OP, default `false`.
- `OVERLEAF_OIDC_USER_ID_FIELD`
* The value of this attribute will be used by Overleaf as the external user ID, defaults to `id`.
Other possible reasonable values are `email` and `username` (corresponding to `preferred_username` OIDC claim).
- `OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN`
* If set to `true`, updates the user `first_name` and `last_name` field on login,
and disables the user details form on `/user/settings` page.
- `OVERLEAF_OIDC_IS_ADMIN_FIELD` and `OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE`
* When both environment variables are set, the login process updates `user.isAdmin = true` if the profile returned by the OP contains the attribute specified by
`OVERLEAF_OIDC_IS_ADMIN_FIELD` and its value matches `OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE`, otherwise `user.isAdmin` is set to `false`.
If `OVERLEAF_OIDC_IS_ADMIN_FIELD` is `email` then the value of the attribute `emails[0].value` is used for match checking.
The redirect URL for your OpenID Provider is `https://my-overleaf-instance.com/oidc/login/callback`.
<details>
<summary><h4>Sample variables.env file</h4></summary>
```
OVERLEAF_APP_NAME="Our Overleaf Instance"
ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
# Enables Thumbnail generation using ImageMagick
ENABLE_CONVERSIONS=true
# Disables email confirmation requirement
EMAIL_CONFIRMATION_DISABLED=true
## Nginx
# NGINX_WORKER_PROCESSES=4
# NGINX_WORKER_CONNECTIONS=768
## Set for TLS via nginx-proxy
# OVERLEAF_BEHIND_PROXY=true
# OVERLEAF_SECURE_COOKIE=true
OVERLEAF_SITE_URL=http://my-overleaf-instance.com
OVERLEAF_NAV_TITLE=Our Overleaf Instance
# OVERLEAF_HEADER_IMAGE_URL=http://somewhere.com/mylogo.png
OVERLEAF_ADMIN_EMAIL=support@example.com
OVERLEAF_LEFT_FOOTER=[{"text": "Contact your support team", "url": "mailto:support@example.com"}]
OVERLEAF_RIGHT_FOOTER=[{"text":"Hello, I am on the Right", "url":"https://github.com/yu-i-i/overleaf-cep"}]
OVERLEAF_EMAIL_FROM_ADDRESS=team@example.com
OVERLEAF_EMAIL_SMTP_HOST=smtp.example.com
OVERLEAF_EMAIL_SMTP_PORT=587
OVERLEAF_EMAIL_SMTP_SECURE=false
# OVERLEAF_EMAIL_SMTP_USER=
# OVERLEAF_EMAIL_SMTP_PASS=
# OVERLEAF_EMAIL_SMTP_NAME=
OVERLEAF_EMAIL_SMTP_LOGGER=false
OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH=true
OVERLEAF_EMAIL_SMTP_IGNORE_TLS=false
OVERLEAF_CUSTOM_EMAIL_FOOTER=This system is run by department x
OVERLEAF_PROXY_LEARN=true
NAV_HIDE_POWERED_BY=true
#################
## OIDC for CE ##
#################
EXTERNAL_AUTH=oidc
OVERLEAF_OIDC_PROVIDER_ID=oidc
OVERLEAF_OIDC_ISSUER=https://keycloak.provider.com/realms/example
OVERLEAF_OIDC_AUTHORIZATION_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/auth
OVERLEAF_OIDC_TOKEN_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/token
OVERLEAF_OIDC_USER_INFO_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/userinfo
OVERLEAF_OIDC_LOGOUT_URL=https://keycloak.provider.com/realms/example/protocol/openid-connect/logout
OVERLEAF_OIDC_CLIENT_ID=Overleaf-OIDC
OVERLEAF_OIDC_CLIENT_SECRET=DoNotUseThisATGgaAcTgCcATgGATTACAagGtTCaGcGTAG
OVERLEAF_OIDC_IDENTITY_SERVICE_NAME='Log in with Keycloak OIDC Provider'
OVERLEAF_OIDC_PROVIDER_NAME=OIDC Keycloak Provider
OVERLEAF_OIDC_PROVIDER_INFO_LINK=https://openid.net
OVERLEAF_OIDC_IS_ADMIN_FIELD=email
OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE=overleaf.admin@example.com
OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN=false
```
</details>
</details>
## Overleaf Docker Image
@@ -65,13 +902,11 @@ in which to run the Overleaf services. Baseimage uses the `runit` service
manager to manage services, and we add our init-scripts from the `server-ce/runit`
folder.
## Contributing
Please see the [CONTRIBUTING](CONTRIBUTING.md) file for information on contributing to the development of Overleaf.
## Authors
[The Overleaf Team](https://www.overleaf.com/about)
<br>
Extensions for CE by: [yu-i-i](https://github.com/yu-i-i/overleaf-cep)
## License
+3
View File
@@ -0,0 +1,3 @@
/compiles/*
!.gitkeep
.env
+90
View File
@@ -0,0 +1,90 @@
# Overleaf Community Edition, development environment
## Building and running
In this `develop` directory, build the services:
```shell
bin/build
```
> [!NOTE]
> If Docker is running out of RAM while building the services in parallel, create a `.env` file in this directory containing `COMPOSE_PARALLEL_LIMIT=1`.
Then start the services:
```shell
bin/up
```
Once the services are running, open <http://localhost/launchpad> to create the first admin account.
## TeX Live
Compiling a PDF requires building a TeX Live image to handle the compilation inside Docker:
```shell
docker build texlive -t texlive-full
```
> [!NOTE]
> To compile on a macOS host, you may need to override the path to the Docker socket by creating a `.env` file in this directory, containing
> `DOCKER_SOCKET_PATH=/var/run/docker.sock.raw`
## Development
To avoid running `bin/build && bin/up` after every code change, you can run Overleaf
Community Edition in _development mode_, where services will automatically update on code changes.
To do this, use the included `bin/dev` script:
```shell
bin/dev
```
This will start all services using `nodemon`, which will automatically monitor the code and restart the services as necessary.
To improve performance, you can start only a subset of the services in development mode by providing a space-separated list to the `bin/dev` script:
```shell
bin/dev [service1] [service2] ... [serviceN]
```
> [!NOTE]
> Starting the `web` service in _development mode_ will only update the `web`
> service when backend code changes. In order to automatically update frontend
> code as well, make sure to start the `webpack` service in _development mode_
> as well.
If no services are named, all services will start in development mode.
## Debugging
When run in _development mode_ most services expose a debugging port to which
you can attach a debugger such as
[the inspector in Chrome's Dev Tools](chrome://inspect/) or one integrated into
an IDE. The following table shows the port exposed on the **host machine** for
each service:
| Service | Port |
| ------------------ | ---- |
| `web` | 9229 |
| `clsi` | 9230 |
| `chat` | 9231 |
| `contacts` | 9232 |
| `docstore` | 9233 |
| `document-updater` | 9234 |
| `filestore` | 9235 |
| `notifications` | 9236 |
| `real-time` | 9237 |
| `references` | 9238 |
| `history-v1` | 9239 |
| `project-history` | 9240 |
To attach to a service using Chrome's _remote debugging_, go to
<chrome://inspect/> and make sure _Discover network targets_ is checked. Next
click _Configure..._ and add an entry `localhost:[service port]` for each of the
services you want to attach a debugger to.
After adding an entry, the service will show up as a _Remote Target_ that you
can inspect and debug.
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker compose build --pull "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --no-deps --detach "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker compose down "$@"
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
docker compose logs --follow --tail 10 --no-color "$@" \
| ggrep --line-buffered --invert-match "global.gc" \
| ggrep --line-buffered --invert-match "health.check" \
| ggrep --line-buffered --invert-match "slow event loop" \
| ggrep --line-buffered --invert-match "process.memoryUsage" \
| ggrep --line-buffered --only-matching "[{].*" \
| bunyan --output short
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker compose exec -it "$@" /bin/bash
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker compose up --detach "$@"
View File
+21
View File
@@ -0,0 +1,21 @@
CHAT_HOST=chat
CLSI_HOST=clsi
CONTACTS_HOST=contacts
DOCSTORE_HOST=docstore
DOCUMENT_UPDATER_HOST=document-updater
FILESTORE_HOST=filestore
GRACEFUL_SHUTDOWN_DELAY_SECONDS=0
HISTORY_V1_HOST=history-v1
LISTEN_ADDRESS=0.0.0.0
MONGO_HOST=mongo
MONGO_URL=mongodb://mongo/sharelatex?directConnection=true
NOTIFICATIONS_HOST=notifications
PROJECT_HISTORY_HOST=project-history
REALTIME_HOST=real-time
REDIS_HOST=redis
REFERENCES_HOST=references
SESSION_SECRET=foo
WEBPACK_HOST=webpack
WEB_API_PASSWORD=overleaf
WEB_API_USER=overleaf
WEB_HOST=web
+150
View File
@@ -0,0 +1,150 @@
services:
clsi:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9230:9229"
volumes:
- ../services/clsi/app:/overleaf/services/clsi/app
- ../services/clsi/app.js:/overleaf/services/clsi/app.js
- ../services/clsi/config:/overleaf/services/clsi/config
chat:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9231:9229"
volumes:
- ../services/chat/app:/overleaf/services/chat/app
- ../services/chat/app.js:/overleaf/services/chat/app.js
- ../services/chat/config:/overleaf/services/chat/config
contacts:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9232:9229"
volumes:
- ../services/contacts/app:/overleaf/services/contacts/app
- ../services/contacts/app.js:/overleaf/services/contacts/app.js
- ../services/contacts/config:/overleaf/services/contacts/config
docstore:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9233:9229"
volumes:
- ../services/docstore/app:/overleaf/services/docstore/app
- ../services/docstore/app.js:/overleaf/services/docstore/app.js
- ../services/docstore/config:/overleaf/services/docstore/config
document-updater:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9234:9229"
volumes:
- ../services/document-updater/app:/overleaf/services/document-updater/app
- ../services/document-updater/app.js:/overleaf/services/document-updater/app.js
- ../services/document-updater/config:/overleaf/services/document-updater/config
filestore:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9235:9229"
volumes:
- ../services/filestore/app:/overleaf/services/filestore/app
- ../services/filestore/app.js:/overleaf/services/filestore/app.js
- ../services/filestore/config:/overleaf/services/filestore/config
history-v1:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9239:9229"
volumes:
- ../services/history-v1/api:/overleaf/services/history-v1/api
- ../services/history-v1/app.js:/overleaf/services/history-v1/app.js
- ../services/history-v1/config:/overleaf/services/history-v1/config
- ../services/history-v1/storage:/overleaf/services/history-v1/storage
- ../services/history-v1/knexfile.js:/overleaf/services/history-v1/knexfile.js
- ../services/history-v1/migrations:/overleaf/services/history-v1/migrations
notifications:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9236:9229"
volumes:
- ../services/notifications/app:/overleaf/services/notifications/app
- ../services/notifications/app.js:/overleaf/services/notifications/app.js
- ../services/notifications/config:/overleaf/services/notifications/config
project-history:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9240:9229"
volumes:
- ../services/project-history/app:/overleaf/services/project-history/app
- ../services/project-history/app.js:/overleaf/services/project-history/app.js
- ../services/project-history/config:/overleaf/services/project-history/config
real-time:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9237:9229"
volumes:
- ../services/real-time/app:/overleaf/services/real-time/app
- ../services/real-time/app.js:/overleaf/services/real-time/app.js
- ../services/real-time/config:/overleaf/services/real-time/config
references:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9238:9229"
volumes:
- ../services/references/app:/overleaf/services/references/app
- ../services/references/config:/overleaf/services/references/config
- ../services/references/app.js:/overleaf/services/references/app.js
web:
command: ["node", "--watch", "app.js", "--watch-locales"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9229:9229"
volumes:
- ../services/web/app:/overleaf/services/web/app
- ../services/web/app.mjs:/overleaf/services/web/app.mjs
- ../services/web/config:/overleaf/services/web/config
- ../services/web/locales:/overleaf/services/web/locales
- ../services/web/modules:/overleaf/services/web/modules
- ../services/web/public:/overleaf/services/web/public
webpack:
volumes:
- ../services/web/app:/overleaf/services/web/app
- ../services/web/config:/overleaf/services/web/config
- ../services/web/frontend:/overleaf/services/web/frontend
- ../services/web/locales:/overleaf/services/web/locales
- ../services/web/modules:/overleaf/services/web/modules
- ../services/web/public:/overleaf/services/web/public
- ../services/web/transform:/overleaf/services/web/transform
- ../services/web/types:/overleaf/services/web/types
- ../services/web/webpack-plugins:/overleaf/services/web/webpack-plugins
+182
View File
@@ -0,0 +1,182 @@
volumes:
clsi-cache:
clsi-output:
filestore-public-files:
filestore-template-files:
filestore-uploads:
filestore-user-files:
mongo-data:
redis-data:
sharelatex-data:
web-data:
history-v1-buckets:
services:
chat:
build:
context: ..
dockerfile: services/chat/Dockerfile
env_file:
- dev.env
clsi:
build:
context: ..
dockerfile: services/clsi/Dockerfile
env_file:
- dev.env
environment:
- DOCKER_RUNNER=true
- TEXLIVE_IMAGE=texlive-full # docker build texlive -t texlive-full
- COMPILES_HOST_DIR=${PWD}/compiles
user: root
volumes:
- ${PWD}/compiles:/overleaf/services/clsi/compiles
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
- clsi-cache:/overleaf/services/clsi/cache
- clsi-output:/overleaf/services/clsi/output
contacts:
build:
context: ..
dockerfile: services/contacts/Dockerfile
env_file:
- dev.env
docstore:
build:
context: ..
dockerfile: services/docstore/Dockerfile
env_file:
- dev.env
document-updater:
build:
context: ..
dockerfile: services/document-updater/Dockerfile
env_file:
- dev.env
filestore:
build:
context: ..
dockerfile: services/filestore/Dockerfile
env_file:
- dev.env
# environment:
# - ENABLE_CONVERSIONS=true
volumes:
- filestore-public-files:/overleaf/services/filestore/public_files
- filestore-template-files:/overleaf/services/filestore/template_files
- filestore-uploads:/overleaf/services/filestore/uploads
- filestore-user-files:/overleaf/services/filestore/user_files
history-v1:
build:
context: ..
dockerfile: services/history-v1/Dockerfile
env_file:
- dev.env
environment:
OVERLEAF_EDITOR_ANALYTICS_BUCKET: "/buckets/analytics"
OVERLEAF_EDITOR_BLOBS_BUCKET: "/buckets/blobs"
OVERLEAF_EDITOR_CHUNKS_BUCKET: "/buckets/chunks"
OVERLEAF_EDITOR_PROJECT_BLOBS_BUCKET: "/buckets/project_blobs"
OVERLEAF_EDITOR_ZIPS_BUCKET: "/buckets/zips"
PERSISTOR_BACKEND: fs
volumes:
- history-v1-buckets:/buckets
mongo:
image: mongo:6.0
command: --replSet overleaf
ports:
- "127.0.0.1:27017:27017" # for debugging
volumes:
- mongo-data:/data/db
- ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1
notifications:
build:
context: ..
dockerfile: services/notifications/Dockerfile
env_file:
- dev.env
project-history:
build:
context: ..
dockerfile: services/project-history/Dockerfile
env_file:
- dev.env
real-time:
build:
context: ..
dockerfile: services/real-time/Dockerfile
env_file:
- dev.env
redis:
image: redis:5
ports:
- "127.0.0.1:6379:6379" # for debugging
volumes:
- redis-data:/data
references:
build:
context: ..
dockerfile: services/references/Dockerfile
env_file:
- dev.env
web:
build:
context: ..
dockerfile: services/web/Dockerfile
target: dev
env_file:
- dev.env
environment:
- APP_NAME=Overleaf Community Edition
- ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
- EMAIL_CONFIRMATION_DISABLED=true
- NODE_ENV=development
- OVERLEAF_ALLOW_PUBLIC_ACCESS=true
command: ["node", "app.mjs"]
volumes:
- sharelatex-data:/var/lib/overleaf
- web-data:/overleaf/services/web/data
depends_on:
- mongo
- redis
- chat
- clsi
- contacts
- docstore
- document-updater
- filestore
- history-v1
- notifications
- project-history
- real-time
- references
webpack:
build:
context: ..
dockerfile: services/web/Dockerfile
target: webpack
command: ["npx", "webpack", "serve", "--config", "webpack.config.dev-env.js"]
ports:
- "127.0.0.1:80:3808"
volumes:
- ./webpack.config.dev-env.js:/overleaf/services/web/webpack.config.dev-env.js
+8
View File
@@ -0,0 +1,8 @@
FROM debian:testing-slim
RUN apt-get update
RUN apt-cache depends texlive-full | grep "Depends: " | grep -v -- "-doc" | grep -v -- "-lang-" | sed 's/Depends: //' | xargs apt-get install -y --no-install-recommends
RUN apt-get install -y --no-install-recommends fontconfig inkscape pandoc python3-pygments
RUN useradd tex
USER tex
+23
View File
@@ -0,0 +1,23 @@
const { merge } = require('webpack-merge')
const base = require('./webpack.config.dev')
module.exports = merge(base, {
devServer: {
allowedHosts: 'auto',
devMiddleware: {
index: false,
},
proxy: [
{
context: '/socket.io/**',
target: 'http://real-time:3026',
ws: true,
},
{
context: ['!**/*.js', '!**/*.css', '!**/*.json'],
target: 'http://web:3000',
},
],
},
})
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

+21 -21
View File
@@ -1,23 +1,23 @@
version: "2.2"
version: '2.2'
services:
sharelatex:
ports:
- 30000:30000
- 30150:30150
- 30120:30120
- 30050:30050
- 30420:30420
- 30030:30030
- 30160:30160
- 30360:30360
- 30130:30130
- 30100:30100
- 30540:30540
- 30640:30640
- 40000:40000
sharelatex:
ports:
- 30000:30000
- 30150:30150
- 30120:30120
- 30050:30050
- 30420:30420
- 30030:30030
- 30160:30160
- 30360:30360
- 30130:30130
- 30100:30100
- 30540:30540
- 30640:30640
- 40000:40000
# Server Pro
- 30070:30070
- 30400:30400
environment:
DEBUG_NODE: "true"
# Server Pro
- 30070:30070
- 30400:30400
environment:
DEBUG_NODE: 'true'
+120 -130
View File
@@ -1,154 +1,144 @@
version: '2.2'
services:
sharelatex:
restart: always
# Server Pro users:
# image: quay.io/sharelatex/sharelatex-pro
image: sharelatex/sharelatex
container_name: sharelatex
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_started
ports:
- 80:80
stop_grace_period: 60s
volumes:
- ~/sharelatex_data:/var/lib/overleaf
########################################################################
#### Server Pro: Uncomment the following line to mount the docker ####
#### socket, required for Sibling Containers to work ####
########################################################################
# - /var/run/docker.sock:/var/run/docker.sock
environment:
OVERLEAF_APP_NAME: Overleaf Community Edition
sharelatex:
restart: always
# Server Pro users:
# image: quay.io/sharelatex/sharelatex-pro
image: sharelatex/sharelatex
container_name: sharelatex
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_started
ports:
- 80:80
stop_grace_period: 60s
volumes:
- ~/sharelatex_data:/var/lib/overleaf
########################################################################
#### Server Pro: Uncomment the following line to mount the docker ####
#### socket, required for Sibling Containers to work ####
########################################################################
# - /var/run/docker.sock:/var/run/docker.sock
environment:
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
OVERLEAF_APP_NAME: Overleaf Community Edition
# Same property, unfortunately with different names in
# different locations
OVERLEAF_REDIS_HOST: redis
REDIS_HOST: redis
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
# IMPORTANT: Required for invite token generation
# OVERLEAF_INVITE_TOKEN_SECRET: generate with `openssl rand -base64 32`
# Same property, unfortunately with different names in
# different locations
OVERLEAF_REDIS_HOST: redis
REDIS_HOST: redis
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file"
ENABLED_LINKED_FILE_TYPES: 'project_file,project_output_file'
# Enables Thumbnail generation using ImageMagick
ENABLE_CONVERSIONS: "true"
# Enables Thumbnail generation using ImageMagick
ENABLE_CONVERSIONS: 'true'
# Disables email confirmation requirement
EMAIL_CONFIRMATION_DISABLED: "true"
# Disables email confirmation requirement
EMAIL_CONFIRMATION_DISABLED: 'true'
## Set for SSL via nginx-proxy
#VIRTUAL_HOST: 103.112.212.22
## Set for SSL via nginx-proxy
#VIRTUAL_HOST: 103.112.212.22
# OVERLEAF_SITE_URL: http://overleaf.example.com
# OVERLEAF_NAV_TITLE: Overleaf Community Edition
# OVERLEAF_HEADER_IMAGE_URL: http://example.com/mylogo.png
# OVERLEAF_HEADER_IMAGE_URL_LIGHT: http://example.com/mylogo-light.png
# OVERLEAF_HEADER_IMAGE_URL_DARK: http://example.com/mylogo-dark.png
# OVERLEAF_ADMIN_EMAIL: support@it.com
# OVERLEAF_SITE_URL: http://overleaf.example.com
# OVERLEAF_NAV_TITLE: Overleaf Community Edition
# OVERLEAF_HEADER_IMAGE_URL: http://example.com/mylogo.png
# OVERLEAF_ADMIN_EMAIL: support@it.com
# OVERLEAF_LEFT_FOOTER: '[{"text": "Another page I want to link to can be found <a href=\"here\">here</a>"} ]'
# OVERLEAF_RIGHT_FOOTER: '[{"text": "Hello I am on the Right"} ]'
# OVERLEAF_LEFT_FOOTER: '[{"text": "Another page I want to link to can be found <a href=\"here\">here</a>"} ]'
# OVERLEAF_RIGHT_FOOTER: '[{"text": "Hello I am on the Right"} ]'
# OVERLEAF_EMAIL_FROM_ADDRESS: "hello@example.com"
# OVERLEAF_EMAIL_FROM_ADDRESS: "hello@example.com"
# OVERLEAF_EMAIL_AWS_SES_ACCESS_KEY_ID:
# OVERLEAF_EMAIL_AWS_SES_SECRET_KEY:
# OVERLEAF_EMAIL_AWS_SES_ACCESS_KEY_ID:
# OVERLEAF_EMAIL_AWS_SES_SECRET_KEY:
# OVERLEAF_EMAIL_SMTP_HOST: smtp.example.com
# OVERLEAF_EMAIL_SMTP_PORT: 587
# OVERLEAF_EMAIL_SMTP_SECURE: false
# OVERLEAF_EMAIL_SMTP_USER:
# OVERLEAF_EMAIL_SMTP_PASS:
# OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH: true
# OVERLEAF_EMAIL_SMTP_IGNORE_TLS: false
# OVERLEAF_EMAIL_SMTP_NAME: '127.0.0.1'
# OVERLEAF_EMAIL_SMTP_LOGGER: true
# OVERLEAF_CUSTOM_EMAIL_FOOTER: "This system is run by department x"
# OVERLEAF_EMAIL_SMTP_HOST: smtp.example.com
# OVERLEAF_EMAIL_SMTP_PORT: 587
# OVERLEAF_EMAIL_SMTP_SECURE: false
# OVERLEAF_EMAIL_SMTP_USER:
# OVERLEAF_EMAIL_SMTP_PASS:
# OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH: true
# OVERLEAF_EMAIL_SMTP_IGNORE_TLS: false
# OVERLEAF_EMAIL_SMTP_NAME: '127.0.0.1'
# OVERLEAF_EMAIL_SMTP_LOGGER: true
# OVERLEAF_CUSTOM_EMAIL_FOOTER: "This system is run by department x"
# ENABLE_CRON_RESOURCE_DELETION: true
# ENABLE_CRON_RESOURCE_DELETION: true
################
## Server Pro ##
################
################
## Server Pro ##
################
## The Community Edition is intended for use in environments where all users are trusted and is not appropriate for
## scenarios where isolation of users is required. Sandboxed Compiles are not available in the Community Edition,
## so the following environment variables must be commented out to avoid compile issues.
##
## Sandboxed Compiles: https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles
SANDBOXED_COMPILES: "true"
### Bind-mount source for /var/lib/overleaf/data/compiles inside the container.
SANDBOXED_COMPILES_HOST_DIR_COMPILES: "/home/user/sharelatex_data/data/compiles"
### Bind-mount source for /var/lib/overleaf/data/output inside the container.
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: "/home/user/sharelatex_data/data/output"
### Backwards compatibility (before Server Pro 5.5)
DOCKER_RUNNER: "true"
SANDBOXED_COMPILES_SIBLING_CONTAINERS: "true"
## Sandboxed Compiles: https://github.com/overleaf/overleaf/wiki/Server-Pro:-Sandboxed-Compiles
SANDBOXED_COMPILES: 'true'
SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true'
### Bind-mount source for /var/lib/overleaf/data/compiles inside the container.
SANDBOXED_COMPILES_HOST_DIR: '/home/user/sharelatex_data/data/compiles'
## Works with test LDAP server shown at bottom of docker compose
# OVERLEAF_LDAP_URL: 'ldap://ldap:389'
# OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com'
# OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})'
# OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com'
# OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone'
# OVERLEAF_LDAP_EMAIL_ATT: 'mail'
# OVERLEAF_LDAP_NAME_ATT: 'cn'
# OVERLEAF_LDAP_LAST_NAME_ATT: 'sn'
# OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true'
## Works with test LDAP server shown at bottom of docker compose
# OVERLEAF_LDAP_URL: 'ldap://ldap:389'
# OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com'
# OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})'
# OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com'
# OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone'
# OVERLEAF_LDAP_EMAIL_ATT: 'mail'
# OVERLEAF_LDAP_NAME_ATT: 'cn'
# OVERLEAF_LDAP_LAST_NAME_ATT: 'sn'
# OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true'
# OVERLEAF_TEMPLATES_USER_ID: "578773160210479700917ee5"
# OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS: '[ {"name":"All Templates","url":"/templates/all"}]'
# OVERLEAF_TEMPLATES_USER_ID: "578773160210479700917ee5"
# OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS: '[ {"name":"All Templates","url":"/templates/all"}]'
# OVERLEAF_PROXY_LEARN: "true"
mongo:
restart: always
image: mongo:8.0
container_name: mongo
command: "--replSet overleaf"
volumes:
- ~/mongo_data:/data/db
- ./bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the replica set.
# This override is not needed when running the setup after starting up mongo.
- mongo:127.0.0.1
healthcheck:
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
interval: 10s
timeout: 10s
retries: 5
# OVERLEAF_PROXY_LEARN: "true"
redis:
restart: always
image: redis:6.2
container_name: redis
volumes:
- ~/redis_data:/data
mongo:
restart: always
image: mongo:6.0
container_name: mongo
command: '--replSet overleaf'
volumes:
- ~/mongo_data:/data/db
- ./bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the replica set.
# This override is not needed when running the setup after starting up mongo.
- mongo:127.0.0.1
healthcheck:
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
interval: 10s
timeout: 10s
retries: 5
# ldap:
# restart: always
# image: rroemhild/test-openldap
# container_name: ldap
redis:
restart: always
image: redis:6.2
container_name: redis
volumes:
- ~/redis_data:/data
# See https://github.com/jwilder/nginx-proxy for documentation on how to configure the nginx-proxy container,
# and https://github.com/overleaf/overleaf/wiki/HTTPS-reverse-proxy-using-Nginx for an example of some recommended
# settings. We recommend using a properly managed nginx instance outside of the Overleaf Server Pro setup,
# but the example here can be used if you'd prefer to run everything with docker-compose
# ldap:
# restart: always
# image: rroemhild/test-openldap
# container_name: ldap
# nginx-proxy:
# image: jwilder/nginx-proxy
# container_name: nginx-proxy
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - /var/run/docker.sock:/tmp/docker.sock:ro
# - /home/overleaf/tmp:/etc/nginx/certs
# See https://github.com/jwilder/nginx-proxy for documentation on how to configure the nginx-proxy container,
# and https://github.com/overleaf/overleaf/wiki/HTTPS-reverse-proxy-using-Nginx for an example of some recommended
# settings. We recommend using a properly managed nginx instance outside of the Overleaf Server Pro setup,
# but the example here can be used if you'd prefer to run everything with docker-compose
# nginx-proxy:
# image: jwilder/nginx-proxy
# container_name: nginx-proxy
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - /var/run/docker.sock:/tmp/docker.sock:ro
# - /home/overleaf/tmp:/etc/nginx/certs
@@ -0,0 +1 @@
node_modules/
@@ -0,0 +1,46 @@
compileFolder
Compiled source #
###################
*.com
*.class
*.dll
*.exe
*.o
*.so
# Packages #
############
# it's better to unpack these files and commit the raw source
# git has its own built in compression methods
*.7z
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
# Logs and databases #
######################
*.log
*.sql
*.sqlite
# OS generated files #
######################
.DS_Store?
ehthumbs.db
Icon?
Thumbs.db
/node_modules/*
data/*/*
**.swp
/log.json
hash_folder
.npmrc
@@ -1,13 +0,0 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
const all = {
require: 'test/setup.js',
...reporterOptions,
}
module.exports = all
@@ -0,0 +1,6 @@
{
"ui": "bdd",
"recursive": "true",
"reporter": "spec",
"require": "test/setup.js"
}
+1
View File
@@ -0,0 +1 @@
20.18.2
@@ -0,0 +1,10 @@
access-token-encryptor
--dependencies=None
--docker-repos=gcr.io/overleaf-ops
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=20.18.2
--public-repo=False
--script-version=4.5.0
+7 -13
View File
@@ -3,31 +3,25 @@
"version": "3.0.0",
"description": "",
"main": "index.js",
"exports": {
".": "./index.js",
"./*": "./*"
},
"scripts": {
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 .",
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"test:ci": "yarn run test:unit",
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
"lint": "eslint --ext .js --ext .cjs --ext .ts --max-warnings 0 --format unix .",
"lint:fix": "eslint --fix --ext .js --ext .cjs --ext .ts .",
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
"test:ci": "npm run test:unit",
"test:unit": "mocha --exit test/**/*.{js,cjs}",
"types:check": "tsc --noEmit"
},
"author": "",
"license": "AGPL-3.0-only",
"dependencies": {
"lodash": "^4.18.1"
"lodash": "^4.17.21"
},
"devDependencies": {
"@types/node": "^24.5.2",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"eslint": "^10.4.0",
"mocha": "^11.1.0",
"mocha-junit-reporter": "^2.2.1",
"mocha-multi-reporters": "^1.5.1",
"sandboxed-module": "^2.0.4",
"typescript": "^5.0.4"
}
@@ -1,9 +0,0 @@
module.exports = {
reporterEnabled: 'spec, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
mochaFile: `reports/junit-mocha-${process.env.TEST_NAME_PATTERN}.xml`,
includePending: true,
jenkinsMode: true,
output: true,
},
}
@@ -1,4 +1,7 @@
{
"extends": "../../tsconfig.backend.json",
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
"include": [
"**/*.js",
"**/*.cjs"
]
}
@@ -1,13 +0,0 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: require.resolve('mocha-multi-reporters'),
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
const all = {
require: 'test/setup.js',
...reporterOptions,
}
module.exports = all
-76
View File
@@ -1,76 +0,0 @@
'use strict'
/**
* cypress-multi-reporters, loadable from inside Cypress.
*
* Cypress builds mocha reporters in its Electron process. That process resolves
* modules through PackherdModuleLoader and does not inherit NODE_OPTIONS, so
* Yarn PnP is not installed there and `require('cypress-multi-reporters')`
* fails with "Cannot find module".
*
* Installing PnP for the rest of that process is not an option either: Cypress
* asks packherd for its own bundled modules under ids like
* `packages/server/lib/browsers/`, which PnP reads as paths inside this
* workspace and then rejects ("it isn't declared in your dependencies"),
* breaking the browser launch.
*
* So install PnP, pull the reporters in through it, and hand resolution back to
* Cypress. Requires made by the modules that came in through PnP keep going
* through PnP, so anything they load later still resolves.
*
* Point Cypress at this package rather than at cypress-multi-reporters:
*
* reporter: require.resolve('@overleaf/cypress-pnp-reporter')
*/
const Module = require('node:module')
const path = require('node:path')
function loadReporters() {
const MultiReporters = require('cypress-multi-reporters')
// MultiReporters resolves the reporters named in reporterEnabled when a run
// starts, long after PnP is gone. It reads mocha's own registry before
// falling back to require(), so register mocha-junit-reporter there while it
// can still be found.
require('mocha').reporters[
'mocha-junit-reporter'
] = require('mocha-junit-reporter')
return MultiReporters
}
if (process.versions.pnp) {
module.exports = loadReporters()
} else {
const stockLoad = Module._load
const stockResolveFilename = Module._resolveFilename
const stockFindPath = Module._findPath
require(path.join(__dirname, '..', '..', '.pnp.cjs')).setup()
const pnpResolveFilename = Module._resolveFilename
const cachedBefore = new Set(Object.keys(Module._cache))
const loadedViaPnp = new Set()
try {
module.exports = loadReporters()
} finally {
for (const filename of Object.keys(Module._cache)) {
if (!cachedBefore.has(filename)) loadedViaPnp.add(filename)
}
// Leave the patched fs in place: the reporters live in zip archives, so
// anything they read lazily still has to go through it.
Module._load = stockLoad
Module._findPath = stockFindPath
Module._resolveFilename = function (request, parent, ...args) {
if (!parent || !loadedViaPnp.has(parent.filename)) {
return stockResolveFilename.call(this, request, parent, ...args)
}
const filename = pnpResolveFilename.call(this, request, parent, ...args)
loadedViaPnp.add(filename)
return filename
}
}
}
@@ -1,30 +0,0 @@
{
"name": "@overleaf/cypress-pnp-reporter",
"description": "cypress-multi-reporters, loadable from Cypress's Electron process, which has no Yarn PnP",
"main": "index.cjs",
"files": [
"index.cjs"
],
"author": "Overleaf (https://www.overleaf.com)",
"private": true,
"scripts": {
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 .",
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
"test:ci": "yarn run test:unit",
"test:unit": "mocha --exit test/**/*.{js,cjs}",
"types:check": "tsc --noEmit"
},
"dependencies": {
"cypress-multi-reporters": "^2.0.5"
},
"devDependencies": {
"@types/node": "^24.5.2",
"chai": "^4.3.6",
"eslint": "^10.4.0",
"mocha": "^11.1.0",
"mocha-junit-reporter": "^2.2.1",
"mocha-multi-reporters": "^1.5.1",
"typescript": "^5.0.4"
}
}
@@ -1,9 +0,0 @@
module.exports = {
reporterEnabled: 'spec, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
mochaFile: `reports/junit-mocha-${process.env.TEST_NAME_PATTERN}.xml`,
includePending: true,
jenkinsMode: true,
output: true,
},
}
@@ -1 +0,0 @@
// no setup yet
@@ -1,52 +0,0 @@
'use strict'
const { expect } = require('chai')
const Mocha = require('mocha')
const JUnitReporter = require('mocha-junit-reporter')
const MultiReporters = require('../../index.cjs')
function buildReporters(reporterEnabled) {
const runner = new Mocha.Runner(new Mocha.Suite(''))
const reporter = new MultiReporters(runner, {
reporterOptions: { reporterEnabled },
})
return reporter._reporters
}
describe('cypress-pnp-reporter', function () {
it('exports cypress-multi-reporters', function () {
expect(MultiReporters).to.equal(require('cypress-multi-reporters'))
})
it('registers mocha-junit-reporter on mocha', function () {
expect(Mocha.reporters['mocha-junit-reporter']).to.equal(JUnitReporter)
})
it('builds the reporters used across the Cypress suites', function () {
const reporters = buildReporters('spec, mocha-junit-reporter')
expect(reporters).to.have.length(2)
expect(reporters[0]).to.be.an.instanceOf(Mocha.reporters.spec)
expect(reporters[1]).to.be.an.instanceOf(JUnitReporter)
})
it('prefers the reporter registered on mocha over resolving it by name', function () {
// Cypress builds the reporters after the shim has handed module resolution
// back, so mocha's registry is the only place mocha-junit-reporter can
// still be found. Everything above this still passes if
// cypress-multi-reporters resolves it by name instead, so pin the lookup
// order with a reporter that is only reachable through the registry.
class Sentinel {}
const registered = Mocha.reporters['mocha-junit-reporter']
Mocha.reporters['mocha-junit-reporter'] = Sentinel
try {
expect(buildReporters('mocha-junit-reporter')[0]).to.be.an.instanceOf(
Sentinel
)
} finally {
Mocha.reporters['mocha-junit-reporter'] = registered
}
})
})
@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.backend.json",
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
}
@@ -1,160 +0,0 @@
// French typographic spacing rules for JSON string values.
// Per https://typographisme.net/post/Les-espaces-typographiques-et-le-web
// - NNBSP before ? ! ;
// - NBSP before :
// - NBSP inside guillemets (after «, before ») — keeps the
// guillemet attached to its content so it cannot be orphaned at a
// line break
// - NNBSP between digit groups (thousands separator)
// - NBSP between a number and a following word/unit (50 Mo, 5 jours)
// - NBSP before %
// - point médian "·" instead of parenthetical (e) for inclusive writing
const NBSP = ' '
const NNBSP = ' '
function autoFix(value) {
let v = value
// Normalize whitespace before ? ! ; to NNBSP
v = v.replace(/\s*([?!;])/g, `${NNBSP}$1`)
v = v.replace(/([^\s])([?!;])/g, `$1${NNBSP}$2`)
// Normalize whitespace before : (after a letter, not before /) to NBSP
v = v.replace(/(\p{L})\s*:(?!\/)/gu, `$1${NBSP}:`)
// NBSP after «
v = v.replace(/«\s*/g, `«${NBSP}`)
// NBSP before »
v = v.replace(/\s*»/g, `${NBSP}»`)
// NNBSP between digits (thousands separator)
v = v.replace(/(\d)\s+(\d)/g, `$1${NNBSP}$2`)
// NBSP between digit and following letter (number + unit/word)
v = v.replace(/(\d)\s+(\p{L})/gu, `$1${NBSP}$2`)
// NBSP before %
v = v.replace(/(\d)\s*%/g, `$1${NBSP}%`)
return v
}
function lint(value) {
const violations = []
for (const m of value.matchAll(/(.)([?!;])/g)) {
if (m[1] !== NNBSP) {
violations.push({
message: `expected NNBSP before "${m[2]}", got ${JSON.stringify(m[1])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\p{L})(\s*):(?!\/)/gu)) {
if (m[2] !== NBSP) {
violations.push({
message: `expected NBSP before ":" after letter ${JSON.stringify(m[1])}, got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/«(.)/g)) {
if (m[1] !== NBSP) {
violations.push({
message: `expected NBSP after "«", got ${JSON.stringify(m[1])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(.)»/g)) {
if (m[1] !== NBSP) {
violations.push({
message: `expected NBSP before "»", got ${JSON.stringify(m[1])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\d)(\s)(\d)/g)) {
if (m[2] !== NNBSP) {
violations.push({
message: `expected NNBSP between digits "${m[1]}${m[3]}", got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\d)(\s)(\p{L})/gu)) {
if (m[2] !== NBSP) {
violations.push({
message: `expected NBSP between digit "${m[1]}" and "${m[3]}", got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\d)(\s*)%/g)) {
if (m[2] !== NBSP) {
violations.push({
message: `expected NBSP before "%" after "${m[1]}", got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
if (value.includes('(e)')) {
violations.push({
message:
'expected point médian "·" instead of "(e)" for inclusive writing',
fixable: false, // suggestion-only
})
}
return violations
}
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
hasSuggestions: true,
docs: {
description:
'Enforce French typographic spacing in JSON string values (NNBSP before ? ! ;, NBSP before : and inside « », etc.).',
},
schema: [],
},
create(context) {
return {
Member(node) {
if (node.value.type !== 'String') return
const original = node.value.value
const violations = lint(original)
if (violations.length === 0) return
const fixed = autoFix(original)
const replacement = JSON.stringify(fixed)
const inclusiveSuggestion = original.includes('(e)')
? JSON.stringify(original.replace(/\(e\)/g, '·e'))
: null
for (const v of violations) {
context.report({
node: node.value,
message: v.message,
...(v.fixable && fixed !== original
? {
fix(fixer) {
return fixer.replaceText(node.value, replacement)
},
}
: {}),
...(!v.fixable && inclusiveSuggestion
? {
suggest: [
{
desc: 'Replace "(e)" with "·e"',
fix(fixer) {
return fixer.replaceText(
node.value,
inclusiveSuggestion
)
},
},
],
}
: {}),
})
}
},
}
},
}
-28
View File
@@ -1,28 +0,0 @@
const pkg = require('./package.json')
module.exports = {
meta: {
name: pkg.name,
version: pkg.version,
},
rules: {
'no-unnecessary-trans': require('./no-unnecessary-trans'),
'prefer-kebab-url': require('./prefer-kebab-url'),
'should-unescape-trans': require('./should-unescape-trans'),
'no-generated-editor-themes': require('./no-generated-editor-themes'),
'require-script-runner': require('./require-script-runner'),
'require-vi-doMock-valid-path': require('./require-vi-doMock-valid-path'),
'require-loading-label': require('./require-loading-label'),
'require-rel-for-docs-links': require('./require-rel-for-docs-links'),
'require-cio-snake-case-properties': require('./require-cio-snake-case-properties'),
'no-throw-in-callback': require('./no-throw-in-callback'),
'no-raw-req-access': require('./no-raw-req-access'),
'no-consecutive-spaces-in-locales': require('./no-consecutive-spaces-in-locales'),
'no-straight-apostrophes-in-locales': require('./no-straight-apostrophes-in-locales'),
'french-typography-in-locales': require('./french-typography-in-locales'),
'sorted-keys-in-locales': require('./sorted-keys-in-locales'),
'locale-variables-match-en': require('./locale-variables-match-en'),
'no-orphan-locale-keys': require('./no-orphan-locale-keys'),
'no-mocha-before': require('./no-mocha-before'),
},
}
@@ -1,93 +0,0 @@
// Ensure variable placeholders (`__name__`) in non-en locale values match
// those in the corresponding en.json key.
//
// Apply this rule to all non-en locale JSON files.
const fs = require('node:fs')
const Path = require('node:path')
// Placeholders always available in translations (injected by the i18n layer
// or globally defined as defaults).
const GLOBALS = new Set(['__appName__'])
// Keys whose translations are allowed to omit specific base placeholders.
const IGNORE_NESTING_FOR = {
over_x_templates_easy_getting_started: ['__templates__'],
all_packages_and_templates: ['__templatesLink__'],
}
function extractPlaceholders(str) {
return Array.from(str.matchAll(/__.*?__/g), m => m[0])
}
const enCache = new Map()
function loadEn(localesDir) {
if (enCache.has(localesDir)) return enCache.get(localesDir)
const path = Path.join(localesDir, 'en.json')
const data = JSON.parse(fs.readFileSync(path, 'utf8'))
enCache.set(localesDir, data)
return data
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Ensure variable placeholders in non-en locale values match the en.json base.',
},
schema: [],
messages: {
missing:
'Translation for key "{{key}}" is missing placeholder(s) {{placeholders}} present in en.json.',
extra:
'Translation for key "{{key}}" has placeholder(s) {{placeholders}} not present in en.json.',
},
},
create(context) {
const filename = context.filename
const localesDir = Path.dirname(filename)
if (Path.basename(filename) === 'en.json') return {}
let base
try {
base = loadEn(localesDir)
} catch {
return {}
}
return {
Member(node) {
if (node.value.type !== 'String') return
const key = node.name.value
if (!(key in base)) return // orphan key — covered by no-orphan-locale-keys
const basePlaceholders = extractPlaceholders(base[key])
const targetPlaceholders = extractPlaceholders(node.value.value)
const ignored = IGNORE_NESTING_FOR[key] || []
const missing = basePlaceholders.filter(
p => !targetPlaceholders.includes(p) && !ignored.includes(p)
)
const extra = targetPlaceholders.filter(
p => !basePlaceholders.includes(p) && !GLOBALS.has(p)
)
if (missing.length > 0) {
context.report({
node: node.value,
messageId: 'missing',
data: { key, placeholders: missing.join(', ') },
})
}
if (extra.length > 0) {
context.report({
node: node.value,
messageId: 'extra',
data: { key, placeholders: extra.join(', ') },
})
}
},
}
},
}
@@ -1,37 +0,0 @@
// Reject runs of two or more consecutive whitespace characters in JSON string
// values. Catches ASCII spaces, NBSP, NNBSP, tabs, and other Unicode whitespace.
const CONSECUTIVE_WHITESPACE = /\s{2,}/
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Disallow runs of two or more consecutive whitespace characters in JSON string values (typically locale files).',
},
schema: [],
messages: {
consecutiveSpaces:
'Locale value contains a run of consecutive whitespace. Collapse to a single space.',
},
},
create(context) {
return {
Member(node) {
if (node.value.type !== 'String') return
const original = node.value.value
if (!CONSECUTIVE_WHITESPACE.test(original)) return
const fixed = original.replace(/\s{2,}/g, ' ')
context.report({
node: node.value,
messageId: 'consecutiveSpaces',
fix(fixer) {
return fixer.replaceText(node.value, JSON.stringify(fixed))
},
})
},
}
},
}
@@ -1,21 +0,0 @@
module.exports = {
meta: {
type: 'error',
docs: {
description:
'Prohibit CodeMirror themes that are generated in a function',
},
},
create(context) {
return {
':matches(ArrowFunctionExpression, FunctionDeclaration, FunctionExpression) CallExpression > MemberExpression[object.name="EditorView"]:matches([property.name="theme"],[property.name="baseTheme"])'(
node
) {
context.report({
node,
message: `EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.`,
})
},
}
},
}
-112
View File
@@ -1,112 +0,0 @@
const Path = require('node:path')
/**
* Block bare mocha `before()` hooks in Cypress e2e suites.
*
* `before()` hooks run once per suite and do NOT re-run when a test retries,
* while `beforeEach`-based hooks (like `beforeWithReRunOnTestRetry`) do.
* Mixing the two also changes ordering: all `before()` hooks run ahead of
* every `beforeEach`, not in source order. This matters in the Server Pro
* e2e suite, where suites change the server config via `startWith()` while
* test setup runs in `beforeWithReRunOnTestRetry()`: when a later suite
* changes the config, both must re-run on retry and in registration order,
* otherwise a retried test runs against setup or config belonging to a
* different suite.
*
* The autofix rewrites `before(fn)` to `beforeWithReRunOnTestRetry(fn)` and
* adds the helper import when missing. Configure the helper location (repo
* root relative, no extension) via the `helperPath` option.
*/
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow mocha before() hooks in favor of beforeWithReRunOnTestRetry()',
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
helperPath: { type: 'string' },
},
additionalProperties: false,
},
],
messages: {
noMochaBefore:
'before() hooks do not re-run when a test retries, so retried tests run against stale setup. Use beforeWithReRunOnTestRetry() instead.',
},
},
create(context) {
const helperPath = context.options[0]?.helperPath
let lastImport = null
let hasHelperImport = false
function helperImportSpecifier() {
const absolute = Path.resolve(context.cwd, helperPath)
let relative = Path.relative(
Path.dirname(Path.resolve(context.cwd, context.filename)),
absolute
)
relative = relative.split(Path.sep).join('/')
if (!relative.startsWith('.')) {
relative = `./${relative}`
}
return relative
}
return {
ImportDeclaration(node) {
lastImport = node
if (
node.specifiers.some(
specifier =>
specifier.type === 'ImportSpecifier' &&
specifier.imported.name === 'beforeWithReRunOnTestRetry'
)
) {
hasHelperImport = true
}
},
CallExpression(node) {
if (
node.callee.type !== 'Identifier' ||
node.callee.name !== 'before'
) {
return
}
// Only the global mocha hook; skip locally defined/imported `before`
const variable = context.sourceCode
.getScope(node)
.references.find(ref => ref.identifier === node.callee)?.resolved
if (variable && variable.defs.length > 0) {
return
}
const fixable = node.arguments.length === 1 && helperPath
context.report({
node: node.callee,
messageId: 'noMochaBefore',
fix: fixable
? fixer => {
const fixes = [
fixer.replaceText(node.callee, 'beforeWithReRunOnTestRetry'),
]
if (!hasHelperImport) {
const importText = `import { beforeWithReRunOnTestRetry } from '${helperImportSpecifier()}'`
fixes.push(
lastImport
? fixer.insertTextAfter(lastImport, `\n${importText}`)
: fixer.insertTextBeforeRange([0, 0], `${importText}\n`)
)
}
return fixes
}
: undefined,
})
},
}
},
}
@@ -1,69 +0,0 @@
// Detect translation keys in non-en locale files that don't exist in
// en.json (orphans).
const fs = require('node:fs')
const Path = require('node:path')
const enCache = new Map()
function loadEnKeys(localesDir) {
if (enCache.has(localesDir)) return enCache.get(localesDir)
const path = Path.join(localesDir, 'en.json')
const data = JSON.parse(fs.readFileSync(path, 'utf8'))
const keys = new Set(Object.keys(data))
enCache.set(localesDir, keys)
return keys
}
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Detect translation keys in non-en locale files that are not present in en.json.',
},
schema: [],
messages: {
orphan:
'Translation key "{{key}}" is not present in en.json (orphan key).',
},
},
create(context) {
const filename = context.filename
const localesDir = Path.dirname(filename)
if (Path.basename(filename) === 'en.json') return {}
let enKeys
try {
enKeys = loadEnKeys(localesDir)
} catch {
return {}
}
return {
'Document > Object'(node) {
const orphans = node.members.filter(m => !enKeys.has(m.name.value))
if (orphans.length === 0) return
const text = context.sourceCode.text
const parsed = JSON.parse(text)
for (const member of orphans) {
delete parsed[member.name.value]
}
const sortedRemaining = Object.keys(parsed).sort()
const cleaned = JSON.stringify(parsed, sortedRemaining, 2) + '\n'
for (const member of orphans) {
context.report({
node: member,
messageId: 'orphan',
data: { key: member.name.value },
fix(fixer) {
return fixer.replaceTextRange([0, text.length], cleaned)
},
})
}
},
}
},
}
@@ -1,64 +0,0 @@
const RAW_FIELDS = new Set(['body', 'query', 'params'])
function isReqIdentifier(node) {
return node && node.type === 'Identifier' && node.name === 'req'
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Disallow reading raw request input (req.body/req.query/req.params); validate it with parseReq() instead',
},
messages: {
noRawReqAccess:
'Do not read req.{{field}} directly; validate request input with parseReq() from @overleaf/validation-tools. Allowlisted infrastructure middleware may use getRawReqInput().',
},
},
create(context) {
return {
MemberExpression(node) {
if (!isReqIdentifier(node.object)) return
const property = node.property
const field =
!node.computed && property.type === 'Identifier'
? property.name
: node.computed &&
property.type === 'Literal' &&
typeof property.value === 'string'
? property.value
: null
if (field === null || !RAW_FIELDS.has(field)) return
// writes stay allowed: body parsers and middleware assign req.body
const parent = node.parent
if (
parent.type === 'AssignmentExpression' &&
parent.left === node &&
parent.operator === '='
) {
return
}
context.report({ node, messageId: 'noRawReqAccess', data: { field } })
},
VariableDeclarator(node) {
if (!isReqIdentifier(node.init)) return
if (node.id.type !== 'ObjectPattern') return
for (const prop of node.id.properties) {
if (
prop.type === 'Property' &&
!prop.computed &&
prop.key.type === 'Identifier' &&
RAW_FIELDS.has(prop.key.name)
) {
context.report({
node: prop,
messageId: 'noRawReqAccess',
data: { field: prop.key.name },
})
}
}
},
}
},
}
@@ -1,36 +0,0 @@
// Reject straight apostrophes (') in JSON string values. Straight apostrophes
// in Angular templates can lead to XSS; use the right single quotation mark
// ’ (U+2019) instead. See https://github.com/overleaf/issues/issues/4478
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Disallow straight apostrophes in JSON string values (typically locale files).',
},
schema: [],
messages: {
straightApostrophe:
"Locale value contains a straight apostrophe ('). Use the right single quotation mark ’ (U+2019) instead.",
},
},
create(context) {
return {
Member(node) {
if (node.value.type !== 'String') return
const original = node.value.value
if (!original.includes("'")) return
const fixed = original.replace(/'/g, '’')
context.report({
node: node.value,
messageId: 'straightApostrophe',
fix(fixer) {
return fixer.replaceText(node.value, JSON.stringify(fixed))
},
})
},
}
},
}
@@ -1,52 +0,0 @@
const CALLBACK_PARAM_NAMES = new Set(['cb', 'callback', 'done', 'next'])
function isCallbackParam(param) {
return (
param && param.type === 'Identifier' && CALLBACK_PARAM_NAMES.has(param.name)
)
}
module.exports = {
meta: {
type: 'error',
docs: {
description: 'Disallow throw statements inside callback-based functions',
},
messages: {
noThrowInCallback:
'Pass the error to the callback instead of throwing in callback-based code.',
},
},
create(context) {
// Stack tracks whether each enclosing function is a callback-style function.
// A callback-style function is non-async and has a last param named cb/callback/done/next.
const stack = []
function enterFunction(node) {
const params = node.params
const isCallback =
!node.async &&
params.length > 0 &&
isCallbackParam(params[params.length - 1])
stack.push(isCallback)
}
function exitFunction() {
stack.pop()
}
return {
FunctionDeclaration: enterFunction,
'FunctionDeclaration:exit': exitFunction,
FunctionExpression: enterFunction,
'FunctionExpression:exit': exitFunction,
ArrowFunctionExpression: enterFunction,
'ArrowFunctionExpression:exit': exitFunction,
ThrowStatement(node) {
if (stack[stack.length - 1]) {
context.report({ node, messageId: 'noThrowInCallback' })
}
},
}
},
}
@@ -1,43 +0,0 @@
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description: 'Prohibit Trans with no components or values',
},
},
create(context) {
return {
'JSXOpeningElement[name.name="Trans"]'(node) {
const attributes = new Map(
node.attributes.map(attr => [attr.name.name, attr])
)
if (!attributes.has('components')) {
if (node.parent.children.length > 0) {
context.report({
node,
message: `Trans components must not have child elements`,
})
} else if (attributes.has('values')) {
context.report({
node,
message: `Use t('…') when there are no components`,
})
} else {
context.report({
node,
message: `Use t('…') when there are no components`,
fix(fixer) {
const i18nKey = attributes.get('i18nKey').value.value
// Note: Prettier can fix indentation
return fixer.replaceText(node.parent, `{t('${i18nKey}')}`)
},
})
}
}
},
}
},
}
-26
View File
@@ -1,26 +0,0 @@
{
"name": "@overleaf/eslint-plugin",
"version": "0.1.0",
"author": "Overleaf (https://www.overleaf.com)",
"license": "AGPL-3.0-only",
"main": "index.js",
"dependencies": {
"lodash": "^4.18.1"
},
"devDependencies": {
"@eslint/json": "^2.0.0",
"@types/node": "^24.5.2",
"@typescript-eslint/parser": "^8.59.4",
"eslint": "^10.4.0",
"typescript": "^5.0.4",
"vitest": "4.1.5"
},
"scripts": {
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 .",
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"test:ci": "yarn run test:unit",
"test:unit": "vitest --config vitest.config.ts",
"types:check": "tsc --noEmit"
}
}
@@ -1,85 +0,0 @@
// URL parts should be kebab-case, but we didn't have this rule in the past.
// The ESLint rule `prefer-kebab-url` will ignore these "legacy" URL parts.
const ignoreWords = {
snake: new Set([
'clear_saml_data',
'confirm_link',
'confirm_university_domain',
'create_recurly_account',
'current_history_content',
'current_user',
'default_email',
'disable_managed_users',
'doc_snapshot',
'enable_history_ranges_support',
'features_override',
'generate_password_reset_url',
'get_assignment',
'get_clone',
'health_check',
'institutional_emails',
'latest_template',
'link_after_saml_response',
'linked_file',
'metrics_segmentation',
'new_users',
'no_autostart_post_gateway',
'personal_info',
'planned_maintenance',
'refresh_features',
'register_admin',
'register_ldap_admin',
'register_saml_admin',
'restore_file',
'revert_file',
'saved_vers',
'send_test_email',
'session_maintenance',
'set_in_session',
'sign_in_to_link',
'split_test',
'sso_configuration_test',
'sso_email',
'sso_enrollment',
'track_changes',
'update_admin',
'user_details',
]),
camel: new Set([
'addWorkflowScope',
'aiErrorAssistant',
'aiFeatureUsage',
'beginAuth',
'brandVariationId',
'closeEditor',
'completeRegistration',
'deactivateOldProjects',
'deletedSubscription',
'disconnectAllUsers',
'editingSession',
'emailSubscription',
'enableManagedUsers',
'externalCollaboration',
'flushProjectToTpds',
'indexAll',
'offboardManagedUser',
'openEditor',
'perfTest',
'pollDropboxForUser',
'resendInvite',
'resendManagedUserInvite',
'salesContactForm',
'showSupport',
'uniExternalCollaboration',
]),
other: new Set([
'Project',
'disableSSO',
'enableSSO',
'resendSSOLinkInvite',
'usersCSV',
]),
}
module.exports = { ignoreWords }
@@ -1,89 +0,0 @@
const _ = require('lodash')
const { ignoreWords } = require('./prefer-kebab-url-ignore')
const removeTextBetweenBrackets = text => {
while (text.includes('[') || text.includes('(')) {
text = text.replaceAll(/\[[^[\]]*]/g, '')
text = text.replaceAll(/\([^()]*\)/g, '')
}
return text
}
const shouldIgnoreWord = str =>
str.includes(':') ||
str.includes('(') ||
str === '*' ||
str.match(/^[a-z0-9.]+$/) ||
ignoreWords.snake.has(str) ||
ignoreWords.camel.has(str) ||
ignoreWords.other.has(str)
const getSuggestion = routePath => {
if (typeof routePath === 'string') {
const kebabed = routePath
.split('/')
.map(word => (shouldIgnoreWord(word) ? word : _.kebabCase(word)))
.join('/')
return kebabed === routePath ? null : `'${kebabed}'`
}
if (routePath instanceof RegExp) {
const words = removeTextBetweenBrackets(routePath.source).match(/[\w-]+/g)
if (!words) return null
let newSource = routePath.source
for (const word of words) {
if (!shouldIgnoreWord(word)) {
newSource = newSource.replaceAll(
new RegExp(`\\b${word}\\b`, 'g'),
_.kebabCase(word)
)
}
}
const kebabed = new RegExp(newSource, routePath.flags)
return kebabed.source === routePath.source ? null : kebabed.toString()
}
}
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
hasSuggestions: true,
docs: {
description: 'Enforce using kebab-case for URL paths',
},
},
create: context => ({
CallExpression(node) {
if (
node.callee.type === 'MemberExpression' &&
node.arguments[0]?.type === 'Literal' &&
[/app/i, /router/i].some(callee =>
typeof callee === 'string'
? node.callee.object.name === callee
: callee.test(node.callee.object.name)
) &&
['get', 'post', 'put', 'delete'].includes(node.callee.property.name)
) {
const routePath = node.arguments[0].value
const suggestion = getSuggestion(routePath)
if (suggestion) {
context.report({
node: node.arguments[0],
message: 'Route path should be in kebab-case.',
suggest: [
{
desc: `Change to kebab-case: ${suggestion}`,
fix: fixer => fixer.replaceText(node.arguments[0], suggestion),
},
],
})
}
}
},
}),
}
@@ -1,111 +0,0 @@
'use strict'
const SNAKE_CASE_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
function isSnakeCase(name) {
return SNAKE_CASE_RE.test(name)
}
function getStaticKeyName(property) {
if (property.computed) return null
if (property.key.type === 'Identifier') return property.key.name
if (property.key.type === 'Literal' && typeof property.key.value === 'string')
return property.key.value
return null
}
/**
* Check if a node is a call to CustomerIoHandler.updateUserAttributes()
* and return the attributes argument (2nd argument)
*/
function getUpdateUserAttributesArg(node) {
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'CustomerIoHandler' &&
node.callee.property.name === 'updateUserAttributes' &&
node.arguments[1]?.type === 'ObjectExpression'
) {
return node.arguments[1]
}
return null
}
/**
* Check if a node is a call to Modules[.promises].hooks.fire('setUserProperties', ...)
* and return the attributes argument (3rd argument)
*/
function getSetUserPropertiesArg(node) {
const callee = node.callee
if (callee.type !== 'MemberExpression' || callee.property.name !== 'fire') {
return null
}
// Check first argument is 'setUserProperties'
if (
!node.arguments[0] ||
node.arguments[0].type !== 'Literal' ||
node.arguments[0].value !== 'setUserProperties'
) {
return null
}
// Match: Modules.hooks.fire or Modules.promises.hooks.fire
const obj = callee.object
if (obj.type === 'MemberExpression' && obj.property.name === 'hooks') {
const parent = obj.object
// Modules.hooks
if (parent.type === 'Identifier' && parent.name === 'Modules') {
if (node.arguments[2]?.type === 'ObjectExpression') {
return node.arguments[2]
}
}
// Modules.promises.hooks
if (
parent.type === 'MemberExpression' &&
parent.property.name === 'promises' &&
parent.object.type === 'Identifier' &&
parent.object.name === 'Modules'
) {
if (node.arguments[2]?.type === 'ObjectExpression') {
return node.arguments[2]
}
}
}
return null
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Enforce snake_case for Customer.io user property attribute names',
},
},
create(context) {
return {
CallExpression(node) {
const attrsNode =
getUpdateUserAttributesArg(node) || getSetUserPropertiesArg(node)
if (!attrsNode) return
for (const property of attrsNode.properties) {
if (property.type === 'SpreadElement') continue
const keyName = getStaticKeyName(property)
if (keyName === null) continue // skip computed/dynamic keys
if (!isSnakeCase(keyName)) {
context.report({
node: property.key,
message: `Customer.io attribute '{{name}}' must be in snake_case.`,
data: { name: keyName },
})
}
}
},
}
},
}
@@ -1,49 +0,0 @@
module.exports = {
meta: {
type: 'problem',
fixable: null,
docs: {
description:
'Require loadingLabel prop when isLoading is specified on OLButton',
},
schema: [],
},
create(context) {
return {
'JSXOpeningElement[name.name="OLButton"]'(node) {
const attributes = new Map(
node.attributes.map(attr => [attr.name?.name, attr])
)
const isLoadingAttr = attributes.get('isLoading')
const loadingLabelAttr = attributes.get('loadingLabel')
if (isLoadingAttr && !loadingLabelAttr) {
const isLoadingValue = isLoadingAttr.value
if (
!isLoadingValue ||
(isLoadingValue.type === 'JSXExpressionContainer' &&
isLoadingValue.expression.type === 'Literal' &&
isLoadingValue.expression.value === true)
) {
context.report({
node: isLoadingAttr,
message:
'Button with isLoading prop must also specify loadingLabel',
})
} else if (
isLoadingValue.type === 'JSXExpressionContainer' &&
isLoadingValue.expression.type !== 'Literal'
) {
context.report({
node: isLoadingAttr,
message:
'Button with isLoading prop must also specify loadingLabel',
})
}
}
},
}
},
}
@@ -1,87 +0,0 @@
const DOCS_HOST = 'docs.overleaf.com'
const REQUIRED_REL_TOKENS = ['noopener', 'noreferrer']
// Elements that forward href/target/rel straight onto a rendered <a>.
const LINK_ELEMENT_NAMES = ['a', 'OLButton']
function getStringLiteralValue(node) {
if (!node) return undefined
if (node.type === 'Literal' && typeof node.value === 'string') {
return node.value
}
if (
node.type === 'JSXExpressionContainer' &&
node.expression.type === 'Literal' &&
typeof node.expression.value === 'string'
) {
return node.expression.value
}
return undefined
}
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Require rel="noopener noreferrer" on links to docs.overleaf.com that open in a new tab, to prevent reverse-tabnabbing',
},
schema: [],
},
create(context) {
const selector = `JSXOpeningElement[name.name=/^(${LINK_ELEMENT_NAMES.join('|')})$/]`
return {
[selector](node) {
const attributes = new Map(
node.attributes
.filter(attr => attr.type === 'JSXAttribute')
.map(attr => [attr.name.name, attr])
)
const hrefAttr = attributes.get('href')
const href = getStringLiteralValue(hrefAttr?.value)
if (!href || !href.includes(DOCS_HOST)) {
return
}
const targetAttr = attributes.get('target')
const target = getStringLiteralValue(targetAttr?.value)
if (!target || target.toLowerCase() !== '_blank') {
return
}
const relAttr = attributes.get('rel')
const rel = getStringLiteralValue(relAttr?.value) || ''
const relTokens = rel.toLowerCase().split(/\s+/).filter(Boolean)
const missingTokens = REQUIRED_REL_TOKENS.filter(
token => !relTokens.includes(token)
)
if (missingTokens.length === 0) {
return
}
context.report({
node: relAttr || node,
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
fix(fixer) {
const newRel = Array.from(
new Set([...relTokens, ...REQUIRED_REL_TOKENS])
).join(' ')
if (relAttr) {
if (!relAttr.value) {
return fixer.replaceText(relAttr, `rel="${newRel}"`)
}
return fixer.replaceText(relAttr.value, `"${newRel}"`)
}
return fixer.insertTextAfter(targetAttr, ` rel="${newRel}"`)
},
})
},
}
},
}
@@ -1,28 +0,0 @@
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Require Script Runner for scripts',
},
},
create(context) {
let hasImport = false
return {
ImportDeclaration(node) {
if (node.source.value.endsWith('lib/ScriptRunner.mjs')) {
hasImport = true
}
},
'Program:exit'() {
if (!hasImport) {
context.report({
loc: { line: 1, column: 0 },
message:
'Please use Script Runner for scripts. Refer to the developer manual (https://manual.dev-overleaf.com/development/code/web_scripts/#monitor-script-execution-and-usage-with-script-runner) for more information.',
})
}
},
}
},
}
@@ -1,139 +0,0 @@
const path = require('node:path')
const fs = require('node:fs')
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure vi.doMock first argument is a resolvable path.',
category: 'Best Practices',
recommended: false,
url: '',
},
fixable: 'code',
hasSuggestions: true,
schema: [],
messages: {
unresolvablePath:
'The path "{{pathValue}}" in vi.doMock() cannot be resolved relative to the current file.',
notAStringLiteral:
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
noArguments: 'vi.doMock() called with no arguments.',
},
},
create(context) {
const currentFilePath = context.filename
// ESLint can sometimes pass <text> or <input> for snippets not in a file
if (currentFilePath === '<text>' || currentFilePath === '<input>') {
return {}
}
const currentDirectory = path.dirname(currentFilePath)
function canResolve(modulePath) {
try {
require.resolve(path.resolve(currentDirectory, modulePath))
return true
} catch (e) {
const absolutePath = path.resolve(currentDirectory, modulePath)
const extensions = [
'',
'.js',
'.mjs',
'.ts',
'.jsx',
'.tsx',
'.json',
'.node',
'/index.js',
'/index.ts',
] // Add common extensions
for (const ext of extensions) {
if (fs.existsSync(absolutePath + ext)) {
return true
}
}
return false
}
}
return {
CallExpression(node) {
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'vi' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'doMock'
) {
if (node.arguments.length === 0) {
context.report({
node,
messageId: 'noArguments',
})
return
}
const firstArg = node.arguments[0]
let pathValue = firstArg.value
if (
firstArg.type !== 'Literal' ||
typeof firstArg.value !== 'string'
) {
if (firstArg.type === 'Identifier') {
const scope = context.sourceCode.getScope(node)
const variable = scope.variables.find(
v => v.name === firstArg.name
)
if (
variable &&
variable.defs.length > 0 &&
variable.defs[0].node.init &&
variable.defs[0].node.init.type === 'Literal' &&
typeof variable.defs[0].node.init.value === 'string'
) {
pathValue = variable.defs[0].node.init.value
if (canResolve(pathValue)) {
return
}
// If the first argument was a variable that didn't resolve then we can't auto-fix it
}
}
context.report({
node: firstArg,
messageId: 'notAStringLiteral',
})
return
}
if (!pathValue.startsWith('.')) {
return
}
if (!canResolve(pathValue)) {
const mjsPath = pathValue.replace('.js', '.mjs')
const additionalReportOptions = {}
if (canResolve(mjsPath)) {
additionalReportOptions.fix = fixer =>
fixer.replaceText(firstArg, `'${mjsPath}'`)
additionalReportOptions.suggest = [
{
desc: `Replace with "${pathValue.replace('.js', '.mjs')}"`,
fix: fixer => fixer.replaceText(firstArg, `'${mjsPath}'`),
},
]
}
context.report({
node: firstArg,
messageId: 'unresolvablePath',
data: {
pathValue,
},
...additionalReportOptions,
})
}
}
},
}
},
}
@@ -1,60 +0,0 @@
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description: 'Ensure that Trans with values has shouldUnescape',
},
},
create(context) {
return {
'JSXOpeningElement[name.name="Trans"]'(node) {
const attributes = new Map(
node.attributes.map(attr => [attr.name.name, attr])
)
if (attributes.has('values') && !attributes.has('shouldUnescape')) {
context.report({
node,
message: 'Trans with values must have shouldUnescape',
fix(fixer) {
return fixer.insertTextAfter(
attributes.get('values'),
'\nshouldUnescape' // Note: Prettier can fix indentation
)
},
})
}
if (attributes.has('values') && attributes.has('shouldUnescape')) {
const tOptions = attributes.get('tOptions')
if (!tOptions) {
context.report({
node,
message:
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
fix(fixer) {
return fixer.insertTextAfter(
attributes.get('shouldUnescape'),
'\ntOptions={{ interpolation: { escapeValue: true } }}' // Note: Prettier can fix indentation
)
},
})
} else {
const property = tOptions.value.expression.properties
.find(p => p.key.name === 'interpolation')
?.value.properties.find(p => p.key.name === 'escapeValue')
if (property?.value.value !== true) {
context.report({
node,
message:
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue set to true',
})
}
}
}
},
}
},
}
@@ -1,43 +0,0 @@
// Enforce alphabetically sorted top-level keys in JSON files.
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Require top-level keys to be sorted alphabetically in JSON files (typically locale files).',
},
schema: [],
messages: {
unsorted:
'Top-level keys are not sorted alphabetically. Key "{{key}}" should come before "{{predecessor}}".',
},
},
create(context) {
return {
'Document > Object'(node) {
const members = node.members
for (let i = 1; i < members.length; i++) {
const prev = members[i - 1].name.value
const curr = members[i].name.value
if (curr < prev) {
const text = context.sourceCode.text
const parsed = JSON.parse(text)
const sortedKeys = Object.keys(parsed).sort()
const sorted = JSON.stringify(parsed, sortedKeys, 2) + '\n'
context.report({
node: members[i],
messageId: 'unsorted',
data: { key: curr, predecessor: prev },
fix(fixer) {
return fixer.replaceTextRange([0, text.length], sorted)
},
})
return // one violation per file is enough; the fix re-sorts everything
}
}
},
}
},
}
-9
View File
@@ -1,9 +0,0 @@
import { afterAll, describe, it } from 'vitest'
import { RuleTester } from 'eslint'
// ESLint's RuleTester discovers the test framework via these hooks. The repo's
// vitest configs deliberately avoid `globals: true`, so wire them explicitly.
RuleTester.afterAll = afterAll
RuleTester.describe = describe
RuleTester.it = it
RuleTester.itOnly = it.only
@@ -1,687 +0,0 @@
import { fileURLToPath } from 'node:url'
import { RuleTester } from 'eslint'
import tsParser from '@typescript-eslint/parser'
import json from '@eslint/json'
import noThrowInCallback from '../../no-throw-in-callback.js'
import noRawReqAccess from '../../no-raw-req-access.js'
import preferKebabUrl from '../../prefer-kebab-url.js'
import noUnnecessaryTrans from '../../no-unnecessary-trans.js'
import shouldUnescapeTrans from '../../should-unescape-trans.js'
import noGeneratedEditorThemes from '../../no-generated-editor-themes.js'
import viDoMockValidPath from '../../require-vi-doMock-valid-path.js'
import requireCioSnakeCaseProperties from '../../require-cio-snake-case-properties.js'
import requireRelForDocsLinks from '../../require-rel-for-docs-links.js'
import noConsecutiveSpacesInLocales from '../../no-consecutive-spaces-in-locales.js'
import noStraightApostrophesInLocales from '../../no-straight-apostrophes-in-locales.js'
import frenchTypographyInLocales from '../../french-typography-in-locales.js'
import sortedKeysInLocales from '../../sorted-keys-in-locales.js'
import noMochaBefore from '../../no-mocha-before.js'
const __filename = fileURLToPath(import.meta.url)
const ruleTester = new RuleTester({
languageOptions: {
parser: tsParser,
ecmaVersion: 'latest',
parserOptions: { ecmaFeatures: { jsx: true } },
},
})
const jsonRuleTester = new RuleTester({
plugins: { json },
language: 'json/json',
})
ruleTester.run('prefer-kebab-url', preferKebabUrl, {
valid: [
{ code: `app.get('/foo-bar')` },
{ code: `app.get('/foo-bar/:id')` },
{ code: `router.post('/foo-bar')` },
{ code: `router.get('/foo-bar/:id/:name/:age')` },
{ code: `webRouter.get('/foo-bar/:user_id/(ProjectName)/get-info')` },
{ code: `webApp.post('/foo-bar/:user_id/(ProjectName)/get-info')` },
{
code: `router.get(/^\\/download\\/project\\/([^/]*)\\/output\\/output\\.pdf$/)`,
},
{
code: `webRouter.get(/^\\/project\\/([^/]*)\\/user\\/([0-9a-f]+)\\/build\\/([0-9a-f-]+)\\/output\\/(.*)$/)`,
},
],
invalid: [
{
code: `app.get('/fooBar')`,
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
{
code: `app.get('/fooBar/:id')`,
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
{
code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
{
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
errors: [
{
message: 'Route path should be in kebab-case.',
suggestions: [
{
desc: 'Change to kebab-case: /^\\/down-load\\/pro-ject\\/([^/]*)\\/out-put\\/out-put\\.pdf$/',
output: `router.get(/^\\/down-load\\/pro-ject\\/([^/]*)\\/out-put\\/out-put\\.pdf$/)`,
},
],
},
],
},
],
})
ruleTester.run('no-unnecessary-trans', noUnnecessaryTrans, {
valid: [
{ code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>` },
],
invalid: [
{
code: `<Trans i18nKey="test" values={{ test: 'foo '}}/>`,
errors: [{ message: `Use t('…') when there are no components` }],
},
{
code: `<Trans i18nKey="test" />`,
errors: [{ message: `Use t('…') when there are no components` }],
output: `{t('test')}`,
},
],
})
ruleTester.run('should-unescape-trans', shouldUnescapeTrans, {
valid: [
{
code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>`,
},
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }}/>`,
},
],
invalid: [
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} />`,
errors: [{ message: 'Trans with values must have shouldUnescape' }],
output: `<Trans i18nKey="test" values={{ foo: 'bar' }}\nshouldUnescape components={{ strong: <strong/> }} />`,
},
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape />`,
errors: [
{
message:
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
},
],
output: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape\ntOptions={{ interpolation: { escapeValue: true } }} />`,
},
],
})
const noGeneratedEditorThemesError =
'EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.'
ruleTester.run('no-generated-editor-themes', noGeneratedEditorThemes, {
valid: [
{
code: `EditorView.theme({ '.cm-editor': { color: 'black' } })`,
},
{
code: `const theme = EditorView.theme({ '.cm-editor': { color: 'black' } })`,
},
],
invalid: [
{
code: `function createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) }`,
errors: [
{
message: noGeneratedEditorThemesError,
},
],
},
{
code: `() => EditorView.theme({ '.cm-editor': { color: 'black' } })`,
errors: [
{
message: noGeneratedEditorThemesError,
},
],
},
{
code: `class Foo { createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) } }`,
errors: [
{
message: noGeneratedEditorThemesError,
},
],
},
],
})
ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
valid: [
{
code: 'vi.doMock("../../require-vi-doMock-valid-path.js")',
filename: __filename,
},
{
code: 'const filename = "../../require-vi-doMock-valid-path.js"; vi.doMock(filename);',
filename: __filename,
},
],
invalid: [
{
code: "vi.doMock('../../require-vi-doMock-valid-path2')",
filename: __filename,
errors: [
{
message:
'The path "../../require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
suggestions: [],
},
],
},
{
code: 'const filename = "../../require-vi-doMock-valid-path2.js"; vi.doMock(filename);',
filename: __filename,
errors: [
{
message:
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
suggestions: [],
},
],
},
],
})
ruleTester.run(
'require-cio-snake-case-properties',
requireCioSnakeCaseProperties,
{
valid: [
// updateUserAttributes with snake_case keys
{
code: `CustomerIoHandler.updateUserAttributes(userId, { plan_type: 'free', group_size: 10 })`,
},
// Modules.promises.hooks.fire with snake_case keys
{
code: `Modules.promises.hooks.fire('setUserProperties', userId, { plan_type: 'free', last_active: 123 })`,
},
// Modules.hooks.fire with snake_case keys
{
code: `Modules.hooks.fire('setUserProperties', userId, { plan_type: 'free' })`,
},
// Single-word keys are valid snake_case
{
code: `CustomerIoHandler.updateUserAttributes(userId, { email: 'a@b.com', role: 'admin' })`,
},
// Computed/dynamic keys are skipped
{
code: `CustomerIoHandler.updateUserAttributes(userId, { [dynamicKey]: true })`,
},
// Spread elements are skipped
{
code: `CustomerIoHandler.updateUserAttributes(userId, { ...existingAttrs })`,
},
// Unrelated function calls are not checked
{
code: `SomeOtherHandler.updateUserAttributes(userId, { camelCase: true })`,
},
// fire() with a different event name is not checked
{
code: `Modules.promises.hooks.fire('someOtherEvent', userId, { camelCase: true })`,
},
],
invalid: [
// camelCase key in updateUserAttributes
{
code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free' })`,
errors: [
{
message: `Customer.io attribute 'planType' must be in snake_case.`,
},
],
},
// kebab-case string key
{
code: `CustomerIoHandler.updateUserAttributes(userId, { 'plan-type': 'free' })`,
errors: [
{
message: `Customer.io attribute 'plan-type' must be in snake_case.`,
},
],
},
// PascalCase key
{
code: `CustomerIoHandler.updateUserAttributes(userId, { PlanType: 'free' })`,
errors: [
{
message: `Customer.io attribute 'PlanType' must be in snake_case.`,
},
],
},
// camelCase in Modules.promises.hooks.fire
{
code: `Modules.promises.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
errors: [
{
message: `Customer.io attribute 'planType' must be in snake_case.`,
},
],
},
// camelCase in Modules.hooks.fire
{
code: `Modules.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
errors: [
{
message: `Customer.io attribute 'planType' must be in snake_case.`,
},
],
},
// Multiple invalid keys report multiple errors
{
code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free', groupSize: 10, plan_term: 'annual' })`,
errors: [
{
message: `Customer.io attribute 'planType' must be in snake_case.`,
},
{
message: `Customer.io attribute 'groupSize' must be in snake_case.`,
},
],
},
],
}
)
const noThrowInCallbackMessage =
'Pass the error to the callback instead of throwing in callback-based code.'
ruleTester.run('no-throw-in-callback', noThrowInCallback, {
valid: [
// Calling the callback with an error is fine
{ code: `function foo(cb) { cb(new Error()) }` },
// async functions may throw (they return a rejected promise)
{ code: `async function foo(cb) { throw new Error() }` },
// Last param not a callback name — not a callback-style function
{ code: `function foo(data) { throw new Error() }` },
// No params at all
{ code: `function foo() { throw new Error() }` },
// throw inside a nested non-callback function is fine
{ code: `function foo(cb) { [1].map(function() { throw new Error() }) }` },
// throw inside a nested async arrow is fine
{ code: `function foo(cb) { [1].map(async () => { throw new Error() }) }` },
],
invalid: [
{
code: `function foo(cb) { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
{
code: `function foo(callback) { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
{
code: `function foo(done) { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
{
code: `function foo(next) { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
{
code: `function foo(data, cb) { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
{
code: `const foo = (cb) => { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
// throw in a nested callback-style function inside another callback function
{
code: `function foo(cb) { bar(function(done) { throw new Error() }) }`,
errors: [{ message: noThrowInCallbackMessage }],
},
],
})
ruleTester.run('no-raw-req-access', noRawReqAccess, {
valid: [
// validated access goes through parseReq
{ code: `const { params, body } = parseReq(req, schema)` },
// allowlisted middleware reads raw input explicitly
{ code: `const { query } = getRawReqInput(req)` },
// writes stay allowed: body parsers and middleware assign req.body
{ code: `req.body = {}` },
{ code: `req.query = parsed` },
// other request fields are not locked
{ code: `const ua = req.headers['user-agent']` },
{ code: `const s = req.session` },
// other objects with the same field names are fine
{ code: `const b = options.body` },
{ code: `const { body } = response` },
],
invalid: [
{
code: `const id = req.params.project_id`,
errors: [{ messageId: 'noRawReqAccess', data: { field: 'params' } }],
},
{
code: `if (req.query.force) { doIt() }`,
errors: [{ messageId: 'noRawReqAccess', data: { field: 'query' } }],
},
{
code: `send(req.body)`,
errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }],
},
// computed string access
{
code: `const b = req['body']`,
errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }],
},
// destructuring reads
{
code: `const { body } = req`,
errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }],
},
{
code: `const { params, query } = req`,
errors: [
{ messageId: 'noRawReqAccess', data: { field: 'params' } },
{ messageId: 'noRawReqAccess', data: { field: 'query' } },
],
},
// compound assignment reads before writing
{
code: `req.body ||= {}`,
errors: [{ messageId: 'noRawReqAccess', data: { field: 'body' } }],
},
// optional chaining
{
code: `const q = req?.query`,
errors: [{ messageId: 'noRawReqAccess', data: { field: 'query' } }],
},
],
})
ruleTester.run('require-rel-for-docs-links', requireRelForDocsLinks, {
valid: [
{
code: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer">x</a>`,
},
{
code: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noreferrer noopener">x</a>`,
},
{ code: `<a href="/learn/foo" target="_blank">x</a>` },
{ code: `<a href="https://docs.overleaf.com/foo">x</a>` },
{ code: `<a href="https://docs.overleaf.com/foo" target="_self">x</a>` },
{
code: `<OLButton href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer">x</OLButton>`,
},
{ code: `<OLButton href="https://docs.overleaf.com/foo">x</OLButton>` },
],
invalid: [
{
code: `<a href="https://docs.overleaf.com/foo" target="_blank">x</a>`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer">x</a>`,
},
{
code: `<a href="https://docs.overleaf.com/foo" target="_BLANK">x</a>`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<a href="https://docs.overleaf.com/foo" target="_BLANK" rel="noopener noreferrer">x</a>`,
},
{
code: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noopener">x</a>`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer">x</a>`,
},
{
code: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noreferrer">x</a>`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noreferrer noopener">x</a>`,
},
{
code: `<OLButton href="https://docs.overleaf.com/foo" target="_blank">x</OLButton>`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<OLButton href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer">x</OLButton>`,
},
{
// Boolean attribute (no value) - relAttr.value is null, must not crash the fixer.
code: `<a href="https://docs.overleaf.com/foo" target="_blank" rel />`,
errors: [
{
message:
'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
},
],
output: `<a href="https://docs.overleaf.com/foo" target="_blank" rel="noopener noreferrer" />`,
},
],
})
jsonRuleTester.run(
'no-consecutive-spaces-in-locales',
noConsecutiveSpacesInLocales,
{
valid: [
{ code: '{ "key": "one space" }' },
{ code: '{ "key": "no whitespace" }' },
],
invalid: [
{
code: '{ "key": "two spaces" }',
errors: [{ messageId: 'consecutiveSpaces' }],
output: '{ "key": "two spaces" }',
},
{
code: '{ "key": "three spaces" }',
errors: [{ messageId: 'consecutiveSpaces' }],
output: '{ "key": "three spaces" }',
},
{
// \t then space → two consecutive whitespace chars
code: '{ "key": "tab\\t and" }',
errors: [{ messageId: 'consecutiveSpaces' }],
output: '{ "key": "tab and" }',
},
],
}
)
jsonRuleTester.run(
'no-straight-apostrophes-in-locales',
noStraightApostrophesInLocales,
{
valid: [
{ code: '{ "key": "no apostrophe" }' },
{ code: '{ "key": "it’s curly" }' },
],
invalid: [
{
code: `{ "key": "it's straight" }`,
errors: [{ messageId: 'straightApostrophe' }],
output: '{ "key": "it’s straight" }',
},
],
}
)
jsonRuleTester.run('sorted-keys-in-locales', sortedKeysInLocales, {
valid: [
{ code: '{\n "a": "1",\n "b": "2"\n}\n' },
{ code: '{}' },
{ code: '{ "only": "one" }' },
],
invalid: [
{
code: '{\n "b": "2",\n "a": "1"\n}\n',
errors: [{ messageId: 'unsorted' }],
output: '{\n "a": "1",\n "b": "2"\n}\n',
},
],
})
jsonRuleTester.run('french-typography-in-locales', frenchTypographyInLocales, {
valid: [
{ code: '{ "key": "Bonjour ?" }' },
{ code: '{ "key": "Liste :" }' },
{ code: '{ "key": "« contenu »" }' },
{ code: '{ "key": "abc 123" }' },
{ code: '{ "key": "10 %" }' },
],
invalid: [
{
code: '{ "key": "Bonjour?" }',
errors: 1,
output: '{ "key": "Bonjour ?" }',
},
{
code: '{ "key": "Bonjour ?" }',
errors: 1,
output: '{ "key": "Bonjour ?" }',
},
{
code: '{ "key": "Liste:" }',
errors: 1,
output: '{ "key": "Liste :" }',
},
{
code: '{ "key": "«contenu»" }',
errors: 2,
output: '{ "key": "« contenu »" }',
},
{
code: '{ "key": "10%" }',
errors: 1,
output: '{ "key": "10 %" }',
},
{
code: '{ "key": "sûr(e)" }',
errors: [
{
message:
'expected point médian "·" instead of "(e)" for inclusive writing',
suggestions: [
{
desc: 'Replace "(e)" with "·e"',
output: '{ "key": "sûr·e" }',
},
],
},
],
},
],
})
const noMochaBeforeOptions = [
{ helperPath: 'server-ce/test/helpers/beforeWithReRunOnTestRetry' },
]
ruleTester.run('no-mocha-before', noMochaBefore, {
valid: [
{
code: `beforeEach(function () { setup() })`,
options: noMochaBeforeOptions,
},
{
code: `import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
beforeWithReRunOnTestRetry(function () { setup() })`,
options: noMochaBeforeOptions,
},
{
// locally defined `before` is not the mocha hook
code: `function before(fn) { fn() }
before(function () { setup() })`,
options: noMochaBeforeOptions,
},
],
invalid: [
{
// rename + import added after the last existing import
filename: 'server-ce/test/foo.spec.ts',
code: `import { login } from './helpers/login'
before(async function () { this.timeout(1000) })`,
options: noMochaBeforeOptions,
errors: 1,
output: `import { login } from './helpers/login'
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
beforeWithReRunOnTestRetry(async function () { this.timeout(1000) })`,
},
{
// no imports yet: import inserted at the top
filename: 'server-ce/test/foo.spec.ts',
code: `before(function () { setup() })`,
options: noMochaBeforeOptions,
errors: 1,
output: `import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
beforeWithReRunOnTestRetry(function () { setup() })`,
},
{
// helper already imported: rename only
filename: 'server-ce/test/foo.spec.ts',
code: `import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
before(function () { setup() })`,
options: noMochaBeforeOptions,
errors: 1,
output: `import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
beforeWithReRunOnTestRetry(function () { setup() })`,
},
{
// file inside helpers/: relative path has no directory prefix
filename: 'server-ce/test/helpers/config.ts',
code: `before(function () { setup() })`,
options: noMochaBeforeOptions,
errors: 1,
output: `import { beforeWithReRunOnTestRetry } from './beforeWithReRunOnTestRetry'
beforeWithReRunOnTestRetry(function () { setup() })`,
},
{
// two-argument mocha form: report without autofix
filename: 'server-ce/test/foo.spec.ts',
code: `before('named hook', function () { setup() })`,
options: noMochaBeforeOptions,
errors: 1,
},
{
// no helperPath configured: report without autofix
filename: 'server-ce/test/foo.spec.ts',
code: `before(function () { setup() })`,
errors: 1,
},
],
})
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.backend.json",
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
}
-25
View File
@@ -1,25 +0,0 @@
import { defineConfig, ViteUserConfig } from 'vitest/config'
let reporterOptions: ViteUserConfig['test'] = {}
if (process.env.CI) {
reporterOptions = {
reporters: [
'default',
[
'junit',
{
classnameTemplate: `Unit tests.{filename}`,
},
],
],
outputFile: 'reports/junit-vitest-unit.xml',
}
}
export default defineConfig({
test: {
include: ['test/unit/**/*.test.{js,ts}'],
setupFiles: ['./test/setup.js'],
isolate: false,
...reporterOptions,
},
})
+1
View File
@@ -0,0 +1 @@
node_modules/
+3
View File
@@ -0,0 +1,3 @@
# managed by monorepo$ bin/update_build_scripts
.npmrc
+6
View File
@@ -0,0 +1,6 @@
{
"ui": "bdd",
"recursive": "true",
"reporter": "spec",
"require": "test/setup.js"
}
+1
View File
@@ -0,0 +1 @@
20.18.2
+10
View File
@@ -0,0 +1,10 @@
fetch-utils
--dependencies=None
--docker-repos=gcr.io/overleaf-ops
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=20.18.2
--public-repo=False
--script-version=4.5.0
+320
View File
@@ -0,0 +1,320 @@
const _ = require('lodash')
const { Readable } = require('node:stream')
const OError = require('@overleaf/o-error')
const fetch = require('node-fetch')
const http = require('node:http')
const https = require('node:https')
/**
* @import { Response } from 'node-fetch'
*/
/**
* Make a request and return the parsed JSON response.
*
* @param {string | URL} url - request URL
* @param {any} [opts] - fetch options
* @return {Promise<any>} the parsed JSON response
* @throws {RequestFailedError} if the response has a failure status code
*/
async function fetchJson(url, opts = {}) {
const { json } = await fetchJsonWithResponse(url, opts)
return json
}
async function fetchJsonWithResponse(url, opts = {}) {
const { fetchOpts } = parseOpts(opts)
fetchOpts.headers = fetchOpts.headers ?? {}
fetchOpts.headers.Accept = fetchOpts.headers.Accept ?? 'application/json'
const response = await performRequest(url, fetchOpts)
if (!response.ok) {
const body = await maybeGetResponseBody(response)
throw new RequestFailedError(url, opts, response, body)
}
const json = await response.json()
return { json, response }
}
/**
* Make a request and return a stream.
*
* If the response body is destroyed, the request is aborted.
*
* @param {string | URL} url - request URL
* @param {any} [opts] - fetch options
* @return {Promise<Readable>}
* @throws {RequestFailedError} if the response has a failure status code
*/
async function fetchStream(url, opts = {}) {
const { stream } = await fetchStreamWithResponse(url, opts)
return stream
}
async function fetchStreamWithResponse(url, opts = {}) {
const { fetchOpts, abortController } = parseOpts(opts)
const response = await performRequest(url, fetchOpts)
if (!response.ok) {
const body = await maybeGetResponseBody(response)
throw new RequestFailedError(url, opts, response, body)
}
abortOnDestroyedResponse(abortController, response)
const stream = response.body
return { stream, response }
}
/**
* Make a request and discard the response.
*
* @param {string | URL} url - request URL
* @param {any} [opts] - fetch options
* @return {Promise<Response>}
* @throws {RequestFailedError} if the response has a failure status code
*/
async function fetchNothing(url, opts = {}) {
const { fetchOpts } = parseOpts(opts)
const response = await performRequest(url, fetchOpts)
if (!response.ok) {
const body = await maybeGetResponseBody(response)
throw new RequestFailedError(url, opts, response, body)
}
await discardResponseBody(response)
return response
}
/**
* Make a request and extract the redirect from the response.
*
* @param {string | URL} url - request URL
* @param {any} [opts] - fetch options
* @return {Promise<string>}
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
*/
async function fetchRedirect(url, opts = {}) {
const { location } = await fetchRedirectWithResponse(url, opts)
return location
}
/**
* Make a request and extract the redirect from the response.
*
* @param {string | URL} url - request URL
* @param {object} opts - fetch options
* @return {Promise<{location: string, response: Response}>}
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
*/
async function fetchRedirectWithResponse(url, opts = {}) {
const { fetchOpts } = parseOpts(opts)
fetchOpts.redirect = 'manual'
const response = await performRequest(url, fetchOpts)
if (response.status < 300 || response.status >= 400) {
const body = await maybeGetResponseBody(response)
throw new RequestFailedError(url, opts, response, body)
}
const location = response.headers.get('Location')
if (!location) {
const body = await maybeGetResponseBody(response)
throw new RequestFailedError(url, opts, response, body).withCause(
new OError('missing Location response header on 3xx response', {
headers: Object.fromEntries(response.headers.entries()),
})
)
}
await discardResponseBody(response)
return { location, response }
}
/**
* Make a request and return a string.
*
* @param {string | URL} url - request URL
* @param {any} [opts] - fetch options
* @return {Promise<string>}
* @throws {RequestFailedError} if the response has a failure status code
*/
async function fetchString(url, opts = {}) {
const { body } = await fetchStringWithResponse(url, opts)
return body
}
async function fetchStringWithResponse(url, opts = {}) {
const { fetchOpts } = parseOpts(opts)
const response = await performRequest(url, fetchOpts)
if (!response.ok) {
const body = await maybeGetResponseBody(response)
throw new RequestFailedError(url, opts, response, body)
}
const body = await response.text()
return { body, response }
}
class RequestFailedError extends OError {
constructor(url, opts, response, body) {
super('request failed', {
url,
method: opts.method ?? 'GET',
status: response.status,
})
this.response = response
if (body != null) {
this.body = body
}
}
}
function parseOpts(opts) {
const fetchOpts = _.omit(opts, ['json', 'signal', 'basicAuth'])
if (opts.json) {
setupJsonBody(fetchOpts, opts.json)
}
if (opts.basicAuth) {
setupBasicAuth(fetchOpts, opts.basicAuth)
}
const abortController = new AbortController()
fetchOpts.signal = abortController.signal
if (opts.signal) {
abortOnSignal(abortController, opts.signal)
}
if (opts.body instanceof Readable) {
abortOnDestroyedRequest(abortController, fetchOpts.body)
}
return { fetchOpts, abortController }
}
function setupJsonBody(fetchOpts, json) {
fetchOpts.body = JSON.stringify(json)
fetchOpts.headers = fetchOpts.headers ?? {}
fetchOpts.headers['Content-Type'] = 'application/json'
}
function setupBasicAuth(fetchOpts, basicAuth) {
fetchOpts.headers = fetchOpts.headers ?? {}
fetchOpts.headers.Authorization =
'Basic ' +
Buffer.from(`${basicAuth.user}:${basicAuth.password}`).toString('base64')
}
function abortOnSignal(abortController, signal) {
const listener = () => {
abortController.abort(signal.reason)
}
if (signal.aborted) {
abortController.abort(signal.reason)
}
signal.addEventListener('abort', listener)
}
function abortOnDestroyedRequest(abortController, stream) {
stream.on('close', () => {
if (!stream.readableEnded) {
abortController.abort()
}
})
}
function abortOnDestroyedResponse(abortController, response) {
response.body.on('close', () => {
if (!response.bodyUsed) {
abortController.abort()
}
})
}
async function performRequest(url, fetchOpts) {
let response
try {
response = await fetch(url, fetchOpts)
} catch (err) {
if (fetchOpts.body instanceof Readable) {
fetchOpts.body.destroy()
}
throw OError.tag(err, err.message, {
url,
method: fetchOpts.method ?? 'GET',
})
}
if (fetchOpts.body instanceof Readable) {
response.body.on('close', () => {
if (!fetchOpts.body.readableEnded) {
fetchOpts.body.destroy()
}
})
}
return response
}
async function discardResponseBody(response) {
// eslint-disable-next-line no-unused-vars
for await (const chunk of response.body) {
// discard the body
}
}
/**
* @param {Response} response
*/
async function maybeGetResponseBody(response) {
try {
return await response.text()
} catch (err) {
return null
}
}
// Define custom http and https agents with support for connect timeouts
class ConnectTimeoutError extends OError {
constructor(options) {
super('connect timeout', options)
}
}
function withTimeout(createConnection, options, callback) {
if (options.connectTimeout) {
// Wrap createConnection in a timeout
const timer = setTimeout(() => {
socket.destroy(new ConnectTimeoutError(options))
}, options.connectTimeout)
const socket = createConnection(options, (err, stream) => {
clearTimeout(timer)
callback(err, stream)
})
return socket
} else {
// Fallback to default createConnection
return createConnection(options, callback)
}
}
class CustomHttpAgent extends http.Agent {
createConnection(options, callback) {
return withTimeout(super.createConnection.bind(this), options, callback)
}
}
class CustomHttpsAgent extends https.Agent {
createConnection(options, callback) {
return withTimeout(super.createConnection.bind(this), options, callback)
}
}
module.exports = {
fetchJson,
fetchJsonWithResponse,
fetchStream,
fetchStreamWithResponse,
fetchNothing,
fetchRedirect,
fetchRedirectWithResponse,
fetchString,
fetchStringWithResponse,
RequestFailedError,
ConnectTimeoutError,
CustomHttpAgent,
CustomHttpsAgent,
}

Some files were not shown because too many files have changed in this diff Show More