@opentermsarchive/engine 16.0.0 → 16.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/dataset/export/index.js +1 -1
- package/src/archivist/collection/index.test.js +2 -1
- package/src/archivist/fetcher/fullDomFetcher.js +46 -12
- package/src/archivist/fetcher/fullDomFetcher.test.js +52 -1
- package/src/archivist/recorder/repositories/factory.js +3 -2
- package/src/archivist/recorder/repositories/git/git.js +21 -15
- package/src/archivist/recorder/repositories/git/index.js +28 -20
- package/src/archivist/recorder/repositories/git/index.test.js +75 -1
- package/src/archivist/recorder/repositories/mongo/index.js +14 -3
- package/src/archivist/recorder/repositories/mongo/index.test.js +27 -1
- package/src/collection-api/routes/index.js +2 -2
package/package.json
CHANGED
|
@@ -20,7 +20,7 @@ const fs = fsApi.promises;
|
|
|
20
20
|
const ARCHIVE_FORMAT = 'zip'; // for supported formats, see https://www.archiverjs.com/docs/archive-formats
|
|
21
21
|
|
|
22
22
|
export default async function generate({ archivePath, releaseDate }) {
|
|
23
|
-
const versionsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.versions.storage')).initialize();
|
|
23
|
+
const versionsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.versions.storage'), { readOnly: true }).initialize(); // The export only reads the repository the tracker writes to
|
|
24
24
|
|
|
25
25
|
const temporaryArchivePath = `${archivePath}${TEMPORARY_SUFFIX}`;
|
|
26
26
|
const archive = await initializeArchive(temporaryArchivePath, path.basename(archivePath, path.extname(archivePath)));
|
|
@@ -13,6 +13,7 @@ describe('Collection', () => {
|
|
|
13
13
|
let metadataBackup;
|
|
14
14
|
let getCollection;
|
|
15
15
|
let collection;
|
|
16
|
+
let instanceCount = 0;
|
|
16
17
|
|
|
17
18
|
before(async () => {
|
|
18
19
|
try {
|
|
@@ -29,7 +30,7 @@ describe('Collection', () => {
|
|
|
29
30
|
});
|
|
30
31
|
|
|
31
32
|
beforeEach(async () => {
|
|
32
|
-
const { getCollection: reloadedGetCollection } = await import(`./index.js?
|
|
33
|
+
const { getCollection: reloadedGetCollection } = await import(`./index.js?instance=${++instanceCount}`); // Ensure a new instance is loaded for each test, even when two tests start within the same millisecond
|
|
33
34
|
|
|
34
35
|
getCollection = reloadedGetCollection;
|
|
35
36
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import puppeteer from 'puppeteer
|
|
1
|
+
import puppeteer from 'puppeteer';
|
|
2
|
+
import { addExtra } from 'puppeteer-extra';
|
|
2
3
|
import stealthPlugin from 'puppeteer-extra-plugin-stealth';
|
|
3
4
|
import navigatorLanguages from 'puppeteer-extra-plugin-stealth/evasions/navigator.languages/index.js';
|
|
4
5
|
import userAgentOverride from 'puppeteer-extra-plugin-stealth/evasions/user-agent-override/index.js';
|
|
@@ -39,7 +40,7 @@ export default async function fetch(url, cssSelectors, config) {
|
|
|
39
40
|
page = await context.newPage();
|
|
40
41
|
client = await page.createCDPSession();
|
|
41
42
|
|
|
42
|
-
await configurePage(page, config);
|
|
43
|
+
await configurePage(page, client, config);
|
|
43
44
|
|
|
44
45
|
const selectors = [].concat(cssSelectors).filter(Boolean);
|
|
45
46
|
|
|
@@ -47,7 +48,7 @@ export default async function fetch(url, cssSelectors, config) {
|
|
|
47
48
|
let handled = null;
|
|
48
49
|
|
|
49
50
|
if (!selectors.length) { // CSS selectors are specified only for HTML content and omitted when fetching a PDF
|
|
50
|
-
({ pdf, handled } = setupPdfInterception(client));
|
|
51
|
+
({ pdf, handled } = await setupPdfInterception(client));
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
let response;
|
|
@@ -125,13 +126,13 @@ export async function launchHeadlessBrowser(language) {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
const { locale, languages } = parseLanguage(language);
|
|
129
|
+
const puppeteerExtra = addExtra(puppeteer); // Fresh instance for each launch, as plugins registered on the shared instance accumulate across launches and their hooks would run once per copy on every page
|
|
128
130
|
const stealth = stealthPlugin();
|
|
129
131
|
|
|
130
132
|
stealth.enabledEvasions.delete('user-agent-override');
|
|
131
133
|
stealth.enabledEvasions.delete('navigator.languages');
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
puppeteer.use(navigatorLanguages({ languages }));
|
|
134
|
+
puppeteerExtra.use(stealth);
|
|
135
|
+
puppeteerExtra.use(navigatorLanguages({ languages }));
|
|
135
136
|
|
|
136
137
|
const options = {
|
|
137
138
|
args: [],
|
|
@@ -156,12 +157,21 @@ export async function launchHeadlessBrowser(language) {
|
|
|
156
157
|
options.args.push('--disable-setuid-sandbox');
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
|
|
160
|
+
const launchedBrowser = await puppeteerExtra.launch(options);
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
launchedBrowser.userAgentOverride = await captureUserAgentOverride(launchedBrowser, locale); // Computed once since the user agent is the same for every page of the browser
|
|
164
|
+
} catch (error) {
|
|
165
|
+
await launchedBrowser.close().catch(() => {});
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
160
168
|
|
|
161
169
|
if (proxyCredentials) {
|
|
162
|
-
|
|
170
|
+
launchedBrowser.proxyCredentials = proxyCredentials;
|
|
163
171
|
}
|
|
164
172
|
|
|
173
|
+
browser = launchedBrowser; // Set only once fully configured, so that no caller gets a browser without its user agent override
|
|
174
|
+
|
|
165
175
|
return browser;
|
|
166
176
|
}
|
|
167
177
|
|
|
@@ -184,22 +194,44 @@ function isValidHttpStatus(status) {
|
|
|
184
194
|
return (status >= 200 && status < 300) || status === 304;
|
|
185
195
|
}
|
|
186
196
|
|
|
187
|
-
async function configurePage(page, config) {
|
|
197
|
+
async function configurePage(page, client, config) {
|
|
188
198
|
await page.setViewport({ width: 1920, height: 1080 }); // Realistic viewport to avoid detection based on default Puppeteer dimensions (800x600)
|
|
189
199
|
await page.setDefaultNavigationTimeout(config.navigationTimeout);
|
|
200
|
+
await client.send('Network.setUserAgentOverride', browser.userAgentOverride);
|
|
190
201
|
|
|
191
202
|
if (browser.proxyCredentials?.username && browser.proxyCredentials?.password) {
|
|
192
203
|
await page.authenticate(browser.proxyCredentials);
|
|
193
204
|
}
|
|
194
205
|
}
|
|
195
206
|
|
|
196
|
-
function
|
|
207
|
+
async function captureUserAgentOverride(browser, locale) { // The stealth evasion sends the override without awaiting it, so a failure would crash the process on an unhandled rejection; running it once on a stand-in page records the override for the fetcher to send itself
|
|
208
|
+
const evasion = userAgentOverride({ locale });
|
|
209
|
+
let override;
|
|
210
|
+
|
|
211
|
+
await evasion.beforeLaunch({ headless: true }); // Otherwise the evasion leaves Accept-Language to browser preferences
|
|
212
|
+
await evasion.onPageCreated({ // The stand-in page exposes only what the evasion reads
|
|
213
|
+
browser: () => browser,
|
|
214
|
+
_client: () => ({
|
|
215
|
+
send: (method, params) => {
|
|
216
|
+
if (method === 'Network.setUserAgentOverride') {
|
|
217
|
+
override = params;
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!override?.userAgent) {
|
|
224
|
+
throw new Error('Could not capture the user agent override from the stealth user-agent-override evasion');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return override;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function setupPdfInterception(client) {
|
|
197
231
|
const pdf = { content: null, status: null };
|
|
198
232
|
let onHandled;
|
|
199
233
|
const handled = new Promise(resolve => { onHandled = resolve; });
|
|
200
234
|
|
|
201
|
-
client.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Response' }] }); // Intercept all responses before Chrome processes them, allowing to capture PDF content before it's handled by the PDF viewer
|
|
202
|
-
|
|
203
235
|
client.on('Fetch.requestPaused', async ({ requestId, resourceType, responseHeaders, responseStatusCode }) => {
|
|
204
236
|
try {
|
|
205
237
|
const contentType = responseHeaders?.find(header => header.name.toLowerCase() === 'content-type')?.value;
|
|
@@ -234,6 +266,8 @@ function setupPdfInterception(client) {
|
|
|
234
266
|
}
|
|
235
267
|
});
|
|
236
268
|
|
|
269
|
+
await client.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Response' }] }); // Intercept all responses before Chrome processes them, allowing to capture PDF content before it is handled by the PDF viewer; enabled once the listener is registered so that no paused request is missed
|
|
270
|
+
|
|
237
271
|
return { pdf, handled };
|
|
238
272
|
}
|
|
239
273
|
|
|
@@ -17,7 +17,7 @@ use(chaiAsPromised);
|
|
|
17
17
|
const dynamicHTML = '<!DOCTYPE html><html><head><title>Dynamic Page</title><script>setTimeout(() => { document.body.innerHTML += "<div class=\'dynamic\'>Loaded</div>"; }, 100);</script></head><body></body></html>';
|
|
18
18
|
const delayedContentHTML = '<!DOCTYPE html><html><head><title>Delayed Content</title><script>setTimeout(() => { document.querySelector(".content").textContent = "Final content"; }, 100);</script></head><body><div class="content"></div></body></html>';
|
|
19
19
|
const langEchoHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-language", navigator.language); document.body.setAttribute("data-languages", navigator.languages.join(","));</script></body></html>';
|
|
20
|
-
const stealthProbeHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-webdriver", String(navigator.webdriver)); document.body.setAttribute("data-user-agent", navigator.userAgent); document.body.setAttribute("data-plugin-count", String(navigator.plugins.length)); document.body.setAttribute("data-viewport-width", String(window.innerWidth)); document.body.setAttribute("data-viewport-height", String(window.innerHeight)); (() => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); if (!gl) { document.body.setAttribute("data-webgl-vendor", "none"); return; } const ext = gl.getExtension("WEBGL_debug_renderer_info"); document.body.setAttribute("data-webgl-vendor", ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : ""); document.body.setAttribute("data-webgl-renderer", ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ""); })();</script></body></html>';
|
|
20
|
+
const stealthProbeHTML = '<!DOCTYPE html><html><body><script>document.body.setAttribute("data-webdriver", String(navigator.webdriver)); document.body.setAttribute("data-user-agent", navigator.userAgent); document.body.setAttribute("data-platform", navigator.platform); document.body.setAttribute("data-ua-brands", navigator.userAgentData ? navigator.userAgentData.brands.map(entry => entry.brand).join(",") : ""); document.body.setAttribute("data-plugin-count", String(navigator.plugins.length)); document.body.setAttribute("data-viewport-width", String(window.innerWidth)); document.body.setAttribute("data-viewport-height", String(window.innerHeight)); (() => { const canvas = document.createElement("canvas"); const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl"); if (!gl) { document.body.setAttribute("data-webgl-vendor", "none"); return; } const ext = gl.getExtension("WEBGL_debug_renderer_info"); document.body.setAttribute("data-webgl-vendor", ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : ""); document.body.setAttribute("data-webgl-renderer", ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ""); })();</script></body></html>';
|
|
21
21
|
|
|
22
22
|
describe('Full DOM Fetcher', function () {
|
|
23
23
|
this.timeout(60000);
|
|
@@ -40,6 +40,11 @@ describe('Full DOM Fetcher', function () {
|
|
|
40
40
|
|
|
41
41
|
response.writeHead(200, { 'Content-Type': 'text/html' }).write(`<!DOCTYPE html><html><body data-accept-language="${acceptLanguage}"></body></html>`);
|
|
42
42
|
}
|
|
43
|
+
if (request.url === '/client-hints-header') {
|
|
44
|
+
const clientHintsBrands = request.headers['sec-ch-ua'] || '';
|
|
45
|
+
|
|
46
|
+
response.writeHead(200, { 'Content-Type': 'text/html' }).write(`<!DOCTYPE html><html><body>${clientHintsBrands}</body></html>`);
|
|
47
|
+
}
|
|
43
48
|
if (request.url === '/lang-echo') {
|
|
44
49
|
response.writeHead(200, { 'Content-Type': 'text/html' }).write(langEchoHTML);
|
|
45
50
|
}
|
|
@@ -76,6 +81,16 @@ describe('Full DOM Fetcher', function () {
|
|
|
76
81
|
|
|
77
82
|
expect(browser1).to.equal(browser2);
|
|
78
83
|
});
|
|
84
|
+
|
|
85
|
+
it('registers plugin hooks once per launch', async () => {
|
|
86
|
+
const initialListenerCount = (await launchHeadlessBrowser('en')).listenerCount('targetcreated');
|
|
87
|
+
|
|
88
|
+
await stopHeadlessBrowser();
|
|
89
|
+
|
|
90
|
+
const relaunchedBrowser = await launchHeadlessBrowser('en');
|
|
91
|
+
|
|
92
|
+
expect(relaunchedBrowser.listenerCount('targetcreated')).to.equal(initialListenerCount);
|
|
93
|
+
});
|
|
79
94
|
});
|
|
80
95
|
|
|
81
96
|
describe('#fetch', () => {
|
|
@@ -136,6 +151,25 @@ describe('Full DOM Fetcher', function () {
|
|
|
136
151
|
expect(content.equals(expectedPDFContent)).to.be.true;
|
|
137
152
|
});
|
|
138
153
|
});
|
|
154
|
+
|
|
155
|
+
context('when the user agent override cannot be applied', () => {
|
|
156
|
+
let browser;
|
|
157
|
+
let userAgentOverride;
|
|
158
|
+
|
|
159
|
+
before(async () => {
|
|
160
|
+
browser = await launchHeadlessBrowser('en');
|
|
161
|
+
({ userAgentOverride } = browser);
|
|
162
|
+
browser.userAgentOverride = {}; // Rejected by Chrome since the user agent is mandatory
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
after(() => {
|
|
166
|
+
browser.userAgentOverride = userAgentOverride;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('rejects with the protocol error', async () => {
|
|
170
|
+
await expect(fetch(`http://127.0.0.1:${SERVER_PORT}/dynamic`, [], config)).to.be.rejectedWith('Network.setUserAgentOverride');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
139
173
|
});
|
|
140
174
|
|
|
141
175
|
describe('Language configuration', () => {
|
|
@@ -201,6 +235,23 @@ describe('Full DOM Fetcher', function () {
|
|
|
201
235
|
expect(content).not.to.match(/HeadlessChrome/);
|
|
202
236
|
});
|
|
203
237
|
|
|
238
|
+
it('removes HeadlessChrome from navigator.userAgentData brands', () => {
|
|
239
|
+
expect(content).to.match(/data-ua-brands="[^"]*Google Chrome[^"]*"/);
|
|
240
|
+
expect(content).not.to.match(/data-ua-brands="[^"]*Headless[^"]*"/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('sends the Sec-CH-UA header without HeadlessChrome', async () => {
|
|
244
|
+
const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/client-hints-header`, [], config);
|
|
245
|
+
|
|
246
|
+
expect(result.content).to.match(/"Google Chrome";v="\d+"/);
|
|
247
|
+
expect(result.content).not.to.match(/Headless/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('does not expose a Linux host through navigator.platform', () => {
|
|
251
|
+
expect(content).to.match(/data-platform="[^"]+"/);
|
|
252
|
+
expect(content).not.to.match(/data-platform="Linux/);
|
|
253
|
+
});
|
|
254
|
+
|
|
204
255
|
it('uses a realistic viewport instead of Puppeteer default', () => {
|
|
205
256
|
expect(content).to.match(/data-viewport-width="1920"/);
|
|
206
257
|
expect(content).to.match(/data-viewport-height="1080"/);
|
|
@@ -4,15 +4,16 @@ import GitRepository from './git/index.js';
|
|
|
4
4
|
import MongoRepository from './mongo/index.js';
|
|
5
5
|
|
|
6
6
|
export default class RepositoryFactory {
|
|
7
|
-
static create(params) {
|
|
7
|
+
static create(params, { readOnly = false } = {}) {
|
|
8
8
|
switch (params.type) {
|
|
9
9
|
case 'git':
|
|
10
10
|
return new GitRepository({
|
|
11
11
|
...params.git,
|
|
12
12
|
path: path.resolve(process.cwd(), params.git.path),
|
|
13
|
+
readOnly,
|
|
13
14
|
});
|
|
14
15
|
case 'mongo':
|
|
15
|
-
return new MongoRepository(params.mongo);
|
|
16
|
+
return new MongoRepository({ ...params.mongo, readOnly });
|
|
16
17
|
default:
|
|
17
18
|
throw new Error(`Unknown storage repository configuration for type '${params.type}'`);
|
|
18
19
|
}
|
|
@@ -17,14 +17,10 @@ export default class Git {
|
|
|
17
17
|
|
|
18
18
|
async initialize() {
|
|
19
19
|
if (!fsApi.existsSync(this.path)) {
|
|
20
|
-
await fs.mkdir(this.path, { recursive: true });
|
|
20
|
+
await fs.mkdir(this.path, { recursive: true }); // simple-git cannot be instantiated on a missing directory
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
this
|
|
24
|
-
trimmed: true,
|
|
25
|
-
maxConcurrentProcesses: 1, // Concurrent runs on the same repository race the index and the commit-graph and can corrupt them
|
|
26
|
-
});
|
|
27
|
-
|
|
23
|
+
this.#connect();
|
|
28
24
|
await this.git.init();
|
|
29
25
|
|
|
30
26
|
return this.git
|
|
@@ -37,6 +33,21 @@ export default class Git {
|
|
|
37
33
|
.addConfig('gc.writeCommitGraph', false); // Prevent automatic `git gc` from also writing the commit-graph: the engine writes it explicitly (see `writeCommitGraph`/`updateCommitGraph`), and a concurrent gc write races those, which can leave a stale `commit-graph.lock` and make subsequent operations fail
|
|
38
34
|
}
|
|
39
35
|
|
|
36
|
+
open() {
|
|
37
|
+
if (!fsApi.existsSync(path.join(this.path, '.git'))) {
|
|
38
|
+
throw new Error(`Repository ${this.path} does not exist, it has to be created by a writer such as the tracker first`); // Without this check, git would silently walk up to an enclosing repository, such as the collection one
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.#connect();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#connect() {
|
|
45
|
+
this.git = simpleGit(this.path, {
|
|
46
|
+
trimmed: true,
|
|
47
|
+
maxConcurrentProcesses: 1, // Concurrent runs on the same repository race the index and the commit-graph and can corrupt them
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
add(filePath) {
|
|
41
52
|
return this.git.add(this.relativePath(filePath));
|
|
42
53
|
}
|
|
@@ -129,6 +140,10 @@ export default class Git {
|
|
|
129
140
|
return this.git.show(options);
|
|
130
141
|
}
|
|
131
142
|
|
|
143
|
+
showBuffer(options) {
|
|
144
|
+
return this.git.showBuffer(options);
|
|
145
|
+
}
|
|
146
|
+
|
|
132
147
|
async cleanUp() {
|
|
133
148
|
await fs.rm(path.join(this.path, '.git', 'objects', 'info', 'commit-graph.lock'), { force: true }); // Remove a leftover commit-graph lock from a previous `commit-graph write` that was killed mid-write (e.g. the process was terminated during a deploy or restart). The commit-graph is a disposable cache rebuilt by `writeCommitGraph`, so clearing a stale lock is safe and prevents every subsequent run from failing.
|
|
134
149
|
await this.git.reset('hard');
|
|
@@ -144,15 +159,6 @@ export default class Git {
|
|
|
144
159
|
]);
|
|
145
160
|
}
|
|
146
161
|
|
|
147
|
-
restore(path, commit) {
|
|
148
|
-
return this.git.raw([
|
|
149
|
-
'restore',
|
|
150
|
-
'-s', commit, // Take the file contents from this specific commit rather than from the index
|
|
151
|
-
'--', // Everything after is a pathspec, not a revision or option
|
|
152
|
-
path,
|
|
153
|
-
]);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
162
|
async destroyHistory() {
|
|
157
163
|
await fs.rm(this.path, { recursive: true });
|
|
158
164
|
|
|
@@ -35,15 +35,22 @@ function canMatchRecordFilePath(...pathSegments) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export default class GitRepository extends RepositoryInterface {
|
|
38
|
-
constructor({ path, author, publish, snapshotIdentiferTemplate }) {
|
|
38
|
+
constructor({ path, author, publish, snapshotIdentiferTemplate, readOnly = false }) {
|
|
39
39
|
super();
|
|
40
40
|
this.path = path;
|
|
41
41
|
this.needsPublication = publish;
|
|
42
|
+
this.readOnly = readOnly; // Readers share the repository with the tracker, so they must never touch the working tree nor the commit-graph: both race the tracker, and the commit-graph write then fails with `commit-graph.lock: File exists`
|
|
42
43
|
this.git = new Git({ path: this.path, author });
|
|
43
44
|
this.snapshotIdentiferTemplate = snapshotIdentiferTemplate;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
async initialize() {
|
|
48
|
+
if (this.readOnly) {
|
|
49
|
+
this.git.open();
|
|
50
|
+
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
await this.git.initialize();
|
|
48
55
|
await this.git.cleanUp(); // Drop all uncommitted changes and remove all leftover files that may be present if the process was killed aggressively
|
|
49
56
|
await this.git.writeCommitGraph(); // Create or replace the commit graph with a new one to ensure it's fully consistent
|
|
@@ -52,6 +59,8 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
async save(record) {
|
|
62
|
+
this.#assertWritable('save records');
|
|
63
|
+
|
|
55
64
|
const { serviceId, termsType, documentId, fetchDate } = record;
|
|
56
65
|
|
|
57
66
|
if (record.isFirstRecord === undefined || record.isFirstRecord === null) {
|
|
@@ -75,6 +84,10 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
async finalize() {
|
|
87
|
+
if (this.readOnly) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
if (this.needsPublication) {
|
|
79
92
|
await this.git.pushChanges();
|
|
80
93
|
}
|
|
@@ -214,31 +227,20 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
214
227
|
}
|
|
215
228
|
}
|
|
216
229
|
|
|
217
|
-
removeAll() {
|
|
218
|
-
|
|
230
|
+
async removeAll() {
|
|
231
|
+
this.#assertWritable('remove records');
|
|
232
|
+
|
|
233
|
+
await this.git.destroyHistory();
|
|
219
234
|
}
|
|
220
235
|
|
|
221
236
|
async loadRecordContent(record) {
|
|
222
237
|
const relativeFilePath = DataMapper.generateFilePath(record.serviceId, record.termsType, record.documentId, record.mimeType);
|
|
223
238
|
|
|
224
|
-
|
|
225
|
-
record.content = await this.git.show(`${record.id}:${relativeFilePath}`);
|
|
226
|
-
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// In case of PDF files, `git show` cannot be used as it converts PDF binary into strings that do not retain the original binary representation
|
|
231
|
-
// It is impossible to restore the original binary data from the resulting string
|
|
232
|
-
let pdfBuffer;
|
|
239
|
+
const objectPath = `${record.id}:${relativeFilePath}`;
|
|
233
240
|
|
|
234
|
-
|
|
235
|
-
await this.git.
|
|
236
|
-
|
|
237
|
-
} finally {
|
|
238
|
-
await this.git.restore(relativeFilePath, 'HEAD'); // …and finally restore the file to its most recent state
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
record.content = pdfBuffer;
|
|
241
|
+
record.content = record.mimeType == mime.getType('pdf')
|
|
242
|
+
? await this.git.showBuffer(objectPath) // Binary content is read straight from the object database, so nothing is ever written to the working tree shared with the tracker
|
|
243
|
+
: await this.git.show(objectPath);
|
|
242
244
|
}
|
|
243
245
|
|
|
244
246
|
getDiffStats(recordId) {
|
|
@@ -308,6 +310,12 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
308
310
|
return this.git.isTracked(`${this.path}/${DataMapper.generateFilePath(serviceId, termsType, documentId)}`);
|
|
309
311
|
}
|
|
310
312
|
|
|
313
|
+
#assertWritable(operation) {
|
|
314
|
+
if (this.readOnly) {
|
|
315
|
+
throw new Error(`Cannot ${operation} in read-only repository ${this.path}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
311
319
|
async #toDomain(commit, { deferContentLoading } = {}) {
|
|
312
320
|
if (!commit) {
|
|
313
321
|
return null;
|
|
@@ -2,7 +2,8 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
|
|
5
|
-
import { expect } from 'chai';
|
|
5
|
+
import { expect, use } from 'chai';
|
|
6
|
+
import chaiAsPromised from 'chai-as-promised';
|
|
6
7
|
import config from 'config';
|
|
7
8
|
import mime from 'mime';
|
|
8
9
|
|
|
@@ -14,6 +15,8 @@ import Git from './git.js';
|
|
|
14
15
|
|
|
15
16
|
import GitRepository from './index.js';
|
|
16
17
|
|
|
18
|
+
use(chaiAsPromised);
|
|
19
|
+
|
|
17
20
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
21
|
|
|
19
22
|
const RECORDER_PATH = path.resolve(__dirname, '../../../../..', config.get('@opentermsarchive/engine.recorder.versions.storage.git.path'));
|
|
@@ -2063,4 +2066,75 @@ describe('GitRepository', () => {
|
|
|
2063
2066
|
});
|
|
2064
2067
|
});
|
|
2065
2068
|
});
|
|
2069
|
+
|
|
2070
|
+
context('when read-only', () => {
|
|
2071
|
+
const UNTRACKED_FILE_PATH = `${RECORDER_PATH}/untracked-file.md`;
|
|
2072
|
+
const COMMIT_GRAPH_PATH = `${RECORDER_PATH}/.git/objects/info/commit-graph`;
|
|
2073
|
+
|
|
2074
|
+
let writer;
|
|
2075
|
+
let record;
|
|
2076
|
+
|
|
2077
|
+
before(async () => {
|
|
2078
|
+
writer = new GitRepository({
|
|
2079
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
|
|
2080
|
+
path: RECORDER_PATH,
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
await writer.git.initialize(); // Bypass GitRepository#initialize, which writes the commit-graph, to check that the read-only repository does not write it either
|
|
2084
|
+
fs.rmSync(COMMIT_GRAPH_PATH, { force: true });
|
|
2085
|
+
|
|
2086
|
+
record = await writer.save(new Version({
|
|
2087
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
2088
|
+
termsType: TERMS_TYPE,
|
|
2089
|
+
content: CONTENT,
|
|
2090
|
+
fetchDate: FETCH_DATE,
|
|
2091
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
2092
|
+
}));
|
|
2093
|
+
|
|
2094
|
+
fs.writeFileSync(UNTRACKED_FILE_PATH, CONTENT);
|
|
2095
|
+
|
|
2096
|
+
subject = new GitRepository({
|
|
2097
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
|
|
2098
|
+
path: RECORDER_PATH,
|
|
2099
|
+
readOnly: true,
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
await subject.initialize();
|
|
2103
|
+
await subject.finalize();
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
after(() => writer.removeAll());
|
|
2107
|
+
|
|
2108
|
+
it('reads the records', async () => {
|
|
2109
|
+
const records = await subject.findAll();
|
|
2110
|
+
|
|
2111
|
+
expect(records.map(({ id }) => id)).to.deep.equal([record.id]);
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
it('leaves uncommitted changes untouched', () => {
|
|
2115
|
+
expect(fs.existsSync(UNTRACKED_FILE_PATH)).to.be.true;
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
it('does not write the commit-graph', () => {
|
|
2119
|
+
expect(fs.existsSync(COMMIT_GRAPH_PATH)).to.be.false;
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
it('rejects saving records', async () => {
|
|
2123
|
+
await expect(subject.save(record)).to.be.rejectedWith(Error, /read-only/);
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
it('rejects removing records', async () => {
|
|
2127
|
+
await expect(subject.removeAll()).to.be.rejectedWith(Error, /read-only/);
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
it('rejects opening a missing repository', async () => {
|
|
2131
|
+
const missingRepository = new GitRepository({
|
|
2132
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
|
|
2133
|
+
path: `${RECORDER_PATH}-missing`,
|
|
2134
|
+
readOnly: true,
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
await expect(missingRepository.initialize()).to.be.rejectedWith(Error, /does not exist/);
|
|
2138
|
+
});
|
|
2139
|
+
});
|
|
2066
2140
|
});
|
|
@@ -10,12 +10,13 @@ import RepositoryInterface from '../interface.js';
|
|
|
10
10
|
import * as DataMapper from './dataMapper.js';
|
|
11
11
|
|
|
12
12
|
export default class MongoRepository extends RepositoryInterface {
|
|
13
|
-
constructor({ database: databaseName, collection: collectionName, connectionURI }) {
|
|
13
|
+
constructor({ database: databaseName, collection: collectionName, connectionURI, readOnly = false }) {
|
|
14
14
|
super();
|
|
15
15
|
|
|
16
16
|
this.client = new MongoClient(connectionURI);
|
|
17
17
|
this.databaseName = databaseName;
|
|
18
18
|
this.collectionName = collectionName;
|
|
19
|
+
this.readOnly = readOnly;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
async initialize() {
|
|
@@ -34,6 +35,8 @@ export default class MongoRepository extends RepositoryInterface {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
async save(record) {
|
|
38
|
+
this.#assertWritable('save records');
|
|
39
|
+
|
|
37
40
|
const { serviceId, termsType, documentId } = record;
|
|
38
41
|
|
|
39
42
|
if (record.isFirstRecord === undefined || record.isFirstRecord === null) {
|
|
@@ -223,8 +226,16 @@ export default class MongoRepository extends RepositoryInterface {
|
|
|
223
226
|
}
|
|
224
227
|
}
|
|
225
228
|
|
|
226
|
-
removeAll() {
|
|
227
|
-
|
|
229
|
+
async removeAll() {
|
|
230
|
+
this.#assertWritable('remove records');
|
|
231
|
+
|
|
232
|
+
await this.collection.deleteMany();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#assertWritable(operation) {
|
|
236
|
+
if (this.readOnly) {
|
|
237
|
+
throw new Error(`Cannot ${operation} in read-only repository ${this.databaseName}.${this.collectionName}`);
|
|
238
|
+
}
|
|
228
239
|
}
|
|
229
240
|
|
|
230
241
|
async loadRecordContent(record) {
|
|
@@ -2,7 +2,8 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
|
|
5
|
-
import { expect } from 'chai';
|
|
5
|
+
import { expect, use } from 'chai';
|
|
6
|
+
import chaiAsPromised from 'chai-as-promised';
|
|
6
7
|
import config from 'config';
|
|
7
8
|
import mime from 'mime';
|
|
8
9
|
import { MongoClient, ObjectId } from 'mongodb';
|
|
@@ -12,6 +13,8 @@ import Version from '../../version.js';
|
|
|
12
13
|
|
|
13
14
|
import MongoRepository from './index.js';
|
|
14
15
|
|
|
16
|
+
use(chaiAsPromised);
|
|
17
|
+
|
|
15
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
19
|
|
|
17
20
|
const { connectionURI } = config.get('@opentermsarchive/engine.recorder.snapshots.storage.mongo');
|
|
@@ -1944,4 +1947,27 @@ describe('MongoRepository', () => {
|
|
|
1944
1947
|
});
|
|
1945
1948
|
});
|
|
1946
1949
|
});
|
|
1950
|
+
|
|
1951
|
+
context('when read-only', () => {
|
|
1952
|
+
before(() => {
|
|
1953
|
+
subject = new MongoRepository({
|
|
1954
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.mongo'),
|
|
1955
|
+
readOnly: true,
|
|
1956
|
+
});
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
it('rejects saving records', async () => {
|
|
1960
|
+
await expect(subject.save(new Version({
|
|
1961
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
1962
|
+
termsType: TERMS_TYPE,
|
|
1963
|
+
content: CONTENT,
|
|
1964
|
+
fetchDate: FETCH_DATE,
|
|
1965
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
1966
|
+
}))).to.be.rejectedWith(Error, /read-only/);
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
it('rejects removing records', async () => {
|
|
1970
|
+
await expect(subject.removeAll()).to.be.rejectedWith(Error, /read-only/);
|
|
1971
|
+
});
|
|
1972
|
+
});
|
|
1947
1973
|
});
|
|
@@ -39,8 +39,8 @@ export default async function apiRouter(basePath) {
|
|
|
39
39
|
const services = await Services.load();
|
|
40
40
|
const collection = await getCollection();
|
|
41
41
|
const versionsStorageConfig = config.get('@opentermsarchive/engine.recorder.versions.storage');
|
|
42
|
-
const versionsRepository = await RepositoryFactory.create(versionsStorageConfig).initialize();
|
|
43
|
-
const snapshotsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.snapshots.storage')).initialize();
|
|
42
|
+
const versionsRepository = await RepositoryFactory.create(versionsStorageConfig, { readOnly: true }).initialize(); // The API only reads the repositories the tracker writes to
|
|
43
|
+
const snapshotsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.snapshots.storage'), { readOnly: true }).initialize();
|
|
44
44
|
const feedConfig = config.get('@opentermsarchive/engine.collection-api.feed');
|
|
45
45
|
const datasetStorage = new DatasetStorage(config.get('@opentermsarchive/engine.dataset.storagePath'));
|
|
46
46
|
|