@jrpool/kilotest 35.0.1 → 36.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/SERVICE.md +45 -9
- package/ai0BalanceForm/index.js +2 -2
- package/diagnoses/index.js +1 -1
- package/index.js +50 -35
- package/mcp.js +4 -0
- package/package.json +1 -1
- package/reannotate/index.js +1 -1
- package/reportHideForm/index.js +1 -1
- package/reportIssue/index.js +1 -1
- package/reportIssues/index.js +1 -1
- package/reportUnhideForm/index.js +1 -1
- package/reportsExpungeForm/index.js +1 -1
- package/reportsPruneForm/index.js +1 -1
- package/reportsRewindForm/index.js +1 -1
- package/researchAgent.js +1 -1
- package/robots.txt +12 -0
- package/sitemap.xml +1 -0
- package/style.css +1 -1
- package/targets/index.js +0 -1
- package/testOrder/index.js +2 -2
- package/testRec/index.js +1 -1
- package/tutorial/index.js +2 -2
- package/util.js +1 -1
- package/wcagRenew/index.js +3 -3
package/SERVICE.md
CHANGED
|
@@ -20,6 +20,8 @@ Any other application `xyz` can be installed at `/opt/jpdev/xyz` on the server.
|
|
|
20
20
|
|
|
21
21
|
## Process management
|
|
22
22
|
|
|
23
|
+
### Process manager
|
|
24
|
+
|
|
23
25
|
Kilotest is managed with [PM2](https://pm2.keymetrics.io) on the server (not on the local development host). The PM2 configuration is specified in the repository as `pm2.config.js`:
|
|
24
26
|
|
|
25
27
|
```javascript
|
|
@@ -49,6 +51,10 @@ pm2 restart kilotest --time --update-env
|
|
|
49
51
|
pm2 save
|
|
50
52
|
```
|
|
51
53
|
|
|
54
|
+
The management of the PM2 logs is [documented by PM2](https://pm2.keymetrics.io/docs/usage/log-management/).
|
|
55
|
+
|
|
56
|
+
### Process tuning
|
|
57
|
+
|
|
52
58
|
The server configuration has been tuned for improved performance. The file edited for this purpose is `/etc/default/zramswap`:
|
|
53
59
|
|
|
54
60
|
```properties
|
|
@@ -190,22 +196,52 @@ The Caddy configuration is maintained and tracked in `/etc/caddy/Caddyfile`. Lea
|
|
|
190
196
|
kilotest.com {
|
|
191
197
|
# Enable Zstandard and Gzip compression of responses.
|
|
192
198
|
encode zstd gzip
|
|
193
|
-
|
|
194
|
-
|
|
199
|
+
# Specify the only paths of forwardable requests.
|
|
200
|
+
@allowedGET {
|
|
201
|
+
method GET
|
|
202
|
+
path /mcp / /index.html /robots.txt /openapi.yaml /openapi.json /swagger.yaml /swagger.json /api-docs /llms.txt /llms-full.txt *.html* /fullReport.json/* /api/* /tutorial/images/* /favicon.* /style.css /sitemap.xml
|
|
203
|
+
}
|
|
204
|
+
@allowedPOST {
|
|
205
|
+
method POST
|
|
206
|
+
path /mcp /retestRec.html/* /testRec.html/* /recAction.html /reannotate.html /wcagRenew.html /api/* /tutorialComment.html
|
|
207
|
+
}
|
|
208
|
+
@allowedOPTIONS method OPTIONS
|
|
209
|
+
handle @allowedOPTIONS {
|
|
210
|
+
header {
|
|
211
|
+
Access-Control-Allow-Origin *
|
|
212
|
+
Access-Control-Allow-Methods GET,POST,OPTIONS
|
|
213
|
+
Access-Control-Allow-Headers Content-Type,Authorization
|
|
214
|
+
}
|
|
215
|
+
respond 204
|
|
216
|
+
}
|
|
217
|
+
# Truncate any initial /qai from the forwarded path.
|
|
195
218
|
handle_path /qai* {
|
|
196
|
-
# Forward any
|
|
219
|
+
# Forward any QAI request to port 3001.
|
|
197
220
|
reverse_proxy localhost:3001
|
|
198
221
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
222
|
+
# Forward any other GET, POST, or OPTIONS request, if allowed, to port 3000.
|
|
223
|
+
handle @allowedGET {
|
|
224
|
+
reverse_proxy localhost:3000 {
|
|
225
|
+
# Improve SSE latency.
|
|
226
|
+
flush_interval -1
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
handle @allowedPOST {
|
|
230
|
+
reverse_proxy localhost:3000 {
|
|
231
|
+
# Improve SSE latency.
|
|
232
|
+
flush_interval -1
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
# Return not-found for any other request.
|
|
236
|
+
handle {
|
|
237
|
+
respond 404
|
|
204
238
|
}
|
|
205
239
|
}
|
|
206
240
|
```
|
|
207
241
|
|
|
208
|
-
This configuration
|
|
242
|
+
This configuration can be futher tightened if experience warrants.
|
|
243
|
+
|
|
244
|
+
The `flush_interval` setting prevents granular reporting by Testaro agents from being buffered, so the updates reach the browser without delay.
|
|
209
245
|
|
|
210
246
|
## Version management
|
|
211
247
|
|
package/ai0BalanceForm/index.js
CHANGED
|
@@ -47,7 +47,7 @@ exports.answer = async (_, search) => {
|
|
|
47
47
|
// Report the error.
|
|
48
48
|
return {
|
|
49
49
|
status: 'error',
|
|
50
|
-
|
|
50
|
+
message: 'Invalid authorization code'
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
}
|
|
@@ -60,7 +60,7 @@ exports.answer = async (_, search) => {
|
|
|
60
60
|
oldBalance = `$${balanceData.balance} is the`;
|
|
61
61
|
}
|
|
62
62
|
// If the balance file does not exist or is invalid:
|
|
63
|
-
catch
|
|
63
|
+
catch {
|
|
64
64
|
oldBalance = 'There is no';
|
|
65
65
|
}
|
|
66
66
|
}
|
package/diagnoses/index.js
CHANGED
package/index.js
CHANGED
|
@@ -28,7 +28,7 @@ const {
|
|
|
28
28
|
reportsPath,
|
|
29
29
|
ruleIDs
|
|
30
30
|
} = require('./util');
|
|
31
|
-
const {handleMCP} = require('./mcp');
|
|
31
|
+
const {handleMCP, mcpPath} = require('./mcp');
|
|
32
32
|
const fs = require('fs/promises');
|
|
33
33
|
const http = require('http');
|
|
34
34
|
const https = require('https');
|
|
@@ -80,7 +80,8 @@ const AI_MODEL0_OUTPUT_PRICE = Number(process.env.AI_MODEL0_OUTPUT_PRICE);
|
|
|
80
80
|
|
|
81
81
|
// Serves or sends an error message.
|
|
82
82
|
const serveError = async (error, response, isHumanUser = true) => {
|
|
83
|
-
|
|
83
|
+
const errorLines = Object.entries(error).map(pair => `${pair[0]}: ${pair[1]}`);
|
|
84
|
+
console.log(errorLines.join('\n') || 'ERROR');
|
|
84
85
|
if (! response.writableEnded) {
|
|
85
86
|
response.statusCode = 400;
|
|
86
87
|
// If the request is from a human user:
|
|
@@ -128,7 +129,7 @@ const checkBalancesForAlerts = async report => {
|
|
|
128
129
|
// Get the recorded AI service 0 balance.
|
|
129
130
|
balanceJSON = await fs.readFile(balancePath, 'utf8');
|
|
130
131
|
}
|
|
131
|
-
catch
|
|
132
|
+
catch {
|
|
132
133
|
console.error('ERROR: AI service 0 balance file missing');
|
|
133
134
|
}
|
|
134
135
|
// If the variables required for an AI service 0 balance alert are defined:
|
|
@@ -169,6 +170,21 @@ const checkBalancesForAlerts = async report => {
|
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
};
|
|
173
|
+
// Creates an error object about a suspicious request.
|
|
174
|
+
const getAbuseError = (request, reason) => {
|
|
175
|
+
const {method, url, headers} = request;
|
|
176
|
+
const ip = headers['x-forwarded-for'] || request.socket.remoteAddress || 'unknown';
|
|
177
|
+
return {
|
|
178
|
+
message: 'Invalid request',
|
|
179
|
+
reason,
|
|
180
|
+
'IP address': ip,
|
|
181
|
+
method,
|
|
182
|
+
URL: url,
|
|
183
|
+
'user agent': headers['user-agent'] || 'none',
|
|
184
|
+
referer: headers.referer || 'none',
|
|
185
|
+
time: new Date().toISOString()
|
|
186
|
+
};
|
|
187
|
+
};
|
|
172
188
|
// Handles a request.
|
|
173
189
|
const requestHandler = async (request, response) => {
|
|
174
190
|
// Sets response headers.
|
|
@@ -194,19 +210,11 @@ const requestHandler = async (request, response) => {
|
|
|
194
210
|
const {pathname, search} = requestURL;
|
|
195
211
|
const pageName = pathname.split('/')[1];
|
|
196
212
|
const pathTail = pathname.split('/').slice(2).join('/');
|
|
197
|
-
// If the request is
|
|
198
|
-
if (method === '
|
|
199
|
-
// Serve response headers, including one allowing requests from other applications.
|
|
200
|
-
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
201
|
-
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
202
|
-
response.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
203
|
-
response.statusCode = 204;
|
|
204
|
-
response.end();
|
|
205
|
-
}
|
|
206
|
-
// Otherwise, if the request is a GET request:
|
|
207
|
-
else if (method === 'GET') {
|
|
213
|
+
// If the request is a GET request:
|
|
214
|
+
if (method === 'GET') {
|
|
208
215
|
// If it is for the model context protocol server:
|
|
209
|
-
if (pathname ===
|
|
216
|
+
if (pathname === mcpPath) {
|
|
217
|
+
// Handle the MCP request.
|
|
210
218
|
await handleMCP(request, response);
|
|
211
219
|
}
|
|
212
220
|
// Otherwise, if it is for the home page:
|
|
@@ -251,6 +259,13 @@ const requestHandler = async (request, response) => {
|
|
|
251
259
|
setHeaders('text/plain', '/llms-full.txt', 'medium');
|
|
252
260
|
response.end(llmsfull);
|
|
253
261
|
}
|
|
262
|
+
// Otherwise, if it is for the XML sitemap:
|
|
263
|
+
else if (pageName === 'sitemap.xml') {
|
|
264
|
+
const sitemap = await fs.readFile('sitemap.xml', 'utf8');
|
|
265
|
+
// Serve it.
|
|
266
|
+
setHeaders('application/xml', '/sitemap.xml', 'medium');
|
|
267
|
+
response.end(sitemap);
|
|
268
|
+
}
|
|
254
269
|
// Otherwise, if it is for a full report download:
|
|
255
270
|
else if (pageName === 'fullReport.json') {
|
|
256
271
|
const [timeStamp, jobID] = pathTail.split('/');
|
|
@@ -260,17 +275,17 @@ const requestHandler = async (request, response) => {
|
|
|
260
275
|
// If the report exists and is hidden:
|
|
261
276
|
if (reportHidden === true) {
|
|
262
277
|
console.log(`ERROR: Hidden report ${timeStamp}-${jobID} requested`);
|
|
263
|
-
// Report this.
|
|
278
|
+
// Report this as suspected abuse.
|
|
264
279
|
await serveError(
|
|
265
|
-
|
|
280
|
+
getAbuseError(request, `Requested report ${timeStamp}-${jobID} not available`),
|
|
266
281
|
response,
|
|
267
282
|
true
|
|
268
283
|
);
|
|
269
284
|
}
|
|
270
|
-
// Otherwise, if
|
|
285
|
+
// Otherwise, if the report does not exist or another error occurred:
|
|
271
286
|
else if (typeof reportHidden === 'string') {
|
|
272
|
-
// Report
|
|
273
|
-
await serveError(
|
|
287
|
+
// Report this as suspected abuse.
|
|
288
|
+
await serveError(getAbuseError(request, reportHidden), response, true);
|
|
274
289
|
}
|
|
275
290
|
// Otherwise, i.e. if the report log is valid and not hidden:
|
|
276
291
|
else {
|
|
@@ -314,14 +329,14 @@ const requestHandler = async (request, response) => {
|
|
|
314
329
|
}
|
|
315
330
|
// Otherwise, i.e. if they are invalid:
|
|
316
331
|
else {
|
|
317
|
-
// Report the error.
|
|
318
|
-
await serveError(
|
|
332
|
+
// Report the error as suspected abuse.
|
|
333
|
+
await serveError(getAbuseError(request, answerData.message), response, true);
|
|
319
334
|
}
|
|
320
335
|
}
|
|
321
336
|
// Otherwise, i.e. if the answer cannot be generated:
|
|
322
337
|
else {
|
|
323
|
-
// Report the error.
|
|
324
|
-
await serveError(
|
|
338
|
+
// Report the error as suspected abuse.
|
|
339
|
+
await serveError(getAbuseError(request, 'Request for nonexistent page'), response, true);
|
|
325
340
|
}
|
|
326
341
|
}
|
|
327
342
|
// Otherwise, if it is for an API service:
|
|
@@ -377,7 +392,7 @@ const requestHandler = async (request, response) => {
|
|
|
377
392
|
setHeaders(mimeTypes[ext] || 'application/octet-stream', null, 'low');
|
|
378
393
|
response.end(img);
|
|
379
394
|
}
|
|
380
|
-
catch
|
|
395
|
+
catch {
|
|
381
396
|
await serveError({message: 'ERROR: Image not found'}, response, true);
|
|
382
397
|
}
|
|
383
398
|
}
|
|
@@ -439,7 +454,7 @@ const requestHandler = async (request, response) => {
|
|
|
439
454
|
// Otherwise, i.e. if they are invalid:
|
|
440
455
|
else {
|
|
441
456
|
// Report the error.
|
|
442
|
-
await serveError({message: answerData.
|
|
457
|
+
await serveError({message: answerData.message}, response, true);
|
|
443
458
|
}
|
|
444
459
|
}
|
|
445
460
|
// Otherwise, i.e. if the request is invalid:
|
|
@@ -473,7 +488,7 @@ const requestHandler = async (request, response) => {
|
|
|
473
488
|
// Otherwise, i.e. if they are invalid:
|
|
474
489
|
else {
|
|
475
490
|
// Report the error.
|
|
476
|
-
await serveError({message: answerData.
|
|
491
|
+
await serveError({message: answerData.message}, response, true);
|
|
477
492
|
}
|
|
478
493
|
}
|
|
479
494
|
}
|
|
@@ -495,18 +510,18 @@ const requestHandler = async (request, response) => {
|
|
|
495
510
|
if (what) {
|
|
496
511
|
// Set a location header for a response.
|
|
497
512
|
response.setHeader('content-location', `${pathname}${search}`);
|
|
498
|
-
// Get the answer data.
|
|
513
|
+
// Get the answer data about the remaining recommendations.
|
|
499
514
|
const answerData = await require(path.join(__dirname, 'testOrder', 'index'))
|
|
500
515
|
.answer(url, what, authCode);
|
|
501
516
|
// If the answer data are valid:
|
|
502
517
|
if (answerData.status === 'ok') {
|
|
503
|
-
// Serve the
|
|
518
|
+
// Serve the test-order page with the remaining recommendations.
|
|
504
519
|
response.end(answerData.answerPage);
|
|
505
520
|
}
|
|
506
521
|
// Otherwise, i.e. if they are invalid:
|
|
507
522
|
else {
|
|
508
523
|
// Report the error.
|
|
509
|
-
await serveError({message: answerData.
|
|
524
|
+
await serveError({message: answerData.message}, response, true);
|
|
510
525
|
}
|
|
511
526
|
}
|
|
512
527
|
// Otherwise, i.e. if it is a rejection:
|
|
@@ -521,7 +536,7 @@ const requestHandler = async (request, response) => {
|
|
|
521
536
|
response.setHeader('content-location', '/recActionForm.html');
|
|
522
537
|
// Get the answer data.
|
|
523
538
|
const answerData = await require(path.join(__dirname, 'recActionForm', 'index')).answer();
|
|
524
|
-
// Serve the test-order form.
|
|
539
|
+
// Serve the test-order form with the remaining recommendations.
|
|
525
540
|
response.end(answerData.answerPage);
|
|
526
541
|
}
|
|
527
542
|
}
|
|
@@ -547,7 +562,7 @@ const requestHandler = async (request, response) => {
|
|
|
547
562
|
// Otherwise, i.e. if they are invalid:
|
|
548
563
|
else {
|
|
549
564
|
// Report the error.
|
|
550
|
-
await serveError({message: answerData.
|
|
565
|
+
await serveError({message: answerData.message}, response, true);
|
|
551
566
|
}
|
|
552
567
|
}
|
|
553
568
|
// Otherwise, if it is a WCAG map renewal:
|
|
@@ -566,7 +581,7 @@ const requestHandler = async (request, response) => {
|
|
|
566
581
|
// Otherwise, i.e. if they are invalid:
|
|
567
582
|
else {
|
|
568
583
|
// Report the error.
|
|
569
|
-
await serveError({message: answerData.
|
|
584
|
+
await serveError({message: answerData.message}, response, true);
|
|
570
585
|
}
|
|
571
586
|
}
|
|
572
587
|
// Otherwise, if it is a request from an agent:
|
|
@@ -753,7 +768,7 @@ const requestHandler = async (request, response) => {
|
|
|
753
768
|
}
|
|
754
769
|
else {
|
|
755
770
|
response.statusCode = 400;
|
|
756
|
-
response.end(JSON.stringify({status: 'error', message: answerData.
|
|
771
|
+
response.end(JSON.stringify({status: 'error', message: answerData.message}));
|
|
757
772
|
}
|
|
758
773
|
}
|
|
759
774
|
// Otherwise, i.e. if it is any other POST request:
|
|
@@ -765,7 +780,7 @@ const requestHandler = async (request, response) => {
|
|
|
765
780
|
}
|
|
766
781
|
// Otherwise, i.e. if it is neither a GET nor a POST request:
|
|
767
782
|
else {
|
|
768
|
-
// Report its invalidity.
|
|
783
|
+
// Report its invalidity. (Caddy handles OPTIONS requests.)
|
|
769
784
|
await serveError({message: 'ERROR: Invalid request method'}, response, true);
|
|
770
785
|
}
|
|
771
786
|
};
|
package/mcp.js
CHANGED
|
@@ -15,6 +15,10 @@ const reportIssuesAPI = require('./reportIssues/api');
|
|
|
15
15
|
const reportIssueAPI = require('./reportIssue/api');
|
|
16
16
|
const testRecFormAPI = require('./testRecForm/api');
|
|
17
17
|
|
|
18
|
+
// CONSTANTS
|
|
19
|
+
|
|
20
|
+
exports.mcpPath = '/mcp';
|
|
21
|
+
|
|
18
22
|
// FUNCTIONS
|
|
19
23
|
|
|
20
24
|
// Creates and returns an McpServer with Kilotest tools registered.
|
package/package.json
CHANGED
package/reannotate/index.js
CHANGED
package/reportHideForm/index.js
CHANGED
package/reportIssue/index.js
CHANGED
package/reportIssues/index.js
CHANGED
package/researchAgent.js
CHANGED
package/robots.txt
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
# robots.txt
|
|
2
2
|
User-agent: *
|
|
3
3
|
Allow: /
|
|
4
|
+
Disallow: /ai0BalanceForm.html
|
|
5
|
+
Disallow: /recActionForm.html
|
|
6
|
+
Disallow: /recAction.html
|
|
7
|
+
Disallow: /reannotateForm.html
|
|
8
|
+
Disallow: /reannotate.html
|
|
9
|
+
Disallow: /reportHideForm.html
|
|
10
|
+
Disallow: /reportUnhideForm.html
|
|
11
|
+
Disallow: /reportsPruneForm.html
|
|
12
|
+
Disallow: /reportsRewindForm.html
|
|
13
|
+
Disallow: /reportsExpungeForm.html
|
|
14
|
+
Disallow: /wcagRenewForm.html
|
|
15
|
+
Disallow: /wcagRenew.html
|
|
4
16
|
Sitemap: https://kilotest.com/sitemap.xml
|
package/sitemap.xml
CHANGED
package/style.css
CHANGED
package/targets/index.js
CHANGED
package/testOrder/index.js
CHANGED
|
@@ -11,7 +11,7 @@ const path = require('path');
|
|
|
11
11
|
|
|
12
12
|
// FUNCTIONS
|
|
13
13
|
|
|
14
|
-
// Implements a test order and returns
|
|
14
|
+
// Implements a test order and returns a revised recommendations page.
|
|
15
15
|
exports.answer = async (url, what, authCode) => {
|
|
16
16
|
// If the arguments are valid:
|
|
17
17
|
if (isURL(url) && what && authCode === process.env.AUTH_CODE) {
|
|
@@ -57,6 +57,6 @@ exports.answer = async (url, what, authCode) => {
|
|
|
57
57
|
// Otherwise, i.e. if the authorization code is invalid, return an error page.
|
|
58
58
|
return {
|
|
59
59
|
status: 'error',
|
|
60
|
-
|
|
60
|
+
message: 'Invalid authorization code'
|
|
61
61
|
};
|
|
62
62
|
};
|
package/testRec/index.js
CHANGED
|
@@ -17,7 +17,7 @@ exports.answer = async (what, url, why) => {
|
|
|
17
17
|
// Return an answer reporting this.
|
|
18
18
|
return {
|
|
19
19
|
status: 'error',
|
|
20
|
-
|
|
20
|
+
message: `Page is already ${status}`
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
// Otherwise, i.e. if it is recommendable, process the recommendation.
|
package/tutorial/index.js
CHANGED
|
@@ -33,11 +33,11 @@ exports.answer = async () => {
|
|
|
33
33
|
// Sanitizes and saves a tutorial comment to comments.json.
|
|
34
34
|
exports.saveComment = async content => {
|
|
35
35
|
if (!content || typeof content !== 'string') {
|
|
36
|
-
return {status: 'error',
|
|
36
|
+
return {status: 'error', message: 'No content provided'};
|
|
37
37
|
}
|
|
38
38
|
const sanitized = sanitize(content);
|
|
39
39
|
if (!sanitized) {
|
|
40
|
-
return {status: 'error',
|
|
40
|
+
return {status: 'error', message: 'Comment is empty after sanitization'};
|
|
41
41
|
}
|
|
42
42
|
let comments = [];
|
|
43
43
|
try {
|
package/util.js
CHANGED
|
@@ -679,7 +679,7 @@ exports.processRec = async (testType, dirName, what, url, why) => {
|
|
|
679
679
|
}
|
|
680
680
|
return {
|
|
681
681
|
status: 'error',
|
|
682
|
-
|
|
682
|
+
message: 'Invalid recommendation'
|
|
683
683
|
};
|
|
684
684
|
};
|
|
685
685
|
// Gets the WCAG Understanding link for a numeric WCAG standard identifier.
|
package/wcagRenew/index.js
CHANGED
|
@@ -50,21 +50,21 @@ exports.answer = async authCode => {
|
|
|
50
50
|
// Report this.
|
|
51
51
|
return {
|
|
52
52
|
status: 'error',
|
|
53
|
-
|
|
53
|
+
message: 'No entries found in WCAG map source'
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
else {
|
|
58
58
|
return {
|
|
59
59
|
status: 'error',
|
|
60
|
-
|
|
60
|
+
message: 'WCAG map source not retrieved'
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
else {
|
|
65
65
|
return {
|
|
66
66
|
status: 'error',
|
|
67
|
-
|
|
67
|
+
message: 'Authorization code invalid'
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
};
|