@jini-ai/http-kit 0.2.1
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/LICENSE +201 -0
- package/README.md +107 -0
- package/dist/active-context.d.ts +99 -0
- package/dist/active-context.d.ts.map +1 -0
- package/dist/active-context.js +77 -0
- package/dist/active-context.js.map +1 -0
- package/dist/adapter.d.ts +36 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +92 -0
- package/dist/adapter.js.map +1 -0
- package/dist/agents.d.ts +55 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +23 -0
- package/dist/agents.js.map +1 -0
- package/dist/api-security-middleware.d.ts +146 -0
- package/dist/api-security-middleware.d.ts.map +1 -0
- package/dist/api-security-middleware.js +276 -0
- package/dist/api-security-middleware.js.map +1 -0
- package/dist/attachments.d.ts +317 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +664 -0
- package/dist/attachments.js.map +1 -0
- package/dist/cancel-owned-runs.d.ts +44 -0
- package/dist/cancel-owned-runs.d.ts.map +1 -0
- package/dist/cancel-owned-runs.js +35 -0
- package/dist/cancel-owned-runs.js.map +1 -0
- package/dist/compat.d.ts +22 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +17 -0
- package/dist/compat.js.map +1 -0
- package/dist/component-catalog.d.ts +74 -0
- package/dist/component-catalog.d.ts.map +1 -0
- package/dist/component-catalog.js +74 -0
- package/dist/component-catalog.js.map +1 -0
- package/dist/connectors.d.ts +165 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +474 -0
- package/dist/connectors.js.map +1 -0
- package/dist/daemon-status.d.ts +72 -0
- package/dist/daemon-status.d.ts.map +1 -0
- package/dist/daemon-status.js +60 -0
- package/dist/daemon-status.js.map +1 -0
- package/dist/db-ops.d.ts +116 -0
- package/dist/db-ops.d.ts.map +1 -0
- package/dist/db-ops.js +191 -0
- package/dist/db-ops.js.map +1 -0
- package/dist/delegated-tools.d.ts +46 -0
- package/dist/delegated-tools.d.ts.map +1 -0
- package/dist/delegated-tools.js +155 -0
- package/dist/delegated-tools.js.map +1 -0
- package/dist/express/run-stream.d.ts +15 -0
- package/dist/express/run-stream.d.ts.map +1 -0
- package/dist/express/run-stream.js +11 -0
- package/dist/express/run-stream.js.map +1 -0
- package/dist/express-index.d.ts +32 -0
- package/dist/express-index.d.ts.map +1 -0
- package/dist/express-index.js +14 -0
- package/dist/express-index.js.map +1 -0
- package/dist/fastify/adapter.d.ts +31 -0
- package/dist/fastify/adapter.d.ts.map +1 -0
- package/dist/fastify/adapter.js +64 -0
- package/dist/fastify/adapter.js.map +1 -0
- package/dist/fastify/agents.d.ts +13 -0
- package/dist/fastify/agents.d.ts.map +1 -0
- package/dist/fastify/agents.js +7 -0
- package/dist/fastify/agents.js.map +1 -0
- package/dist/fastify/api-security-middleware.d.ts +64 -0
- package/dist/fastify/api-security-middleware.d.ts.map +1 -0
- package/dist/fastify/api-security-middleware.js +139 -0
- package/dist/fastify/api-security-middleware.js.map +1 -0
- package/dist/fastify/compat.d.ts +22 -0
- package/dist/fastify/compat.d.ts.map +1 -0
- package/dist/fastify/compat.js +16 -0
- package/dist/fastify/compat.js.map +1 -0
- package/dist/fastify/daemon-status.d.ts +22 -0
- package/dist/fastify/daemon-status.d.ts.map +1 -0
- package/dist/fastify/daemon-status.js +9 -0
- package/dist/fastify/daemon-status.js.map +1 -0
- package/dist/fastify/host-tools.d.ts +13 -0
- package/dist/fastify/host-tools.d.ts.map +1 -0
- package/dist/fastify/host-tools.js +8 -0
- package/dist/fastify/host-tools.js.map +1 -0
- package/dist/fastify/index.d.ts +36 -0
- package/dist/fastify/index.d.ts.map +1 -0
- package/dist/fastify/index.js +18 -0
- package/dist/fastify/index.js.map +1 -0
- package/dist/fastify/local-daemon-request.d.ts +43 -0
- package/dist/fastify/local-daemon-request.d.ts.map +1 -0
- package/dist/fastify/local-daemon-request.js +155 -0
- package/dist/fastify/local-daemon-request.js.map +1 -0
- package/dist/fastify/origin.d.ts +21 -0
- package/dist/fastify/origin.d.ts.map +1 -0
- package/dist/fastify/origin.js +14 -0
- package/dist/fastify/origin.js.map +1 -0
- package/dist/fastify/request.d.ts +20 -0
- package/dist/fastify/request.d.ts.map +1 -0
- package/dist/fastify/request.js +25 -0
- package/dist/fastify/request.js.map +1 -0
- package/dist/fastify/response.d.ts +20 -0
- package/dist/fastify/response.d.ts.map +1 -0
- package/dist/fastify/response.js +41 -0
- package/dist/fastify/response.js.map +1 -0
- package/dist/fastify/route-registration-guard.d.ts +70 -0
- package/dist/fastify/route-registration-guard.d.ts.map +1 -0
- package/dist/fastify/route-registration-guard.js +69 -0
- package/dist/fastify/route-registration-guard.js.map +1 -0
- package/dist/fastify/run-stream.d.ts +18 -0
- package/dist/fastify/run-stream.d.ts.map +1 -0
- package/dist/fastify/run-stream.js +10 -0
- package/dist/fastify/run-stream.js.map +1 -0
- package/dist/fastify/runs.d.ts +17 -0
- package/dist/fastify/runs.d.ts.map +1 -0
- package/dist/fastify/runs.js +33 -0
- package/dist/fastify/runs.js.map +1 -0
- package/dist/frontend-control.d.ts +131 -0
- package/dist/frontend-control.d.ts.map +1 -0
- package/dist/frontend-control.js +77 -0
- package/dist/frontend-control.js.map +1 -0
- package/dist/frontend-sessions.d.ts +100 -0
- package/dist/frontend-sessions.d.ts.map +1 -0
- package/dist/frontend-sessions.js +180 -0
- package/dist/frontend-sessions.js.map +1 -0
- package/dist/health.d.ts +91 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +53 -0
- package/dist/health.js.map +1 -0
- package/dist/host-tools.d.ts +174 -0
- package/dist/host-tools.d.ts.map +1 -0
- package/dist/host-tools.js +338 -0
- package/dist/host-tools.js.map +1 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/local-daemon-request.d.ts +44 -0
- package/dist/local-daemon-request.d.ts.map +1 -0
- package/dist/local-daemon-request.js +145 -0
- package/dist/local-daemon-request.js.map +1 -0
- package/dist/media.d.ts +131 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +352 -0
- package/dist/media.js.map +1 -0
- package/dist/memory.d.ts +230 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +331 -0
- package/dist/memory.js.map +1 -0
- package/dist/model-proxy.d.ts +45 -0
- package/dist/model-proxy.d.ts.map +1 -0
- package/dist/model-proxy.js +619 -0
- package/dist/model-proxy.js.map +1 -0
- package/dist/origin-validation.d.ts +64 -0
- package/dist/origin-validation.d.ts.map +1 -0
- package/dist/origin-validation.js +226 -0
- package/dist/origin-validation.js.map +1 -0
- package/dist/origin.d.ts +32 -0
- package/dist/origin.d.ts.map +1 -0
- package/dist/origin.js +14 -0
- package/dist/origin.js.map +1 -0
- package/dist/pack-http.d.ts +30 -0
- package/dist/pack-http.d.ts.map +1 -0
- package/dist/pack-http.js +12 -0
- package/dist/pack-http.js.map +1 -0
- package/dist/raw-sse.d.ts +44 -0
- package/dist/raw-sse.d.ts.map +1 -0
- package/dist/raw-sse.js +93 -0
- package/dist/raw-sse.js.map +1 -0
- package/dist/remote-run-events.d.ts +66 -0
- package/dist/remote-run-events.d.ts.map +1 -0
- package/dist/remote-run-events.js +230 -0
- package/dist/remote-run-events.js.map +1 -0
- package/dist/request.d.ts +19 -0
- package/dist/request.d.ts.map +1 -0
- package/dist/request.js +25 -0
- package/dist/request.js.map +1 -0
- package/dist/research.d.ts +42 -0
- package/dist/research.d.ts.map +1 -0
- package/dist/research.js +316 -0
- package/dist/research.js.map +1 -0
- package/dist/response.d.ts +14 -0
- package/dist/response.d.ts.map +1 -0
- package/dist/response.js +45 -0
- package/dist/response.js.map +1 -0
- package/dist/route-manifest.d.ts +31 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +103 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/route-registration-guard.d.ts +65 -0
- package/dist/route-registration-guard.d.ts.map +1 -0
- package/dist/route-registration-guard.js +71 -0
- package/dist/route-registration-guard.js.map +1 -0
- package/dist/routines.d.ts +114 -0
- package/dist/routines.d.ts.map +1 -0
- package/dist/routines.js +320 -0
- package/dist/routines.js.map +1 -0
- package/dist/run-stream.d.ts +60 -0
- package/dist/run-stream.d.ts.map +1 -0
- package/dist/run-stream.js +108 -0
- package/dist/run-stream.js.map +1 -0
- package/dist/runs.d.ts +79 -0
- package/dist/runs.d.ts.map +1 -0
- package/dist/runs.js +232 -0
- package/dist/runs.js.map +1 -0
- package/dist/sse.d.ts +139 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +157 -0
- package/dist/sse.js.map +1 -0
- package/dist/terminals.d.ts +125 -0
- package/dist/terminals.d.ts.map +1 -0
- package/dist/terminals.js +349 -0
- package/dist/terminals.js.map +1 -0
- package/dist/tool-catalog.d.ts +68 -0
- package/dist/tool-catalog.d.ts.map +1 -0
- package/dist/tool-catalog.js +68 -0
- package/dist/tool-catalog.js.map +1 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace-root.d.ts +69 -0
- package/dist/workspace-root.d.ts.map +1 -0
- package/dist/workspace-root.js +63 -0
- package/dist/workspace-root.js.map +1 -0
- package/dist/xai.d.ts +86 -0
- package/dist/xai.d.ts.map +1 -0
- package/dist/xai.js +668 -0
- package/dist/xai.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module attachments
|
|
3
|
+
*
|
|
4
|
+
* `POST /api/attachments`, `DELETE /api/attachments` — an HTTP route pack for user file/image
|
|
5
|
+
* uploads staged for one agent run, over a narrow, transport-owned `AttachmentStore` port plus a
|
|
6
|
+
* batteries-included disk implementation (`createDiskAttachmentStore`).
|
|
7
|
+
*
|
|
8
|
+
* **The capability this closes.** A chat composer that accepts drag-and-drop needs somewhere to put
|
|
9
|
+
* the bytes, a way to hand the agent a path it is actually allowed to read, and a guarantee that
|
|
10
|
+
* those bytes disappear when the run ends. That is three separate concerns (an upload endpoint, a
|
|
11
|
+
* trusted-metadata registry, and a run-scoped lifetime) which every host would otherwise hand-roll.
|
|
12
|
+
* This module owns all three; `@jini-ai/chat-react`'s `createDaemonAttachmentUploader` is the
|
|
13
|
+
* matching client half.
|
|
14
|
+
*
|
|
15
|
+
* **Trust model.** A renderer supplies bytes, a filename, and a batch id. None of that is trusted:
|
|
16
|
+
* - the filename is reduced to a basename over a conservative character allowlist
|
|
17
|
+
* (`sanitizeAttachmentName`) and is only ever used as *display* text plus a short extension
|
|
18
|
+
* suffix — never as the stored filename, which is a fresh `randomUUID()`;
|
|
19
|
+
* - the `kind` (`'image' | 'file'`) is sniffed from the leading bytes (`detectAttachmentKind`),
|
|
20
|
+
* never taken from a renderer-controlled MIME type or file extension;
|
|
21
|
+
* - the byte count is measured while streaming, never read from a client-supplied length;
|
|
22
|
+
* - the returned `path` is an opaque `attachment:<uuid>` capability id, not a filesystem path, so a
|
|
23
|
+
* renderer never learns where the upload root is and cannot name a file it did not upload.
|
|
24
|
+
* `claim()` is what exchanges those ids for real paths, server-side.
|
|
25
|
+
*
|
|
26
|
+
* **What `claim()` guarantees.** Exactly once per registered attachment, and only if the file is
|
|
27
|
+
* still the same file: `lstat` must report a regular non-symlink whose `dev`/`ino`/`size` match
|
|
28
|
+
* what was recorded at registration, and `realpath` must equal the recorded path (so neither the
|
|
29
|
+
* file nor any parent directory was swapped for a symlink between upload and run start). Every
|
|
30
|
+
* claimed attachment must belong to one batch, so the single `batchDirectory` a host grants the
|
|
31
|
+
* agent read access to cannot be widened by mixing batches.
|
|
32
|
+
*
|
|
33
|
+
* **Storage lifetime is daemon-lifetime, not persistent.** `createDiskAttachmentStore` empties its
|
|
34
|
+
* upload directory on construction: files left behind by an interrupted previous process cannot be
|
|
35
|
+
* authenticated against an in-memory registry that no longer exists, so they are removed rather
|
|
36
|
+
* than adopted. Unclaimed uploads also expire by TTL (`pruneExpired`), and a run's claimed files are
|
|
37
|
+
* deleted by `cleanupRun`.
|
|
38
|
+
*
|
|
39
|
+
* **This pack does not auto-wire itself into a run's lifecycle**, because no generic hook for that
|
|
40
|
+
* exists — the same deliberate choice `@jini-ai/daemon`'s `createRunScopedContextStore` makes. A
|
|
41
|
+
* host claims in its own `onRunStarted` and cleans up in a `finally`, roughly:
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* const store = await createDiskAttachmentStore({ uploadDirectory });
|
|
45
|
+
* // ... httpExtensions: [(app, { adapter }) => registerAttachmentRoutes(app, { store }, adapter)]
|
|
46
|
+
* onRunStarted: (context) => {
|
|
47
|
+
* void (async () => {
|
|
48
|
+
* try {
|
|
49
|
+
* const claimed = await store.claim(attachmentRefsFrom(context.request), context.run.id);
|
|
50
|
+
* await executor.run({
|
|
51
|
+
* runId: context.run.id,
|
|
52
|
+
* // ... prompt, cwd, agentId
|
|
53
|
+
* ...(claimed.batchDirectory === undefined ? {} : {
|
|
54
|
+
* imagePaths: claimed.attachments.filter((a) => a.kind === 'image').map((a) => a.path),
|
|
55
|
+
* extraAllowedDirs: [claimed.batchDirectory],
|
|
56
|
+
* uploadRoot: claimed.batchDirectory,
|
|
57
|
+
* }),
|
|
58
|
+
* });
|
|
59
|
+
* } finally {
|
|
60
|
+
* await store.cleanupRun(context.run.id);
|
|
61
|
+
* }
|
|
62
|
+
* })();
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* `imagePaths`/`extraAllowedDirs`/`uploadRoot` are pre-existing `AgentExecutor.run()` options; this
|
|
67
|
+
* module only produces real values for them.
|
|
68
|
+
*
|
|
69
|
+
* **Body-parser ordering (`POST` reads the raw request stream).** The upload route streams
|
|
70
|
+
* `request` straight to disk, so any body-parsing middleware that has already consumed the stream
|
|
71
|
+
* leaves nothing to write. The classic way to hit this is a JSON body parser mounted app-wide: a
|
|
72
|
+
* user drops a `.json` file, the browser sets `content-type: application/json`, and
|
|
73
|
+
* `express.json()` eats the body — which without a guard shows up as the deeply unhelpful
|
|
74
|
+
* "attachment is empty". `registerAttachmentRoutes` detects an already-consumed stream up front and
|
|
75
|
+
* reports it as a host misconfiguration (`'attachment-body-consumed'`) instead of failing silently.
|
|
76
|
+
* Mount this pack before any global body parser, or scope the parser so it skips this path.
|
|
77
|
+
*
|
|
78
|
+
* **SEC-005 redaction**: a store failure can carry filesystem paths a caller must not see, so
|
|
79
|
+
* anything that is not an explicitly-classified `AttachmentRejectedError` becomes a
|
|
80
|
+
* correlation-id-bearing generic `INTERNAL_ERROR`, with the real error reaching a host-owned sink —
|
|
81
|
+
* the same `reportInternalError` shape `media.ts`/`delegated-tools.ts` use.
|
|
82
|
+
*/
|
|
83
|
+
import { chmod, lstat, mkdir, open, readdir, realpath, rm, rmdir } from 'node:fs/promises';
|
|
84
|
+
import { randomUUID } from 'node:crypto';
|
|
85
|
+
import { basename, dirname, extname, resolve } from 'node:path';
|
|
86
|
+
import { createApiError } from '@jini-ai/protocol';
|
|
87
|
+
import { guardSameOrigin } from './origin.js';
|
|
88
|
+
import { sendApiError, sendJson } from './response.js';
|
|
89
|
+
/**
|
|
90
|
+
* A refusal this module classified itself, as opposed to an unexpected filesystem/programming
|
|
91
|
+
* error. The route pack turns the `reason` into a status code and lets the `message` through to the
|
|
92
|
+
* caller; anything that is *not* one of these is redacted to a generic `INTERNAL_ERROR`.
|
|
93
|
+
*/
|
|
94
|
+
export class AttachmentRejectedError extends Error {
|
|
95
|
+
reason;
|
|
96
|
+
constructor(reason, message) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.name = 'AttachmentRejectedError';
|
|
99
|
+
this.reason = reason;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** The subset of `AttachmentRejectionReason` a caller is allowed to see a real message for. */
|
|
103
|
+
const REJECTION_STATUS = {
|
|
104
|
+
'invalid-batch': 400,
|
|
105
|
+
'attachment-too-large': 413,
|
|
106
|
+
'batch-count-exceeded': 413,
|
|
107
|
+
'batch-too-large': 413,
|
|
108
|
+
'storage-full': 413,
|
|
109
|
+
'empty-attachment': 400,
|
|
110
|
+
'too-many-concurrent-uploads': 429,
|
|
111
|
+
'invalid-cleanup-request': 400,
|
|
112
|
+
// The remaining reasons describe a broken or hostile server-side state. They are never given a
|
|
113
|
+
// real message over HTTP (see `respondToUploadFailure`); the entries exist so a host catching a
|
|
114
|
+
// rejection from `claim()` outside HTTP can still classify it.
|
|
115
|
+
'attachment-body-consumed': 500,
|
|
116
|
+
'attachment-integrity': 500,
|
|
117
|
+
'too-many-attachments': 400,
|
|
118
|
+
'duplicate-attachment': 400,
|
|
119
|
+
'attachment-unknown-or-claimed': 400,
|
|
120
|
+
'mixed-batch': 400,
|
|
121
|
+
};
|
|
122
|
+
/** `ApiError` code per refusal, so `sendApiError` produces the standard envelope. */
|
|
123
|
+
function apiErrorForRejection(error) {
|
|
124
|
+
const status = REJECTION_STATUS[error.reason];
|
|
125
|
+
if (status === 413)
|
|
126
|
+
return createApiError('PAYLOAD_TOO_LARGE', error.message);
|
|
127
|
+
if (status === 429)
|
|
128
|
+
return createApiError('RATE_LIMITED', error.message);
|
|
129
|
+
return createApiError('BAD_REQUEST', error.message);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Accepted batch id shape. Deliberately narrow — long enough for a UUID, and admitting no `.`,
|
|
133
|
+
* `/`, or `\`, which is what makes `resolve(uploadRoot, batchId)` provably a single
|
|
134
|
+
* non-traversing path segment.
|
|
135
|
+
*/
|
|
136
|
+
const BATCH_ID_PATTERN = /^[a-zA-Z0-9-]{8,80}$/u;
|
|
137
|
+
/** Bytes of leading signature `detectAttachmentKind` needs (WEBP's marker ends at byte 12). */
|
|
138
|
+
const SIGNATURE_BYTES = 12;
|
|
139
|
+
/**
|
|
140
|
+
* `true` when the file on disk is still the same file registration accepted.
|
|
141
|
+
*
|
|
142
|
+
* Every condition is a distinct real attack: a regular file swapped for a symlink or directory, a
|
|
143
|
+
* parent directory swapped for a symlink pointing elsewhere, the file replaced by a different file
|
|
144
|
+
* at the same path (new inode), the same inode truncated or appended to (new size), or the path
|
|
145
|
+
* now resolving onto a different device.
|
|
146
|
+
*
|
|
147
|
+
* Pure and exported on purpose. Staging a *device* change for a file that keeps its path is not
|
|
148
|
+
* something a test can do on a real filesystem, and a check that can only be exercised in
|
|
149
|
+
* production is a check nobody knows works — a fake identity states each case directly.
|
|
150
|
+
*/
|
|
151
|
+
export function isUnchangedAttachment(recorded, observed) {
|
|
152
|
+
return observed.isRegularFile
|
|
153
|
+
&& observed.canonicalPath === recorded.filePath
|
|
154
|
+
&& observed.dev === recorded.dev
|
|
155
|
+
&& observed.ino === recorded.ino
|
|
156
|
+
&& observed.size === recorded.size;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Phase 1 of `claim()`: synchronously reserves `attachments` for `runId`, or throws (releasing
|
|
160
|
+
* whatever it already reserved this call) the first time a requested attachment turns out to be
|
|
161
|
+
* unknown or already claimed.
|
|
162
|
+
*
|
|
163
|
+
* The reservation loop deliberately has no `await`: this store's exactly-once guarantee is what
|
|
164
|
+
* stops two runs being handed the same real path on disk, and `claim` is reachable concurrently
|
|
165
|
+
* (two run starts, one shared attachment). Nothing between `records.get` and the assignment of
|
|
166
|
+
* `record.claimedRunId` may ever become asynchronous — that window is exactly where a concurrent
|
|
167
|
+
* call would get its turn and could observe the same record as still unclaimed.
|
|
168
|
+
*
|
|
169
|
+
* Exported so this invariant can be exercised directly against a plain `Map` of fabricated
|
|
170
|
+
* records, without going through a disk-backed store.
|
|
171
|
+
*/
|
|
172
|
+
export function reserveAttachmentRecords(attachments, records, runId, maxAttachments) {
|
|
173
|
+
if (attachments.length > maxAttachments) {
|
|
174
|
+
throw new AttachmentRejectedError('too-many-attachments', 'Too many attachments');
|
|
175
|
+
}
|
|
176
|
+
const requestedPaths = new Set(attachments.map((attachment) => attachment.path));
|
|
177
|
+
if (requestedPaths.size !== attachments.length) {
|
|
178
|
+
throw new AttachmentRejectedError('duplicate-attachment', 'Duplicate attachment');
|
|
179
|
+
}
|
|
180
|
+
const claimed = [];
|
|
181
|
+
for (const requested of attachments) {
|
|
182
|
+
const record = records.get(requested.path);
|
|
183
|
+
if (!record || record.claimedRunId !== undefined) {
|
|
184
|
+
for (const reserved of claimed)
|
|
185
|
+
delete reserved.claimedRunId;
|
|
186
|
+
throw new AttachmentRejectedError('attachment-unknown-or-claimed', 'Attachment is unknown or already claimed');
|
|
187
|
+
}
|
|
188
|
+
record.claimedRunId = runId;
|
|
189
|
+
claimed.push(record);
|
|
190
|
+
}
|
|
191
|
+
return claimed;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Phase 2 of `claim()`: re-verifies every already-reserved record against the filesystem — not
|
|
195
|
+
* merely re-read, but re-checked against what registration recorded, so someone able to write into
|
|
196
|
+
* the batch directory between upload and run start cannot get the agent to read a file of their
|
|
197
|
+
* choosing — and checks every claimed attachment shares one batch. Returns the shared batch
|
|
198
|
+
* directory on success (or `''` for an empty `claimed`, which `claim()` never passes in — it
|
|
199
|
+
* returns before calling this — but which a direct caller can still treat as "no batch").
|
|
200
|
+
*
|
|
201
|
+
* Throws without releasing `claimed`'s reservations; releasing is the caller's job (`claim`'s own
|
|
202
|
+
* `catch`), so a rejected claim still leaves nothing half-claimed and the caller can retry with a
|
|
203
|
+
* corrected set.
|
|
204
|
+
*
|
|
205
|
+
* Exported so the integrity and mixed-batch checks can be exercised directly against fabricated
|
|
206
|
+
* records over real files on disk, without a full `createDiskAttachmentStore` around them.
|
|
207
|
+
*/
|
|
208
|
+
export async function verifyClaimedAttachments(claimed) {
|
|
209
|
+
let batchDirectory = '';
|
|
210
|
+
for (const [index, record] of claimed.entries()) {
|
|
211
|
+
const info = await lstat(record.filePath);
|
|
212
|
+
const canonicalPath = await realpath(record.filePath);
|
|
213
|
+
if (!isUnchangedAttachment(record, {
|
|
214
|
+
isRegularFile: info.isFile(),
|
|
215
|
+
dev: info.dev,
|
|
216
|
+
ino: info.ino,
|
|
217
|
+
size: info.size,
|
|
218
|
+
canonicalPath,
|
|
219
|
+
})) {
|
|
220
|
+
throw new AttachmentRejectedError('attachment-integrity', 'Attachment changed after upload');
|
|
221
|
+
}
|
|
222
|
+
if (index > 0 && record.batchDirectory !== batchDirectory) {
|
|
223
|
+
throw new AttachmentRejectedError('mixed-batch', 'Attachments must belong to one batch');
|
|
224
|
+
}
|
|
225
|
+
batchDirectory = record.batchDirectory;
|
|
226
|
+
}
|
|
227
|
+
return batchDirectory;
|
|
228
|
+
}
|
|
229
|
+
/** Renders a byte cap the way a person would read it, for a message a user actually sees. */
|
|
230
|
+
function formatByteLimit(bytes) {
|
|
231
|
+
const megabytes = bytes / (1024 * 1024);
|
|
232
|
+
return Number.isInteger(megabytes) ? `${megabytes} MB` : `${bytes} bytes`;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Reduces an untrusted upload filename to a display-only basename over a conservative allowlist.
|
|
236
|
+
* Never used as the name of the stored file — see this module's trust-model doc.
|
|
237
|
+
*/
|
|
238
|
+
export function sanitizeAttachmentName(requestedName) {
|
|
239
|
+
if (typeof requestedName !== 'string')
|
|
240
|
+
return 'attachment';
|
|
241
|
+
return basename(requestedName).replaceAll(/[^a-zA-Z0-9._ -]/gu, '_') || 'attachment';
|
|
242
|
+
}
|
|
243
|
+
/** `true` when `body`'s first 8 bytes are the PNG signature. */
|
|
244
|
+
export function hasPngSignature(body) {
|
|
245
|
+
return body.length >= 8
|
|
246
|
+
&& body[0] === 0x89
|
|
247
|
+
&& body[1] === 0x50
|
|
248
|
+
&& body[2] === 0x4e
|
|
249
|
+
&& body[3] === 0x47;
|
|
250
|
+
}
|
|
251
|
+
/** `true` when `body`'s first 3 bytes are the JPEG start-of-image marker. */
|
|
252
|
+
export function hasJpegSignature(body) {
|
|
253
|
+
return body.length >= 3
|
|
254
|
+
&& body[0] === 0xff
|
|
255
|
+
&& body[1] === 0xd8
|
|
256
|
+
&& body[2] === 0xff;
|
|
257
|
+
}
|
|
258
|
+
/** `true` when `body`'s first 6 bytes spell either GIF version tag. */
|
|
259
|
+
export function hasGifSignature(body) {
|
|
260
|
+
const signature = new TextDecoder().decode(body.slice(0, 6));
|
|
261
|
+
return signature === 'GIF87a' || signature === 'GIF89a';
|
|
262
|
+
}
|
|
263
|
+
/** `true` when `body` opens with a RIFF container whose form type is WEBP. */
|
|
264
|
+
export function hasWebpSignature(body) {
|
|
265
|
+
return body.length >= 12
|
|
266
|
+
&& new TextDecoder().decode(body.slice(0, 4)) === 'RIFF'
|
|
267
|
+
&& new TextDecoder().decode(body.slice(8, 12)) === 'WEBP';
|
|
268
|
+
}
|
|
269
|
+
/** Every recognized image signature, checked in this order until one matches. */
|
|
270
|
+
const IMAGE_SIGNATURE_MATCHERS = [
|
|
271
|
+
hasPngSignature,
|
|
272
|
+
hasJpegSignature,
|
|
273
|
+
hasGifSignature,
|
|
274
|
+
hasWebpSignature,
|
|
275
|
+
];
|
|
276
|
+
/**
|
|
277
|
+
* Infers `'image'` from the leading bytes rather than from a renderer-controlled MIME type or file
|
|
278
|
+
* extension. PNG, JPEG, GIF87a/89a, and WEBP are recognized; everything else is `'file'`.
|
|
279
|
+
*
|
|
280
|
+
* `kind` decides whether a path is later passed to `AgentExecutor.run()`'s `imagePaths`, so letting
|
|
281
|
+
* a renderer assert it would let a renderer choose how the agent runtime parses the bytes.
|
|
282
|
+
*/
|
|
283
|
+
export function detectAttachmentKind(body) {
|
|
284
|
+
return IMAGE_SIGNATURE_MATCHERS.some((matchesSignature) => matchesSignature(body)) ? 'image' : 'file';
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Streams a request body straight to a private file under a hard byte cap, keeping only the
|
|
288
|
+
* leading signature bytes in memory so an upload never costs memory proportional to its size.
|
|
289
|
+
*
|
|
290
|
+
* Opened `wx`, so this can never overwrite an existing file. A partial write is removed before the
|
|
291
|
+
* rejection propagates: the cap is enforced *during* the stream, which necessarily means some bytes
|
|
292
|
+
* already reached disk by the time it trips.
|
|
293
|
+
*/
|
|
294
|
+
export async function writeBoundedAttachmentBody({ request, filePath, maxBytes, mode = 0o600, }) {
|
|
295
|
+
const handle = await open(filePath, 'wx', mode);
|
|
296
|
+
let total = 0;
|
|
297
|
+
let signature = Buffer.alloc(0);
|
|
298
|
+
try {
|
|
299
|
+
for await (const chunk of request) {
|
|
300
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
301
|
+
total += buffer.byteLength;
|
|
302
|
+
if (total > maxBytes) {
|
|
303
|
+
throw new AttachmentRejectedError('attachment-too-large', `Each attachment must be ${formatByteLimit(maxBytes)} or smaller`);
|
|
304
|
+
}
|
|
305
|
+
if (signature.byteLength < SIGNATURE_BYTES) {
|
|
306
|
+
signature = Buffer.concat([signature, buffer.subarray(0, SIGNATURE_BYTES - signature.byteLength)], Math.min(SIGNATURE_BYTES, signature.byteLength + buffer.byteLength));
|
|
307
|
+
}
|
|
308
|
+
await handle.write(buffer);
|
|
309
|
+
}
|
|
310
|
+
await handle.close();
|
|
311
|
+
await chmod(filePath, mode);
|
|
312
|
+
return { size: total, signature };
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
// Reached both while the handle is still open (the byte cap tripped, or the request stream
|
|
316
|
+
// errored) and after it was already closed (`chmod` failed). No `.catch` is needed to tell those
|
|
317
|
+
// apart: Node's `FileHandle.close()` is idempotent — closing an already-closed handle resolves
|
|
318
|
+
// — so this can only reject for a handle that is genuinely still open and unclosable, which is
|
|
319
|
+
// a failure worth surfacing rather than swallowing.
|
|
320
|
+
await handle.close();
|
|
321
|
+
await rm(filePath, { force: true });
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The disk-backed `AttachmentStore` this package ships. Every default matches what a chat composer
|
|
327
|
+
* needs out of the box; a host that wants different quotas passes them rather than reimplementing
|
|
328
|
+
* the port.
|
|
329
|
+
*
|
|
330
|
+
* @complexity `register`/`claim` are O(n) in the number of tracked records (a small bounded number
|
|
331
|
+
* — `maxStoredAttachments`), which is what keeps the quota decision synchronous; see `register`.
|
|
332
|
+
*/
|
|
333
|
+
export async function createDiskAttachmentStore({ uploadDirectory, maxAttachments = 10, maxBatchBytes = 50 * 1024 * 1024, maxStoredAttachments = 100, maxStoredBytes = 200 * 1024 * 1024, retentionMs = 60 * 60 * 1_000, }) {
|
|
334
|
+
await mkdir(uploadDirectory, { recursive: true, mode: 0o700 });
|
|
335
|
+
await chmod(uploadDirectory, 0o700);
|
|
336
|
+
const canonicalUploadDirectory = await realpath(uploadDirectory);
|
|
337
|
+
// Uploads live only as long as this store does. A file left by an interrupted previous process
|
|
338
|
+
// has no record to authenticate it against, so it is removed rather than adopted.
|
|
339
|
+
for (const entry of await readdir(canonicalUploadDirectory)) {
|
|
340
|
+
await rm(resolve(canonicalUploadDirectory, entry), { recursive: true, force: true });
|
|
341
|
+
}
|
|
342
|
+
const records = new Map();
|
|
343
|
+
const resolveBatchDirectory = (batchId) => {
|
|
344
|
+
if (!BATCH_ID_PATTERN.test(batchId)) {
|
|
345
|
+
throw new AttachmentRejectedError('invalid-batch', 'Invalid attachment batch');
|
|
346
|
+
}
|
|
347
|
+
// No containment re-check follows, and two things together are why — **both** are load-bearing:
|
|
348
|
+
// `BATCH_ID_PATTERN` admits only `[a-zA-Z0-9-]`, so `batchId` carries no `.`, `/`, or `\`; and
|
|
349
|
+
// `resolve` normalizes, so the result is exactly `<canonicalUploadDirectory><sep><batchId>`, one
|
|
350
|
+
// non-traversing segment deeper. Weakening the pattern (a `.` would be enough) or dropping the
|
|
351
|
+
// `resolve` reintroduces traversal here. Verified by sweeping 5,040,504 `(uploadRoot, batchId)`
|
|
352
|
+
// pairs on both posix and win32: zero escape the upload root.
|
|
353
|
+
return resolve(canonicalUploadDirectory, batchId);
|
|
354
|
+
};
|
|
355
|
+
const removeEmptyBatch = async (batchDirectory) => {
|
|
356
|
+
try {
|
|
357
|
+
await rmdir(batchDirectory);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// Non-empty (another unclaimed upload still occupies it) or already gone. Both are fine:
|
|
361
|
+
// this is opportunistic tidying, never the thing that makes a delete correct.
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
const deleteRecord = async (record) => {
|
|
365
|
+
records.delete(record.id);
|
|
366
|
+
await rm(record.filePath, { force: true });
|
|
367
|
+
await removeEmptyBatch(record.batchDirectory);
|
|
368
|
+
};
|
|
369
|
+
const totalBytes = (candidates) => candidates.reduce((total, record) => total + record.size, 0);
|
|
370
|
+
return {
|
|
371
|
+
async createBatchDirectory(batchId) {
|
|
372
|
+
const directory = resolveBatchDirectory(batchId);
|
|
373
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
374
|
+
await chmod(directory, 0o700);
|
|
375
|
+
return directory;
|
|
376
|
+
},
|
|
377
|
+
async register(input) {
|
|
378
|
+
const batchDirectory = resolveBatchDirectory(input.batchId);
|
|
379
|
+
// `resolve` is NOT redundant with the route's own `resolve`, and this line is the precondition
|
|
380
|
+
// the containment argument below depends on. Do not remove it or take a pre-resolved path from
|
|
381
|
+
// the caller: `register` is a public port method, so `input.path` is untrusted.
|
|
382
|
+
const filePath = resolve(input.path);
|
|
383
|
+
// Containment: the file's parent directory must be *exactly* the batch directory — stronger
|
|
384
|
+
// than a `path.relative`-based "is inside" test, which also admits nested subdirectories and
|
|
385
|
+
// the batch directory itself.
|
|
386
|
+
//
|
|
387
|
+
// Why parent-equality is sufficient: `resolve` above guarantees `filePath` is absolute and
|
|
388
|
+
// normalized with no `..` left in the path body, so `dirname(filePath) === batchDirectory`
|
|
389
|
+
// implies `filePath` is `batchDirectory + sep + basename` with a real, non-traversing
|
|
390
|
+
// basename. This is *not* an unconditional path-algebra identity — without the `resolve`,
|
|
391
|
+
// `dirname('/a/..') === '/a'` is true while `/a/..` escapes `/a`. The normalization is what
|
|
392
|
+
// makes the argument hold; a differential fuzz of 6,000,000 `input.path` values against the
|
|
393
|
+
// previous two-part check found zero inputs this accepts that containment rejected.
|
|
394
|
+
//
|
|
395
|
+
// Deliberately OUTSIDE the `try` below, whose `catch` unlinks `filePath`. A path that failed
|
|
396
|
+
// containment must never reach that cleanup, or this port becomes an arbitrary-file-delete
|
|
397
|
+
// primitive for anything the daemon can unlink.
|
|
398
|
+
if (dirname(filePath) !== batchDirectory) {
|
|
399
|
+
throw new AttachmentRejectedError('attachment-integrity', 'Attachment path is outside its batch');
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const info = await lstat(filePath);
|
|
403
|
+
// `lstat` (not `stat`): a symlink must be seen as a symlink, not followed. `isFile()` is
|
|
404
|
+
// false for a symlink and for a directory, which is the whole check.
|
|
405
|
+
if (!info.isFile()) {
|
|
406
|
+
throw new AttachmentRejectedError('attachment-integrity', 'Attachment is not a regular file');
|
|
407
|
+
}
|
|
408
|
+
const canonical = await realpath(filePath);
|
|
409
|
+
if (canonical !== filePath) {
|
|
410
|
+
throw new AttachmentRejectedError('attachment-integrity', 'Attachment path is not canonical');
|
|
411
|
+
}
|
|
412
|
+
// Quotas are decided here, immediately before `records.set`, with no `await` in between —
|
|
413
|
+
// so two concurrent registrations cannot both observe the last free slot (or the last free
|
|
414
|
+
// bytes) and both commit. Moving any of this above the `await`s would reintroduce that
|
|
415
|
+
// race; adding an `await` below would too.
|
|
416
|
+
const batchRecords = [...records.values()]
|
|
417
|
+
.filter((record) => record.batchId === input.batchId);
|
|
418
|
+
if (batchRecords.length >= maxAttachments) {
|
|
419
|
+
throw new AttachmentRejectedError('batch-count-exceeded', `You can attach at most ${maxAttachments} files to one message`);
|
|
420
|
+
}
|
|
421
|
+
if (totalBytes(batchRecords) + info.size > maxBatchBytes) {
|
|
422
|
+
throw new AttachmentRejectedError('batch-too-large', `Attachments for one message must total ${formatByteLimit(maxBatchBytes)} or less`);
|
|
423
|
+
}
|
|
424
|
+
if (records.size >= maxStoredAttachments
|
|
425
|
+
|| totalBytes([...records.values()]) + info.size > maxStoredBytes) {
|
|
426
|
+
throw new AttachmentRejectedError('storage-full', 'Attachment storage is full');
|
|
427
|
+
}
|
|
428
|
+
const id = `attachment:${randomUUID()}`;
|
|
429
|
+
const record = {
|
|
430
|
+
id,
|
|
431
|
+
filePath,
|
|
432
|
+
name: input.name,
|
|
433
|
+
kind: input.kind,
|
|
434
|
+
size: info.size,
|
|
435
|
+
batchId: input.batchId,
|
|
436
|
+
batchDirectory,
|
|
437
|
+
dev: info.dev,
|
|
438
|
+
ino: info.ino,
|
|
439
|
+
createdAt: Date.now(),
|
|
440
|
+
};
|
|
441
|
+
records.set(id, record);
|
|
442
|
+
return { path: id, name: record.name, kind: record.kind, size: record.size };
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
// A file this store refused to take ownership of must not be left behind. `rm` is
|
|
446
|
+
// deliberately non-recursive (never recurse over an attacker-influenced path) and its own
|
|
447
|
+
// failure is swallowed, so the real rejection above always propagates instead of being
|
|
448
|
+
// masked by e.g. EISDIR from a directory sitting at `filePath`.
|
|
449
|
+
await rm(filePath, { force: true }).catch(() => undefined);
|
|
450
|
+
await removeEmptyBatch(batchDirectory);
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
async claim(attachments, runId) {
|
|
455
|
+
if (attachments.length === 0)
|
|
456
|
+
return { attachments: [] };
|
|
457
|
+
// Phase 1 (`reserveAttachmentRecords`) reserves synchronously; Phase 2
|
|
458
|
+
// (`verifyClaimedAttachments`) re-validates what is now held exclusively. Any Phase 2 failure
|
|
459
|
+
// releases the whole reservation here, so a rejected claim still leaves nothing half-claimed
|
|
460
|
+
// and the caller can retry with a corrected set. Only reservations made by *this* call are
|
|
461
|
+
// released, so a concurrent winner's claim is never revoked by a loser's rollback.
|
|
462
|
+
const claimed = reserveAttachmentRecords(attachments, records, runId, maxAttachments);
|
|
463
|
+
try {
|
|
464
|
+
const batchDirectory = await verifyClaimedAttachments(claimed);
|
|
465
|
+
return {
|
|
466
|
+
attachments: claimed.map((record) => ({
|
|
467
|
+
path: record.filePath,
|
|
468
|
+
name: record.name,
|
|
469
|
+
kind: record.kind,
|
|
470
|
+
size: record.size,
|
|
471
|
+
})),
|
|
472
|
+
batchDirectory,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
for (const record of claimed)
|
|
477
|
+
delete record.claimedRunId;
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
async deleteUnclaimed(batchId, paths) {
|
|
482
|
+
const batchDirectory = resolveBatchDirectory(batchId);
|
|
483
|
+
for (const attachmentId of new Set(paths)) {
|
|
484
|
+
const record = records.get(attachmentId);
|
|
485
|
+
if (record && record.batchId === batchId && record.claimedRunId === undefined) {
|
|
486
|
+
await deleteRecord(record);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
await removeEmptyBatch(batchDirectory);
|
|
490
|
+
},
|
|
491
|
+
async cleanupRun(runId) {
|
|
492
|
+
for (const record of [...records.values()]) {
|
|
493
|
+
if (record.claimedRunId === runId)
|
|
494
|
+
await deleteRecord(record);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
async pruneExpired(now = Date.now()) {
|
|
498
|
+
for (const record of [...records.values()]) {
|
|
499
|
+
if (record.claimedRunId === undefined && now - record.createdAt >= retentionMs) {
|
|
500
|
+
await deleteRecord(record);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
async dispose() {
|
|
505
|
+
for (const record of [...records.values()])
|
|
506
|
+
await deleteRecord(record);
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
export const ATTACHMENTS_ROUTE_PATH = '/api/attachments';
|
|
511
|
+
function defaultInternalErrorSink(context) {
|
|
512
|
+
// eslint-disable-next-line no-console
|
|
513
|
+
console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
|
|
514
|
+
}
|
|
515
|
+
function reportInternalError(deps, source, error, batchId) {
|
|
516
|
+
const correlationId = randomUUID();
|
|
517
|
+
const sink = deps.onInternalError ?? defaultInternalErrorSink;
|
|
518
|
+
sink({ source, batchId, correlationId, error });
|
|
519
|
+
return createApiError('INTERNAL_ERROR', 'an internal error occurred', { requestId: correlationId });
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Sends the right status for an upload failure: an explicitly-classified rejection keeps its real
|
|
523
|
+
* message (the composer shows it to the user, so "you can attach at most 10 files" has to survive),
|
|
524
|
+
* while an integrity failure or any unexpected error is redacted to a generic `INTERNAL_ERROR`.
|
|
525
|
+
*/
|
|
526
|
+
function respondToUploadFailure(res, deps, error, batchId, source) {
|
|
527
|
+
if (error instanceof AttachmentRejectedError && REJECTION_STATUS[error.reason] !== 500) {
|
|
528
|
+
sendApiError(res, REJECTION_STATUS[error.reason], apiErrorForRejection(error));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
sendApiError(res, 500, reportInternalError(deps, source, error, batchId));
|
|
532
|
+
}
|
|
533
|
+
/** `false` when the same-origin guard rejected the request (and already answered it). */
|
|
534
|
+
function passesOriginGuard(req, res, deps, adapter) {
|
|
535
|
+
if (deps.requireSameOrigin === false)
|
|
536
|
+
return true;
|
|
537
|
+
const origin = guardSameOrigin(req, adapter);
|
|
538
|
+
if (origin.ok)
|
|
539
|
+
return true;
|
|
540
|
+
sendApiError(res, 403, origin.error);
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Handles one upload: rate-limit, batch directory, bounded stream to disk, signature sniff,
|
|
545
|
+
* register. Exported so a host mounting its own path (or its own framework) can reuse the whole
|
|
546
|
+
* body without re-deriving the ordering, which is load-bearing — see the `finally`.
|
|
547
|
+
*/
|
|
548
|
+
export async function handleAttachmentUpload(req, res, deps, state) {
|
|
549
|
+
const maxConcurrentUploads = deps.maxConcurrentUploads ?? 4;
|
|
550
|
+
const maxAttachmentBytes = deps.maxAttachmentBytes ?? 20 * 1024 * 1024;
|
|
551
|
+
const name = sanitizeAttachmentName(req.query.name);
|
|
552
|
+
const batchId = typeof req.query.batch === 'string' ? req.query.batch : '';
|
|
553
|
+
// Every refusal below goes through `respondToUploadFailure`, so the reason -> status/code mapping
|
|
554
|
+
// lives in exactly one place rather than being partly inlined here.
|
|
555
|
+
if (state.activeUploads >= maxConcurrentUploads) {
|
|
556
|
+
respondToUploadFailure(res, deps, new AttachmentRejectedError('too-many-concurrent-uploads', 'Too many attachment uploads are in progress'), batchId, 'attachment-upload');
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
// Checked before a single byte is written: a drained stream would otherwise produce a zero-byte
|
|
560
|
+
// file and the misleading "attachment is empty". See this module's body-parser doc.
|
|
561
|
+
if (req.readableEnded) {
|
|
562
|
+
respondToUploadFailure(res, deps, new AttachmentRejectedError('attachment-body-consumed', `the request body was already consumed before ${ATTACHMENTS_ROUTE_PATH} received it — mount this route pack before any global body parser, or scope that parser to skip this path`), batchId, 'attachment-upload');
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
state.activeUploads += 1;
|
|
566
|
+
try {
|
|
567
|
+
await deps.store.pruneExpired();
|
|
568
|
+
const batchDirectory = await deps.store.createBatchDirectory(batchId);
|
|
569
|
+
// A fresh UUID, never the client's filename. The extension is carried over (bounded) only
|
|
570
|
+
// because some agent runtimes decide how to read a file from its suffix.
|
|
571
|
+
const suffix = extname(name).slice(0, 12);
|
|
572
|
+
const path = resolve(batchDirectory, `${randomUUID()}${suffix}`);
|
|
573
|
+
const upload = await writeBoundedAttachmentBody({ request: req, filePath: path, maxBytes: maxAttachmentBytes });
|
|
574
|
+
if (upload.size === 0) {
|
|
575
|
+
await rm(path, { force: true });
|
|
576
|
+
sendApiError(res, 400, createApiError('BAD_REQUEST', 'Attachment is empty'));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const attachment = await deps.store.register({
|
|
580
|
+
batchId,
|
|
581
|
+
path,
|
|
582
|
+
name,
|
|
583
|
+
kind: detectAttachmentKind(upload.signature),
|
|
584
|
+
size: upload.size,
|
|
585
|
+
});
|
|
586
|
+
sendJson(res, 201, { attachment });
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
respondToUploadFailure(res, deps, error, batchId, 'attachment-upload');
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
state.activeUploads -= 1;
|
|
593
|
+
// Removes the batch directory when this failed upload left it empty. Passing no paths is
|
|
594
|
+
// deliberate: a *successful* upload must not be deleted here, and `deleteUnclaimed` with an
|
|
595
|
+
// empty list does exactly the directory tidying and nothing else.
|
|
596
|
+
await deps.store.deleteUnclaimed(batchId, []).catch(() => undefined);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Handles a client abandoning a batch (a failed multi-file upload, a cleared composer). Only
|
|
601
|
+
* *unclaimed* uploads can be deleted this way, so a caller cannot use it to pull files out from
|
|
602
|
+
* under a run that already claimed them.
|
|
603
|
+
*/
|
|
604
|
+
export async function handleAttachmentCleanup(req, res, deps) {
|
|
605
|
+
const maxCleanupPaths = deps.maxCleanupPaths ?? 10;
|
|
606
|
+
const body = req.body;
|
|
607
|
+
if (typeof body?.batchId !== 'string'
|
|
608
|
+
|| !Array.isArray(body.paths)
|
|
609
|
+
|| body.paths.length > maxCleanupPaths
|
|
610
|
+
|| !body.paths.every((path) => typeof path === 'string')) {
|
|
611
|
+
sendApiError(res, 400, createApiError('BAD_REQUEST', 'Invalid attachment cleanup request'));
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
try {
|
|
615
|
+
await deps.store.deleteUnclaimed(body.batchId, body.paths);
|
|
616
|
+
res.status(204).end();
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
respondToUploadFailure(res, deps, error, body.batchId, 'attachment-cleanup');
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Mounts `POST`/`DELETE /api/attachments` on `app`. A pack's `http(app, services)` calls this
|
|
624
|
+
* directly.
|
|
625
|
+
*
|
|
626
|
+
* Hand-mounted rather than built from `defineJsonRoute`/`mountJsonRoute` for two concrete reasons:
|
|
627
|
+
* the upload reads the raw request stream (a JSON-parsed `req.body` is exactly what must not have
|
|
628
|
+
* happened), and the cleanup answers `204` with no body, which a JSON responder cannot express.
|
|
629
|
+
*/
|
|
630
|
+
export function registerAttachmentRoutes(app, deps, adapter) {
|
|
631
|
+
// Per-registration, not module-level: two daemons in one process (a test harness, an embedded
|
|
632
|
+
// second host) must not share one upload budget.
|
|
633
|
+
const state = { activeUploads: 0 };
|
|
634
|
+
app.post(ATTACHMENTS_ROUTE_PATH, async (req, res) => {
|
|
635
|
+
try {
|
|
636
|
+
if (!passesOriginGuard(req, res, deps, adapter))
|
|
637
|
+
return;
|
|
638
|
+
await handleAttachmentUpload(req, res, deps, state);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
// `handleAttachmentUpload` already catches everything inside its own body — this only ever
|
|
642
|
+
// fires for `passesOriginGuard`/`guardSameOrigin` throwing, which is Result-returning by
|
|
643
|
+
// contract but not guaranteed never to throw (`isLocalSameOrigin` really does throw on a
|
|
644
|
+
// malformed `JINI_ALLOWED_ORIGINS` entry — see the paired regression test). Mounting this
|
|
645
|
+
// route bypasses `mountJsonRoute`'s adapter (see this function's own doc), so nothing else
|
|
646
|
+
// stood between that throw and an unhandled rejection with no process-level guard anywhere
|
|
647
|
+
// in this package's path.
|
|
648
|
+
if (!res.headersSent)
|
|
649
|
+
respondToUploadFailure(res, deps, error, null, 'attachment-upload');
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
app.delete(ATTACHMENTS_ROUTE_PATH, async (req, res) => {
|
|
653
|
+
try {
|
|
654
|
+
if (!passesOriginGuard(req, res, deps, adapter))
|
|
655
|
+
return;
|
|
656
|
+
await handleAttachmentCleanup(req, res, deps);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
if (!res.headersSent)
|
|
660
|
+
sendApiError(res, 500, reportInternalError(deps, 'attachment-cleanup', error, null));
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
//# sourceMappingURL=attachments.js.map
|