mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38250cd239 | ||
|
|
c7579e3e74 | ||
|
|
fa93cbab49 | ||
|
|
f3075e1a0c | ||
|
|
661bbf1de1 | ||
|
|
341ddc2106 | ||
|
|
77cae80c16 | ||
|
|
c8baf113d4 | ||
|
|
82d8e2fde3 | ||
|
|
d0735d5d8a | ||
|
|
3828f4c51c | ||
|
|
1e35481223 | ||
|
|
5d3441e08f | ||
|
|
a55f2a497f | ||
|
|
5e465336ba | ||
|
|
f13a251381 | ||
|
|
1e0e9473d2 | ||
|
|
9a1d84887a | ||
|
|
6c4ba6ff5e | ||
|
|
dc99fbc810 | ||
|
|
ccecdc3fb5 | ||
|
|
d4e0184b5e | ||
|
|
92fd2b0997 | ||
|
|
ff9872d8bf | ||
|
|
8bf99c7e29 | ||
|
|
df7a4fa9ff | ||
|
|
ea73e68f56 | ||
|
|
2d4a923bfc | ||
|
|
7ae7736a8c | ||
|
|
b1cf1f3c4b | ||
|
|
7e7db051e5 | ||
|
|
820f3a1e2d | ||
|
|
b569fad605 | ||
|
|
0829a07998 | ||
|
|
ec4ad68791 | ||
|
|
623f78905b | ||
|
|
031d83a605 | ||
|
|
bbb212bdf6 | ||
|
|
0567c8927e | ||
|
|
4ef5f28282 | ||
|
|
32ee988a26 | ||
|
|
9374c22bb6 | ||
|
|
a47638c120 | ||
|
|
a7349c0c7e | ||
|
|
83f2ec5f1c | ||
|
|
5c68279105 | ||
|
|
e226d1b179 | ||
|
|
e59d381d60 | ||
|
|
b6e33c120d | ||
|
|
9472d977b0 | ||
|
|
5673353770 | ||
|
|
942bd4b7f8 | ||
|
|
5db0f4fece | ||
|
|
9db3d2cda3 | ||
|
|
d871ce961a | ||
|
|
0f8e6979e2 | ||
|
|
c3814a9062 | ||
|
|
de346104a1 | ||
|
|
4499088928 | ||
|
|
8a3ff69f3e | ||
|
|
76623f85eb | ||
|
|
1348946421 | ||
|
|
1f9d03c389 | ||
|
|
51a9ce881c | ||
|
|
59a140fe30 | ||
|
|
13fb6cd418 | ||
|
|
343a7de538 | ||
|
|
0b2b709638 | ||
|
|
b4896abdea | ||
|
|
b72a9f9e42 | ||
|
|
164f160243 | ||
|
|
6a4647ee60 | ||
|
|
4a9e22c487 | ||
|
|
b062f92ce9 | ||
|
|
6c97b44b5b | ||
|
|
cb2a4739ff | ||
|
|
9755e7f500 | ||
|
|
6dec15bb6f | ||
|
|
4e77cc0471 | ||
|
|
60c66ad1d5 | ||
|
|
c9f570f108 | ||
|
|
4d5be374cd | ||
|
|
9880977b34 | ||
|
|
f76afc3aba | ||
|
|
1e53bff130 | ||
|
|
6becc486fa | ||
|
|
8f5267cde9 | ||
|
|
4e42c6d232 | ||
|
|
f502d4e4b5 | ||
|
|
e965b4f0f0 | ||
|
|
5f8118ee61 | ||
|
|
effbd8ed6b | ||
|
|
02c474afbb | ||
|
|
73b49a0dfe | ||
|
|
65af49b820 | ||
|
|
3a1e4f13ed | ||
|
|
4671f26904 | ||
|
|
23fb6e3ebb | ||
|
|
a2416a499c | ||
|
|
ff04f9a073 | ||
|
|
e534b32953 | ||
|
|
da1567cc51 | ||
|
|
f59d616422 | ||
|
|
e094fb59df | ||
|
|
efc0289497 | ||
|
|
bcb5af48ae | ||
|
|
e6db566a9c | ||
|
|
c7350d5877 | ||
|
|
3f6acabf26 | ||
|
|
dc78d3f028 | ||
|
|
6b3472e3a8 | ||
|
|
8dc220fdc5 | ||
|
|
ffe7d44174 | ||
|
|
5b9c6985d4 | ||
|
|
6e4e540c5a | ||
|
|
837519b08d | ||
|
|
9bc7fcf77a | ||
|
|
dbd14d5e86 | ||
|
|
36719cbf3a | ||
|
|
899d4cd66f | ||
|
|
3bd8b49ae1 | ||
|
|
cfeed73e52 | ||
|
|
81d51d1498 | ||
|
|
bbd5300f66 | ||
|
|
8dea2fab50 | ||
|
|
a66d4389bf | ||
|
|
9aee49e3b8 | ||
|
|
636231490e | ||
|
|
0d0450b7ca |
@@ -0,0 +1,25 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.{pug,coffee}]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.{pug,patch}]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[Jenkinsfile]
|
||||||
|
insert_final_newline = false
|
||||||
|
max_line_length = off
|
||||||
Generated
-58
@@ -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,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)) {
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
diff --git a/lib/read.js b/lib/read.js
|
||||||
|
index fce6283..6131c31 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
@@ -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']) ||
|
|
||||||
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
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
diff --git a/lib/make-middleware.js b/lib/make-middleware.js
|
||||||
|
index ee50988..de77364 100644
|
||||||
|
--- a/lib/make-middleware.js
|
||||||
|
+++ b/lib/make-middleware.js
|
||||||
|
@@ -164,7 +164,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')) {
|
||||||
@@ -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')) {
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
diff --git a/lib/index.js b/lib/index.js
|
diff --git a/lib/index.js b/lib/index.js
|
||||||
index 567ff5d..a7a38dd 100644
|
index 567ff5d..8eb45f7 100644
|
||||||
--- a/lib/index.js
|
--- a/lib/index.js
|
||||||
+++ b/lib/index.js
|
+++ b/lib/index.js
|
||||||
@@ -545,8 +545,8 @@ function clone(instance) {
|
@@ -545,8 +545,8 @@ function clone(instance) {
|
||||||
@@ -74,24 +74,3 @@ index 567ff5d..a7a38dd 100644
|
|||||||
response = new Response(body, response_options);
|
response = new Response(body, response_options);
|
||||||
resolve(response);
|
resolve(response);
|
||||||
return;
|
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,82 +1,10 @@
|
|||||||
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
|
diff --git a/lib/utils.js b/lib/utils.js
|
||||||
index 486f9e1..4584507 100644
|
index 486f9e1..4584507 100644
|
||||||
--- a/lib/utils.js
|
--- a/lib/utils.js
|
||||||
+++ b/lib/utils.js
|
+++ b/lib/utils.js
|
||||||
@@ -24,7 +24,7 @@ exports.originalURL = function(req, options) {
|
@@ -24,7 +24,7 @@ exports.originalURL = function(req, options) {
|
||||||
var trustProxy = options.proxy;
|
var trustProxy = options.proxy;
|
||||||
|
|
||||||
var proto = (req.headers['x-forwarded-proto'] || '').toLowerCase()
|
var proto = (req.headers['x-forwarded-proto'] || '').toLowerCase()
|
||||||
- , tls = req.connection.encrypted || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0])
|
- , tls = req.connection.encrypted || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0])
|
||||||
+ , tls = (req.connection && req.connection.encrypted) || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0])
|
+ , tls = (req.connection && req.connection.encrypted) || (trustProxy && 'https' == proto.split(/\s*,\s*/)[0])
|
||||||
|
|||||||
@@ -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,
|
|
||||||
@@ -9,6 +9,18 @@ diff --git a/package.json b/package.json
|
|||||||
"description": "library for extracting marketing attribution data (such as search terms) from referrer URLs",
|
"description": "library for extracting marketing attribution data (such as search terms) from referrer URLs",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@@ -15,9 +15,9 @@
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
- "js-yaml": "~2.1.0"
|
||||||
|
+ "js-yaml": "^4.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
- "mocha": "~1.12.0"
|
||||||
|
+ "mocha": "~10.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
diff --git a/index.js b/index.js
|
diff --git a/index.js b/index.js
|
||||||
--- a/index.js
|
--- a/index.js
|
||||||
+++ b/index.js
|
+++ b/index.js
|
||||||
|
|||||||
@@ -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
-85
@@ -7,94 +7,10 @@ enableScripts: true
|
|||||||
|
|
||||||
enableTelemetry: false
|
enableTelemetry: false
|
||||||
|
|
||||||
nodeLinker: pnp
|
nodeLinker: node-modules
|
||||||
|
|
||||||
npmMinimalAgeGate: 3d
|
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:
|
supportedArchitectures:
|
||||||
cpu:
|
cpu:
|
||||||
- current
|
- current
|
||||||
|
|||||||
+1
-6
@@ -5,7 +5,7 @@ In most cases the original code has been modified, optimized, or extended.
|
|||||||
|
|
||||||
## Symbol palette
|
## Symbol palette
|
||||||
|
|
||||||
The Symbol palette feature is based on the
|
The symbol palette feature is based on the
|
||||||
[original Overleaf implementation](https://github.com/overleaf/web/tree/master/frontend/js/features/symbol-palette)
|
[original Overleaf implementation](https://github.com/overleaf/web/tree/master/frontend/js/features/symbol-palette)
|
||||||
|
|
||||||
The original code was slightly improved, particularly in the parts related to keyboard input.
|
The original code was slightly improved, particularly in the parts related to keyboard input.
|
||||||
@@ -47,11 +47,6 @@ from [here](https://github.com/ayaka-notes/overleaf-pro/commit/06a30fe9a0ed75e5a
|
|||||||
|
|
||||||
The Sign Up page is based on [this code](https://github.com/ayaka-notes/overleaf-pro/tree/feat-public-registeration).
|
The Sign Up page is based on [this code](https://github.com/ayaka-notes/overleaf-pro/tree/feat-public-registeration).
|
||||||
|
|
||||||
## Python script runner
|
|
||||||
|
|
||||||
The Python script runner feature is the original Overleaf implementation, which
|
|
||||||
[was hidden behind a paywall](https://github.com/overleaf/overleaf/commit/b90a8500d5df763d1a9b00352928a79c7e247de7) in later releases.
|
|
||||||
|
|
||||||
# Acknowledgments
|
# Acknowledgments
|
||||||
|
|
||||||
Thanks to the users of the project for valuable feedback, suggestions,
|
Thanks to the users of the project for valuable feedback, suggestions,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<img src="doc/screenshot.png" alt="A screenshot of a project being edited in Overleaf Extended Community Edition">
|
<img src="doc/screenshot.png" alt="A screenshot of a project being edited in Overleaf Extended Community Edition">
|
||||||
<p align="center">
|
<p align="center">
|
||||||
Figure 1: A screenshot of a project being edited in Overleaf CE+
|
Figure 1: A screenshot of a project being edited in Overleaf Extended Community Edition.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Community Edition
|
## Community Edition
|
||||||
@@ -40,7 +40,6 @@ The present "extended" version of Overleaf CE includes:
|
|||||||
- Reference Search and Pick Tool
|
- Reference Search and Pick Tool
|
||||||
- Document Import (`.docx`, `.md`) and Export (`.docx`, `.md`, `.html`)
|
- Document Import (`.docx`, `.md`) and Export (`.docx`, `.md`, `.html`)
|
||||||
- Advanced administrator tools for managing user accounts and projects
|
- Advanced administrator tools for managing user accounts and projects
|
||||||
- Python script runner
|
|
||||||
- Logo tools
|
- Logo tools
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
@@ -82,13 +81,13 @@ folder are added.
|
|||||||
## Authors
|
## Authors
|
||||||
|
|
||||||
[The Overleaf Team](https://www.overleaf.com/about)\
|
[The Overleaf Team](https://www.overleaf.com/about)\
|
||||||
[yu-i-i](https://github.com/yu-i-i), [davrot](https://github.com/davrot) — CE extensions; references to adapted code are listed in [`CREDITS`](CREDITS.md). See also the authors of individual commits.
|
[yu-i-i](https://github.com/yu-i-i), [davrot](https://github.com/davrot) — CE extensions; references to adapted code are listed in [`CREDITS`](CREDITS.md)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file.
|
The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file.
|
||||||
|
|
||||||
Copyright (c) Overleaf, 2014-2026.\
|
Copyright (c) Overleaf, 2014-2026.\
|
||||||
Copyright (c) @yu-i-i, @davrot, 2024-2026, for CE extensions.
|
Copyright (c) @yu-i-i, 2024-2026, for CE extensions.
|
||||||
|
|
||||||
Portions of the code are derived from other open-source projects; see [`CREDITS`](CREDITS.md).
|
Portions of the code are derived from other open-source projects; see [`CREDITS`](CREDITS.md).
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/compiles/*
|
||||||
|
!.gitkeep
|
||||||
|
.env
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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 `node --watch`, 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 |
|
||||||
|
| `docstore` | 9233 |
|
||||||
|
| `document-updater` | 9234 |
|
||||||
|
| `filestore` | 9235 |
|
||||||
|
| `notifications` | 9236 |
|
||||||
|
| `real-time` | 9237 |
|
||||||
|
| `history-v1` | 9239 |
|
||||||
|
| `project-history` | 9240 |
|
||||||
|
| `linked-url-proxy` | 9241 |
|
||||||
|
|
||||||
|
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.
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
docker compose build --pull "$@"
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --no-deps --detach "$@"
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
docker compose down "$@"
|
||||||
Executable
+9
@@ -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
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
docker compose exec -it "$@" /bin/bash
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
docker compose up --detach "$@"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CHAT_HOST=chat
|
||||||
|
CLSI_HOST=clsi
|
||||||
|
DOWNLOAD_HOST=clsi-nginx
|
||||||
|
DOCSTORE_HOST=docstore
|
||||||
|
DOCUMENT_UPDATER_HOST=document-updater
|
||||||
|
FILESTORE_HOST=filestore
|
||||||
|
GRACEFUL_SHUTDOWN_DELAY_SECONDS=0
|
||||||
|
HISTORY_V1_HOST=history-v1
|
||||||
|
HISTORY_REDIS_HOST=redis
|
||||||
|
LINKED_URL_PROXY_HOST=linked-url-proxy
|
||||||
|
LISTEN_ADDRESS=0.0.0.0
|
||||||
|
MONGO_HOST=mongo
|
||||||
|
MONGO_URL=mongodb://mongo/sharelatex?directConnection=true
|
||||||
|
NOTIFICATIONS_HOST=notifications
|
||||||
|
PROJECT_HISTORY_HOST=project-history
|
||||||
|
QUEUES_REDIS_HOST=redis
|
||||||
|
DSMP_REDIS_HOST=redis
|
||||||
|
REALTIME_HOST=real-time
|
||||||
|
REDIS_HOST=redis
|
||||||
|
SESSION_SECRET=foo
|
||||||
|
V1_HISTORY_HOST=history-v1
|
||||||
|
WEBPACK_HOST=webpack
|
||||||
|
WEB_API_PASSWORD=overleaf
|
||||||
|
WEB_API_USER=overleaf
|
||||||
|
WEB_HOST=web
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
linked-url-proxy:
|
||||||
|
command: ["node", "--watch", "app.mjs"]
|
||||||
|
environment:
|
||||||
|
- NODE_OPTIONS=--inspect=0.0.0.0:9229
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9241:9229"
|
||||||
|
volumes:
|
||||||
|
- ../services/linked-url-proxy/app:/overleaf/services/linked-url-proxy/app
|
||||||
|
- ../services/linked-url-proxy/config:/overleaf/services/linked-url-proxy/config
|
||||||
|
- ../services/linked-url-proxy/app.mjs:/overleaf/services/linked-url-proxy/app.mjs
|
||||||
|
|
||||||
|
notifications:
|
||||||
|
command: ["node", "--watch", "app.ts"]
|
||||||
|
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.ts:/overleaf/services/notifications/app.ts
|
||||||
|
- ../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
|
||||||
|
|
||||||
|
web:
|
||||||
|
command: ["node", "--watch", "app.mjs", "--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
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
volumes:
|
||||||
|
clsi-cache:
|
||||||
|
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
|
||||||
|
target: with-texlive
|
||||||
|
env_file:
|
||||||
|
- dev.env
|
||||||
|
environment:
|
||||||
|
- SANDBOXED_COMPILES=false
|
||||||
|
user: root
|
||||||
|
volumes:
|
||||||
|
- ${PWD}/compiles:/overleaf/services/clsi/compiles
|
||||||
|
- ${PWD}/output:/overleaf/services/clsi/output
|
||||||
|
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
||||||
|
- clsi-cache:/overleaf/services/clsi/cache
|
||||||
|
|
||||||
|
clsi-nginx:
|
||||||
|
image: nginx:1.28
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
- /var/cache/nginx
|
||||||
|
- /run
|
||||||
|
volumes:
|
||||||
|
- ${PWD}/output:/output:ro
|
||||||
|
- ../services/clsi/nginx.conf:/etc/nginx/conf.d/nginx.conf:ro
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
linked-url-proxy:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: services/linked-url-proxy/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- dev.env
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
image: mongo:8
|
||||||
|
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:7
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:6379:6379" # for debugging
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
|
||||||
|
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,zotero
|
||||||
|
- 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
|
||||||
|
- docstore
|
||||||
|
- document-updater
|
||||||
|
- filestore
|
||||||
|
- history-v1
|
||||||
|
- linked-url-proxy
|
||||||
|
- notifications
|
||||||
|
- project-history
|
||||||
|
- real-time
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
git-bridge:
|
||||||
|
image: quay.io/sharelatex/git-bridge:latest
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
GIT_BRIDGE_API_BASE_URL: "http://web:3000/api/v0"
|
||||||
|
GIT_BRIDGE_OAUTH2_SERVER: "http://web:3000"
|
||||||
|
GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000"
|
||||||
|
GIT_BRIDGE_ROOT_DIR: "/data/git-bridge"
|
||||||
|
LOG_LEVEL: "DEBUG"
|
||||||
|
volumes:
|
||||||
|
- ./data/git-bridge:/data/git-bridge
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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: '/git/**',
|
||||||
|
target: 'http://git-bridge:8000',
|
||||||
|
pathRewrite: { '^/git': '' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: ['!**/*.js', '!**/*.css', '!**/*.json'],
|
||||||
|
target: 'http://web:3000',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 412 KiB After Width: | Height: | Size: 525 KiB |
@@ -47,8 +47,6 @@ services:
|
|||||||
# OVERLEAF_SITE_URL: http://overleaf.example.com
|
# OVERLEAF_SITE_URL: http://overleaf.example.com
|
||||||
# OVERLEAF_NAV_TITLE: Overleaf Community Edition
|
# OVERLEAF_NAV_TITLE: Overleaf Community Edition
|
||||||
# OVERLEAF_HEADER_IMAGE_URL: http://example.com/mylogo.png
|
# 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_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_LEFT_FOOTER: '[{"text": "Another page I want to link to can be found <a href=\"here\">here</a>"} ]'
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM cypress/included:15.12.0
|
||||||
|
ARG USER_UID=1000
|
||||||
|
ARG USER_GID=1000
|
||||||
|
|
||||||
|
# Corepack setup, shared between all the images.
|
||||||
|
ENV PATH="/overleaf/node_modules/.bin:$PATH"
|
||||||
|
ENV COREPACK_HOME=/opt/corepack
|
||||||
|
RUN corepack enable && corepack install -g yarn@4.14.1
|
||||||
|
ENV COREPACK_ENABLE_NETWORK=0
|
||||||
|
|
||||||
|
WORKDIR /overleaf
|
||||||
|
|
||||||
|
RUN sed -i s/node:x:1000:/node:x:${USER_GID}:/ /etc/group \
|
||||||
|
&& sed -i s_node:x:1000:1000::/home/node:/bin/bash_node:x:${USER_UID}:${USER_GID}::/home/node:/bin/bash_ /etc/passwd \
|
||||||
|
&& chown -R node:node /home/node \
|
||||||
|
&& chown node:node /overleaf
|
||||||
|
|
||||||
|
USER node
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24.14.1
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
access-token-encryptor
|
||||||
|
--dependencies=None
|
||||||
|
--env-add=
|
||||||
|
--env-pass-through=
|
||||||
|
--esmock-loader=False
|
||||||
|
--is-library=True
|
||||||
|
--node-version=24.14.1
|
||||||
|
--package-name=@overleaf/access-token-encryptor
|
||||||
|
--pipeline-owner=32
|
||||||
|
--public-repo=False
|
||||||
@@ -3,13 +3,9 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"exports": {
|
|
||||||
".": "./index.js",
|
|
||||||
"./*": "./*"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
"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": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||||
"test:ci": "yarn run test:unit",
|
"test:ci": "yarn run test:unit",
|
||||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||||
@@ -21,10 +17,8 @@
|
|||||||
"lodash": "^4.18.1"
|
"lodash": "^4.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.5.2",
|
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"eslint": "^10.4.0",
|
|
||||||
"mocha": "^11.1.0",
|
"mocha": "^11.1.0",
|
||||||
"mocha-junit-reporter": "^2.2.1",
|
"mocha-junit-reporter": "^2.2.1",
|
||||||
"mocha-multi-reporters": "^1.5.1",
|
"mocha-multi-reporters": "^1.5.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||||
mochaJunitReporterReporterOptions: {
|
mochaJunitReporterReporterOptions: {
|
||||||
mochaFile: `reports/junit-mocha-${process.env.TEST_NAME_PATTERN}.xml`,
|
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
|
||||||
includePending: true,
|
includePending: true,
|
||||||
jenkinsMode: true,
|
jenkinsMode: true,
|
||||||
output: true,
|
output: true,
|
||||||
|
|||||||
@@ -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 +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"]
|
|
||||||
}
|
|
||||||
@@ -13,16 +13,13 @@ module.exports = {
|
|||||||
'require-script-runner': require('./require-script-runner'),
|
'require-script-runner': require('./require-script-runner'),
|
||||||
'require-vi-doMock-valid-path': require('./require-vi-doMock-valid-path'),
|
'require-vi-doMock-valid-path': require('./require-vi-doMock-valid-path'),
|
||||||
'require-loading-label': require('./require-loading-label'),
|
'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'),
|
'require-cio-snake-case-properties': require('./require-cio-snake-case-properties'),
|
||||||
'no-throw-in-callback': require('./no-throw-in-callback'),
|
'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-consecutive-spaces-in-locales': require('./no-consecutive-spaces-in-locales'),
|
||||||
'no-straight-apostrophes-in-locales': require('./no-straight-apostrophes-in-locales'),
|
'no-straight-apostrophes-in-locales': require('./no-straight-apostrophes-in-locales'),
|
||||||
'french-typography-in-locales': require('./french-typography-in-locales'),
|
'french-typography-in-locales': require('./french-typography-in-locales'),
|
||||||
'sorted-keys-in-locales': require('./sorted-keys-in-locales'),
|
'sorted-keys-in-locales': require('./sorted-keys-in-locales'),
|
||||||
'locale-variables-match-en': require('./locale-variables-match-en'),
|
'locale-variables-match-en': require('./locale-variables-match-en'),
|
||||||
'no-orphan-locale-keys': require('./no-orphan-locale-keys'),
|
'no-orphan-locale-keys': require('./no-orphan-locale-keys'),
|
||||||
'no-mocha-before': require('./no-mocha-before'),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -9,18 +9,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/json": "^2.0.0",
|
"@eslint/json": "^2.0.0",
|
||||||
"@types/node": "^24.5.2",
|
"@typescript-eslint/parser": "^8.59.4"
|
||||||
"@typescript-eslint/parser": "^8.59.4",
|
},
|
||||||
"eslint": "^10.4.0",
|
"peerDependencies": {
|
||||||
"typescript": "^5.0.4",
|
"eslint": "^10.4.0"
|
||||||
"vitest": "4.1.5"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
"test": "node rules.test.js"
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ const ignoreWords = {
|
|||||||
'resendManagedUserInvite',
|
'resendManagedUserInvite',
|
||||||
'salesContactForm',
|
'salesContactForm',
|
||||||
'showSupport',
|
'showSupport',
|
||||||
'uniExternalCollaboration',
|
|
||||||
]),
|
]),
|
||||||
other: new Set([
|
other: new Set([
|
||||||
'Project',
|
'Project',
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const getSuggestion = routePath => {
|
|||||||
|
|
||||||
if (routePath instanceof RegExp) {
|
if (routePath instanceof RegExp) {
|
||||||
const words = removeTextBetweenBrackets(routePath.source).match(/[\w-]+/g)
|
const words = removeTextBetweenBrackets(routePath.source).match(/[\w-]+/g)
|
||||||
if (!words) return null
|
if (!words) return routePath
|
||||||
|
|
||||||
let newSource = routePath.source
|
let newSource = routePath.source
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
@@ -42,7 +42,9 @@ const getSuggestion = routePath => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kebabed = new RegExp(newSource, routePath.flags)
|
const kebabed = new RegExp(newSource, routePath.flags)
|
||||||
return kebabed.source === routePath.source ? null : kebabed.toString()
|
return kebabed.source.toString() === routePath.source.toString()
|
||||||
|
? null
|
||||||
|
: kebabed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}"`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
+20
-252
@@ -1,23 +1,17 @@
|
|||||||
import { fileURLToPath } from 'node:url'
|
const { RuleTester } = require('eslint')
|
||||||
import { RuleTester } from 'eslint'
|
const tsParser = require('@typescript-eslint/parser')
|
||||||
import tsParser from '@typescript-eslint/parser'
|
const json = require('@eslint/json').default
|
||||||
import json from '@eslint/json'
|
const noThrowInCallback = require('./no-throw-in-callback')
|
||||||
import noThrowInCallback from '../../no-throw-in-callback.js'
|
const preferKebabUrl = require('./prefer-kebab-url')
|
||||||
import noRawReqAccess from '../../no-raw-req-access.js'
|
const noUnnecessaryTrans = require('./no-unnecessary-trans')
|
||||||
import preferKebabUrl from '../../prefer-kebab-url.js'
|
const shouldUnescapeTrans = require('./should-unescape-trans')
|
||||||
import noUnnecessaryTrans from '../../no-unnecessary-trans.js'
|
const noGeneratedEditorThemes = require('./no-generated-editor-themes')
|
||||||
import shouldUnescapeTrans from '../../should-unescape-trans.js'
|
const viDoMockValidPath = require('./require-vi-doMock-valid-path')
|
||||||
import noGeneratedEditorThemes from '../../no-generated-editor-themes.js'
|
const requireCioSnakeCaseProperties = require('./require-cio-snake-case-properties')
|
||||||
import viDoMockValidPath from '../../require-vi-doMock-valid-path.js'
|
const noConsecutiveSpacesInLocales = require('./no-consecutive-spaces-in-locales')
|
||||||
import requireCioSnakeCaseProperties from '../../require-cio-snake-case-properties.js'
|
const noStraightApostrophesInLocales = require('./no-straight-apostrophes-in-locales')
|
||||||
import requireRelForDocsLinks from '../../require-rel-for-docs-links.js'
|
const frenchTypographyInLocales = require('./french-typography-in-locales')
|
||||||
import noConsecutiveSpacesInLocales from '../../no-consecutive-spaces-in-locales.js'
|
const sortedKeysInLocales = require('./sorted-keys-in-locales')
|
||||||
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({
|
const ruleTester = new RuleTester({
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
@@ -69,15 +63,7 @@ ruleTester.run('prefer-kebab-url', preferKebabUrl, {
|
|||||||
{
|
{
|
||||||
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
|
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||||
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$/)`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -170,28 +156,28 @@ ruleTester.run('no-generated-editor-themes', noGeneratedEditorThemes, {
|
|||||||
ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
|
ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
|
||||||
valid: [
|
valid: [
|
||||||
{
|
{
|
||||||
code: 'vi.doMock("../../require-vi-doMock-valid-path.js")',
|
code: 'vi.doMock("./require-vi-doMock-valid-path.js")',
|
||||||
filename: __filename,
|
filename: __filename,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'const filename = "../../require-vi-doMock-valid-path.js"; vi.doMock(filename);',
|
code: 'const filename = "./require-vi-doMock-valid-path.js"; vi.doMock(filename);',
|
||||||
filename: __filename,
|
filename: __filename,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
invalid: [
|
invalid: [
|
||||||
{
|
{
|
||||||
code: "vi.doMock('../../require-vi-doMock-valid-path2')",
|
code: "vi.doMock('./require-vi-doMock-valid-path2')",
|
||||||
filename: __filename,
|
filename: __filename,
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'The path "../../require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
|
'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'const filename = "../../require-vi-doMock-valid-path2.js"; vi.doMock(filename);',
|
code: 'const filename = "./require-vi-doMock-valid-path2.js"; vi.doMock(filename);',
|
||||||
filename: __filename,
|
filename: __filename,
|
||||||
errors: [
|
errors: [
|
||||||
{
|
{
|
||||||
@@ -354,146 +340,6 @@ ruleTester.run('no-throw-in-callback', noThrowInCallback, {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
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(
|
jsonRuleTester.run(
|
||||||
'no-consecutive-spaces-in-locales',
|
'no-consecutive-spaces-in-locales',
|
||||||
noConsecutiveSpacesInLocales,
|
noConsecutiveSpacesInLocales,
|
||||||
@@ -607,81 +453,3 @@ jsonRuleTester.run('french-typography-in-locales', frenchTypographyInLocales, {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.backend.json",
|
"extends": "../../tsconfig.backend.json",
|
||||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
"include": ["**/*.js"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24.14.1
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
fetch-utils
|
||||||
|
--dependencies=None
|
||||||
|
--env-add=
|
||||||
|
--env-pass-through=
|
||||||
|
--esmock-loader=False
|
||||||
|
--is-library=True
|
||||||
|
--node-version=24.14.1
|
||||||
|
--package-name=@overleaf/fetch-utils
|
||||||
|
--pipeline-owner=32
|
||||||
|
--public-repo=False
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
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')
|
||||||
|
|
||||||
|
let logger
|
||||||
|
|
||||||
|
function setLogger(loggerInstance) {
|
||||||
|
logger = loggerInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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, detachSignal } = parseOpts(opts, url)
|
||||||
|
fetchOpts.headers = fetchOpts.headers ?? {}
|
||||||
|
fetchOpts.headers.Accept = fetchOpts.headers.Accept ?? 'application/json'
|
||||||
|
|
||||||
|
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||||
|
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, detachSignal } = parseOpts(opts, url)
|
||||||
|
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||||
|
|
||||||
|
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, detachSignal } = parseOpts(opts, url)
|
||||||
|
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||||
|
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, detachSignal } = parseOpts(opts, url)
|
||||||
|
fetchOpts.redirect = 'manual'
|
||||||
|
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||||
|
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, detachSignal } = parseOpts(opts, url)
|
||||||
|
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||||
|
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, url) {
|
||||||
|
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
|
||||||
|
let detachSignal
|
||||||
|
if (opts.signal) {
|
||||||
|
detachSignal = abortOnSignal(abortController, opts.signal)
|
||||||
|
} else {
|
||||||
|
let overTimeoutStart
|
||||||
|
const stack = new Error().stack
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
overTimeoutStart = process.hrtime.bigint()
|
||||||
|
}, 120000)
|
||||||
|
detachSignal = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (overTimeoutStart && logger) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
method: opts.method ?? 'GET',
|
||||||
|
overTimeoutMs:
|
||||||
|
Number(process.hrtime.bigint() - overTimeoutStart) / 1e6,
|
||||||
|
stack,
|
||||||
|
},
|
||||||
|
'Fetch request did not complete within 120 seconds'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opts.body instanceof Readable) {
|
||||||
|
abortOnDestroyedRequest(abortController, fetchOpts.body)
|
||||||
|
}
|
||||||
|
return { fetchOpts, abortController, detachSignal }
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return () => {
|
||||||
|
signal.removeEventListener('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, detachSignal) {
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await fetch(url, fetchOpts)
|
||||||
|
} catch (err) {
|
||||||
|
detachSignal()
|
||||||
|
if (fetchOpts.body instanceof Readable) {
|
||||||
|
fetchOpts.body.destroy()
|
||||||
|
}
|
||||||
|
throw OError.tag(err, err.message, {
|
||||||
|
url,
|
||||||
|
method: fetchOpts.method ?? 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
response.body.on('close', detachSignal)
|
||||||
|
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,
|
||||||
|
setLogger,
|
||||||
|
}
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
import { Readable, Duplex } from 'node:stream'
|
|
||||||
import OError from '@overleaf/o-error'
|
|
||||||
import fetch, { Headers } from 'node-fetch'
|
|
||||||
import type { RequestInit, Response } from 'node-fetch'
|
|
||||||
import http from 'node:http'
|
|
||||||
import https from 'node:https'
|
|
||||||
import net from 'node:net'
|
|
||||||
import tls from 'node:tls'
|
|
||||||
|
|
||||||
let logger: { warn: (...args: any[]) => void } | undefined
|
|
||||||
|
|
||||||
type BasicAuthOptions = {
|
|
||||||
user: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchRequestOptions = RequestInit & {
|
|
||||||
json?: unknown
|
|
||||||
basicAuth?: BasicAuthOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchHeaders = NonNullable<RequestInit['headers']>
|
|
||||||
|
|
||||||
type ParsedFetchOptions = Omit<
|
|
||||||
FetchRequestOptions,
|
|
||||||
'json' | 'signal' | 'basicAuth' | 'headers'
|
|
||||||
> & {
|
|
||||||
headers: Headers
|
|
||||||
signal?: AbortSignal
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLogger(loggerInstance: { warn: (...args: any[]) => void }) {
|
|
||||||
logger = loggerInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make a request and return the parsed JSON response.
|
|
||||||
*
|
|
||||||
* @throws {RequestFailedError} if the response has a failure status code
|
|
||||||
*/
|
|
||||||
async function fetchJson(url: string | URL, opts: FetchRequestOptions = {}) {
|
|
||||||
const { json } = await fetchJsonWithResponse(url, opts)
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJsonWithResponse(
|
|
||||||
url: string | URL,
|
|
||||||
opts: FetchRequestOptions = {}
|
|
||||||
) {
|
|
||||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
|
||||||
if (!fetchOpts.headers.has('Accept')) {
|
|
||||||
fetchOpts.headers.set('Accept', 'application/json')
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* @throws {RequestFailedError} if the response has a failure status code
|
|
||||||
*/
|
|
||||||
async function fetchStream(url: string | URL, opts: FetchRequestOptions = {}) {
|
|
||||||
const { stream } = await fetchStreamWithResponse(url, opts)
|
|
||||||
return stream
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchStreamWithResponse(
|
|
||||||
url: string | URL,
|
|
||||||
opts: FetchRequestOptions = {}
|
|
||||||
) {
|
|
||||||
const { fetchOpts, abortController, detachSignal } = parseOpts(opts, url)
|
|
||||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
|
||||||
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* @throws {RequestFailedError} if the response has a failure status code
|
|
||||||
*/
|
|
||||||
async function fetchNothing(url: string | URL, opts: FetchRequestOptions = {}) {
|
|
||||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
|
||||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
|
|
||||||
*/
|
|
||||||
async function fetchRedirect(
|
|
||||||
url: string | URL,
|
|
||||||
opts: FetchRequestOptions = {}
|
|
||||||
) {
|
|
||||||
const { location } = await fetchRedirectWithResponse(url, opts)
|
|
||||||
return location
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make a request and extract the redirect from the response.
|
|
||||||
*
|
|
||||||
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
|
|
||||||
*/
|
|
||||||
async function fetchRedirectWithResponse(
|
|
||||||
url: string | URL,
|
|
||||||
opts: FetchRequestOptions = {}
|
|
||||||
) {
|
|
||||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
|
||||||
fetchOpts.redirect = 'manual'
|
|
||||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* @throws {RequestFailedError} if the response has a failure status code
|
|
||||||
*/
|
|
||||||
async function fetchString(url: string | URL, opts: FetchRequestOptions = {}) {
|
|
||||||
const { body } = await fetchStringWithResponse(url, opts)
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchStringWithResponse(
|
|
||||||
url: string | URL,
|
|
||||||
opts: FetchRequestOptions = {}
|
|
||||||
) {
|
|
||||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
|
||||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
|
||||||
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 {
|
|
||||||
response: Response
|
|
||||||
body?: string
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
url: string | URL,
|
|
||||||
opts: Pick<FetchRequestOptions, 'method'>,
|
|
||||||
response: Response,
|
|
||||||
body: string | null
|
|
||||||
) {
|
|
||||||
super('request failed', {
|
|
||||||
url,
|
|
||||||
method: opts.method ?? 'GET',
|
|
||||||
status: response.status,
|
|
||||||
...([400, 409, 413, 422].includes(response.status) ? { body } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
this.response = response
|
|
||||||
if (body != null) {
|
|
||||||
this.body = body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupDefaultAgent(fetchOpts: ParsedFetchOptions) {
|
|
||||||
// Provide a function to get the agent for each request as there may be
|
|
||||||
// multiple requests with different protocols due to redirects.
|
|
||||||
fetchOpts.agent = (url: URL) =>
|
|
||||||
url.protocol === 'https:' ? httpsAgent : httpAgent
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalise the headers option into a Headers instance, which is what
|
|
||||||
* node-fetch builds from it anyway.
|
|
||||||
*
|
|
||||||
* Headers with an unset value are dropped: header values are stringified, so
|
|
||||||
* they would otherwise be sent as the literal string `undefined`.
|
|
||||||
*/
|
|
||||||
function parseHeaders(headers: FetchHeaders): Headers {
|
|
||||||
// A Headers instance can't hold unset values. Pass it through rather than
|
|
||||||
// iterate it, which would collapse repeated headers into a single
|
|
||||||
// comma-separated value.
|
|
||||||
if (headers instanceof Headers) {
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
// Not in the TS type, but node-fetch accepts any iterable of entries, such as
|
|
||||||
// a Map or a global Headers instance. Those expose no own enumerable
|
|
||||||
// properties, so they have to be iterated rather than passed to
|
|
||||||
// Object.entries().
|
|
||||||
const entries: Iterable<[string, unknown]> =
|
|
||||||
Symbol.iterator in headers
|
|
||||||
? (headers as Iterable<[string, unknown]>)
|
|
||||||
: Object.entries(headers)
|
|
||||||
const parsedHeaders = new Headers()
|
|
||||||
for (const [name, value] of entries) {
|
|
||||||
if (value != null) {
|
|
||||||
parsedHeaders.append(name, String(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsedHeaders
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOpts(opts: FetchRequestOptions, url: string | URL) {
|
|
||||||
const { json, signal, basicAuth, headers, ...rawFetchOpts } = opts
|
|
||||||
const fetchOpts: ParsedFetchOptions = {
|
|
||||||
...rawFetchOpts,
|
|
||||||
headers: parseHeaders(headers ?? {}),
|
|
||||||
}
|
|
||||||
if (json) {
|
|
||||||
setupJsonBody(fetchOpts, json)
|
|
||||||
}
|
|
||||||
if (basicAuth) {
|
|
||||||
setupBasicAuth(fetchOpts, basicAuth)
|
|
||||||
}
|
|
||||||
if (!fetchOpts.agent) {
|
|
||||||
setupDefaultAgent(fetchOpts)
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortController = new AbortController()
|
|
||||||
fetchOpts.signal = abortController.signal
|
|
||||||
let detachSignal: () => void
|
|
||||||
if (signal) {
|
|
||||||
detachSignal = abortOnSignal(abortController, signal)
|
|
||||||
} else {
|
|
||||||
let overTimeoutStart: bigint | undefined
|
|
||||||
const stack = new Error().stack
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
overTimeoutStart = process.hrtime.bigint()
|
|
||||||
}, 120000)
|
|
||||||
detachSignal = () => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
if (overTimeoutStart && logger) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
url,
|
|
||||||
method: fetchOpts.method ?? 'GET',
|
|
||||||
overTimeoutMs:
|
|
||||||
Number(process.hrtime.bigint() - overTimeoutStart) / 1e6,
|
|
||||||
stack,
|
|
||||||
},
|
|
||||||
'Fetch request did not complete within 120 seconds'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (fetchOpts.body instanceof Readable) {
|
|
||||||
abortOnDestroyedRequest(abortController, fetchOpts.body)
|
|
||||||
}
|
|
||||||
return { fetchOpts, abortController, detachSignal }
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupJsonBody(fetchOpts: ParsedFetchOptions, json: unknown) {
|
|
||||||
fetchOpts.body = JSON.stringify(json)
|
|
||||||
fetchOpts.headers.set('Content-Type', 'application/json')
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupBasicAuth(
|
|
||||||
fetchOpts: ParsedFetchOptions,
|
|
||||||
basicAuth: BasicAuthOptions
|
|
||||||
) {
|
|
||||||
fetchOpts.headers.set(
|
|
||||||
'Authorization',
|
|
||||||
'Basic ' +
|
|
||||||
Buffer.from(`${basicAuth.user}:${basicAuth.password}`).toString('base64')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function abortOnSignal(abortController: AbortController, signal: AbortSignal) {
|
|
||||||
const listener = () => {
|
|
||||||
abortController.abort(signal.reason)
|
|
||||||
}
|
|
||||||
if (signal.aborted) {
|
|
||||||
abortController.abort(signal.reason)
|
|
||||||
}
|
|
||||||
signal.addEventListener('abort', listener)
|
|
||||||
return () => {
|
|
||||||
signal.removeEventListener('abort', listener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function abortOnDestroyedRequest(
|
|
||||||
abortController: AbortController,
|
|
||||||
stream: Readable
|
|
||||||
) {
|
|
||||||
stream.on('close', () => {
|
|
||||||
if (!stream.readableEnded) {
|
|
||||||
abortController.abort()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function abortOnDestroyedResponse(
|
|
||||||
abortController: AbortController,
|
|
||||||
response: Response
|
|
||||||
) {
|
|
||||||
response.body.on('close', () => {
|
|
||||||
if (!response.bodyUsed) {
|
|
||||||
abortController.abort()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function performRequest(
|
|
||||||
url: string | URL,
|
|
||||||
fetchOpts: ParsedFetchOptions,
|
|
||||||
detachSignal: () => void
|
|
||||||
) {
|
|
||||||
let response
|
|
||||||
try {
|
|
||||||
response = await fetch(url, fetchOpts)
|
|
||||||
} catch (err: any) {
|
|
||||||
detachSignal()
|
|
||||||
if (fetchOpts.body instanceof Readable) {
|
|
||||||
fetchOpts.body.destroy()
|
|
||||||
}
|
|
||||||
throw OError.tag(err, err.message, {
|
|
||||||
url,
|
|
||||||
method: fetchOpts.method ?? 'GET',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
response.body.on('close', detachSignal)
|
|
||||||
if (fetchOpts.body instanceof Readable) {
|
|
||||||
const requestBodyStream = fetchOpts.body
|
|
||||||
response.body.on('close', () => {
|
|
||||||
if (!requestBodyStream.readableEnded) {
|
|
||||||
requestBodyStream.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discardResponseBody(response: Response) {
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
for await (const chunk of response.body) {
|
|
||||||
// discard the body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function maybeGetResponseBody(response: 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: any) {
|
|
||||||
super('connect timeout', options)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryToCreateConnection(
|
|
||||||
createConnection: (options: any) => Duplex,
|
|
||||||
options: any,
|
|
||||||
callback: (err: any, socket: any) => void
|
|
||||||
) {
|
|
||||||
let socket: Duplex
|
|
||||||
try {
|
|
||||||
socket = createConnection(options)
|
|
||||||
} catch (err) {
|
|
||||||
callback(err, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
socket.destroy(new ConnectTimeoutError(options))
|
|
||||||
}, options.connectTimeout)
|
|
||||||
const onConnect = () => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
socket.off('error', onError)
|
|
||||||
callback(null, socket)
|
|
||||||
}
|
|
||||||
const onError = (err: any) => {
|
|
||||||
clearTimeout(timer)
|
|
||||||
socket.off('connect', onConnect)
|
|
||||||
callback(err, null)
|
|
||||||
}
|
|
||||||
socket.once('connect', onConnect)
|
|
||||||
socket.once('error', onError)
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeout(
|
|
||||||
createConnection: (options: any) => Duplex,
|
|
||||||
options: any,
|
|
||||||
callback: (err: any, socket: any) => void
|
|
||||||
) {
|
|
||||||
const attempt = (remainingAttempts: number) => {
|
|
||||||
remainingAttempts--
|
|
||||||
tryToCreateConnection(createConnection, options, (err, socket) => {
|
|
||||||
if (err && remainingAttempts > 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
attempt(remainingAttempts)
|
|
||||||
}, options.connectRetryInterval ?? 100)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
callback(err, socket)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
attempt(3)
|
|
||||||
}
|
|
||||||
|
|
||||||
class CustomHttpAgent extends http.Agent {
|
|
||||||
constructor(options: any) {
|
|
||||||
if (!(options.connectTimeout > 0)) {
|
|
||||||
throw new Error(
|
|
||||||
'CustomHttpAgent must be called with positive connectTimeout'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
super(options)
|
|
||||||
}
|
|
||||||
createConnection(options: any, callback: any) {
|
|
||||||
withTimeout(net.createConnection, options, callback)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CustomHttpsAgent extends https.Agent {
|
|
||||||
constructor(options: any) {
|
|
||||||
if (!(options.connectTimeout > 0)) {
|
|
||||||
throw new Error(
|
|
||||||
'CustomHttpsAgent must be called with positive connectTimeout'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
super(options)
|
|
||||||
}
|
|
||||||
createConnection(options: any, callback: any) {
|
|
||||||
withTimeout(tls.connect, options, callback)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_CONNECT_TIME = 1000
|
|
||||||
const httpAgent = new CustomHttpAgent({ connectTimeout: MAX_CONNECT_TIME })
|
|
||||||
const httpsAgent = new CustomHttpsAgent({ connectTimeout: MAX_CONNECT_TIME })
|
|
||||||
|
|
||||||
export {
|
|
||||||
fetchJson,
|
|
||||||
fetchJsonWithResponse,
|
|
||||||
fetchStream,
|
|
||||||
fetchStreamWithResponse,
|
|
||||||
fetchNothing,
|
|
||||||
fetchRedirect,
|
|
||||||
fetchRedirectWithResponse,
|
|
||||||
fetchString,
|
|
||||||
fetchStringWithResponse,
|
|
||||||
RequestFailedError,
|
|
||||||
ConnectTimeoutError,
|
|
||||||
CustomHttpAgent,
|
|
||||||
CustomHttpsAgent,
|
|
||||||
setLogger,
|
|
||||||
}
|
|
||||||
@@ -2,34 +2,28 @@
|
|||||||
"name": "@overleaf/fetch-utils",
|
"name": "@overleaf/fetch-utils",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "utilities for node-fetch",
|
"description": "utilities for node-fetch",
|
||||||
"main": "index.ts",
|
"main": "index.js",
|
||||||
"exports": {
|
|
||||||
"import": "./index.ts",
|
|
||||||
"require": "./index.ts",
|
|
||||||
"default": "./index.ts"
|
|
||||||
},
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
"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": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||||
"test:ci": "yarn run test:unit",
|
"test:ci": "yarn run test:unit",
|
||||||
"test:unit": "vitest --config vitest.config.ts",
|
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||||
"types:check": "tsc --noEmit"
|
"types:check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"author": "Overleaf (https://www.overleaf.com)",
|
"author": "Overleaf (https://www.overleaf.com)",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash": "^4.17.24",
|
|
||||||
"@types/node": "^24.5.2",
|
|
||||||
"@types/node-fetch": "^2.6.13",
|
"@types/node-fetch": "^2.6.13",
|
||||||
|
"body-parser": "1.20.4",
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"eslint": "^10.4.0",
|
|
||||||
"express": "4.22.1",
|
"express": "4.22.1",
|
||||||
|
"mocha": "^11.1.0",
|
||||||
|
"mocha-junit-reporter": "^2.2.1",
|
||||||
|
"mocha-multi-reporters": "^1.5.1",
|
||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
"typescript": "^5.0.4",
|
"typescript": "^5.0.4"
|
||||||
"vitest": "4.1.5"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@overleaf/o-error": "workspace:*",
|
"@overleaf/o-error": "workspace:*",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||||
mochaJunitReporterReporterOptions: {
|
mochaJunitReporterReporterOptions: {
|
||||||
mochaFile: `reports/junit-mocha-${process.env.TEST_NAME_PATTERN}.xml`,
|
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
|
||||||
includePending: true,
|
includePending: true,
|
||||||
jenkinsMode: true,
|
jenkinsMode: true,
|
||||||
output: true,
|
output: true,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import chai from 'chai'
|
const chai = require('chai')
|
||||||
import chaiAsPromised from 'chai-as-promised'
|
const chaiAsPromised = require('chai-as-promised')
|
||||||
|
|
||||||
chai.use(chaiAsPromised)
|
chai.use(chaiAsPromised)
|
||||||
|
|||||||
@@ -1,652 +0,0 @@
|
|||||||
import { expect } from 'chai'
|
|
||||||
import dns from 'node:dns'
|
|
||||||
import http from 'node:http'
|
|
||||||
import { once } from 'node:events'
|
|
||||||
import events from 'node:events'
|
|
||||||
import fs from 'node:fs'
|
|
||||||
import { pipeline } from 'node:stream/promises'
|
|
||||||
import { Readable } from 'node:stream'
|
|
||||||
import { describe, beforeAll, beforeEach, afterAll, it } from 'vitest'
|
|
||||||
import { AbortError, FetchError, Headers } from 'node-fetch'
|
|
||||||
import selfsigned from 'selfsigned'
|
|
||||||
import { TestServer } from './helpers/TestServer.js'
|
|
||||||
import {
|
|
||||||
fetchJson,
|
|
||||||
fetchStream,
|
|
||||||
fetchNothing,
|
|
||||||
fetchRedirect,
|
|
||||||
fetchString,
|
|
||||||
RequestFailedError,
|
|
||||||
CustomHttpAgent,
|
|
||||||
CustomHttpsAgent,
|
|
||||||
} from '../../index.ts'
|
|
||||||
|
|
||||||
const HTTP_PORT = 30001
|
|
||||||
const HTTPS_PORT = 30002
|
|
||||||
|
|
||||||
const url = pathname => `http://example.com:${HTTP_PORT}${pathname}`
|
|
||||||
const httpsUrl = pathname => `https://example.com:${HTTPS_PORT}${pathname}`
|
|
||||||
|
|
||||||
const _originalLookup = dns.lookup
|
|
||||||
// Custom DNS resolver function
|
|
||||||
dns.lookup = (hostname, options, callback) => {
|
|
||||||
if (hostname === 'example.com') {
|
|
||||||
// If the hostname is our test case, return the ip address for the test server
|
|
||||||
if (options?.all) {
|
|
||||||
callback(null, [{ address: '127.0.0.1', family: 4 }])
|
|
||||||
} else {
|
|
||||||
callback(null, '127.0.0.1', 4)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Otherwise, use the default lookup
|
|
||||||
_originalLookup(hostname, options, callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('fetch-utils', function () {
|
|
||||||
let server
|
|
||||||
let PUBLIC_CERT
|
|
||||||
|
|
||||||
beforeAll(async function () {
|
|
||||||
server = new TestServer()
|
|
||||||
const attrs = [{ name: 'commonName', value: 'example.com' }]
|
|
||||||
const pems = await selfsigned.generate(attrs, { days: 365, keySize: 2048 })
|
|
||||||
|
|
||||||
const PRIVATE_KEY = pems.private
|
|
||||||
PUBLIC_CERT = pems.cert
|
|
||||||
await server.start(HTTP_PORT, HTTPS_PORT, {
|
|
||||||
key: PRIVATE_KEY,
|
|
||||||
cert: PUBLIC_CERT,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(function () {
|
|
||||||
server.lastReq = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async function () {
|
|
||||||
await server.stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('fetchJson', function () {
|
|
||||||
it('parses a JSON response', async function () {
|
|
||||||
const json = await fetchJson(url('/json/hello'))
|
|
||||||
expect(json).to.deep.equal({ msg: 'hello' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('parses JSON in the request', async function () {
|
|
||||||
const json = await fetchJson(url('/json/add'), {
|
|
||||||
method: 'POST',
|
|
||||||
json: { a: 2, b: 3 },
|
|
||||||
})
|
|
||||||
expect(json).to.deep.equal({ sum: 5 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accepts stringified JSON as body', async function () {
|
|
||||||
const json = await fetchJson(url('/json/add'), {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ a: 2, b: 3 }),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
expect(json).to.deep.equal({ sum: 5 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws a FetchError when the payload is not JSON', async function () {
|
|
||||||
await expect(fetchJson(url('/hello'))).to.be.rejectedWith(FetchError)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aborts the request if JSON parsing fails', async function () {
|
|
||||||
await expect(fetchJson(url('/large'))).to.be.rejectedWith(FetchError)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles errors when the payload is JSON', async function () {
|
|
||||||
await expect(fetchJson(url('/json/500'))).to.be.rejectedWith(
|
|
||||||
RequestFailedError
|
|
||||||
)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles errors when the payload is not JSON', async function () {
|
|
||||||
await expect(fetchJson(url('/500'))).to.be.rejectedWith(
|
|
||||||
RequestFailedError
|
|
||||||
)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('supports abort signals', async function () {
|
|
||||||
await expect(
|
|
||||||
abortOnceReceived(signal => fetchJson(url('/hang'), { signal }), server)
|
|
||||||
).to.be.rejectedWith(AbortError)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('supports basic auth', async function () {
|
|
||||||
const json = await fetchJson(url('/json/basic-auth'), {
|
|
||||||
basicAuth: { user: 'user', password: 'pass' },
|
|
||||||
})
|
|
||||||
expect(json).to.deep.equal({ key: 'verysecret' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets an Authorization header for basic auth', async function () {
|
|
||||||
await fetchJson(url('/json/basic-auth'), {
|
|
||||||
basicAuth: { user: 'user', password: 'pass' },
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers.authorization).to.equal(
|
|
||||||
'Basic ' + Buffer.from('user:pass').toString('base64')
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("destroys the request body if it doesn't get consumed", async function () {
|
|
||||||
const stream = Readable.from(infiniteIterator())
|
|
||||||
await fetchJson(url('/json/ignore-request'), {
|
|
||||||
method: 'POST',
|
|
||||||
body: stream,
|
|
||||||
})
|
|
||||||
expect(stream.destroyed).to.be.true
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('headers', function () {
|
|
||||||
it('sets an Accept header of application/json by default', async function () {
|
|
||||||
await fetchJson(url('/json/hello'))
|
|
||||||
expect(server.lastReq.headers.accept).to.equal('application/json')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('passes provided headers', async function () {
|
|
||||||
await fetchJson(url('/json/hello'), {
|
|
||||||
headers: { 'x-some-value': 'value' },
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers['x-some-value']).to.equal('value')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('respects an explicitly provided Accept header', async function () {
|
|
||||||
await fetchJson(url('/json/hello'), {
|
|
||||||
headers: { Accept: 'application/vnd.api+json' },
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers.accept).to.equal(
|
|
||||||
'application/vnd.api+json'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets the default Accept header when headers are provided as a Headers instance', async function () {
|
|
||||||
await fetchJson(url('/json/hello'), {
|
|
||||||
headers: new Headers({ 'X-Foo': 'bar' }),
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers.accept).to.equal('application/json')
|
|
||||||
expect(server.lastReq.headers['x-foo']).to.equal('bar')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets the default Accept header when headers are provided as an array of tuples', async function () {
|
|
||||||
await fetchJson(url('/json/hello'), {
|
|
||||||
headers: [['X-Foo', 'bar']],
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers.accept).to.equal('application/json')
|
|
||||||
expect(server.lastReq.headers['x-foo']).to.equal('bar')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats an unset Accept header as absent and applies the default', async function () {
|
|
||||||
await fetchJson(url('/json/hello'), {
|
|
||||||
headers: { Accept: undefined },
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers.accept).to.equal('application/json')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits headers with unset values rather than sending "undefined"', async function () {
|
|
||||||
await fetchJson(url('/json/hello'), {
|
|
||||||
headers: { 'x-some-value': undefined, 'x-other-value': null },
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers).to.not.have.property('x-some-value')
|
|
||||||
expect(server.lastReq.headers).to.not.have.property('x-other-value')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits headers with unset values provided as an array of tuples', async function () {
|
|
||||||
await fetchJson(url('/json/hello'), {
|
|
||||||
headers: [
|
|
||||||
['X-Foo', 'bar'],
|
|
||||||
['X-Unset', undefined],
|
|
||||||
],
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers['x-foo']).to.equal('bar')
|
|
||||||
expect(server.lastReq.headers).to.not.have.property('x-unset')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits headers with unset values provided as a Map', async function () {
|
|
||||||
await fetchJson(url('/json/hello'), {
|
|
||||||
headers: new Map([
|
|
||||||
['X-Foo', 'bar'],
|
|
||||||
['X-Unset', undefined],
|
|
||||||
]),
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers['x-foo']).to.equal('bar')
|
|
||||||
expect(server.lastReq.headers).to.not.have.property('x-unset')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps headers provided as a global Headers instance', async function () {
|
|
||||||
// Not an instance of node-fetch's Headers: it exposes no own
|
|
||||||
// enumerable properties to iterate over.
|
|
||||||
await fetchJson(url('/json/hello'), {
|
|
||||||
headers: new globalThis.Headers({ 'X-Foo': 'bar' }),
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers['x-foo']).to.equal('bar')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sets a Content-Type header of application/json when sending a JSON body', async function () {
|
|
||||||
await fetchJson(url('/json/add'), {
|
|
||||||
method: 'POST',
|
|
||||||
json: { a: 2, b: 3 },
|
|
||||||
})
|
|
||||||
expect(server.lastReq.headers['content-type']).to.equal(
|
|
||||||
'application/json'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('fetchStream', function () {
|
|
||||||
it('returns a stream', async function () {
|
|
||||||
const stream = await fetchStream(url('/large'))
|
|
||||||
const text = await streamToString(stream)
|
|
||||||
expect(text).to.equal(server.largePayload)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aborts the request when the stream is destroyed', async function () {
|
|
||||||
const stream = await fetchStream(url('/large'))
|
|
||||||
stream.destroy()
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aborts the request when the request body is destroyed before transfer', async function () {
|
|
||||||
const stream = Readable.from(infiniteIterator())
|
|
||||||
const promise = fetchStream(url('/hang'), {
|
|
||||||
method: 'POST',
|
|
||||||
body: stream,
|
|
||||||
})
|
|
||||||
stream.destroy()
|
|
||||||
await expect(promise).to.be.rejectedWith(AbortError)
|
|
||||||
await wait(80)
|
|
||||||
expect(server.lastReq).to.be.undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aborts the request when the request body is destroyed during transfer', async function () {
|
|
||||||
const stream = Readable.from(infiniteIterator())
|
|
||||||
// Note: this test won't work on `/hang`
|
|
||||||
const promise = fetchStream(url('/sink'), {
|
|
||||||
method: 'POST',
|
|
||||||
body: stream,
|
|
||||||
})
|
|
||||||
await once(server.events, 'request-received')
|
|
||||||
stream.destroy()
|
|
||||||
await expect(promise).to.be.rejectedWith(AbortError)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles errors', async function () {
|
|
||||||
await expect(fetchStream(url('/500'))).to.be.rejectedWith(
|
|
||||||
RequestFailedError
|
|
||||||
)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('supports abort signals', async function () {
|
|
||||||
await expect(
|
|
||||||
abortOnceReceived(
|
|
||||||
signal => fetchStream(url('/hang'), { signal }),
|
|
||||||
server
|
|
||||||
)
|
|
||||||
).to.be.rejectedWith(AbortError)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('destroys the request body when an error occurs', async function () {
|
|
||||||
const stream = Readable.from(infiniteIterator())
|
|
||||||
await expect(
|
|
||||||
abortOnceReceived(
|
|
||||||
signal =>
|
|
||||||
fetchStream(url('/hang'), {
|
|
||||||
method: 'POST',
|
|
||||||
body: stream,
|
|
||||||
signal,
|
|
||||||
}),
|
|
||||||
server
|
|
||||||
)
|
|
||||||
).to.be.rejectedWith(AbortError)
|
|
||||||
expect(stream.destroyed).to.be.true
|
|
||||||
})
|
|
||||||
|
|
||||||
it('detaches from signal on success', async function () {
|
|
||||||
const signal = AbortSignal.timeout(10_000)
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
const s = await fetchStream(url('/hello'), { signal })
|
|
||||||
expect(events.getEventListeners(signal, 'abort')).to.have.length(1)
|
|
||||||
await pipeline(s, fs.createWriteStream('/dev/null'))
|
|
||||||
expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('detaches from signal on error', async function () {
|
|
||||||
const signal = AbortSignal.timeout(10_000)
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
try {
|
|
||||||
await fetchStream(url('/500'), { signal })
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof RequestFailedError && err.response.status === 500)
|
|
||||||
continue
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('fetchNothing', function () {
|
|
||||||
it('closes the connection', async function () {
|
|
||||||
await fetchNothing(url('/large'))
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aborts the request when the request body is destroyed before transfer', async function () {
|
|
||||||
const stream = Readable.from(infiniteIterator())
|
|
||||||
const promise = fetchNothing(url('/hang'), {
|
|
||||||
method: 'POST',
|
|
||||||
body: stream,
|
|
||||||
})
|
|
||||||
stream.destroy()
|
|
||||||
await expect(promise).to.be.rejectedWith(AbortError)
|
|
||||||
expect(server.lastReq).to.be.undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
it('aborts the request when the request body is destroyed during transfer', async function () {
|
|
||||||
const stream = Readable.from(infiniteIterator())
|
|
||||||
// Note: this test won't work on `/hang`
|
|
||||||
const promise = fetchNothing(url('/sink'), {
|
|
||||||
method: 'POST',
|
|
||||||
body: stream,
|
|
||||||
})
|
|
||||||
await once(server.events, 'request-received')
|
|
||||||
stream.destroy()
|
|
||||||
await expect(promise).to.be.rejectedWith(AbortError)
|
|
||||||
await wait(80)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("doesn't abort the request if the request body ends normally", async function () {
|
|
||||||
const stream = Readable.from('hello there')
|
|
||||||
await fetchNothing(url('/sink'), { method: 'POST', body: stream })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles errors', async function () {
|
|
||||||
await expect(fetchNothing(url('/500'))).to.be.rejectedWith(
|
|
||||||
RequestFailedError
|
|
||||||
)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('supports abort signals', async function () {
|
|
||||||
await expect(
|
|
||||||
abortOnceReceived(
|
|
||||||
signal => fetchNothing(url('/hang'), { signal }),
|
|
||||||
server
|
|
||||||
)
|
|
||||||
).to.be.rejectedWith(AbortError)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('destroys the request body when an error occurs', async function () {
|
|
||||||
const stream = Readable.from(infiniteIterator())
|
|
||||||
await expect(
|
|
||||||
abortOnceReceived(
|
|
||||||
signal =>
|
|
||||||
fetchNothing(url('/hang'), {
|
|
||||||
method: 'POST',
|
|
||||||
body: stream,
|
|
||||||
signal,
|
|
||||||
}),
|
|
||||||
server
|
|
||||||
)
|
|
||||||
).to.be.rejectedWith(AbortError)
|
|
||||||
expect(stream.destroyed).to.be.true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('RequestFailedError', function () {
|
|
||||||
it('includes the response body in OError.info for a 400', async function () {
|
|
||||||
const err = await getRejection(fetchNothing(url('/400')))
|
|
||||||
expect(err.info).to.have.property('body', 'boom-400')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes the response body in OError.info for a 409', async function () {
|
|
||||||
const err = await getRejection(fetchNothing(url('/409')))
|
|
||||||
expect(err.info).to.have.property('body', 'boom-409')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes the response body in OError.info for a 413', async function () {
|
|
||||||
const err = await getRejection(fetchNothing(url('/413')))
|
|
||||||
expect(err.info).to.have.property('body', 'boom-413')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('includes the response body in OError.info for a 422', async function () {
|
|
||||||
const err = await getRejection(fetchNothing(url('/422')))
|
|
||||||
expect(err.info).to.have.property('body', 'boom-422')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits the response body from OError.info for a 500', async function () {
|
|
||||||
const err = await getRejection(fetchNothing(url('/500')))
|
|
||||||
expect(err.body).to.equal('Internal Server Error')
|
|
||||||
expect(err.info).to.not.have.property('body')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('fetchString', function () {
|
|
||||||
it('returns a string', async function () {
|
|
||||||
const body = await fetchString(url('/hello'))
|
|
||||||
expect(body).to.equal('hello')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles errors', async function () {
|
|
||||||
await expect(fetchString(url('/500'))).to.be.rejectedWith(
|
|
||||||
RequestFailedError
|
|
||||||
)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('fetchRedirect', function () {
|
|
||||||
it('returns the immediate redirect', async function () {
|
|
||||||
const body = await fetchRedirect(url('/redirect/1'))
|
|
||||||
expect(body).to.equal(url('/redirect/2'))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects status 200', async function () {
|
|
||||||
await expect(fetchRedirect(url('/hello'))).to.be.rejectedWith(
|
|
||||||
RequestFailedError
|
|
||||||
)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects empty redirect', async function () {
|
|
||||||
await expect(fetchRedirect(url('/redirect/empty-location')))
|
|
||||||
.to.be.rejectedWith(RequestFailedError)
|
|
||||||
.and.eventually.have.property('cause')
|
|
||||||
.and.to.have.property('message')
|
|
||||||
.to.equal('missing Location response header on 3xx response')
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles errors', async function () {
|
|
||||||
await expect(fetchRedirect(url('/500'))).to.be.rejectedWith(
|
|
||||||
RequestFailedError
|
|
||||||
)
|
|
||||||
await expectRequestAborted(server.lastReq)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('CustomHttpAgent', function () {
|
|
||||||
it('makes an http request successfully', async function () {
|
|
||||||
const agent = new CustomHttpAgent({ connectTimeout: 100 })
|
|
||||||
const body = await fetchString(url('/hello'), { agent })
|
|
||||||
expect(body).to.equal('hello')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('times out when accessing a non-routable address', async function () {
|
|
||||||
const agent = new CustomHttpAgent({ connectTimeout: 10 })
|
|
||||||
await expect(fetchString('http://10.255.255.255/', { agent }))
|
|
||||||
.to.be.rejectedWith(FetchError)
|
|
||||||
.and.eventually.have.property('message')
|
|
||||||
.and.to.equal(
|
|
||||||
'request to http://10.255.255.255/ failed, reason: connect timeout'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retries after a delay', { timeout: 5_000 }, async function () {
|
|
||||||
// The test should finish after 1.5s.
|
|
||||||
// t 0ms: first connect fails with ECONNREFUSED
|
|
||||||
// t 500ms: listen on port
|
|
||||||
// t1000ms: retry to connect again, works
|
|
||||||
// t1500ms: respond to request
|
|
||||||
const agent = new CustomHttpAgent({
|
|
||||||
connectTimeout: 100,
|
|
||||||
connectRetryInterval: 1_000,
|
|
||||||
})
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
res.write('hello')
|
|
||||||
res.end()
|
|
||||||
server.close()
|
|
||||||
}, 500)
|
|
||||||
})
|
|
||||||
// Grab a dynamic port, then close again so that the first connect
|
|
||||||
// attempt is refused before the server starts listening for real.
|
|
||||||
await once(server.listen(0), 'listening')
|
|
||||||
const { port } = server.address()
|
|
||||||
await new Promise(resolve => server.close(resolve))
|
|
||||||
const t0 = performance.now()
|
|
||||||
setTimeout(() => {
|
|
||||||
server.listen(port)
|
|
||||||
}, 500)
|
|
||||||
const body = await fetchString(`http://127.0.0.1:${port}`, { agent })
|
|
||||||
expect(body).to.equal('hello')
|
|
||||||
const t1 = performance.now()
|
|
||||||
expect(t1 - t0).to.be.at.least(1_500)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not open a stray connection when the socket errors after connect', async function () {
|
|
||||||
const agent = new CustomHttpAgent({
|
|
||||||
connectTimeout: 100,
|
|
||||||
connectRetryInterval: 10,
|
|
||||||
})
|
|
||||||
const connections = []
|
|
||||||
const server = http.createServer(req => {
|
|
||||||
// Reset the established connection before responding. Doing this in
|
|
||||||
// the request handler guarantees the client finished the connect
|
|
||||||
// phase, so the ECONNRESET arrives after 'connect' has fired.
|
|
||||||
req.socket.resetAndDestroy()
|
|
||||||
})
|
|
||||||
server.on('connection', socket => connections.push(socket))
|
|
||||||
await once(server.listen(0), 'listening')
|
|
||||||
const { port } = server.address()
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
fetchString(`http://127.0.0.1:${port}/`, { agent })
|
|
||||||
).to.be.rejectedWith(FetchError)
|
|
||||||
// Leave time for a buggy connect retry to open a stray connection.
|
|
||||||
await wait(200)
|
|
||||||
expect(connections).to.have.length(1)
|
|
||||||
} finally {
|
|
||||||
for (const socket of connections) socket.destroy()
|
|
||||||
server.close()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('CustomHttpsAgent', function () {
|
|
||||||
it('makes an https request successfully', async function () {
|
|
||||||
const agent = new CustomHttpsAgent({
|
|
||||||
connectTimeout: 100,
|
|
||||||
ca: PUBLIC_CERT,
|
|
||||||
})
|
|
||||||
const body = await fetchString(httpsUrl('/hello'), { agent })
|
|
||||||
expect(body).to.equal('hello')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects an untrusted server', async function () {
|
|
||||||
const agent = new CustomHttpsAgent({
|
|
||||||
connectTimeout: 100,
|
|
||||||
})
|
|
||||||
await expect(fetchString(httpsUrl('/hello'), { agent }))
|
|
||||||
.to.be.rejectedWith(FetchError)
|
|
||||||
.and.eventually.have.property('code')
|
|
||||||
.and.to.equal('DEPTH_ZERO_SELF_SIGNED_CERT')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('times out when accessing a non-routable address', async function () {
|
|
||||||
const agent = new CustomHttpsAgent({ connectTimeout: 10 })
|
|
||||||
await expect(fetchString('https://10.255.255.255/', { agent }))
|
|
||||||
.to.be.rejectedWith(FetchError)
|
|
||||||
.and.eventually.have.property('message')
|
|
||||||
.and.to.equal(
|
|
||||||
'request to https://10.255.255.255/ failed, reason: connect timeout'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function streamToString(stream) {
|
|
||||||
let s = ''
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
s += chunk
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
async function* infiniteIterator() {
|
|
||||||
let i = 1
|
|
||||||
while (true) {
|
|
||||||
yield `chunk ${i++}\n`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {(signal: AbortSignal) => Promise<any>} func
|
|
||||||
* @param {TestServer} server
|
|
||||||
*/
|
|
||||||
async function abortOnceReceived(func, server) {
|
|
||||||
const controller = new AbortController()
|
|
||||||
const promise = func(controller.signal)
|
|
||||||
expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(1)
|
|
||||||
await once(server.events, 'request-received')
|
|
||||||
controller.abort()
|
|
||||||
try {
|
|
||||||
return await promise
|
|
||||||
} finally {
|
|
||||||
expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(
|
|
||||||
0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectRequestAborted(req) {
|
|
||||||
if (!req.destroyed) {
|
|
||||||
try {
|
|
||||||
await once(req, 'close')
|
|
||||||
} catch (err) {
|
|
||||||
// `once` throws if req emits an 'error' event.
|
|
||||||
// We ignore `Error: aborted` when the request is aborted.
|
|
||||||
if (err.message !== 'aborted') {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expect(req.destroyed).to.be.true
|
|
||||||
}
|
|
||||||
|
|
||||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
||||||
|
|
||||||
async function getRejection(promise) {
|
|
||||||
try {
|
|
||||||
await promise
|
|
||||||
} catch (err) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
expect.fail('expected promise to reject')
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const events = require('node:events')
|
||||||
|
const { FetchError, AbortError } = require('node-fetch')
|
||||||
|
const { Readable } = require('node:stream')
|
||||||
|
const { pipeline } = require('node:stream/promises')
|
||||||
|
const { once } = require('node:events')
|
||||||
|
const { TestServer } = require('./helpers/TestServer')
|
||||||
|
const selfsigned = require('selfsigned')
|
||||||
|
const {
|
||||||
|
fetchJson,
|
||||||
|
fetchStream,
|
||||||
|
fetchNothing,
|
||||||
|
fetchRedirect,
|
||||||
|
fetchString,
|
||||||
|
RequestFailedError,
|
||||||
|
CustomHttpAgent,
|
||||||
|
CustomHttpsAgent,
|
||||||
|
} = require('../..')
|
||||||
|
|
||||||
|
const HTTP_PORT = 30001
|
||||||
|
const HTTPS_PORT = 30002
|
||||||
|
|
||||||
|
const dns = require('node:dns')
|
||||||
|
const _originalLookup = dns.lookup
|
||||||
|
// Custom DNS resolver function
|
||||||
|
dns.lookup = (hostname, options, callback) => {
|
||||||
|
if (hostname === 'example.com') {
|
||||||
|
// If the hostname is our test case, return the ip address for the test server
|
||||||
|
if (options?.all) {
|
||||||
|
callback(null, [{ address: '127.0.0.1', family: 4 }])
|
||||||
|
} else {
|
||||||
|
callback(null, '127.0.0.1', 4)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Otherwise, use the default lookup
|
||||||
|
_originalLookup(hostname, options, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fetch-utils', function () {
|
||||||
|
let PUBLIC_CERT
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.server = new TestServer()
|
||||||
|
const attrs = [{ name: 'commonName', value: 'example.com' }]
|
||||||
|
const pems = await selfsigned.generate(attrs, { days: 365, keySize: 2048 })
|
||||||
|
|
||||||
|
const PRIVATE_KEY = pems.private
|
||||||
|
PUBLIC_CERT = pems.cert
|
||||||
|
await this.server.start(HTTP_PORT, HTTPS_PORT, {
|
||||||
|
key: PRIVATE_KEY,
|
||||||
|
cert: PUBLIC_CERT,
|
||||||
|
})
|
||||||
|
this.url = path => `http://example.com:${HTTP_PORT}${path}`
|
||||||
|
this.httpsUrl = path => `https://example.com:${HTTPS_PORT}${path}`
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
this.server.lastReq = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await this.server.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchJson', function () {
|
||||||
|
it('parses a JSON response', async function () {
|
||||||
|
const json = await fetchJson(this.url('/json/hello'))
|
||||||
|
expect(json).to.deep.equal({ msg: 'hello' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses JSON in the request', async function () {
|
||||||
|
const json = await fetchJson(this.url('/json/add'), {
|
||||||
|
method: 'POST',
|
||||||
|
json: { a: 2, b: 3 },
|
||||||
|
})
|
||||||
|
expect(json).to.deep.equal({ sum: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts stringified JSON as body', async function () {
|
||||||
|
const json = await fetchJson(this.url('/json/add'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ a: 2, b: 3 }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
expect(json).to.deep.equal({ sum: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws a FetchError when the payload is not JSON', async function () {
|
||||||
|
await expect(fetchJson(this.url('/hello'))).to.be.rejectedWith(FetchError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aborts the request if JSON parsing fails', async function () {
|
||||||
|
await expect(fetchJson(this.url('/large'))).to.be.rejectedWith(FetchError)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles errors when the payload is JSON', async function () {
|
||||||
|
await expect(fetchJson(this.url('/json/500'))).to.be.rejectedWith(
|
||||||
|
RequestFailedError
|
||||||
|
)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles errors when the payload is not JSON', async function () {
|
||||||
|
await expect(fetchJson(this.url('/500'))).to.be.rejectedWith(
|
||||||
|
RequestFailedError
|
||||||
|
)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports abort signals', async function () {
|
||||||
|
await expect(
|
||||||
|
abortOnceReceived(
|
||||||
|
signal => fetchJson(this.url('/hang'), { signal }),
|
||||||
|
this.server
|
||||||
|
)
|
||||||
|
).to.be.rejectedWith(AbortError)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports basic auth', async function () {
|
||||||
|
const json = await fetchJson(this.url('/json/basic-auth'), {
|
||||||
|
basicAuth: { user: 'user', password: 'pass' },
|
||||||
|
})
|
||||||
|
expect(json).to.deep.equal({ key: 'verysecret' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("destroys the request body if it doesn't get consumed", async function () {
|
||||||
|
const stream = Readable.from(infiniteIterator())
|
||||||
|
await fetchJson(this.url('/json/ignore-request'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: stream,
|
||||||
|
})
|
||||||
|
expect(stream.destroyed).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchStream', function () {
|
||||||
|
it('returns a stream', async function () {
|
||||||
|
const stream = await fetchStream(this.url('/large'))
|
||||||
|
const text = await streamToString(stream)
|
||||||
|
expect(text).to.equal(this.server.largePayload)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aborts the request when the stream is destroyed', async function () {
|
||||||
|
const stream = await fetchStream(this.url('/large'))
|
||||||
|
stream.destroy()
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aborts the request when the request body is destroyed before transfer', async function () {
|
||||||
|
const stream = Readable.from(infiniteIterator())
|
||||||
|
const promise = fetchStream(this.url('/hang'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: stream,
|
||||||
|
})
|
||||||
|
stream.destroy()
|
||||||
|
await expect(promise).to.be.rejectedWith(AbortError)
|
||||||
|
await wait(80)
|
||||||
|
expect(this.server.lastReq).to.be.undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aborts the request when the request body is destroyed during transfer', async function () {
|
||||||
|
const stream = Readable.from(infiniteIterator())
|
||||||
|
// Note: this test won't work on `/hang`
|
||||||
|
const promise = fetchStream(this.url('/sink'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: stream,
|
||||||
|
})
|
||||||
|
await once(this.server.events, 'request-received')
|
||||||
|
stream.destroy()
|
||||||
|
await expect(promise).to.be.rejectedWith(AbortError)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles errors', async function () {
|
||||||
|
await expect(fetchStream(this.url('/500'))).to.be.rejectedWith(
|
||||||
|
RequestFailedError
|
||||||
|
)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports abort signals', async function () {
|
||||||
|
await expect(
|
||||||
|
abortOnceReceived(
|
||||||
|
signal => fetchStream(this.url('/hang'), { signal }),
|
||||||
|
this.server
|
||||||
|
)
|
||||||
|
).to.be.rejectedWith(AbortError)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('destroys the request body when an error occurs', async function () {
|
||||||
|
const stream = Readable.from(infiniteIterator())
|
||||||
|
await expect(
|
||||||
|
abortOnceReceived(
|
||||||
|
signal =>
|
||||||
|
fetchStream(this.url('/hang'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: stream,
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
this.server
|
||||||
|
)
|
||||||
|
).to.be.rejectedWith(AbortError)
|
||||||
|
expect(stream.destroyed).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detaches from signal on success', async function () {
|
||||||
|
const signal = AbortSignal.timeout(10_000)
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const s = await fetchStream(this.url('/hello'), { signal })
|
||||||
|
expect(events.getEventListeners(signal, 'abort')).to.have.length(1)
|
||||||
|
await pipeline(s, fs.createWriteStream('/dev/null'))
|
||||||
|
expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detaches from signal on error', async function () {
|
||||||
|
const signal = AbortSignal.timeout(10_000)
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
try {
|
||||||
|
await fetchStream(this.url('/500'), { signal })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RequestFailedError && err.response.status === 500)
|
||||||
|
continue
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
expect(events.getEventListeners(signal, 'abort')).to.have.length(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchNothing', function () {
|
||||||
|
it('closes the connection', async function () {
|
||||||
|
await fetchNothing(this.url('/large'))
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aborts the request when the request body is destroyed before transfer', async function () {
|
||||||
|
const stream = Readable.from(infiniteIterator())
|
||||||
|
const promise = fetchNothing(this.url('/hang'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: stream,
|
||||||
|
})
|
||||||
|
stream.destroy()
|
||||||
|
await expect(promise).to.be.rejectedWith(AbortError)
|
||||||
|
expect(this.server.lastReq).to.be.undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aborts the request when the request body is destroyed during transfer', async function () {
|
||||||
|
const stream = Readable.from(infiniteIterator())
|
||||||
|
// Note: this test won't work on `/hang`
|
||||||
|
const promise = fetchNothing(this.url('/sink'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: stream,
|
||||||
|
})
|
||||||
|
await once(this.server.events, 'request-received')
|
||||||
|
stream.destroy()
|
||||||
|
await expect(promise).to.be.rejectedWith(AbortError)
|
||||||
|
await wait(80)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("doesn't abort the request if the request body ends normally", async function () {
|
||||||
|
const stream = Readable.from('hello there')
|
||||||
|
await fetchNothing(this.url('/sink'), { method: 'POST', body: stream })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles errors', async function () {
|
||||||
|
await expect(fetchNothing(this.url('/500'))).to.be.rejectedWith(
|
||||||
|
RequestFailedError
|
||||||
|
)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports abort signals', async function () {
|
||||||
|
await expect(
|
||||||
|
abortOnceReceived(
|
||||||
|
signal => fetchNothing(this.url('/hang'), { signal }),
|
||||||
|
this.server
|
||||||
|
)
|
||||||
|
).to.be.rejectedWith(AbortError)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('destroys the request body when an error occurs', async function () {
|
||||||
|
const stream = Readable.from(infiniteIterator())
|
||||||
|
await expect(
|
||||||
|
abortOnceReceived(
|
||||||
|
signal =>
|
||||||
|
fetchNothing(this.url('/hang'), {
|
||||||
|
method: 'POST',
|
||||||
|
body: stream,
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
this.server
|
||||||
|
)
|
||||||
|
).to.be.rejectedWith(AbortError)
|
||||||
|
expect(stream.destroyed).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchString', function () {
|
||||||
|
it('returns a string', async function () {
|
||||||
|
const body = await fetchString(this.url('/hello'))
|
||||||
|
expect(body).to.equal('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles errors', async function () {
|
||||||
|
await expect(fetchString(this.url('/500'))).to.be.rejectedWith(
|
||||||
|
RequestFailedError
|
||||||
|
)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fetchRedirect', function () {
|
||||||
|
it('returns the immediate redirect', async function () {
|
||||||
|
const body = await fetchRedirect(this.url('/redirect/1'))
|
||||||
|
expect(body).to.equal(this.url('/redirect/2'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects status 200', async function () {
|
||||||
|
await expect(fetchRedirect(this.url('/hello'))).to.be.rejectedWith(
|
||||||
|
RequestFailedError
|
||||||
|
)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty redirect', async function () {
|
||||||
|
await expect(fetchRedirect(this.url('/redirect/empty-location')))
|
||||||
|
.to.be.rejectedWith(RequestFailedError)
|
||||||
|
.and.eventually.have.property('cause')
|
||||||
|
.and.to.have.property('message')
|
||||||
|
.to.equal('missing Location response header on 3xx response')
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles errors', async function () {
|
||||||
|
await expect(fetchRedirect(this.url('/500'))).to.be.rejectedWith(
|
||||||
|
RequestFailedError
|
||||||
|
)
|
||||||
|
await expectRequestAborted(this.server.lastReq)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CustomHttpAgent', function () {
|
||||||
|
it('makes an http request successfully', async function () {
|
||||||
|
const agent = new CustomHttpAgent({ connectTimeout: 100 })
|
||||||
|
const body = await fetchString(this.url('/hello'), { agent })
|
||||||
|
expect(body).to.equal('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('times out when accessing a non-routable address', async function () {
|
||||||
|
const agent = new CustomHttpAgent({ connectTimeout: 10 })
|
||||||
|
await expect(fetchString('http://10.255.255.255/', { agent }))
|
||||||
|
.to.be.rejectedWith(FetchError)
|
||||||
|
.and.eventually.have.property('message')
|
||||||
|
.and.to.equal(
|
||||||
|
'request to http://10.255.255.255/ failed, reason: connect timeout'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CustomHttpsAgent', function () {
|
||||||
|
it('makes an https request successfully', async function () {
|
||||||
|
const agent = new CustomHttpsAgent({
|
||||||
|
connectTimeout: 100,
|
||||||
|
ca: PUBLIC_CERT,
|
||||||
|
})
|
||||||
|
const body = await fetchString(this.httpsUrl('/hello'), { agent })
|
||||||
|
expect(body).to.equal('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an untrusted server', async function () {
|
||||||
|
const agent = new CustomHttpsAgent({
|
||||||
|
connectTimeout: 100,
|
||||||
|
})
|
||||||
|
await expect(fetchString(this.httpsUrl('/hello'), { agent }))
|
||||||
|
.to.be.rejectedWith(FetchError)
|
||||||
|
.and.eventually.have.property('code')
|
||||||
|
.and.to.equal('DEPTH_ZERO_SELF_SIGNED_CERT')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('times out when accessing a non-routable address', async function () {
|
||||||
|
const agent = new CustomHttpsAgent({ connectTimeout: 10 })
|
||||||
|
await expect(fetchString('https://10.255.255.255/', { agent }))
|
||||||
|
.to.be.rejectedWith(FetchError)
|
||||||
|
.and.eventually.have.property('message')
|
||||||
|
.and.to.equal(
|
||||||
|
'request to https://10.255.255.255/ failed, reason: connect timeout'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function streamToString(stream) {
|
||||||
|
let s = ''
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
s += chunk
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* infiniteIterator() {
|
||||||
|
let i = 1
|
||||||
|
while (true) {
|
||||||
|
yield `chunk ${i++}\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {(signal: AbortSignal) => Promise<any>} func
|
||||||
|
* @param {TestServer} server
|
||||||
|
*/
|
||||||
|
async function abortOnceReceived(func, server) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const promise = func(controller.signal)
|
||||||
|
expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(1)
|
||||||
|
await once(server.events, 'request-received')
|
||||||
|
controller.abort()
|
||||||
|
try {
|
||||||
|
return await promise
|
||||||
|
} finally {
|
||||||
|
expect(events.getEventListeners(controller.signal, 'abort')).to.have.length(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectRequestAborted(req) {
|
||||||
|
if (!req.destroyed) {
|
||||||
|
try {
|
||||||
|
await once(req, 'close')
|
||||||
|
} catch (err) {
|
||||||
|
// `once` throws if req emits an 'error' event.
|
||||||
|
// We ignore `Error: aborted` when the request is aborted.
|
||||||
|
if (err.message !== 'aborted') {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(req.destroyed).to.be.true
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import express from 'express'
|
const express = require('express')
|
||||||
import { EventEmitter } from 'node:events'
|
const bodyParser = require('body-parser')
|
||||||
import http from 'node:http'
|
const { EventEmitter } = require('node:events')
|
||||||
import https from 'node:https'
|
const http = require('node:http')
|
||||||
import { promisify } from 'node:util'
|
const https = require('node:https')
|
||||||
|
const { promisify } = require('node:util')
|
||||||
|
|
||||||
class TestServer {
|
class TestServer {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.app = express()
|
this.app = express()
|
||||||
this.events = new EventEmitter()
|
this.events = new EventEmitter()
|
||||||
|
|
||||||
this.app.use(express.json())
|
this.app.use(bodyParser.json())
|
||||||
this.app.use((req, res, next) => {
|
this.app.use((req, res, next) => {
|
||||||
this.events.emit('request-received')
|
this.events.emit('request-received')
|
||||||
this.lastReq = req
|
this.lastReq = req
|
||||||
@@ -39,22 +40,6 @@ class TestServer {
|
|||||||
res.sendStatus(500)
|
res.sendStatus(500)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.app.get('/400', (req, res) => {
|
|
||||||
res.status(400).send('boom-400')
|
|
||||||
})
|
|
||||||
|
|
||||||
this.app.get('/409', (req, res) => {
|
|
||||||
res.status(409).send('boom-409')
|
|
||||||
})
|
|
||||||
|
|
||||||
this.app.get('/413', (req, res) => {
|
|
||||||
res.status(413).send('boom-413')
|
|
||||||
})
|
|
||||||
|
|
||||||
this.app.get('/422', (req, res) => {
|
|
||||||
res.status(422).send('boom-422')
|
|
||||||
})
|
|
||||||
|
|
||||||
this.app.post('/sink', (req, res) => {
|
this.app.post('/sink', (req, res) => {
|
||||||
req.on('data', () => {})
|
req.on('data', () => {})
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
@@ -151,4 +136,4 @@ class TestServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { TestServer }
|
module.exports = { TestServer }
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.backend.json",
|
"extends": "../../tsconfig.backend.json",
|
||||||
"compilerOptions": {
|
|
||||||
"noImplicitAny": true
|
|
||||||
},
|
|
||||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24.14.1
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
logger
|
||||||
|
--dependencies=None
|
||||||
|
--env-add=
|
||||||
|
--env-pass-through=
|
||||||
|
--esmock-loader=False
|
||||||
|
--is-library=True
|
||||||
|
--node-version=24.14.1
|
||||||
|
--package-name=@overleaf/logger
|
||||||
|
--pipeline-owner=32
|
||||||
|
--public-repo=False
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
const Stream = require('node:stream')
|
const Stream = require('node:stream')
|
||||||
const { setTimeout } = require('node:timers/promises')
|
|
||||||
const bunyan = require('bunyan')
|
const bunyan = require('bunyan')
|
||||||
const GCPManager = require('./gcp-manager')
|
const GCPManager = require('./gcp-manager')
|
||||||
const Serializers = require('./serializers')
|
const Serializers = require('./serializers')
|
||||||
@@ -8,22 +7,8 @@ const {
|
|||||||
GCEMetadataLogLevelChecker,
|
GCEMetadataLogLevelChecker,
|
||||||
} = require('./log-level-checker')
|
} = require('./log-level-checker')
|
||||||
const { setLogger } = require('@overleaf/fetch-utils')
|
const { setLogger } = require('@overleaf/fetch-utils')
|
||||||
const {
|
|
||||||
setLogger: setValidationToolsLogger,
|
|
||||||
} = require('@overleaf/validation-tools')
|
|
||||||
|
|
||||||
// Delay (ms) before exiting to allow final logs to be flushed.
|
|
||||||
const EXIT_DELAY = 500
|
|
||||||
|
|
||||||
const LoggingManager = {
|
const LoggingManager = {
|
||||||
/**
|
|
||||||
* The serializers registered by addSerializer, kept here rather than only on
|
|
||||||
* the logger so that they survive a later initialize() call. A module that
|
|
||||||
* registers one as it is imported would otherwise lose it, since a service
|
|
||||||
* initializes its logger after its imports have run.
|
|
||||||
*/
|
|
||||||
customSerializers: {},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} name - The name of the logger
|
* @param {string} name - The name of the logger
|
||||||
*/
|
*/
|
||||||
@@ -42,35 +27,15 @@ const LoggingManager = {
|
|||||||
error: Serializers.err,
|
error: Serializers.err,
|
||||||
req: Serializers.req,
|
req: Serializers.req,
|
||||||
res: Serializers.res,
|
res: Serializers.res,
|
||||||
...this.customSerializers,
|
|
||||||
},
|
},
|
||||||
streams: options.streams ?? [this._getOutputStreamConfig()],
|
streams: options.streams ?? [this._getOutputStreamConfig()],
|
||||||
})
|
})
|
||||||
this._setupRingBuffer()
|
this._setupRingBuffer()
|
||||||
this._setupLogLevelChecker()
|
this._setupLogLevelChecker()
|
||||||
setLogger(this)
|
setLogger(this)
|
||||||
setValidationToolsLogger(this)
|
|
||||||
return this
|
return this
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a serializer, which decides what gets recorded for a named log
|
|
||||||
* field, e.g. `logger.error({ dropboxResponseError: err }, '...')`. Callers
|
|
||||||
* add their own on top of the ones initialize() sets up, and keep them across
|
|
||||||
* a later initialize(), so registering one as a module is imported works.
|
|
||||||
*
|
|
||||||
* A serializer takes whatever was logged under that field, so it receives an
|
|
||||||
* unknown and narrows it itself, and returns the value to record in its
|
|
||||||
* place, which is only ever JSON encoded.
|
|
||||||
*
|
|
||||||
* @param {string} name - the log field the serializer applies to
|
|
||||||
* @param {(value: unknown) => unknown} serializer
|
|
||||||
*/
|
|
||||||
addSerializer(name, serializer) {
|
|
||||||
this.customSerializers[name] = serializer
|
|
||||||
this.logger.serializers[name] = serializer
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Record<string, any>|string} attributes - Attributes to log (nice serialization for err, req, res)
|
* @param {Record<string, any>|string} attributes - Attributes to log (nice serialization for err, req, res)
|
||||||
* @param {string} [message] - Optional message
|
* @param {string} [message] - Optional message
|
||||||
@@ -131,16 +96,6 @@ const LoggingManager = {
|
|||||||
this.logger.fatal(attributes, message)
|
this.logger.fatal(attributes, message)
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Exit after a delay to ensure that log messages are flushed
|
|
||||||
* @param {number} code
|
|
||||||
*/
|
|
||||||
|
|
||||||
async exit(code) {
|
|
||||||
await setTimeout(EXIT_DELAY)
|
|
||||||
process.exit(code)
|
|
||||||
},
|
|
||||||
|
|
||||||
_getOutputStreamConfig() {
|
_getOutputStreamConfig() {
|
||||||
switch (process.env.LOGGING_FORMAT) {
|
switch (process.env.LOGGING_FORMAT) {
|
||||||
case 'gke': {
|
case 'gke': {
|
||||||
|
|||||||
@@ -7,15 +7,11 @@
|
|||||||
"url": "https://github.com/overleaf/overleaf"
|
"url": "https://github.com/overleaf/overleaf"
|
||||||
},
|
},
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"exports": {
|
|
||||||
".": "./index.js",
|
|
||||||
"./*": "./*"
|
|
||||||
},
|
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
"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": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||||
"test:ci": "yarn run test:unit",
|
"test:ci": "yarn run test:unit",
|
||||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||||
@@ -25,13 +21,10 @@
|
|||||||
"@google-cloud/logging-bunyan": "^5.1.0",
|
"@google-cloud/logging-bunyan": "^5.1.0",
|
||||||
"@overleaf/fetch-utils": "workspace:*",
|
"@overleaf/fetch-utils": "workspace:*",
|
||||||
"@overleaf/o-error": "workspace:*",
|
"@overleaf/o-error": "workspace:*",
|
||||||
"@overleaf/validation-tools": "workspace:*",
|
|
||||||
"bunyan": "^1.8.14"
|
"bunyan": "^1.8.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.5.2",
|
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
"eslint": "^10.4.0",
|
|
||||||
"mocha": "^11.1.0",
|
"mocha": "^11.1.0",
|
||||||
"mocha-junit-reporter": "^2.2.1",
|
"mocha-junit-reporter": "^2.2.1",
|
||||||
"mocha-multi-reporters": "^1.5.1",
|
"mocha-multi-reporters": "^1.5.1",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const OError = require('@overleaf/o-error')
|
const OError = require('@overleaf/o-error')
|
||||||
const { getRawReqInput } = require('@overleaf/validation-tools')
|
|
||||||
|
|
||||||
function errSerializer(err) {
|
function errSerializer(err) {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
@@ -37,14 +36,11 @@ function reqSerializer(req) {
|
|||||||
'content-length': headers['content-length'],
|
'content-length': headers['content-length'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// raw access justified: case 2 (final error-handler logging) — this
|
if (req.params) {
|
||||||
// serializer must not throw while extracting log context, and bunyan
|
const projectId =
|
||||||
// silently drops the whole `req` field (with a stderr warning) if it did
|
req.params.projectId || req.params.project_id || req.params.Project_id
|
||||||
const { params } = getRawReqInput(req)
|
const userId = req.params.userId || req.params.user_id
|
||||||
if (params) {
|
const docId = req.params.docId || req.params.doc_id
|
||||||
const projectId = params.projectId || params.project_id || params.Project_id
|
|
||||||
const userId = params.userId || params.user_id
|
|
||||||
const docId = params.docId || params.doc_id
|
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
entry.projectId = projectId
|
entry.projectId = projectId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||||
mochaJunitReporterReporterOptions: {
|
mochaJunitReporterReporterOptions: {
|
||||||
mochaFile: `reports/junit-mocha-${process.env.TEST_NAME_PATTERN}.xml`,
|
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
|
||||||
includePending: true,
|
includePending: true,
|
||||||
jenkinsMode: true,
|
jenkinsMode: true,
|
||||||
output: true,
|
output: true,
|
||||||
|
|||||||
@@ -7,11 +7,6 @@ chai.should()
|
|||||||
chai.use(sinonChai)
|
chai.use(sinonChai)
|
||||||
|
|
||||||
SandboxedModule.configure({
|
SandboxedModule.configure({
|
||||||
requires: {
|
|
||||||
// pulls in mongodb (via zod ObjectId helpers), which does not compile in
|
|
||||||
// the sandbox (missing TextEncoder)
|
|
||||||
'@overleaf/validation-tools': require('@overleaf/validation-tools'),
|
|
||||||
},
|
|
||||||
globals: { Buffer, JSON, console, process },
|
globals: { Buffer, JSON, console, process },
|
||||||
sourceTransformers: {
|
sourceTransformers: {
|
||||||
removeNodePrefix: function (source) {
|
removeNodePrefix: function (source) {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ describe('LoggingManager', function () {
|
|||||||
info: sinon.stub(),
|
info: sinon.stub(),
|
||||||
level: sinon.stub(),
|
level: sinon.stub(),
|
||||||
warn: sinon.stub(),
|
warn: sinon.stub(),
|
||||||
serializers: {},
|
|
||||||
}
|
}
|
||||||
this.Bunyan = {
|
this.Bunyan = {
|
||||||
createLogger: sinon.stub().returns(this.bunyanLogger),
|
createLogger: sinon.stub().returns(this.bunyanLogger),
|
||||||
@@ -47,14 +46,10 @@ describe('LoggingManager', function () {
|
|||||||
this.fetchUtils = {
|
this.fetchUtils = {
|
||||||
setLogger: sinon.stub(),
|
setLogger: sinon.stub(),
|
||||||
}
|
}
|
||||||
this.validationTools = {
|
|
||||||
setLogger: sinon.stub(),
|
|
||||||
}
|
|
||||||
this.LoggingManager = SandboxedModule.require(MODULE_PATH, {
|
this.LoggingManager = SandboxedModule.require(MODULE_PATH, {
|
||||||
requires: {
|
requires: {
|
||||||
bunyan: this.Bunyan,
|
bunyan: this.Bunyan,
|
||||||
'@overleaf/fetch-utils': this.fetchUtils,
|
'@overleaf/fetch-utils': this.fetchUtils,
|
||||||
'@overleaf/validation-tools': this.validationTools,
|
|
||||||
'./log-level-checker': this.LogLevelChecker,
|
'./log-level-checker': this.LogLevelChecker,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -127,37 +122,6 @@ describe('LoggingManager', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('addSerializer', function () {
|
|
||||||
beforeEach(function () {
|
|
||||||
this.serializer = sinon.stub()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('registers the serializer on the current logger', function () {
|
|
||||||
this.LoggingManager.addSerializer('custom', this.serializer)
|
|
||||||
expect(this.bunyanLogger.serializers.custom).to.equal(this.serializer)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the serializer through a later initialize', function () {
|
|
||||||
// A module registering a serializer as it is imported does so before the
|
|
||||||
// service initializes its own logger, which replaces the bunyan instance.
|
|
||||||
this.LoggingManager.addSerializer('custom', this.serializer)
|
|
||||||
this.Bunyan.createLogger.reset()
|
|
||||||
this.LoggingManager.initialize(this.loggerName)
|
|
||||||
expect(
|
|
||||||
this.Bunyan.createLogger.firstCall.args[0].serializers.custom
|
|
||||||
).to.equal(this.serializer)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the serializers initialize sets up', function () {
|
|
||||||
this.LoggingManager.addSerializer('custom', this.serializer)
|
|
||||||
this.Bunyan.createLogger.reset()
|
|
||||||
this.LoggingManager.initialize(this.loggerName)
|
|
||||||
expect(
|
|
||||||
this.Bunyan.createLogger.firstCall.args[0].serializers
|
|
||||||
).to.include.keys('err', 'error', 'req', 'res')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('bunyan logging', function () {
|
describe('bunyan logging', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
this.logArgs = [{ foo: 'bar' }, 'foo', 'bar']
|
this.logArgs = [{ foo: 'bar' }, 'foo', 'bar']
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
const path = require('node:path')
|
|
||||||
const { expect } = require('chai')
|
|
||||||
|
|
||||||
const MODULE_PATH = path.join(__dirname, '../../serializers.js')
|
|
||||||
const serializers = require(MODULE_PATH)
|
|
||||||
|
|
||||||
// Mirrors the symbols the patched express (.yarn/patches/express-npm-*.patch)
|
|
||||||
// installs on req in REQ_LOCKDOWN_MODE=warn|throw, and how
|
|
||||||
// @overleaf/validation-tools' getRawReqInput() reads them.
|
|
||||||
const RAW_BODY = Symbol.for('overleaf.lockdown.rawBody')
|
|
||||||
const RAW_QUERY = Symbol.for('overleaf.lockdown.rawQuery')
|
|
||||||
const RAW_PARAMS = Symbol.for('overleaf.lockdown.rawParams')
|
|
||||||
const INSTALLED = Symbol.for('overleaf.lockdown.installed')
|
|
||||||
|
|
||||||
// Replicates what the patched express does to a request in throw mode:
|
|
||||||
// parsed input lives in symbol-keyed fields, the public `params` getter
|
|
||||||
// throws instead of returning it.
|
|
||||||
function lockedRequest({ params, ...rest }) {
|
|
||||||
const req = { ...rest }
|
|
||||||
req[INSTALLED] = true
|
|
||||||
req[RAW_PARAMS] = params
|
|
||||||
req[RAW_QUERY] = {}
|
|
||||||
req[RAW_BODY] = undefined
|
|
||||||
Object.defineProperty(req, 'params', {
|
|
||||||
configurable: true,
|
|
||||||
enumerable: false,
|
|
||||||
get() {
|
|
||||||
throw new Error('raw request input is forbidden (req.params)')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('reqSerializer', function () {
|
|
||||||
it('returns undefined/falsy req untouched', function () {
|
|
||||||
expect(serializers.req(null)).to.equal(null)
|
|
||||||
expect(serializers.req(undefined)).to.equal(undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('extracts method, url, headers and remote address', function () {
|
|
||||||
const req = {
|
|
||||||
method: 'GET',
|
|
||||||
originalUrl: '/project/123',
|
|
||||||
headers: {
|
|
||||||
referer: 'https://example.com',
|
|
||||||
'user-agent': 'test-agent',
|
|
||||||
'content-length': '42',
|
|
||||||
},
|
|
||||||
ip: '127.0.0.1',
|
|
||||||
params: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(serializers.req(req)).to.deep.equal({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/project/123',
|
|
||||||
remoteAddress: '127.0.0.1',
|
|
||||||
headers: {
|
|
||||||
referer: 'https://example.com',
|
|
||||||
'user-agent': 'test-agent',
|
|
||||||
'content-length': '42',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('extracts projectId/userId/docId from ordinary, readable req.params', function () {
|
|
||||||
const req = {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/',
|
|
||||||
headers: {},
|
|
||||||
params: {
|
|
||||||
project_id: 'project-1',
|
|
||||||
user_id: 'user-1',
|
|
||||||
doc_id: 'doc-1',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = serializers.req(req)
|
|
||||||
expect(entry.projectId).to.equal('project-1')
|
|
||||||
expect(entry.userId).to.equal('user-1')
|
|
||||||
expect(entry.docId).to.equal('doc-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('prefers the camelCase param name when both spellings are present', function () {
|
|
||||||
const req = {
|
|
||||||
method: 'GET',
|
|
||||||
url: '/',
|
|
||||||
headers: {},
|
|
||||||
params: {
|
|
||||||
projectId: 'camel-project',
|
|
||||||
project_id: 'snake-project',
|
|
||||||
userId: 'camel-user',
|
|
||||||
user_id: 'snake-user',
|
|
||||||
docId: 'camel-doc',
|
|
||||||
doc_id: 'snake-doc',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = serializers.req(req)
|
|
||||||
expect(entry.projectId).to.equal('camel-project')
|
|
||||||
expect(entry.userId).to.equal('camel-user')
|
|
||||||
expect(entry.docId).to.equal('camel-doc')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits projectId/userId/docId when req.params has none of the recognised fields', function () {
|
|
||||||
const req = { method: 'GET', url: '/', headers: {}, params: {} }
|
|
||||||
const entry = serializers.req(req)
|
|
||||||
expect(entry).to.not.have.property('projectId')
|
|
||||||
expect(entry).to.not.have.property('userId')
|
|
||||||
expect(entry).to.not.have.property('docId')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not throw when req.params is a lockdown-installed throwing getter, and still extracts the ids via the raw accessor', function () {
|
|
||||||
const req = lockedRequest({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/project/123',
|
|
||||||
headers: {},
|
|
||||||
params: {
|
|
||||||
project_id: 'project-1',
|
|
||||||
user_id: 'user-1',
|
|
||||||
doc_id: 'doc-1',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sanity check: touching req.params directly really does throw, the way
|
|
||||||
// it would under REQ_LOCKDOWN_MODE=throw.
|
|
||||||
expect(() => req.params).to.throw(/raw request input is forbidden/)
|
|
||||||
|
|
||||||
let entry
|
|
||||||
expect(() => {
|
|
||||||
entry = serializers.req(req)
|
|
||||||
}).to.not.throw()
|
|
||||||
|
|
||||||
expect(entry.projectId).to.equal('project-1')
|
|
||||||
expect(entry.userId).to.equal('user-1')
|
|
||||||
expect(entry.docId).to.equal('doc-1')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
24.14.1
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
metrics
|
||||||
|
--dependencies=None
|
||||||
|
--env-add=
|
||||||
|
--env-pass-through=
|
||||||
|
--esmock-loader=False
|
||||||
|
--is-library=True
|
||||||
|
--node-version=24.14.1
|
||||||
|
--package-name=@overleaf/metrics
|
||||||
|
--pipeline-owner=32
|
||||||
|
--public-repo=False
|
||||||
@@ -34,15 +34,14 @@ function initializeOpenTelemetryInstrumentation() {
|
|||||||
const {
|
const {
|
||||||
getNodeAutoInstrumentations,
|
getNodeAutoInstrumentations,
|
||||||
} = require('@opentelemetry/auto-instrumentations-node')
|
} = require('@opentelemetry/auto-instrumentations-node')
|
||||||
const { resourceFromAttributes } = require('@opentelemetry/resources')
|
const { Resource } = require('@opentelemetry/resources')
|
||||||
const {
|
const {
|
||||||
ATTR_SERVICE_NAME,
|
SemanticResourceAttributes,
|
||||||
ATTR_SERVICE_NAMESPACE,
|
|
||||||
} = require('@opentelemetry/semantic-conventions')
|
} = require('@opentelemetry/semantic-conventions')
|
||||||
|
|
||||||
const resource = resourceFromAttributes({
|
const resource = new Resource({
|
||||||
[ATTR_SERVICE_NAME]: APP_NAME,
|
[SemanticResourceAttributes.SERVICE_NAME]: APP_NAME,
|
||||||
[ATTR_SERVICE_NAMESPACE]: 'Overleaf',
|
[SemanticResourceAttributes.SERVICE_NAMESPACE]: 'Overleaf',
|
||||||
'host.type': 'VM',
|
'host.type': 'VM',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,23 +9,20 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
|
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
|
||||||
"@google-cloud/profiler": "^7.0.0",
|
"@google-cloud/profiler": "^6.0.4",
|
||||||
"@opentelemetry/api": "^1.9.1",
|
"@opentelemetry/api": "^1.9.1",
|
||||||
"@opentelemetry/auto-instrumentations-node": "^0.78.0",
|
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
||||||
"@opentelemetry/resources": "^2.9.0",
|
"@opentelemetry/resources": "^2.7.1",
|
||||||
"@opentelemetry/sdk-node": "^0.220.0",
|
"@opentelemetry/sdk-node": "^0.218.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.41.1",
|
"@opentelemetry/semantic-conventions": "^1.41.1",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"prom-client": "^14.1.1",
|
"prom-client": "^14.1.1",
|
||||||
"yn": "^3.1.1"
|
"yn": "^3.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@overleaf/logger": "workspace:*",
|
|
||||||
"@types/node": "^24.5.2",
|
|
||||||
"bunyan": "^1.0.0",
|
"bunyan": "^1.0.0",
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
"eslint": "^10.4.0",
|
|
||||||
"mocha": "^11.1.0",
|
"mocha": "^11.1.0",
|
||||||
"mocha-junit-reporter": "^2.2.1",
|
"mocha-junit-reporter": "^2.2.1",
|
||||||
"mocha-multi-reporters": "^1.5.1",
|
"mocha-multi-reporters": "^1.5.1",
|
||||||
@@ -34,10 +31,10 @@
|
|||||||
"typescript": "^5.0.4"
|
"typescript": "^5.0.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 .",
|
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||||
"test:acceptance": "mocha --recursive --exit --grep=${TEST_NAME_PATTERN:-} test/acceptance",
|
"test:acceptance": "mocha --recursive --exit --grep=${MOCHA_GREP:-} test/acceptance",
|
||||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||||
"test:ci": "yarn run test:unit",
|
"test:ci": "yarn run test:unit",
|
||||||
"types:check": "tsc --noEmit"
|
"types:check": "tsc --noEmit"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||||
mochaJunitReporterReporterOptions: {
|
mochaJunitReporterReporterOptions: {
|
||||||
mochaFile: `reports/junit-mocha-${process.env.TEST_NAME_PATTERN}.xml`,
|
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
|
||||||
includePending: true,
|
includePending: true,
|
||||||
jenkinsMode: true,
|
jenkinsMode: true,
|
||||||
output: true,
|
output: true,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
24.14.1
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
mongo-utils
|
||||||
|
--dependencies=None
|
||||||
|
--env-add=
|
||||||
|
--env-pass-through=
|
||||||
|
--esmock-loader=False
|
||||||
|
--is-library=True
|
||||||
|
--node-version=24.14.1
|
||||||
|
--package-name=@overleaf/mongo-utils
|
||||||
|
--pipeline-owner=32
|
||||||
|
--public-repo=False
|
||||||
|
--tsconfig-no-implicit-any=True
|
||||||
@@ -3,14 +3,10 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "utilities to help working with mongo",
|
"description": "utilities to help working with mongo",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"exports": {
|
|
||||||
".": "./index.js",
|
|
||||||
"./*": "./*"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 .",
|
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||||
"test:ci": "yarn run test:unit",
|
"test:ci": "yarn run test:unit",
|
||||||
"types:check": "tsc --noEmit"
|
"types:check": "tsc --noEmit"
|
||||||
@@ -22,9 +18,7 @@
|
|||||||
"mongodb-legacy": "6.1.3"
|
"mongodb-legacy": "6.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.5.2",
|
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
"eslint": "^10.4.0",
|
|
||||||
"mocha": "^11.1.0",
|
"mocha": "^11.1.0",
|
||||||
"mocha-junit-reporter": "^2.2.1",
|
"mocha-junit-reporter": "^2.2.1",
|
||||||
"mocha-multi-reporters": "^1.5.1",
|
"mocha-multi-reporters": "^1.5.1",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
24.14.1
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
o-error
|
||||||
|
--dependencies=None
|
||||||
|
--env-add=
|
||||||
|
--env-pass-through=
|
||||||
|
--esmock-loader=False
|
||||||
|
--is-library=True
|
||||||
|
--node-version=24.14.1
|
||||||
|
--package-name=@overleaf/o-error
|
||||||
|
--pipeline-owner=32
|
||||||
|
--public-repo=False
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yarn run --silent test",
|
"build": "yarn run --silent test",
|
||||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 .",
|
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
"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": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||||
"test:ci": "yarn run test:unit",
|
"test:ci": "yarn run test:unit",
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
"@types/chai": "^4.3.0",
|
"@types/chai": "^4.3.0",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
"eslint": "^10.4.0",
|
|
||||||
"mocha": "^11.1.0",
|
"mocha": "^11.1.0",
|
||||||
"mocha-junit-reporter": "^2.2.1",
|
"mocha-junit-reporter": "^2.2.1",
|
||||||
"mocha-multi-reporters": "^1.5.1",
|
"mocha-multi-reporters": "^1.5.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||||
mochaJunitReporterReporterOptions: {
|
mochaJunitReporterReporterOptions: {
|
||||||
mochaFile: `reports/junit-mocha-${process.env.TEST_NAME_PATTERN}.xml`,
|
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
|
||||||
includePending: true,
|
includePending: true,
|
||||||
jenkinsMode: true,
|
jenkinsMode: true,
|
||||||
output: true,
|
output: true,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
24.14.1
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
object-persistor
|
||||||
|
--dependencies=None
|
||||||
|
--env-add=
|
||||||
|
--env-pass-through=
|
||||||
|
--esmock-loader=False
|
||||||
|
--is-library=True
|
||||||
|
--node-version=24.14.1
|
||||||
|
--package-name=@overleaf/object-persistor
|
||||||
|
--pipeline-owner=32
|
||||||
|
--public-repo=False
|
||||||
|
--tsconfig-no-implicit-any=True
|
||||||
@@ -3,14 +3,10 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"description": "Module for storing objects in multiple backends, with fallback on 404 to assist migration between them",
|
"description": "Module for storing objects in multiple backends, with fallback on 404 to assist migration between them",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"exports": {
|
|
||||||
".": "./index.js",
|
|
||||||
"./*": "./*"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 .",
|
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||||
"test:ci": "yarn run test:unit",
|
"test:ci": "yarn run test:unit",
|
||||||
"types:check": "tsc --noEmit"
|
"types:check": "tsc --noEmit"
|
||||||
@@ -37,11 +33,8 @@
|
|||||||
"tiny-async-pool": "^1.1.0"
|
"tiny-async-pool": "^1.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash": "^4.17.24",
|
|
||||||
"@types/node": "^24.5.2",
|
|
||||||
"chai": "^4.3.6",
|
"chai": "^4.3.6",
|
||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"eslint": "^10.4.0",
|
|
||||||
"mocha": "^11.1.0",
|
"mocha": "^11.1.0",
|
||||||
"mocha-junit-reporter": "^2.2.1",
|
"mocha-junit-reporter": "^2.2.1",
|
||||||
"mocha-multi-reporters": "^1.5.1",
|
"mocha-multi-reporters": "^1.5.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||||
mochaJunitReporterReporterOptions: {
|
mochaJunitReporterReporterOptions: {
|
||||||
mochaFile: `reports/junit-mocha-${process.env.TEST_NAME_PATTERN}.xml`,
|
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
|
||||||
includePending: true,
|
includePending: true,
|
||||||
jenkinsMode: true,
|
jenkinsMode: true,
|
||||||
output: true,
|
output: true,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
24.14.1
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
overleaf-editor-core
|
||||||
|
--dependencies=None
|
||||||
|
--env-add=
|
||||||
|
--env-pass-through=
|
||||||
|
--esmock-loader=False
|
||||||
|
--is-library=True
|
||||||
|
--node-version=24.14.1
|
||||||
|
--package-name=overleaf-editor-core
|
||||||
|
--pipeline-owner=44
|
||||||
|
--public-repo=False
|
||||||
@@ -2,7 +2,6 @@ const AddCommentOperation = require('./lib/operation/add_comment_operation')
|
|||||||
const Author = require('./lib/author')
|
const Author = require('./lib/author')
|
||||||
const AuthorList = require('./lib/author_list')
|
const AuthorList = require('./lib/author_list')
|
||||||
const Blob = require('./lib/blob')
|
const Blob = require('./lib/blob')
|
||||||
const BlobStoreBase = require('./lib/blob_store_base')
|
|
||||||
const Change = require('./lib/change')
|
const Change = require('./lib/change')
|
||||||
const ChangeRequest = require('./lib/change_request')
|
const ChangeRequest = require('./lib/change_request')
|
||||||
const ChangeNote = require('./lib/change_note')
|
const ChangeNote = require('./lib/change_note')
|
||||||
@@ -26,38 +25,11 @@ const Operation = require('./lib/operation')
|
|||||||
const RestoreOrigin = require('./lib/origin/restore_origin')
|
const RestoreOrigin = require('./lib/origin/restore_origin')
|
||||||
const RestoreFileOrigin = require('./lib/origin/restore_file_origin')
|
const RestoreFileOrigin = require('./lib/origin/restore_file_origin')
|
||||||
const Origin = require('./lib/origin')
|
const Origin = require('./lib/origin')
|
||||||
const { EDITOR_ORIGIN_KIND } = require('./lib/origin')
|
|
||||||
const OtClient = require('./lib/ot_client')
|
const OtClient = require('./lib/ot_client')
|
||||||
const {
|
|
||||||
HISTORY_FILE_TREE_STAGE,
|
|
||||||
historyIsSourceOfTruth,
|
|
||||||
} = require('./lib/ot_migration_stages')
|
|
||||||
const {
|
|
||||||
chooseRootDoc,
|
|
||||||
isRootDocCandidate,
|
|
||||||
setMainPathnameOperations,
|
|
||||||
} = require('./lib/root_doc')
|
|
||||||
const rebaseChanges = require('./lib/rebase')
|
|
||||||
const {
|
|
||||||
editorChangeIdentity,
|
|
||||||
editorChangeIdentityOf,
|
|
||||||
isSameEditorChange,
|
|
||||||
isChangeFrom,
|
|
||||||
} = require('./lib/change_identity')
|
|
||||||
const TextOperation = require('./lib/operation/text_operation')
|
const TextOperation = require('./lib/operation/text_operation')
|
||||||
const EditOperation = require('./lib/operation/edit_operation')
|
const EditOperation = require('./lib/operation/edit_operation')
|
||||||
const safePathname = require('./lib/safe_pathname')
|
const safePathname = require('./lib/safe_pathname')
|
||||||
const {
|
|
||||||
DOCUMENT_METADATA_KEYS,
|
|
||||||
isDocumentMetadata,
|
|
||||||
hasDocumentMetadataFlag,
|
|
||||||
withDocumentMetadataFlag,
|
|
||||||
} = require('./lib/file_metadata')
|
|
||||||
const Snapshot = require('./lib/snapshot')
|
const Snapshot = require('./lib/snapshot')
|
||||||
const {
|
|
||||||
DEFAULT_TEXT_EXTENSIONS,
|
|
||||||
DEFAULT_EDITABLE_FILENAMES,
|
|
||||||
} = require('./lib/text_file_defaults')
|
|
||||||
const util = require('./lib/util')
|
const util = require('./lib/util')
|
||||||
const V2DocVersions = require('./lib/v2_doc_versions')
|
const V2DocVersions = require('./lib/v2_doc_versions')
|
||||||
const {
|
const {
|
||||||
@@ -82,15 +54,12 @@ exports.AddCommentOperation = AddCommentOperation
|
|||||||
exports.Author = Author
|
exports.Author = Author
|
||||||
exports.AuthorList = AuthorList
|
exports.AuthorList = AuthorList
|
||||||
exports.Blob = Blob
|
exports.Blob = Blob
|
||||||
exports.BlobStoreBase = BlobStoreBase
|
|
||||||
exports.Change = Change
|
exports.Change = Change
|
||||||
exports.ChangeRequest = ChangeRequest
|
exports.ChangeRequest = ChangeRequest
|
||||||
exports.ChangeNote = ChangeNote
|
exports.ChangeNote = ChangeNote
|
||||||
exports.Chunk = Chunk
|
exports.Chunk = Chunk
|
||||||
exports.ChunkResponse = ChunkResponse
|
exports.ChunkResponse = ChunkResponse
|
||||||
exports.Comment = Comment
|
exports.Comment = Comment
|
||||||
exports.DEFAULT_TEXT_EXTENSIONS = DEFAULT_TEXT_EXTENSIONS
|
|
||||||
exports.DEFAULT_EDITABLE_FILENAMES = DEFAULT_EDITABLE_FILENAMES
|
|
||||||
exports.DeleteCommentOperation = DeleteCommentOperation
|
exports.DeleteCommentOperation = DeleteCommentOperation
|
||||||
exports.File = File
|
exports.File = File
|
||||||
exports.FileMap = FileMap
|
exports.FileMap = FileMap
|
||||||
@@ -111,25 +80,10 @@ exports.Operation = Operation
|
|||||||
exports.RestoreOrigin = RestoreOrigin
|
exports.RestoreOrigin = RestoreOrigin
|
||||||
exports.RestoreFileOrigin = RestoreFileOrigin
|
exports.RestoreFileOrigin = RestoreFileOrigin
|
||||||
exports.Origin = Origin
|
exports.Origin = Origin
|
||||||
exports.EDITOR_ORIGIN_KIND = EDITOR_ORIGIN_KIND
|
|
||||||
exports.OtClient = OtClient
|
exports.OtClient = OtClient
|
||||||
exports.HISTORY_FILE_TREE_STAGE = HISTORY_FILE_TREE_STAGE
|
|
||||||
exports.historyIsSourceOfTruth = historyIsSourceOfTruth
|
|
||||||
exports.chooseRootDoc = chooseRootDoc
|
|
||||||
exports.isRootDocCandidate = isRootDocCandidate
|
|
||||||
exports.setMainPathnameOperations = setMainPathnameOperations
|
|
||||||
exports.rebaseChanges = rebaseChanges
|
|
||||||
exports.editorChangeIdentity = editorChangeIdentity
|
|
||||||
exports.editorChangeIdentityOf = editorChangeIdentityOf
|
|
||||||
exports.isSameEditorChange = isSameEditorChange
|
|
||||||
exports.isChangeFrom = isChangeFrom
|
|
||||||
exports.TextOperation = TextOperation
|
exports.TextOperation = TextOperation
|
||||||
exports.EditOperation = EditOperation
|
exports.EditOperation = EditOperation
|
||||||
exports.safePathname = safePathname
|
exports.safePathname = safePathname
|
||||||
exports.DOCUMENT_METADATA_KEYS = DOCUMENT_METADATA_KEYS
|
|
||||||
exports.isDocumentMetadata = isDocumentMetadata
|
|
||||||
exports.hasDocumentMetadataFlag = hasDocumentMetadataFlag
|
|
||||||
exports.withDocumentMetadataFlag = withDocumentMetadataFlag
|
|
||||||
exports.Snapshot = Snapshot
|
exports.Snapshot = Snapshot
|
||||||
exports.util = util
|
exports.util = util
|
||||||
exports.V2DocVersions = V2DocVersions
|
exports.V2DocVersions = V2DocVersions
|
||||||
|
|||||||
@@ -38,18 +38,6 @@ class Blob {
|
|||||||
*/
|
*/
|
||||||
static MAX_EDITABLE_BYTE_LENGTH_BOUND = 3 * TextOperation.MAX_STRING_LENGTH
|
static MAX_EDITABLE_BYTE_LENGTH_BOUND = 3 * TextOperation.MAX_STRING_LENGTH
|
||||||
|
|
||||||
/**
|
|
||||||
* The hash of empty content, which is the same for every project.
|
|
||||||
*
|
|
||||||
* git's hash of an empty blob, as `blobHashFromString` computes it: it is a property of the
|
|
||||||
* content rather than of anything stored, so it can be recognised without looking
|
|
||||||
* anything up. `File.EMPTY_FILE_HASH` is the same value under the name a file's
|
|
||||||
* content goes by.
|
|
||||||
*
|
|
||||||
* @type {string}
|
|
||||||
*/
|
|
||||||
static EMPTY_HASH = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'
|
|
||||||
|
|
||||||
static NotFoundError = NotFoundError
|
static NotFoundError = NotFoundError
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
const Blob = require('./blob')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The parts of reading a blob that every blob store answers the same way.
|
|
||||||
*
|
|
||||||
* The hash of empty content is a property of the content rather than of anything
|
|
||||||
* stored: every project's empty file has it, whether or not anything ever wrote a
|
|
||||||
* blob under it, and a file may reference it before anything has. So no store has
|
|
||||||
* to look it up, and every store that did was paying a lookup -- a database query,
|
|
||||||
* an HTTP request, a read of a cache that offline mode may be the only thing
|
|
||||||
* standing in for -- to be told what the hash already says. Deserializing a blob
|
|
||||||
* that holds JSON is likewise the same wherever it is read from.
|
|
||||||
*
|
|
||||||
* Implementations extend this and provide `fetchString`, which is only ever called
|
|
||||||
* for a hash that needs fetching. Spelling either of these out in each store
|
|
||||||
* instead is a line per store, and the store written next is the one that forgets.
|
|
||||||
*
|
|
||||||
* `getObject` inherits the empty-hash answer through `getString`, so it parses `''`
|
|
||||||
* and fails. That is right: a ranges blob is written from `JSON.stringify`, which
|
|
||||||
* never yields empty content, so a hash reaching `getObject` is one something
|
|
||||||
* stored. A store that reads or repairs its JSON differently overrides it.
|
|
||||||
*/
|
|
||||||
class BlobStoreBase {
|
|
||||||
/**
|
|
||||||
* Fetch a blob's content as a string.
|
|
||||||
*
|
|
||||||
* @param {string} hash hexadecimal SHA-1 hash
|
|
||||||
* @return {Promise<string>}
|
|
||||||
*/
|
|
||||||
async getString(hash) {
|
|
||||||
if (hash === Blob.EMPTY_HASH) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return await this.fetchString(hash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a blob holding JSON and deserialize it.
|
|
||||||
*
|
|
||||||
* @template [T=unknown]
|
|
||||||
* @param {string} hash hexadecimal SHA-1 hash
|
|
||||||
* @return {Promise<T>}
|
|
||||||
*/
|
|
||||||
async getObject(hash) {
|
|
||||||
return /** @type {T} */ (JSON.parse(await this.getString(hash)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a blob's content as a string from wherever this store keeps it.
|
|
||||||
*
|
|
||||||
* Called only for a hash that something needs to have stored, so an
|
|
||||||
* implementation may treat a miss as an error.
|
|
||||||
*
|
|
||||||
* @abstract
|
|
||||||
* @param {string} hash hexadecimal SHA-1 hash
|
|
||||||
* @return {Promise<string>}
|
|
||||||
*/
|
|
||||||
async fetchString(hash) {
|
|
||||||
throw new Error(
|
|
||||||
`${this.constructor.name} does not implement fetchString(hash)`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = BlobStoreBase
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user