@opentermsarchive/engine 14.1.0 → 15.1.0
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/src/archivist/extract/exposedFilters.js +15 -0
- package/src/archivist/extract/exposedFilters.test.js +49 -1
- package/src/archivist/fetcher/fullDomFetcher.test.js +1 -1
- package/src/archivist/index.test.js +1 -1
- package/src/archivist/recorder/repositories/git/dataMapper.js +4 -0
- package/src/archivist/recorder/repositories/git/git.js +90 -8
- package/src/archivist/recorder/repositories/git/index.js +91 -4
- package/src/archivist/recorder/repositories/git/index.test.js +288 -0
- package/src/archivist/recorder/repositories/interface.js +34 -0
- package/src/archivist/recorder/repositories/mongo/index.js +53 -0
- package/src/archivist/recorder/repositories/mongo/index.test.js +171 -0
- package/src/archivist/services/index.test.js +1 -1
- package/src/collection-api/middlewares/errors.js +1 -1
- package/src/collection-api/routes/index.js +2 -1
- package/src/collection-api/routes/services.js +34 -21
- package/src/collection-api/routes/versions.js +521 -27
- package/src/collection-api/routes/versions.test.js +655 -25
package/package.json
CHANGED
|
@@ -26,3 +26,18 @@ export function removeQueryParams(webPageDOM, paramsToRemove = []) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
const SPACE_SEPARATORS = /\p{Zs}/gu;
|
|
31
|
+
|
|
32
|
+
export function convertSpacesToStandard(webPageDOM) {
|
|
33
|
+
const walker = webPageDOM.createTreeWalker(webPageDOM.body, webPageDOM.defaultView.NodeFilter.SHOW_TEXT);
|
|
34
|
+
|
|
35
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
36
|
+
const original = node.nodeValue;
|
|
37
|
+
const normalized = original.replace(SPACE_SEPARATORS, ' ');
|
|
38
|
+
|
|
39
|
+
if (normalized !== original) {
|
|
40
|
+
node.nodeValue = normalized;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect } from 'chai';
|
|
2
2
|
|
|
3
3
|
import createWebPageDOM from './dom.js';
|
|
4
|
-
import { removeQueryParams } from './exposedFilters.js';
|
|
4
|
+
import { convertSpacesToStandard, removeQueryParams } from './exposedFilters.js';
|
|
5
5
|
|
|
6
6
|
describe('exposedFilters', () => {
|
|
7
7
|
let webPageDOM;
|
|
@@ -265,4 +265,52 @@ fetch(trackingUrl);
|
|
|
265
265
|
});
|
|
266
266
|
});
|
|
267
267
|
});
|
|
268
|
+
|
|
269
|
+
describe('#convertSpacesToStandard', () => {
|
|
270
|
+
describe('with Unicode space separators in text', () => {
|
|
271
|
+
let element;
|
|
272
|
+
|
|
273
|
+
before(() => {
|
|
274
|
+
element = webPageDOM.createElement('p');
|
|
275
|
+
element.textContent = 'a\u00A0b\u202Fc\u2009d\u3000e';
|
|
276
|
+
webPageDOM.body.appendChild(element);
|
|
277
|
+
|
|
278
|
+
convertSpacesToStandard(webPageDOM);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
after(() => {
|
|
282
|
+
element.remove();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('replaces them with a regular space', () => {
|
|
286
|
+
expect(element.textContent).to.equal('a b c d e');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('with Unicode space separators in an attribute', () => {
|
|
291
|
+
let element;
|
|
292
|
+
const className = 'label\u00A0primary';
|
|
293
|
+
|
|
294
|
+
before(() => {
|
|
295
|
+
element = webPageDOM.createElement('a');
|
|
296
|
+
element.setAttribute('class', className);
|
|
297
|
+
element.textContent = 'read\u00A0the\u00A0policy';
|
|
298
|
+
webPageDOM.body.appendChild(element);
|
|
299
|
+
|
|
300
|
+
convertSpacesToStandard(webPageDOM);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
after(() => {
|
|
304
|
+
element.remove();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('leaves attribute values untouched', () => {
|
|
308
|
+
expect(element.getAttribute('class')).to.equal(className);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('replaces them in the text content', () => {
|
|
312
|
+
expect(element.textContent).to.equal('read the policy');
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
268
316
|
});
|
|
@@ -79,7 +79,7 @@ describe('Full DOM Fetcher', function () {
|
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
describe('#fetch', () => {
|
|
82
|
-
const config = { navigationTimeout:
|
|
82
|
+
const config = { navigationTimeout: 5000, waitForElementsTimeout: 5000, language: 'en' };
|
|
83
83
|
|
|
84
84
|
it('waits for dynamically injected elements to appear in the DOM', async () => {
|
|
85
85
|
const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/dynamic`, ['.dynamic'], config);
|
|
@@ -30,6 +30,10 @@ const MULTIPLE_SOURCE_DOCUMENTS_PREFIX = 'This version was recorded after extrac
|
|
|
30
30
|
|
|
31
31
|
export const COMMIT_MESSAGE_PREFIXES_REGEXP = new RegExp(`^(${Object.values(COMMIT_MESSAGE_PREFIXES).join('|')})`);
|
|
32
32
|
|
|
33
|
+
export function isTechnicalUpgrade(message) {
|
|
34
|
+
return message.startsWith(COMMIT_MESSAGE_PREFIXES.technicalUpgrade) || message.startsWith(COMMIT_MESSAGE_PREFIXES.deprecated_refilter);
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
export function toPersistence(record, snapshotIdentiferTemplate) {
|
|
34
38
|
const { serviceId, termsType, documentId, snapshotIds = [], mimeType, metadata } = record;
|
|
35
39
|
|
|
@@ -22,7 +22,7 @@ export default class Git {
|
|
|
22
22
|
|
|
23
23
|
this.git = simpleGit(this.path, {
|
|
24
24
|
trimmed: true,
|
|
25
|
-
maxConcurrentProcesses: 1,
|
|
25
|
+
maxConcurrentProcesses: 1, // Concurrent runs on the same repository race the index and the commit-graph and can corrupt them
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
await this.git.init();
|
|
@@ -106,7 +106,8 @@ export default class Git {
|
|
|
106
106
|
|
|
107
107
|
return commits;
|
|
108
108
|
} catch (error) {
|
|
109
|
-
|
|
109
|
+
// `bad object` is raised for a well-formed but absent object ID; like an unknown revision, it means "no match" rather than a hard failure
|
|
110
|
+
if (/unknown revision or path not in the working tree|does not have any commits yet|bad object/.test(error.message)) {
|
|
110
111
|
return [];
|
|
111
112
|
}
|
|
112
113
|
|
|
@@ -132,15 +133,24 @@ export default class Git {
|
|
|
132
133
|
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.
|
|
133
134
|
await this.git.reset('hard');
|
|
134
135
|
|
|
135
|
-
return this.git.clean('f', '-d');
|
|
136
|
+
return this.git.clean('f', '-d'); // Force-remove untracked files (`f`) and untracked directories (`-d`)
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
getFullHash(shortHash) {
|
|
139
|
-
return this.git.show([
|
|
140
|
+
return this.git.show([
|
|
141
|
+
shortHash,
|
|
142
|
+
'--pretty=%H', // Print the full 40-character commit hash
|
|
143
|
+
'-s', // Suppress the diff output, only the formatted hash is wanted
|
|
144
|
+
]);
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
restore(path, commit) {
|
|
143
|
-
return this.git.raw([
|
|
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
|
+
]);
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
async destroyHistory() {
|
|
@@ -154,14 +164,86 @@ export default class Git {
|
|
|
154
164
|
}
|
|
155
165
|
|
|
156
166
|
async listFiles(path) {
|
|
157
|
-
return (await this.git.raw([ 'ls-files', path ])).split('\n');
|
|
167
|
+
return (await this.git.raw([ 'ls-files', '--', path ])).split('\n'); // Everything after "--" is a pathspec, not a revision or option
|
|
158
168
|
}
|
|
159
169
|
|
|
160
170
|
async writeCommitGraph() {
|
|
161
|
-
await this.git.raw([
|
|
171
|
+
await this.git.raw([
|
|
172
|
+
'commit-graph',
|
|
173
|
+
'write',
|
|
174
|
+
'--reachable', // Cover every commit reachable from the refs, so the whole history is indexed
|
|
175
|
+
'--changed-paths', // Also store the changed-path Bloom filters that speed up path-limited log/diff
|
|
176
|
+
]);
|
|
162
177
|
}
|
|
163
178
|
|
|
164
179
|
async updateCommitGraph() {
|
|
165
|
-
await this.git.raw([
|
|
180
|
+
await this.git.raw([
|
|
181
|
+
'commit-graph',
|
|
182
|
+
'write',
|
|
183
|
+
'--reachable',
|
|
184
|
+
'--changed-paths',
|
|
185
|
+
'--append', // Extend the existing commit-graph instead of rewriting it in full
|
|
186
|
+
]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async listPathRevisions(pathFilter) {
|
|
190
|
+
let output;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
// Ordering and technical-upgrade filtering are done by the caller in memory, so `--author-date-order`/`--grep`/`--name-only` are deliberately omitted to keep this walk lean
|
|
194
|
+
output = await this.git.raw([
|
|
195
|
+
'log',
|
|
196
|
+
'--no-merges',
|
|
197
|
+
'--format=%H%x09%at%x09%s', // Tab-separated hash, author date and subject: the minimum needed to order versions and detect technical upgrades, with no diff or message body loaded
|
|
198
|
+
'--', // Everything after is a pathspec, never a revision or an option
|
|
199
|
+
pathFilter,
|
|
200
|
+
]);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (/unknown revision or path not in the working tree|does not have any commits yet/.test(error.message)) {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!output) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return output.trim().split('\n').filter(Boolean).map(line => {
|
|
214
|
+
const [ hash, timestamp, ...subjectParts ] = line.split('\t');
|
|
215
|
+
|
|
216
|
+
return { hash, timestamp: parseInt(timestamp, 10), subject: subjectParts.join('\t') };
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async getDiffStats(commitHash) {
|
|
221
|
+
const output = await this.git.raw([
|
|
222
|
+
'show',
|
|
223
|
+
'--numstat', // Report added/deleted line counts per file as tab-separated numbers, instead of a textual diff
|
|
224
|
+
'--format=', // Drop the commit header so the output holds only the numstat lines
|
|
225
|
+
commitHash,
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
let additions = 0;
|
|
229
|
+
let deletions = 0;
|
|
230
|
+
|
|
231
|
+
for (const line of output.trim().split('\n')) {
|
|
232
|
+
if (!line) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const [ added, deleted ] = line.split('\t');
|
|
237
|
+
|
|
238
|
+
// Binary files show '-' for additions/deletions
|
|
239
|
+
if (added !== '-') {
|
|
240
|
+
additions += parseInt(added, 10);
|
|
241
|
+
}
|
|
242
|
+
if (deleted !== '-') {
|
|
243
|
+
deletions += parseInt(deleted, 10);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { additions, deletions };
|
|
166
248
|
}
|
|
167
249
|
}
|
|
@@ -15,6 +15,25 @@ import Git from './git.js';
|
|
|
15
15
|
|
|
16
16
|
const fs = fsApi.promises;
|
|
17
17
|
|
|
18
|
+
const RECORD_ID_REGEXP = /^[0-9a-f]{7,40}$/i; // Git commit SHA 7 (abbreviated) to 40 (full) hexadecimal characters. Prevent value such as `--output=…` to be parsed as a command-line option
|
|
19
|
+
|
|
20
|
+
const CONTROL_CHARACTERS_REGEXP = /\p{Cc}/u; // Matches any Unicode "control" character: the C0 range (U+0000 to U+001F), DEL (U+007F) and the C1 range (U+0080 to U+009F), i.e. 65 non-printable characters including NUL. The `u` flag is required for the `\p{...}` property escape to be recognised, otherwise the pattern would match the literal text `p{Cc}`. Legitimate service IDs, terms types and document IDs never contain these, and NUL in particular can truncate a value once it reaches git or the filesystem, so any segment holding one is rejected.
|
|
21
|
+
|
|
22
|
+
// Keeps hostile values from reaching git, where a pathspec that resolves outside the repository (such as `../foo/*`) aborts with an error that exposes the repository location.
|
|
23
|
+
function isPlainPathSegment(segment) {
|
|
24
|
+
return segment.length > 0
|
|
25
|
+
&& segment !== '.'
|
|
26
|
+
&& segment !== '..'
|
|
27
|
+
&& !segment.includes('/')
|
|
28
|
+
&& !segment.includes('\\')
|
|
29
|
+
&& !CONTROL_CHARACTERS_REGEXP.test(segment);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canMatchRecordFilePath(...pathSegments) {
|
|
33
|
+
// A non-string segment means "not provided" (`undefined`, or `false` for an absent document ID) and constrains nothing
|
|
34
|
+
return pathSegments.every(segment => typeof segment !== 'string' || isPlainPathSegment(segment));
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
export default class GitRepository extends RepositoryInterface {
|
|
19
38
|
constructor({ path, author, publish, snapshotIdentiferTemplate }) {
|
|
20
39
|
super();
|
|
@@ -64,6 +83,10 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
async findLatest(serviceId, termsType, documentId) {
|
|
86
|
+
if (!canMatchRecordFilePath(serviceId, termsType, documentId)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
67
90
|
const matchingFilesPaths = await this.git.listFiles(DataMapper.generateFilePath(serviceId, termsType, documentId));
|
|
68
91
|
|
|
69
92
|
if (!matchingFilesPaths.length) {
|
|
@@ -76,35 +99,95 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
76
99
|
}
|
|
77
100
|
|
|
78
101
|
async findByDate(serviceId, termsType, date, documentId) {
|
|
102
|
+
if (!canMatchRecordFilePath(serviceId, termsType, documentId)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
79
106
|
const filePath = DataMapper.generateFilePath(serviceId, termsType, documentId);
|
|
80
|
-
const commit = await this.git.getCommit([ `--until=${date?.toISOString()}`, filePath ]);
|
|
107
|
+
const commit = await this.git.getCommit([ `--until=${date?.toISOString()}`, '--', filePath ]);
|
|
81
108
|
|
|
82
109
|
return this.#toDomain(commit);
|
|
83
110
|
}
|
|
84
111
|
|
|
85
112
|
async findById(recordId) {
|
|
86
|
-
|
|
113
|
+
if (!RECORD_ID_REGEXP.test(recordId)) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const commit = await this.git.getCommit([ '--end-of-options', recordId ]); // `--end-of-options` forces git to treat `recordId` as a revision, never as an option: a second line of defence that keeps the lookup safe from argument injection even if the format guard above is ever relaxed
|
|
87
118
|
|
|
88
119
|
return this.#toDomain(commit);
|
|
89
120
|
}
|
|
90
121
|
|
|
122
|
+
async findMetadataById(recordId) {
|
|
123
|
+
if (!RECORD_ID_REGEXP.test(recordId)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const commit = await this.git.getCommit([ '--end-of-options', recordId ]); // `--end-of-options` forces git to treat `recordId` as a revision, never as an option: a second line of defence that keeps the lookup safe from argument injection even if the format guard above is ever relaxed
|
|
128
|
+
|
|
129
|
+
return this.#toDomain(commit, { deferContentLoading: true });
|
|
130
|
+
}
|
|
131
|
+
|
|
91
132
|
async findAll({ limit, offset, includeTechnicalUpgrades = true } = {}) {
|
|
92
133
|
return Promise.all((await this.#getCommits({ limit, offset, includeTechnicalUpgrades })).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
|
|
93
134
|
}
|
|
94
135
|
|
|
136
|
+
async findByServiceAndTermsType(serviceId, termsType, { limit, offset, includeTechnicalUpgrades = true } = {}) {
|
|
137
|
+
if (!canMatchRecordFilePath(serviceId, termsType)) {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const pathPattern = DataMapper.generateFilePath(serviceId, termsType);
|
|
142
|
+
|
|
143
|
+
return Promise.all((await this.#getCommits({ pathFilter: pathPattern, limit, offset, includeTechnicalUpgrades })).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
|
|
144
|
+
}
|
|
145
|
+
|
|
95
146
|
async findByService(serviceId, { limit, offset, includeTechnicalUpgrades = true } = {}) {
|
|
147
|
+
if (!canMatchRecordFilePath(serviceId)) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
|
|
96
151
|
const pathPattern = DataMapper.generateFilePath(serviceId);
|
|
97
152
|
|
|
98
153
|
return Promise.all((await this.#getCommits({ pathFilter: pathPattern, limit, offset, includeTechnicalUpgrades })).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
|
|
99
154
|
}
|
|
100
155
|
|
|
101
|
-
async
|
|
156
|
+
async getNavigationIds(serviceId, termsType, versionId, { includeTechnicalUpgrades = true } = {}) {
|
|
157
|
+
if (!canMatchRecordFilePath(serviceId, termsType)) {
|
|
158
|
+
return { first: null, prev: null, next: null, last: null };
|
|
159
|
+
}
|
|
160
|
+
|
|
102
161
|
const pathPattern = DataMapper.generateFilePath(serviceId, termsType);
|
|
162
|
+
let revisions = await this.git.listPathRevisions(pathPattern); // single lean walk of the terms history
|
|
103
163
|
|
|
104
|
-
|
|
164
|
+
if (!includeTechnicalUpgrades) {
|
|
165
|
+
revisions = revisions.filter(revision => !DataMapper.isTechnicalUpgrade(revision.subject));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Deterministic total order: most recent first, commit SHA as a stable tiebreaker for versions sharing the same fetch date (git stores second precision).
|
|
169
|
+
// prev/next are then adjacent entries in this single order, so navigation always round-trips, unlike the previous chronological/topological mix.
|
|
170
|
+
revisions.sort((a, b) => b.timestamp - a.timestamp || (a.hash < b.hash ? -1 : 1));
|
|
171
|
+
|
|
172
|
+
const index = revisions.findIndex(revision => revision.hash === versionId);
|
|
173
|
+
|
|
174
|
+
if (index === -1) {
|
|
175
|
+
return { first: null, prev: null, next: null, last: null };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
last: revisions[0].hash,
|
|
180
|
+
first: revisions[revisions.length - 1].hash,
|
|
181
|
+
next: index > 0 ? revisions[index - 1].hash : null,
|
|
182
|
+
prev: index < revisions.length - 1 ? revisions[index + 1].hash : null,
|
|
183
|
+
};
|
|
105
184
|
}
|
|
106
185
|
|
|
107
186
|
async count(serviceId, termsType) {
|
|
187
|
+
if (!canMatchRecordFilePath(serviceId, termsType)) {
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
108
191
|
const grepOptions = Object.values(DataMapper.COMMIT_MESSAGE_PREFIXES).map(prefix => `--grep=${prefix}`);
|
|
109
192
|
const pathOptions = [];
|
|
110
193
|
|
|
@@ -158,6 +241,10 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
158
241
|
record.content = pdfBuffer;
|
|
159
242
|
}
|
|
160
243
|
|
|
244
|
+
getDiffStats(recordId) {
|
|
245
|
+
return this.git.getDiffStats(recordId);
|
|
246
|
+
}
|
|
247
|
+
|
|
161
248
|
async #getCommits({ pathFilter, reverse = false, limit, offset, includeTechnicalUpgrades = true } = {}) {
|
|
162
249
|
const prefixes = includeTechnicalUpgrades
|
|
163
250
|
? DataMapper.COMMIT_MESSAGE_PREFIXES
|