pompelmi 1.2.4 → 1.4.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/README.md +86 -3
- package/package.json +1 -1
- package/src/BufferScanner.js +67 -0
- package/src/ClamAVScanner.js +71 -5
- package/src/StreamScanner.js +67 -0
- package/src/index.js +3 -3
- package/.claude/settings.local.json +0 -51
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<img src="./src/grapefruit.png" width="88" alt="pompelmi logo">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<h1 align="center">pompelmi — ClamAV Antivirus Scanning for Node.js</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center"><strong>ClamAV antivirus scanning for Node.js — clean, typed, zero dependencies.</strong></p>
|
|
8
8
|
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
<img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker available">
|
|
14
14
|
<img src="https://img.shields.io/badge/license-ISC-blue.svg" alt="license">
|
|
15
15
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="zero dependencies">
|
|
16
|
+
<img src="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml/badge.svg" alt="Node.js CI">
|
|
17
|
+
<img src="https://github.com/pompelmi/pompelmi/actions/workflows/release.yml/badge.svg" alt="npm publish">
|
|
16
18
|
</p>
|
|
17
19
|
|
|
18
|
-

|
|
19
|
-
|
|
20
20
|
---
|
|
21
21
|
|
|
22
22
|
## Overview
|
|
@@ -43,6 +43,8 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
43
43
|
## Features
|
|
44
44
|
|
|
45
45
|
- Single `scan(filePath, [options])` function — works locally or against a remote clamd instance
|
|
46
|
+
- `scanBuffer(buffer, [options])` — scan in-memory Buffers directly, no temp file required in TCP mode
|
|
47
|
+
- `scanStream(stream, [options])` — scan a Readable stream directly. In TCP mode, streamed to clamd with no disk I/O.
|
|
46
48
|
- Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
|
|
47
49
|
- Full TCP/clamd support via the INSTREAM protocol with configurable host, port, and timeout
|
|
48
50
|
- Built-in helpers to install ClamAV and update virus definitions programmatically
|
|
@@ -214,6 +216,32 @@ const files = ['/uploads/a.pdf', '/uploads/b.zip', '/uploads/c.png'];
|
|
|
214
216
|
const results = await Promise.all(files.map((f) => scan(f)));
|
|
215
217
|
```
|
|
216
218
|
|
|
219
|
+
### Scan a Buffer
|
|
220
|
+
|
|
221
|
+
```js
|
|
222
|
+
const { scanBuffer, Verdict } = require('pompelmi');
|
|
223
|
+
|
|
224
|
+
// Useful with multer memoryStorage or any in-memory upload
|
|
225
|
+
const result = await scanBuffer(req.file.buffer);
|
|
226
|
+
|
|
227
|
+
if (result === Verdict.Malicious) throw new Error('Malware detected.');
|
|
228
|
+
if (result === Verdict.ScanError) console.warn('Scan incomplete.');
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Scan a Stream
|
|
232
|
+
|
|
233
|
+
```js
|
|
234
|
+
const { scanStream, Verdict } = require('pompelmi');
|
|
235
|
+
const { Readable } = require('stream');
|
|
236
|
+
|
|
237
|
+
// Useful for S3 getObject, HTTP downloads, or any piped source
|
|
238
|
+
const stream = s3.getObject({ Bucket, Key }).createReadStream();
|
|
239
|
+
const result = await scanStream(stream);
|
|
240
|
+
|
|
241
|
+
if (result === Verdict.Malicious) throw new Error('Malware detected.');
|
|
242
|
+
if (result === Verdict.ScanError) console.warn('Scan incomplete.');
|
|
243
|
+
```
|
|
244
|
+
|
|
217
245
|
---
|
|
218
246
|
|
|
219
247
|
## Docker / Remote Scanning
|
|
@@ -286,6 +314,60 @@ Verdict.ScanError.description // 'ScanError'
|
|
|
286
314
|
|
|
287
315
|
---
|
|
288
316
|
|
|
317
|
+
### `scanBuffer(buffer, [options])`
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
scanBuffer(
|
|
321
|
+
buffer: Buffer,
|
|
322
|
+
options?: { host?: string; port?: number; timeout?: number }
|
|
323
|
+
): Promise<symbol>
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
| Parameter | Type | Description |
|
|
327
|
+
|---|---|---|
|
|
328
|
+
| `buffer` | `Buffer` | The in-memory buffer to scan |
|
|
329
|
+
| `options` | `object` | Same options as `scan()` — host, port, timeout |
|
|
330
|
+
|
|
331
|
+
**Returns** the same three Symbol verdicts as `scan()`: `Verdict.Clean`, `Verdict.Malicious`, `Verdict.ScanError`.
|
|
332
|
+
|
|
333
|
+
**Rejects** with the same error types as `scan()` where applicable, plus:
|
|
334
|
+
|
|
335
|
+
| Condition | Error message |
|
|
336
|
+
|---|---|
|
|
337
|
+
| `buffer` is not a Buffer | `buffer must be a Buffer` |
|
|
338
|
+
| `buffer` is empty | `buffer is empty` |
|
|
339
|
+
|
|
340
|
+
In TCP mode (`host` or `port` provided), the buffer is streamed directly to clamd via the INSTREAM protocol — no data is written to disk. In local mode, a temp file is written to `os.tmpdir()` and deleted automatically in a `finally` block regardless of outcome.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
### `scanStream(stream, [options])`
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
scanStream(
|
|
348
|
+
stream: Readable,
|
|
349
|
+
options?: { host?: string; port?: number; timeout?: number }
|
|
350
|
+
): Promise<symbol>
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
| Parameter | Type | Description |
|
|
354
|
+
|---|---|---|
|
|
355
|
+
| `stream` | `Readable` | Node.js Readable stream to scan |
|
|
356
|
+
| `options` | `object` | Same options as `scan()` — host, port, timeout |
|
|
357
|
+
|
|
358
|
+
**Returns** the same three Symbol verdicts as `scan()`: `Verdict.Clean`, `Verdict.Malicious`, `Verdict.ScanError`.
|
|
359
|
+
|
|
360
|
+
**Rejects** with the same error types as `scan()` where applicable, plus:
|
|
361
|
+
|
|
362
|
+
| Condition | Error message |
|
|
363
|
+
|---|---|
|
|
364
|
+
| `stream` is not a Readable | `stream must be a Readable` |
|
|
365
|
+
| Stream emits error | propagated as-is |
|
|
366
|
+
|
|
367
|
+
In TCP mode (`host` or `port` provided), the stream is piped directly to clamd via the INSTREAM protocol — no data is written to disk. In local mode, the stream is piped to a temp file in `os.tmpdir()` that is deleted automatically in a `finally` block.
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
289
371
|
### `ClamAVInstaller()` _(internal)_
|
|
290
372
|
|
|
291
373
|
Installs ClamAV using the platform's native package manager. Resolves immediately if ClamAV is already installed.
|
|
@@ -349,6 +431,7 @@ The [`examples/`](./examples/) directory contains standalone runnable scripts. E
|
|
|
349
431
|
| [`scan-multiple-files.js`](examples/scan-multiple-files.js) | Concurrent scans with `Promise.all` |
|
|
350
432
|
| [`scan-directory.js`](examples/scan-directory.js) | Recursively scan every file in a directory |
|
|
351
433
|
| [`scan-buffer.js`](examples/scan-buffer.js) | Scan an in-memory Buffer via a temp-file shim |
|
|
434
|
+
| [`scan-stream.js`](examples/scan-stream.js) | Scan an S3 getObject Readable stream with scanStream() |
|
|
352
435
|
| [`rest-api-server.js`](examples/rest-api-server.js) | Minimal HTTP server exposing `POST /scan` |
|
|
353
436
|
| [`s3-scan-before-upload.js`](examples/s3-scan-before-upload.js) | Scan locally, then upload to S3 only if clean |
|
|
354
437
|
| [`cli-scan.js`](examples/cli-scan.js) | CLI tool: scan file paths, exit non-zero on threats |
|
package/package.json
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const { Verdict } = require('./verdicts.js');
|
|
5
|
+
|
|
6
|
+
const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
|
|
7
|
+
const CHUNK_SIZE = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
function parseClamdResponse(raw) {
|
|
10
|
+
const text = raw.toString('utf8').trim();
|
|
11
|
+
if (text === 'stream: OK') return Verdict.Clean;
|
|
12
|
+
if (text.endsWith(' FOUND')) return Verdict.Malicious;
|
|
13
|
+
return Verdict.ScanError;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scan an in-memory Buffer by streaming it to a running clamd instance over TCP.
|
|
18
|
+
* No data is written to disk.
|
|
19
|
+
*
|
|
20
|
+
* @param {Buffer} buffer
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {string} [options.host='127.0.0.1']
|
|
23
|
+
* @param {number} [options.port=3310]
|
|
24
|
+
* @param {number} [options.timeout=15000]
|
|
25
|
+
* @returns {Promise<symbol>}
|
|
26
|
+
*/
|
|
27
|
+
function scanBufferViaClamd(buffer, { host = '127.0.0.1', port = 3310, timeout = 15_000 } = {}) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const socket = net.createConnection({ host, port });
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let settled = false;
|
|
32
|
+
|
|
33
|
+
function settle(fn, value) {
|
|
34
|
+
if (settled) return;
|
|
35
|
+
settled = true;
|
|
36
|
+
socket.destroy();
|
|
37
|
+
fn(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
socket.setTimeout(timeout);
|
|
41
|
+
socket.on('timeout', () =>
|
|
42
|
+
settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
|
|
43
|
+
);
|
|
44
|
+
socket.on('error', (err) => settle(reject, err));
|
|
45
|
+
socket.on('data', (chunk) => chunks.push(chunk));
|
|
46
|
+
socket.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
47
|
+
|
|
48
|
+
socket.on('connect', () => {
|
|
49
|
+
socket.write(CLAMD_INSTREAM);
|
|
50
|
+
|
|
51
|
+
let offset = 0;
|
|
52
|
+
while (offset < buffer.length) {
|
|
53
|
+
const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
|
|
54
|
+
const header = Buffer.allocUnsafe(4);
|
|
55
|
+
header.writeUInt32BE(chunk.length, 0);
|
|
56
|
+
socket.write(header);
|
|
57
|
+
socket.write(chunk);
|
|
58
|
+
offset += chunk.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
socket.write(Buffer.alloc(4)); // terminating zero-length chunk
|
|
62
|
+
socket.end();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { scanBufferViaClamd };
|
package/src/ClamAVScanner.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
const { nativeSpawn: spawn }
|
|
2
|
-
const fs
|
|
3
|
-
const
|
|
4
|
-
const
|
|
1
|
+
const { nativeSpawn: spawn } = require('./spawn.js');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { Readable } = require('stream');
|
|
6
|
+
const { SCAN_RESULTS } = require('./config.js');
|
|
7
|
+
const { scanViaClamd } = require('./ClamdScanner.js');
|
|
8
|
+
const { scanBufferViaClamd } = require('./BufferScanner.js');
|
|
9
|
+
const { scanStreamViaClamd } = require('./StreamScanner.js');
|
|
5
10
|
|
|
6
11
|
const MESSAGES = {
|
|
7
12
|
FILE_NOT_FOUND: (filePath) => `File not found: ${filePath}`,
|
|
@@ -34,4 +39,65 @@ function scan(filePath, options = {}) {
|
|
|
34
39
|
});
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
async function scanBuffer(buffer, options = {}) {
|
|
43
|
+
if (!Buffer.isBuffer(buffer)) {
|
|
44
|
+
throw new Error('buffer must be a Buffer');
|
|
45
|
+
}
|
|
46
|
+
if (buffer.length === 0) {
|
|
47
|
+
throw new Error('buffer is empty');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (options.host !== undefined || options.port !== undefined) {
|
|
51
|
+
return scanBufferViaClamd(buffer, options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tmpPath = path.join(
|
|
55
|
+
os.tmpdir(),
|
|
56
|
+
`pompelmi-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
fs.writeFileSync(tmpPath, buffer);
|
|
60
|
+
try {
|
|
61
|
+
return await scan(tmpPath);
|
|
62
|
+
} finally {
|
|
63
|
+
fs.unlink(tmpPath, () => {});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function scanStream(stream, options = {}) {
|
|
68
|
+
if (!(stream instanceof Readable)) {
|
|
69
|
+
throw new Error('stream must be a Readable');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (options.host !== undefined || options.port !== undefined) {
|
|
73
|
+
return scanStreamViaClamd(stream, options);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tmpPath = path.join(
|
|
77
|
+
os.tmpdir(),
|
|
78
|
+
`pompelmi-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
await new Promise((resolve, reject) => {
|
|
82
|
+
let settled = false;
|
|
83
|
+
function settle(err) {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
if (err) reject(err);
|
|
87
|
+
else resolve();
|
|
88
|
+
}
|
|
89
|
+
const writable = fs.createWriteStream(tmpPath);
|
|
90
|
+
stream.on('error', settle);
|
|
91
|
+
writable.on('error', settle);
|
|
92
|
+
writable.on('finish', () => settle(null));
|
|
93
|
+
stream.pipe(writable);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
return await scan(tmpPath);
|
|
98
|
+
} finally {
|
|
99
|
+
fs.unlink(tmpPath, () => {});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { scan, scanBuffer, scanStream };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const { Verdict } = require('./verdicts.js');
|
|
5
|
+
|
|
6
|
+
const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
|
|
7
|
+
|
|
8
|
+
function parseClamdResponse(raw) {
|
|
9
|
+
const text = raw.toString('utf8').trim();
|
|
10
|
+
if (text === 'stream: OK') return Verdict.Clean;
|
|
11
|
+
if (text.endsWith(' FOUND')) return Verdict.Malicious;
|
|
12
|
+
return Verdict.ScanError;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scan a Readable stream by piping it to a running clamd instance over TCP.
|
|
17
|
+
* No data is written to disk.
|
|
18
|
+
*
|
|
19
|
+
* @param {import('stream').Readable} stream
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {string} [options.host='127.0.0.1']
|
|
22
|
+
* @param {number} [options.port=3310]
|
|
23
|
+
* @param {number} [options.timeout=15000]
|
|
24
|
+
* @returns {Promise<symbol>}
|
|
25
|
+
*/
|
|
26
|
+
function scanStreamViaClamd(stream, { host = '127.0.0.1', port = 3310, timeout = 15_000 } = {}) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const socket = net.createConnection({ host, port });
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let settled = false;
|
|
31
|
+
|
|
32
|
+
function settle(fn, value) {
|
|
33
|
+
if (settled) return;
|
|
34
|
+
settled = true;
|
|
35
|
+
socket.destroy();
|
|
36
|
+
fn(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
socket.setTimeout(timeout);
|
|
40
|
+
socket.on('timeout', () =>
|
|
41
|
+
settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
|
|
42
|
+
);
|
|
43
|
+
socket.on('error', (err) => settle(reject, err));
|
|
44
|
+
socket.on('data', (chunk) => chunks.push(chunk));
|
|
45
|
+
socket.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
46
|
+
|
|
47
|
+
socket.on('connect', () => {
|
|
48
|
+
socket.write(CLAMD_INSTREAM);
|
|
49
|
+
|
|
50
|
+
stream.on('error', (err) => settle(reject, err));
|
|
51
|
+
|
|
52
|
+
stream.on('data', (chunk) => {
|
|
53
|
+
const header = Buffer.allocUnsafe(4);
|
|
54
|
+
header.writeUInt32BE(chunk.length, 0);
|
|
55
|
+
socket.write(header);
|
|
56
|
+
socket.write(chunk);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
stream.on('end', () => {
|
|
60
|
+
socket.write(Buffer.alloc(4)); // terminating zero-length chunk
|
|
61
|
+
socket.end();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { scanStreamViaClamd };
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { scan }
|
|
2
|
-
const { Verdict }
|
|
1
|
+
const { scan, scanBuffer, scanStream } = require('./ClamAVScanner.js');
|
|
2
|
+
const { Verdict } = require('./verdicts.js');
|
|
3
3
|
|
|
4
|
-
module.exports = { scan, Verdict };
|
|
4
|
+
module.exports = { scan, scanBuffer, scanStream, Verdict };
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(node:*)",
|
|
5
|
-
"Bash(echo \"EXIT:$?\")",
|
|
6
|
-
"Bash(echo \"EXIT_CODE:$?\")",
|
|
7
|
-
"Bash(tee /tmp/pompelmi_test_out.txt)",
|
|
8
|
-
"Bash(echo \"done: $?\")",
|
|
9
|
-
"Bash(ls /Users/tommy/pompelmi/pompelmi/*.md)",
|
|
10
|
-
"Bash(ls /Users/tommy/pompelmi/pompelmi/LICENSE*)",
|
|
11
|
-
"WebSearch",
|
|
12
|
-
"WebFetch(domain:pompelmi.app)",
|
|
13
|
-
"WebFetch(domain:news.ycombinator.com)",
|
|
14
|
-
"WebFetch(domain:dev.to)",
|
|
15
|
-
"WebFetch(domain:socket.dev)",
|
|
16
|
-
"WebFetch(domain:helpnetsecurity.com)",
|
|
17
|
-
"WebFetch(domain:nodeweekly.com)",
|
|
18
|
-
"WebFetch(domain:bytes.dev)",
|
|
19
|
-
"WebFetch(domain:www.helpnetsecurity.com)",
|
|
20
|
-
"WebFetch(domain:www.enveil.com)",
|
|
21
|
-
"WebFetch(domain:img.helpnetsecurity.com)",
|
|
22
|
-
"WebFetch(domain:logo.clearbit.com)",
|
|
23
|
-
"WebFetch(domain:cdn.brandfetch.io)",
|
|
24
|
-
"WebFetch(domain:cooperpress.com)",
|
|
25
|
-
"WebFetch(domain:wirexsystems.com)",
|
|
26
|
-
"WebFetch(domain:www.zlti.com)",
|
|
27
|
-
"Bash(gh run:*)",
|
|
28
|
-
"Bash(gh api:*)",
|
|
29
|
-
"WebFetch(domain:api.github.com)",
|
|
30
|
-
"WebFetch(domain:raw.githubusercontent.com)",
|
|
31
|
-
"Bash(git -C /Users/tommy/pompelmi/pompelmi status)",
|
|
32
|
-
"Bash(git -C /Users/tommy/pompelmi/pompelmi log --oneline -5)",
|
|
33
|
-
"Bash(git pull:*)",
|
|
34
|
-
"Bash(git add:*)",
|
|
35
|
-
"Bash(git commit -m ':*)",
|
|
36
|
-
"Bash(git push:*)",
|
|
37
|
-
"Bash(grep -E '\\\\.\\(png|svg|ico|webp\\)$')",
|
|
38
|
-
"Bash(ls -d /Users/tommy/pompelmi/pompelmi/*/)",
|
|
39
|
-
"Bash(node --test test/unit.test.js)",
|
|
40
|
-
"Bash(echo \"exit:$?\")",
|
|
41
|
-
"Read(//tmp/**)",
|
|
42
|
-
"Bash(tee /Users/tommy/pompelmi/pompelmi/test_out.txt)",
|
|
43
|
-
"Bash(npm install:*)",
|
|
44
|
-
"Bash(npm ls:*)",
|
|
45
|
-
"Bash(wait)",
|
|
46
|
-
"Bash(git rebase *)",
|
|
47
|
-
"Bash(python3 *)",
|
|
48
|
-
"Bash(git *)"
|
|
49
|
-
]
|
|
50
|
-
}
|
|
51
|
-
}
|