@opentermsarchive/engine 11.0.2 → 12.0.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/.eslintrc.yaml +3 -0
- package/config/default.json +5 -0
- package/config/test.json +4 -1
- package/package.json +3 -2
- package/scripts/reporter/duplicate/index.js +1 -1
- package/src/archivist/collection/index.test.js +1 -1
- package/src/archivist/recorder/record.js +21 -0
- package/src/archivist/recorder/repositories/git/dataMapper.js +20 -11
- package/src/archivist/recorder/repositories/git/git.js +14 -2
- package/src/archivist/recorder/repositories/git/index.js +67 -11
- package/src/archivist/recorder/repositories/git/index.test.js +282 -6
- package/src/archivist/recorder/repositories/interface.js +44 -6
- package/src/archivist/recorder/repositories/mongo/index.js +67 -4
- package/src/archivist/recorder/repositories/mongo/index.test.js +330 -13
- package/src/archivist/recorder/version.test.js +38 -0
- package/src/archivist/services/index.js +1 -1
- package/src/collection-api/routes/feed.js +253 -0
- package/src/collection-api/routes/feed.test.js +739 -0
- package/src/collection-api/routes/index.js +16 -1
- package/src/collection-api/routes/services.js +1 -2
- package/src/collection-api/routes/services.test.js +4 -41
- package/src/collection-api/routes/versions.js +78 -78
- package/src/collection-api/routes/versions.test.js +4 -4
- package/src/collection-api/server.js +2 -0
- package/src/reporter/gitlab/index.js +1 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { js2xml } from 'xml-js';
|
|
3
|
+
|
|
4
|
+
import { getCollection } from '../../archivist/collection/index.js';
|
|
5
|
+
import { toISODateWithoutMilliseconds } from '../../archivist/utils/date.js';
|
|
6
|
+
|
|
7
|
+
const RECORD_TYPES = {
|
|
8
|
+
firstRecord: 'First record',
|
|
9
|
+
change: 'Change',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const TAG_AUTHORITY = 'opentermsarchive.org,2026'; // Tag URI authority (RFC 4151). The year fixes the scheme inception and must never change: it would invalidate every previously emitted feed and entry ID.
|
|
13
|
+
const FEED_AUTHOR_NAME = 'Open Terms Archive engine';
|
|
14
|
+
|
|
15
|
+
const SCHEMES = Object.freeze({
|
|
16
|
+
service: `tag:${TAG_AUTHORITY}:scheme:service`,
|
|
17
|
+
termsType: `tag:${TAG_AUTHORITY}:scheme:terms-type`,
|
|
18
|
+
recordType: `tag:${TAG_AUTHORITY}:scheme:record-type`,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function buildAbsoluteBaseUrl(req) {
|
|
22
|
+
const host = req.get('X-Forwarded-Host') ?? req.get('host'); // Behind a trusted reverse proxy, the public host comes from X-Forwarded-Host. req.get('host') only sees the internal Host header, so we read the forwarded value explicitly and fall back to the direct host for non-proxied setups (dev, tests).
|
|
23
|
+
|
|
24
|
+
return `${req.protocol}://${host}${req.baseUrl}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function classifyRecordType(version) {
|
|
28
|
+
return version.isFirstRecord ? RECORD_TYPES.firstRecord : RECORD_TYPES.change;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// xml-js does not escape attribute values by default — callers are expected to pre-escape. We wire this helper to js2xml's attributeValueFn so every emitted attribute goes through it, regardless of where it's built. Without this, a serviceId like "AT&T Mobile" would yield malformed XML rejected by strict feed readers (libxml2-based).
|
|
32
|
+
function escapeXmlAttribute(value) {
|
|
33
|
+
return String(value)
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildVersionLink(baseUrl, version) {
|
|
41
|
+
const encodedDate = encodeURIComponent(toISODateWithoutMilliseconds(version.fetchDate));
|
|
42
|
+
const encodedService = encodeURIComponent(version.serviceId);
|
|
43
|
+
const encodedTermsType = encodeURIComponent(version.termsType);
|
|
44
|
+
|
|
45
|
+
return `${baseUrl}/version/${encodedService}/${encodedTermsType}/${encodedDate}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildEntryId(storageType, collection, version) {
|
|
49
|
+
return `tag:${TAG_AUTHORITY}:version:${collection.metadata?.id}:${storageType}:${version.id}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildFeedId(collection, ...suffix) {
|
|
53
|
+
return [ `tag:${TAG_AUTHORITY}:feed`, collection.metadata?.id, ...suffix ].join(':');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildEntry(storageType, versionUrlTemplate, baseUrl, collection, version) {
|
|
57
|
+
const href = versionUrlTemplate?.replace('%VERSION_ID', version.id) ?? buildVersionLink(baseUrl, version);
|
|
58
|
+
const type = versionUrlTemplate ? 'text/html' : 'application/json'; // The default link points to the JSON Version API; operators who configure a versionUrlTemplate typically target a human-readable page (e.g. a GitHub commit), which is HTML.
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: { _text: buildEntryId(storageType, collection, version) },
|
|
62
|
+
link: { _attributes: { rel: 'alternate', type, href } },
|
|
63
|
+
title: { _text: version.displayTitle },
|
|
64
|
+
updated: { _text: version.fetchDate.toISOString() },
|
|
65
|
+
category: [
|
|
66
|
+
{ _attributes: { term: version.serviceId, scheme: SCHEMES.service } },
|
|
67
|
+
{ _attributes: { term: version.termsType, scheme: SCHEMES.termsType } },
|
|
68
|
+
{ _attributes: { term: classifyRecordType(version), scheme: SCHEMES.recordType } },
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function computeLatestFetchDate(versions) {
|
|
74
|
+
return versions.length > 0 ? versions[0].fetchDate : new Date(0); // Atom 1.0 requires a feed-level <updated>. When no entry exists yet, fall back to the Unix epoch so the value is stable across requests, emitting `new Date()` would defeat conditional GET caching for empty feeds.
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildFeedDocument({ storageType, versionUrlTemplate, collection, selfHref, feedId, versions, baseUrl, latestFetchDate }) {
|
|
78
|
+
const feed = {
|
|
79
|
+
_attributes: { xmlns: 'http://www.w3.org/2005/Atom' },
|
|
80
|
+
title: { _text: collection.metadata.name },
|
|
81
|
+
id: { _text: feedId },
|
|
82
|
+
updated: { _text: latestFetchDate.toISOString() },
|
|
83
|
+
link: { _attributes: { rel: 'self', type: 'application/atom+xml', href: selfHref } },
|
|
84
|
+
author: { name: { _text: FEED_AUTHOR_NAME } },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (collection.metadata?.tagline) {
|
|
88
|
+
feed.subtitle = { _text: collection.metadata.tagline };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (collection.metadata?.logo) {
|
|
92
|
+
feed.logo = { _text: collection.metadata.logo };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
feed.entry = versions.map(version => buildEntry(storageType, versionUrlTemplate, baseUrl, collection, version));
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
_declaration: { _attributes: { version: '1.0', encoding: 'utf-8' } },
|
|
99
|
+
feed,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sendFeed(req, res, opts) {
|
|
104
|
+
const latestFetchDate = computeLatestFetchDate(opts.versions);
|
|
105
|
+
|
|
106
|
+
res.set('Last-Modified', latestFetchDate.toUTCString()); // Setting Last-Modified before checking req.fresh enables Express to compare it with If-Modified-Since and return 304 when nothing changed since the reader's last fetch; the headline optimisation for Atom feeds, which are typically polled every few minutes.
|
|
107
|
+
|
|
108
|
+
if (req.fresh) {
|
|
109
|
+
return res.status(304).end();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
res.set('Content-Type', 'application/atom+xml; charset=utf-8');
|
|
113
|
+
const document = buildFeedDocument({ ...opts, latestFetchDate });
|
|
114
|
+
|
|
115
|
+
return res.status(200).send(js2xml(document, { compact: true, spaces: 2, attributeValueFn: escapeXmlAttribute }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {object} services The services to be exposed by the API
|
|
120
|
+
* @param {object} versionsRepository The versions repository instance
|
|
121
|
+
* @param {string} storageType The storage type identifier of the versions repository
|
|
122
|
+
* @param {number} feedLimit Maximum number of entries returned by feed endpoints
|
|
123
|
+
* @param {string} [versionUrlTemplate] Optional URL template with %VERSION_ID placeholder; when set, replaces the API link as each entry's alternate href
|
|
124
|
+
* @returns {express.Router} The router instance
|
|
125
|
+
* @swagger
|
|
126
|
+
* tags:
|
|
127
|
+
* name: Feeds
|
|
128
|
+
* description: Atom feeds of version changes
|
|
129
|
+
*/
|
|
130
|
+
export default function feedRouter(services, versionsRepository, storageType, feedLimit, versionUrlTemplate) {
|
|
131
|
+
const router = express.Router();
|
|
132
|
+
|
|
133
|
+
async function renderFeed(req, res, { selfHref, suffix = [], versions }) {
|
|
134
|
+
const collection = await getCollection();
|
|
135
|
+
const baseUrl = buildAbsoluteBaseUrl(req);
|
|
136
|
+
const feedId = buildFeedId(collection, ...suffix);
|
|
137
|
+
|
|
138
|
+
return sendFeed(req, res, { storageType, versionUrlTemplate, collection, selfHref, feedId, versions, baseUrl });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @swagger
|
|
143
|
+
* /feed:
|
|
144
|
+
* get:
|
|
145
|
+
* summary: Atom feed of the latest version changes across the whole collection.
|
|
146
|
+
* tags: [Feeds]
|
|
147
|
+
* produces:
|
|
148
|
+
* - application/atom+xml
|
|
149
|
+
* responses:
|
|
150
|
+
* 200:
|
|
151
|
+
* description: An Atom 1.0 feed listing the latest version records, newest first. The maximum number of entries is server-configured.
|
|
152
|
+
* content:
|
|
153
|
+
* application/atom+xml:
|
|
154
|
+
* schema:
|
|
155
|
+
* type: string
|
|
156
|
+
*/
|
|
157
|
+
router.get('/feed', async (req, res) => {
|
|
158
|
+
const versions = await versionsRepository.findAll({ limit: feedLimit, includeTechnicalUpgrades: false });
|
|
159
|
+
const selfHref = `${buildAbsoluteBaseUrl(req)}/feed`;
|
|
160
|
+
|
|
161
|
+
return renderFeed(req, res, { selfHref, versions });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @swagger
|
|
166
|
+
* /feed/{serviceId}:
|
|
167
|
+
* get:
|
|
168
|
+
* summary: Atom feed of the latest version changes scoped to a single service.
|
|
169
|
+
* tags: [Feeds]
|
|
170
|
+
* produces:
|
|
171
|
+
* - application/atom+xml
|
|
172
|
+
* parameters:
|
|
173
|
+
* - in: path
|
|
174
|
+
* name: serviceId
|
|
175
|
+
* description: The ID of the service.
|
|
176
|
+
* schema:
|
|
177
|
+
* type: string
|
|
178
|
+
* required: true
|
|
179
|
+
* responses:
|
|
180
|
+
* 200:
|
|
181
|
+
* description: An Atom 1.0 feed listing the latest version records for the given service, newest first.
|
|
182
|
+
* content:
|
|
183
|
+
* application/atom+xml:
|
|
184
|
+
* schema:
|
|
185
|
+
* type: string
|
|
186
|
+
* 404:
|
|
187
|
+
* description: No service matching the provided ID is found.
|
|
188
|
+
*/
|
|
189
|
+
router.get('/feed/:serviceId', async (req, res) => {
|
|
190
|
+
const service = Object.hasOwn(services, req.params.serviceId) ? services[req.params.serviceId] : null;
|
|
191
|
+
|
|
192
|
+
if (!service) {
|
|
193
|
+
return res.status(404).send('Service not found');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const versions = await versionsRepository.findByService(service.id, { limit: feedLimit, includeTechnicalUpgrades: false });
|
|
197
|
+
const selfHref = `${buildAbsoluteBaseUrl(req)}/feed/${encodeURIComponent(service.id)}`;
|
|
198
|
+
|
|
199
|
+
return renderFeed(req, res, { selfHref, suffix: [service.id], versions });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @swagger
|
|
204
|
+
* /feed/{serviceId}/{termsType}:
|
|
205
|
+
* get:
|
|
206
|
+
* summary: Atom feed of the latest version changes scoped to a service and terms type.
|
|
207
|
+
* tags: [Feeds]
|
|
208
|
+
* produces:
|
|
209
|
+
* - application/atom+xml
|
|
210
|
+
* parameters:
|
|
211
|
+
* - in: path
|
|
212
|
+
* name: serviceId
|
|
213
|
+
* description: The ID of the service.
|
|
214
|
+
* schema:
|
|
215
|
+
* type: string
|
|
216
|
+
* required: true
|
|
217
|
+
* - in: path
|
|
218
|
+
* name: termsType
|
|
219
|
+
* description: The terms type declared by the service (e.g. "Terms of Service", "Privacy Policy").
|
|
220
|
+
* schema:
|
|
221
|
+
* type: string
|
|
222
|
+
* required: true
|
|
223
|
+
* responses:
|
|
224
|
+
* 200:
|
|
225
|
+
* description: An Atom 1.0 feed listing the latest version records for the given service and terms type, newest first.
|
|
226
|
+
* content:
|
|
227
|
+
* application/atom+xml:
|
|
228
|
+
* schema:
|
|
229
|
+
* type: string
|
|
230
|
+
* 404:
|
|
231
|
+
* description: Either the service ID does not match any service or the terms type is not declared by that service.
|
|
232
|
+
*/
|
|
233
|
+
router.get('/feed/:serviceId/:termsType', async (req, res) => {
|
|
234
|
+
const service = Object.hasOwn(services, req.params.serviceId) ? services[req.params.serviceId] : null;
|
|
235
|
+
|
|
236
|
+
if (!service) {
|
|
237
|
+
return res.status(404).send('Service not found');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const { termsType } = req.params;
|
|
241
|
+
|
|
242
|
+
if (!service.getTermsTypes().includes(termsType)) {
|
|
243
|
+
return res.status(404).send('Terms type not found for this service');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const versions = await versionsRepository.findByServiceAndTermsType(service.id, termsType, { limit: feedLimit, includeTechnicalUpgrades: false });
|
|
247
|
+
const selfHref = `${buildAbsoluteBaseUrl(req)}/feed/${encodeURIComponent(service.id)}/${encodeURIComponent(termsType)}`;
|
|
248
|
+
|
|
249
|
+
return renderFeed(req, res, { selfHref, suffix: [ service.id, termsType ], versions });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return router;
|
|
253
|
+
}
|