pompelmi 1.15.0 → 1.16.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 CHANGED
@@ -89,7 +89,7 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
89
89
  - Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
90
90
  - Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
91
91
  - Built-in helpers to install ClamAV and update virus definitions programmatically
92
- - Works with Express, Fastify, NestJS, Hono, and any other Node.js HTTP framework
92
+ - Works with Express, Fastify, NestJS, Hono, Remix, SvelteKit, and any other Node.js HTTP framework
93
93
  - Works with Node.js and Bun — uses `Bun.file()` for faster file reading when available
94
94
  - Interactive demo at [pompelmi.app/demo](https://pompelmi.app/demo.html) — try before you install
95
95
  - Zero runtime dependencies — ships nothing but source code
@@ -109,6 +109,8 @@ Official integration packages for popular frameworks:
109
109
  | [@pompelmi/nestjs](./packages/nestjs/) | NestJS | `npm i @pompelmi/nestjs` |
110
110
  | [@pompelmi/fastify](./packages/fastify/) | Fastify | `npm i @pompelmi/fastify` |
111
111
  | [@pompelmi/hono](./packages/hono/) | Hono | `npm i @pompelmi/hono` |
112
+ | [@pompelmi/remix](./packages/remix/) | Remix | `npm i @pompelmi/remix` |
113
+ | [@pompelmi/sveltekit](./packages/sveltekit/) | SvelteKit | `npm i @pompelmi/sveltekit` |
112
114
  | [@pompelmi/testing](./packages/testing/) | Jest/Vitest/Node | `npm i -D @pompelmi/testing` |
113
115
 
114
116
  ### NestJS
@@ -155,6 +157,40 @@ app.use('/upload/*', pompelmiMiddleware({
155
157
  app.post('/upload', async (c) => c.json({ ok: true }))
156
158
  ```
157
159
 
160
+ ### Remix
161
+
162
+ ```ts
163
+ import { unstable_parseMultipartFormData, json } from '@remix-run/node'
164
+ import { pompelmiUploadHandler } from '@pompelmi/remix'
165
+
166
+ export async function action({ request }) {
167
+ // Throws HTTP 422 automatically if malware is detected
168
+ const formData = await unstable_parseMultipartFormData(
169
+ request,
170
+ pompelmiUploadHandler({ host: 'localhost', port: 3310 })
171
+ )
172
+ const file = formData.get('file')
173
+ return json({ name: file.name, size: file.size, ok: true })
174
+ }
175
+ ```
176
+
177
+ ### SvelteKit
178
+
179
+ ```ts
180
+ // +page.server.ts
181
+ import { scanUpload } from '@pompelmi/sveltekit'
182
+ import type { Actions } from './$types'
183
+
184
+ export const actions: Actions = {
185
+ default: async ({ request }) => {
186
+ const formData = await request.formData()
187
+ // Throws HTTP 422 automatically if malware is detected
188
+ await scanUpload(formData.get('file') as File, { host: 'localhost', port: 3310 })
189
+ return { success: true }
190
+ }
191
+ }
192
+ ```
193
+
158
194
  ---
159
195
 
160
196
  ## Requirements
package/llms.txt CHANGED
@@ -20,6 +20,18 @@ Options: `{ host?: string, port?: number, timeout?: number }`
20
20
  - Local mode: spawns `clamscan`, maps exit codes to verdicts
21
21
  - TCP mode: streams to clamd via INSTREAM protocol (set `host`)
22
22
 
23
+ ## Framework integrations
24
+
25
+ Official packages for popular frameworks:
26
+
27
+ - `@pompelmi/nestjs` — NestJS module (`PompelmiModule`, `PompelmiService`, `PompelmiInterceptor`)
28
+ - `@pompelmi/fastify` — Fastify plugin (decorates `fastify.pompelmi` with scan/preHandler)
29
+ - `@pompelmi/hono` — Hono middleware (`pompelmiMiddleware`) for Node.js, Bun, Cloudflare Workers
30
+ - `@pompelmi/remix` — Remix upload handler (`pompelmiUploadHandler` for `unstable_parseMultipartFormData`)
31
+ - `@pompelmi/sveltekit` — SvelteKit helper (`scanUpload`, `scanFormData` for +page.server.ts and +server.ts)
32
+ - `@pompelmi/nextjs` — Next.js App Router / Pages Router helpers
33
+ - `@pompelmi/testing` — test utilities (`mockClean`, `mockInfected`, `withMockedPompelmi`)
34
+
23
35
  ## Installation
24
36
 
25
37
  npm install pompelmi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "ClamAV for humans — scan any file and get back Clean, Malicious, or ScanError. No daemons. No cloud. No native bindings.",
5
5
  "license": "ISC",
6
6
  "author": "pompelmi contributors",
@@ -28,7 +28,10 @@
28
28
  "fastify",
29
29
  "nestjs",
30
30
  "multer",
31
- "zero-dependencies"
31
+ "zero-dependencies",
32
+ "remix",
33
+ "sveltekit",
34
+ "svelte"
32
35
  ],
33
36
  "type": "commonjs",
34
37
  "main": "./src/index.js",
@@ -37,7 +40,7 @@
37
40
  "pompelmi": "./bin/pompelmi.js"
38
41
  },
39
42
  "scripts": {
40
- "test": "node --test test/unit.test.js && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node --test packages/hono/test/index.test.js && node --test packages/testing/test/index.test.js && node test/scan.test.js",
43
+ "test": "node --test test/unit.test.js && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node --test packages/hono/test/index.test.js && node --test packages/testing/test/index.test.js && node --test packages/remix/test/index.test.js && node --test packages/sveltekit/test/index.test.js && node test/scan.test.js",
41
44
  "lint": "eslint src/"
42
45
  },
43
46
  "publishConfig": {
@@ -0,0 +1,142 @@
1
+ # @pompelmi/remix
2
+
3
+ Remix upload handler for [pompelmi](https://pompelmi.app) — in-process ClamAV virus scanning with zero extra dependencies.
4
+
5
+ Works with **Remix v1 and v2** on Node.js, and is compatible with the `unstable_parseMultipartFormData` API.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @pompelmi/remix pompelmi
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```ts
16
+ import { unstable_parseMultipartFormData, json } from '@remix-run/node'
17
+ import { pompelmiUploadHandler } from '@pompelmi/remix'
18
+ import type { ActionFunctionArgs } from '@remix-run/node'
19
+
20
+ export async function action({ request }: ActionFunctionArgs) {
21
+ const formData = await unstable_parseMultipartFormData(
22
+ request,
23
+ pompelmiUploadHandler({ host: 'localhost', port: 3310 })
24
+ )
25
+
26
+ const file = formData.get('file') as File
27
+ return json({ name: file.name, size: file.size, ok: true })
28
+ }
29
+ ```
30
+
31
+ If a malicious file is uploaded, `pompelmiUploadHandler` throws a `Response` with HTTP **422** — Remix catches it automatically and returns it to the client.
32
+
33
+ ## With an inner handler
34
+
35
+ Chain with any Remix upload handler (e.g. `unstable_createFileUploadHandler`) to store clean files to disk:
36
+
37
+ ```ts
38
+ import {
39
+ unstable_parseMultipartFormData,
40
+ unstable_createFileUploadHandler,
41
+ json,
42
+ } from '@remix-run/node'
43
+ import { pompelmiUploadHandler } from '@pompelmi/remix'
44
+
45
+ const uploadToTmp = unstable_createFileUploadHandler({ directory: '/tmp/uploads' })
46
+
47
+ export async function action({ request }) {
48
+ const formData = await unstable_parseMultipartFormData(
49
+ request,
50
+ pompelmiUploadHandler({
51
+ host: 'localhost',
52
+ port: 3310,
53
+ inner: uploadToTmp, // called only if file is clean
54
+ })
55
+ )
56
+ const file = formData.get('file') // NodeOnDiskFile from inner handler
57
+ return json({ ok: true })
58
+ }
59
+ ```
60
+
61
+ ## Scan a specific field only
62
+
63
+ Use `field` to restrict scanning to a single form field. Other file fields are passed through to `inner` (or returned as `File` objects) without scanning:
64
+
65
+ ```ts
66
+ pompelmiUploadHandler({
67
+ host: 'localhost',
68
+ port: 3310,
69
+ field: 'avatar', // only scan the 'avatar' field
70
+ })
71
+ ```
72
+
73
+ ## Custom error response
74
+
75
+ ```ts
76
+ pompelmiUploadHandler({
77
+ host: 'localhost',
78
+ port: 3310,
79
+ onInfected: ({ filename }) => {
80
+ console.warn(`Blocked malicious upload: ${filename}`)
81
+ throw new Response(
82
+ JSON.stringify({ error: 'Malware detected', filename }),
83
+ { status: 422, headers: { 'Content-Type': 'application/json' } }
84
+ )
85
+ },
86
+ })
87
+ ```
88
+
89
+ ## Route example (full)
90
+
91
+ ```tsx
92
+ // app/routes/upload.tsx
93
+ import {
94
+ unstable_parseMultipartFormData,
95
+ json,
96
+ type ActionFunctionArgs,
97
+ } from '@remix-run/node'
98
+ import { Form, useActionData } from '@remix-run/react'
99
+ import { pompelmiUploadHandler } from '@pompelmi/remix'
100
+
101
+ export async function action({ request }: ActionFunctionArgs) {
102
+ // Throws HTTP 422 automatically if malware is detected
103
+ const formData = await unstable_parseMultipartFormData(
104
+ request,
105
+ pompelmiUploadHandler({ host: 'localhost', port: 3310 })
106
+ )
107
+
108
+ const file = formData.get('document') as File
109
+ if (!file) return json({ error: 'No file provided' }, { status: 400 })
110
+
111
+ return json({ name: file.name, size: file.size, ok: true })
112
+ }
113
+
114
+ export default function Upload() {
115
+ const data = useActionData<typeof action>()
116
+ return (
117
+ <Form method="post" encType="multipart/form-data">
118
+ <input type="file" name="document" />
119
+ <button type="submit">Upload</button>
120
+ {data?.ok && <p>Uploaded: {data.name} ({data.size} bytes)</p>}
121
+ </Form>
122
+ )
123
+ }
124
+ ```
125
+
126
+ ## Configuration Reference
127
+
128
+ | Option | Type | Default | Description |
129
+ |--------|------|---------|-------------|
130
+ | `field` | `string` | — | Only scan this field; others pass through unscanned |
131
+ | `inner` | `UploadHandler` | — | Inner handler for clean files (e.g. file-upload, memory) |
132
+ | `host` | `string` | — | clamd hostname (enables TCP mode) |
133
+ | `port` | `number` | `3310` | clamd port |
134
+ | `socket` | `string` | — | UNIX domain socket path |
135
+ | `timeout` | `number` | `15000` | Socket idle timeout in ms |
136
+ | `retries` | `number` | `0` | Retry attempts |
137
+ | `retryDelay` | `number` | `1000` | Delay between retries in ms |
138
+ | `onInfected` | `Function` | — | Called with `{ name, filename }` when malware detected |
139
+
140
+ ## License
141
+
142
+ ISC — see root [LICENSE](../../LICENSE).
@@ -0,0 +1,50 @@
1
+ import type { ScanOptions } from 'pompelmi';
2
+
3
+ export interface UploadHandlerArgs {
4
+ name: string;
5
+ filename: string | undefined;
6
+ contentType: string;
7
+ data: AsyncIterable<Uint8Array>;
8
+ }
9
+
10
+ export type RemixUploadHandler = (args: UploadHandlerArgs) => Promise<File | string | undefined>;
11
+
12
+ export interface PompelmiRemixOptions extends ScanOptions {
13
+ /**
14
+ * Only scan this form field name; other file fields are passed straight to `inner`.
15
+ * When omitted, all file fields are scanned.
16
+ */
17
+ field?: string;
18
+ /**
19
+ * Inner UploadHandler to call for clean files (e.g. unstable_createMemoryUploadHandler).
20
+ * When omitted, clean files are returned as Web API `File` objects.
21
+ */
22
+ inner?: RemixUploadHandler;
23
+ /**
24
+ * Called with `{ name, filename }` when a malicious file is detected.
25
+ * Must throw a Response (or return one that will be thrown by the caller).
26
+ * If omitted, throws an HTTP 422 Response automatically.
27
+ */
28
+ onInfected?: (args: { name: string; filename: string }) => never | Promise<never>;
29
+ }
30
+
31
+ /**
32
+ * Creates a Remix-compatible UploadHandler that scans each uploaded file with
33
+ * pompelmi before forwarding it to an optional inner handler.
34
+ *
35
+ * @example
36
+ * import { unstable_parseMultipartFormData } from '@remix-run/node'
37
+ * import { pompelmiUploadHandler } from '@pompelmi/remix'
38
+ *
39
+ * export async function action({ request }: ActionFunctionArgs) {
40
+ * const formData = await unstable_parseMultipartFormData(
41
+ * request,
42
+ * pompelmiUploadHandler({ host: 'localhost', port: 3310 })
43
+ * )
44
+ * const file = formData.get('file') as File
45
+ * return json({ name: file.name, size: file.size })
46
+ * }
47
+ */
48
+ export function pompelmiUploadHandler(options?: PompelmiRemixOptions): RemixUploadHandler;
49
+
50
+ export { Verdict } from 'pompelmi';
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const { scanBuffer, Verdict } = require('pompelmi');
4
+
5
+ // File is a global in Node 20+; on Node 18 it lives in node:buffer
6
+ const NodeFile = globalThis.File ?? require('node:buffer').File;
7
+
8
+ const SCAN_KEYS = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
9
+
10
+ function buildScanOptions(options) {
11
+ const out = {};
12
+ for (const k of SCAN_KEYS) {
13
+ if (options[k] !== undefined) out[k] = options[k];
14
+ }
15
+ return out;
16
+ }
17
+
18
+ async function collectStream(data) {
19
+ const chunks = [];
20
+ for await (const chunk of data) {
21
+ chunks.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk));
22
+ }
23
+ return Buffer.concat(chunks);
24
+ }
25
+
26
+ /**
27
+ * Creates a Remix UploadHandler that scans each uploaded file with pompelmi
28
+ * before passing it to an optional inner handler.
29
+ *
30
+ * Usage in a Remix action:
31
+ *
32
+ * import { unstable_parseMultipartFormData } from '@remix-run/node'
33
+ * import { pompelmiUploadHandler } from '@pompelmi/remix'
34
+ *
35
+ * export async function action({ request }) {
36
+ * const formData = await unstable_parseMultipartFormData(
37
+ * request,
38
+ * pompelmiUploadHandler({ host: 'localhost', port: 3310 })
39
+ * )
40
+ * // file is guaranteed clean here
41
+ * }
42
+ *
43
+ * @param {object} [options]
44
+ * @param {string} [options.field] - Only scan this field; pass others through (default: scan all files).
45
+ * @param {Function} [options.inner] - Inner UploadHandler for clean files. Defaults to storing as File.
46
+ * @param {string} [options.host] - clamd hostname.
47
+ * @param {number} [options.port=3310] - clamd port.
48
+ * @param {string} [options.socket] - UNIX socket path.
49
+ * @param {number} [options.timeout=15000] - Socket idle timeout in ms.
50
+ * @param {number} [options.retries=0] - Retry attempts.
51
+ * @param {number} [options.retryDelay=1000] - Delay between retries in ms.
52
+ * @param {Function} [options.onInfected] - Called with ({ name, filename }) when malware is detected.
53
+ * Must throw or return a Response. Default throws HTTP 422.
54
+ */
55
+ function pompelmiUploadHandler(options) {
56
+ const { field, inner, onInfected } = options || {};
57
+ const scanOptions = buildScanOptions(options || {});
58
+
59
+ return async function remixUploadHandler({ name, filename, contentType, data }) {
60
+ // Skip non-file fields (no filename) or fields not targeted
61
+ if (!filename || (field && name !== field)) {
62
+ if (typeof inner === 'function') {
63
+ return inner({ name, filename, contentType, data });
64
+ }
65
+ const buf = await collectStream(data);
66
+ // Text field (no filename) → return as string; file field → return as File
67
+ if (!filename) return buf.toString('utf8');
68
+ return new NodeFile([buf], filename, { type: contentType });
69
+ }
70
+
71
+ const buffer = await collectStream(data);
72
+
73
+ if (buffer.length > 0) {
74
+ const result = await scanBuffer(buffer, scanOptions);
75
+ if (result === Verdict.Malicious) {
76
+ if (typeof onInfected === 'function') {
77
+ return onInfected({ name, filename });
78
+ }
79
+ throw new Response(
80
+ JSON.stringify({ error: 'Malware detected', filename }),
81
+ { status: 422, headers: { 'Content-Type': 'application/json' } }
82
+ );
83
+ }
84
+ }
85
+
86
+ if (typeof inner === 'function') {
87
+ // Replay the buffer through the inner handler via an async generator
88
+ async function* replay() { yield buffer; }
89
+ return inner({ name, filename, contentType, data: replay() });
90
+ }
91
+
92
+ return new NodeFile([buffer], filename, { type: contentType });
93
+ };
94
+ }
95
+
96
+ module.exports = { pompelmiUploadHandler, Verdict };
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@pompelmi/remix",
3
+ "version": "1.0.0",
4
+ "description": "Remix upload handler for pompelmi — in-process ClamAV virus scanning with zero extra dependencies",
5
+ "license": "ISC",
6
+ "author": "pompelmi contributors",
7
+ "homepage": "https://pompelmi.app",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/pompelmi/pompelmi.git",
11
+ "directory": "packages/remix"
12
+ },
13
+ "keywords": [
14
+ "remix",
15
+ "remix-run",
16
+ "upload-handler",
17
+ "clamav",
18
+ "antivirus",
19
+ "virus-scan",
20
+ "malware",
21
+ "file-upload",
22
+ "security",
23
+ "pompelmi",
24
+ "fullstack"
25
+ ],
26
+ "main": "./index.js",
27
+ "types": "./index.d.ts",
28
+ "scripts": {
29
+ "test": "node --test test/index.test.js"
30
+ },
31
+ "peerDependencies": {
32
+ "pompelmi": ">=1.15.0"
33
+ },
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org/",
36
+ "access": "public"
37
+ }
38
+ }
@@ -0,0 +1,138 @@
1
+ # @pompelmi/sveltekit
2
+
3
+ SvelteKit helper for [pompelmi](https://pompelmi.app) — in-process ClamAV virus scanning with zero extra dependencies.
4
+
5
+ Works with both **+page.server.ts actions** and **+server.ts API routes** on Node.js.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @pompelmi/sveltekit pompelmi
11
+ ```
12
+
13
+ > **Requires** the [`@sveltejs/adapter-node`](https://kit.svelte.dev/docs/adapter-node) adapter. ClamAV is a Node.js process and cannot run on edge runtimes.
14
+
15
+ ## Quick Start
16
+
17
+ ### Form action (+page.server.ts)
18
+
19
+ ```ts
20
+ import { scanUpload } from '@pompelmi/sveltekit'
21
+ import type { Actions } from './$types'
22
+
23
+ export const actions: Actions = {
24
+ default: async ({ request }) => {
25
+ const formData = await request.formData()
26
+ const file = formData.get('file') as File
27
+
28
+ // Throws HTTP 422 Response automatically if malware is detected
29
+ await scanUpload(file, { host: 'localhost', port: 3310 })
30
+
31
+ // File is guaranteed clean here — save to storage, etc.
32
+ return { success: true, name: file.name }
33
+ }
34
+ }
35
+ ```
36
+
37
+ If a malicious file is uploaded, `scanUpload` throws a `Response` with HTTP **422** and a JSON body `{ error: 'Malware detected', filename }`. SvelteKit's error boundary catches it automatically.
38
+
39
+ ### API route (+server.ts)
40
+
41
+ ```ts
42
+ import { scanUpload } from '@pompelmi/sveltekit'
43
+ import { json } from '@sveltejs/kit'
44
+ import type { RequestHandler } from './$types'
45
+
46
+ export const POST: RequestHandler = async ({ request }) => {
47
+ const formData = await request.formData()
48
+ const file = formData.get('file') as File
49
+
50
+ await scanUpload(file, { host: 'localhost', port: 3310 })
51
+
52
+ return json({ ok: true, name: file.name, size: file.size })
53
+ }
54
+ ```
55
+
56
+ ## Scan all files in FormData
57
+
58
+ Use `scanFormData` to scan every file field at once. Throws on the first malicious file:
59
+
60
+ ```ts
61
+ import { scanFormData } from '@pompelmi/sveltekit'
62
+
63
+ export const actions: Actions = {
64
+ default: async ({ request }) => {
65
+ const formData = await request.formData()
66
+
67
+ // Scans all File/Blob values; throws 422 if any is malicious
68
+ await scanFormData(formData, { host: 'localhost', port: 3310 })
69
+
70
+ return { success: true }
71
+ }
72
+ }
73
+ ```
74
+
75
+ ## Custom error handling
76
+
77
+ ```ts
78
+ import { scanUpload } from '@pompelmi/sveltekit'
79
+ import { fail } from '@sveltejs/kit'
80
+
81
+ export const actions: Actions = {
82
+ default: async ({ request }) => {
83
+ const formData = await request.formData()
84
+ const file = formData.get('file') as File
85
+
86
+ await scanUpload(file, {
87
+ host: 'localhost',
88
+ port: 3310,
89
+ onInfected: ({ filename }) => {
90
+ console.warn(`Blocked malicious upload: ${filename}`)
91
+ // Use SvelteKit's fail() to return a form error
92
+ throw fail(422, { error: `${filename} contains malware` })
93
+ },
94
+ })
95
+
96
+ return { success: true }
97
+ }
98
+ }
99
+ ```
100
+
101
+ ## Route component example (full)
102
+
103
+ ```svelte
104
+ <!-- src/routes/upload/+page.svelte -->
105
+ <script lang="ts">
106
+ import { enhance } from '$app/forms'
107
+ import type { ActionData } from './$types'
108
+
109
+ export let form: ActionData
110
+ </script>
111
+
112
+ <form method="POST" enctype="multipart/form-data" use:enhance>
113
+ <input type="file" name="file" required />
114
+ <button type="submit">Upload & Scan</button>
115
+
116
+ {#if form?.error}
117
+ <p style="color: red">{form.error}</p>
118
+ {:else if form?.success}
119
+ <p>Uploaded: {form.name}</p>
120
+ {/if}
121
+ </form>
122
+ ```
123
+
124
+ ## Configuration Reference
125
+
126
+ | Option | Type | Default | Description |
127
+ |--------|------|---------|-------------|
128
+ | `host` | `string` | — | clamd hostname (enables TCP mode) |
129
+ | `port` | `number` | `3310` | clamd port |
130
+ | `socket` | `string` | — | UNIX domain socket path |
131
+ | `timeout` | `number` | `15000` | Socket idle timeout in ms |
132
+ | `retries` | `number` | `0` | Retry attempts |
133
+ | `retryDelay` | `number` | `1000` | Delay between retries in ms |
134
+ | `onInfected` | `Function` | — | Called with `{ filename }` when malware detected; must throw |
135
+
136
+ ## License
137
+
138
+ ISC — see root [LICENSE](../../LICENSE).
@@ -0,0 +1,59 @@
1
+ import type { ScanOptions } from 'pompelmi';
2
+
3
+ export interface PompelmiSvelteKitOptions extends ScanOptions {
4
+ /**
5
+ * Called with `{ filename }` when a malicious file is detected.
6
+ * Must throw (e.g. throw error(422, { message: 'Malware detected' })).
7
+ * If omitted, throws an HTTP 422 Response automatically.
8
+ */
9
+ onInfected?: (args: { filename: string }) => never | Promise<never>;
10
+ }
11
+
12
+ /**
13
+ * Scans an uploaded file with pompelmi inside a SvelteKit server action or
14
+ * API route (+page.server.ts / +server.ts). Throws HTTP 422 on malicious files.
15
+ *
16
+ * @example
17
+ * // +page.server.ts
18
+ * import { scanUpload } from '@pompelmi/sveltekit'
19
+ * import type { Actions } from './$types'
20
+ *
21
+ * export const actions: Actions = {
22
+ * default: async ({ request }) => {
23
+ * const formData = await request.formData()
24
+ * await scanUpload(formData.get('file') as File, { host: 'localhost', port: 3310 })
25
+ * return { success: true }
26
+ * }
27
+ * }
28
+ *
29
+ * @example
30
+ * // +server.ts (API route)
31
+ * import { scanUpload } from '@pompelmi/sveltekit'
32
+ * import { json } from '@sveltejs/kit'
33
+ * import type { RequestHandler } from './$types'
34
+ *
35
+ * export const POST: RequestHandler = async ({ request }) => {
36
+ * const formData = await request.formData()
37
+ * await scanUpload(formData.get('file') as File, { host: 'localhost', port: 3310 })
38
+ * return json({ ok: true })
39
+ * }
40
+ */
41
+ export function scanUpload(
42
+ file: File | Blob | Buffer | null | undefined,
43
+ options?: PompelmiSvelteKitOptions
44
+ ): Promise<symbol>;
45
+
46
+ /**
47
+ * Scans all File/Blob values in a FormData object.
48
+ * Throws HTTP 422 on the first malicious file found.
49
+ *
50
+ * @example
51
+ * const formData = await request.formData()
52
+ * await scanFormData(formData, { host: 'localhost', port: 3310 })
53
+ */
54
+ export function scanFormData(
55
+ formData: FormData,
56
+ options?: PompelmiSvelteKitOptions
57
+ ): Promise<void>;
58
+
59
+ export { Verdict } from 'pompelmi';
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ const { scanBuffer, Verdict } = require('pompelmi');
4
+
5
+ // File is a global in Node 20+; on Node 18 it lives in node:buffer
6
+ const NodeFile = globalThis.File ?? require('node:buffer').File;
7
+
8
+ const SCAN_KEYS = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
9
+
10
+ function buildScanOptions(options) {
11
+ const out = {};
12
+ for (const k of SCAN_KEYS) {
13
+ if (options[k] !== undefined) out[k] = options[k];
14
+ }
15
+ return out;
16
+ }
17
+
18
+ async function toBuffer(file) {
19
+ if (Buffer.isBuffer(file)) return file;
20
+ if (file instanceof Uint8Array) return Buffer.from(file);
21
+ // Web API File / Blob
22
+ if (file && typeof file.arrayBuffer === 'function') {
23
+ return Buffer.from(await file.arrayBuffer());
24
+ }
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * Scans an uploaded file with pompelmi inside a SvelteKit server action or
30
+ * API route. Throws an HTTP 422 Response automatically on malicious files.
31
+ *
32
+ * Use in a +page.server.ts action or +server.ts route:
33
+ *
34
+ * import { scanUpload } from '@pompelmi/sveltekit'
35
+ *
36
+ * export const actions = {
37
+ * default: async ({ request }) => {
38
+ * const formData = await request.formData()
39
+ * await scanUpload(formData.get('file'), { host: 'localhost', port: 3310 })
40
+ * // file is guaranteed clean here
41
+ * return { success: true }
42
+ * }
43
+ * }
44
+ *
45
+ * @param {File|Blob|Buffer} file - The uploaded file to scan.
46
+ * @param {object} [options]
47
+ * @param {string} [options.host] - clamd hostname (enables TCP mode).
48
+ * @param {number} [options.port=3310] - clamd port.
49
+ * @param {string} [options.socket] - UNIX domain socket path.
50
+ * @param {number} [options.timeout] - Socket idle timeout in ms.
51
+ * @param {number} [options.retries] - Retry attempts.
52
+ * @param {number} [options.retryDelay] - Delay between retries in ms.
53
+ * @param {Function} [options.onInfected] - Called with ({ filename }) when malware
54
+ * is detected. Must throw. Defaults to
55
+ * throwing HTTP 422 Response.
56
+ * @returns {Promise<symbol>} Verdict.Clean or Verdict.ScanError (never returns Verdict.Malicious).
57
+ */
58
+ async function scanUpload(file, options) {
59
+ if (!file) return Verdict.Clean;
60
+
61
+ const scanOptions = buildScanOptions(options || {});
62
+ const buffer = await toBuffer(file);
63
+
64
+ if (!buffer || buffer.length === 0) return Verdict.Clean;
65
+
66
+ const result = await scanBuffer(buffer, scanOptions);
67
+
68
+ if (result === Verdict.Malicious) {
69
+ const filename = (file && typeof file.name === 'string') ? file.name : 'upload';
70
+ const onInfected = options && options.onInfected;
71
+ if (typeof onInfected === 'function') {
72
+ return onInfected({ filename });
73
+ }
74
+ throw new Response(
75
+ JSON.stringify({ error: 'Malware detected', filename }),
76
+ { status: 422, headers: { 'Content-Type': 'application/json' } }
77
+ );
78
+ }
79
+
80
+ return result;
81
+ }
82
+
83
+ /**
84
+ * Scans all File/Blob values in a FormData object. Throws on the first
85
+ * malicious file found.
86
+ *
87
+ * @param {FormData} formData - The parsed form data.
88
+ * @param {object} [options] - Same options as scanUpload.
89
+ * @returns {Promise<void>}
90
+ */
91
+ async function scanFormData(formData, options) {
92
+ if (!formData || typeof formData.entries !== 'function') return;
93
+ for (const [, value] of formData.entries()) {
94
+ if (value instanceof NodeFile || (value && typeof value.arrayBuffer === 'function')) {
95
+ await scanUpload(value, options);
96
+ }
97
+ }
98
+ }
99
+
100
+ module.exports = { scanUpload, scanFormData, Verdict };
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@pompelmi/sveltekit",
3
+ "version": "1.0.0",
4
+ "description": "SvelteKit helper for pompelmi — in-process ClamAV virus scanning with zero extra dependencies",
5
+ "license": "ISC",
6
+ "author": "pompelmi contributors",
7
+ "homepage": "https://pompelmi.app",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/pompelmi/pompelmi.git",
11
+ "directory": "packages/sveltekit"
12
+ },
13
+ "keywords": [
14
+ "sveltekit",
15
+ "svelte",
16
+ "server-action",
17
+ "api-route",
18
+ "clamav",
19
+ "antivirus",
20
+ "virus-scan",
21
+ "malware",
22
+ "file-upload",
23
+ "security",
24
+ "pompelmi"
25
+ ],
26
+ "main": "./index.js",
27
+ "types": "./index.d.ts",
28
+ "scripts": {
29
+ "test": "node --test test/index.test.js"
30
+ },
31
+ "peerDependencies": {
32
+ "pompelmi": ">=1.15.0"
33
+ },
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org/",
36
+ "access": "public"
37
+ }
38
+ }