@tangle-network/agent-app 0.60.194 → 0.60.196
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/dist/assistant/index.js +2 -2
- package/dist/chat-react/index.js +2 -2
- package/dist/chat-routes/attachment-store.d.ts +18 -0
- package/dist/chat-routes/attachment-upload.d.ts +20 -2
- package/dist/chat-routes/attachment-validation.d.ts +3 -0
- package/dist/chat-routes/binary-sniff.d.ts +28 -0
- package/dist/chat-routes/index.js +279 -15
- package/dist/chat-routes/index.js.map +1 -1
- package/dist/{chunk-6RFNLZCK.js → chunk-2XUIWCRW.js} +26 -5
- package/dist/{chunk-6RFNLZCK.js.map → chunk-2XUIWCRW.js.map} +1 -1
- package/dist/{chunk-PBQYVAB6.js → chunk-2ZRXJ3NO.js} +138 -34
- package/dist/chunk-2ZRXJ3NO.js.map +1 -0
- package/dist/operator/chat-adapter.d.ts +2 -0
- package/dist/operator/client.d.ts +10 -0
- package/dist/operator/contract.d.ts +12 -4
- package/dist/operator/index.js +41 -14
- package/dist/operator/index.js.map +1 -1
- package/dist/operator/server.d.ts +18 -0
- package/dist/web-react/index.js +7 -3
- package/dist/web-react/use-composer-attachments.d.ts +13 -1
- package/package.json +1 -1
- package/dist/chunk-PBQYVAB6.js.map +0 -1
package/dist/assistant/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import "../chunk-Y6PRDUFJ.js";
|
|
|
9
9
|
import "../chunk-GEYACSFW.js";
|
|
10
10
|
import {
|
|
11
11
|
ChatComposer
|
|
12
|
-
} from "../chunk-
|
|
12
|
+
} from "../chunk-2XUIWCRW.js";
|
|
13
13
|
import "../chunk-L7MB2LLM.js";
|
|
14
14
|
import "../chunk-GNBBY3KN.js";
|
|
15
15
|
import "../chunk-A2GG4D4A.js";
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from "../chunk-AVP5SNIG.js";
|
|
29
29
|
import "../chunk-RASGKEAL.js";
|
|
30
30
|
import "../chunk-FQYAJYPW.js";
|
|
31
|
-
import "../chunk-
|
|
31
|
+
import "../chunk-2ZRXJ3NO.js";
|
|
32
32
|
import "../chunk-VBRUG4N5.js";
|
|
33
33
|
import "../chunk-B7CL7ENS.js";
|
|
34
34
|
import "../chunk-PV33JZKV.js";
|
package/dist/chat-react/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ComposerModeControls,
|
|
3
3
|
EntryComposer
|
|
4
|
-
} from "../chunk-
|
|
4
|
+
} from "../chunk-2XUIWCRW.js";
|
|
5
5
|
import "../chunk-L7MB2LLM.js";
|
|
6
6
|
import "../chunk-SJWIZT7B.js";
|
|
7
7
|
import "../chunk-5IS3GCMR.js";
|
|
8
8
|
import "../chunk-HEPEGY6Q.js";
|
|
9
9
|
import "../chunk-XG6FODYO.js";
|
|
10
10
|
import "../chunk-RASGKEAL.js";
|
|
11
|
-
import "../chunk-
|
|
11
|
+
import "../chunk-2ZRXJ3NO.js";
|
|
12
12
|
import "../chunk-VBRUG4N5.js";
|
|
13
13
|
import "../chunk-AMUXRAUO.js";
|
|
14
14
|
import "../chunk-YHRWZZJN.js";
|
|
@@ -98,6 +98,24 @@ export interface AttachmentWriteOptions {
|
|
|
98
98
|
* {@link createAtomicAttachmentWriter} for ownership-safe writes.
|
|
99
99
|
*/
|
|
100
100
|
export type WriteAttachmentFn = (scopeId: string, path: string, content: Uint8Array | string, opts: AttachmentWriteOptions) => Promise<AttachmentWriteResult>;
|
|
101
|
+
/** Options for a streamed write. `size` is the exact byte count `content`
|
|
102
|
+
* yields. `mediaType` is decided from the file name and its first bytes,
|
|
103
|
+
* before the route has read the rest: an archive's central directory sits at
|
|
104
|
+
* its end, so the route's response, not this value, carries the final type. */
|
|
105
|
+
export interface AttachmentStreamWriteOptions extends AttachmentWriteOptions {
|
|
106
|
+
size: number;
|
|
107
|
+
mediaType: string;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Streamed writer port for an upload too large to hold in memory. `content`
|
|
111
|
+
* yields exactly `opts.size` bytes. The route checks the file while it
|
|
112
|
+
* streams and withholds the final bytes until the whole file has passed, so
|
|
113
|
+
* on a refusal `content` errors instead of ending. The adapter must make the
|
|
114
|
+
* bytes readable at `path` only when `content` ends cleanly, and must leave
|
|
115
|
+
* whatever was stored at `path` before untouched when it errors. A store put
|
|
116
|
+
* of a fixed-length stream (R2 `put` behind `FixedLengthStream`) does both.
|
|
117
|
+
*/
|
|
118
|
+
export type WriteAttachmentStreamFn = (scopeId: string, path: string, content: ReadableStream<Uint8Array>, opts: AttachmentStreamWriteOptions) => Promise<AttachmentWriteResult>;
|
|
101
119
|
/** Options for an ownership-safe writer. */
|
|
102
120
|
export interface AtomicAttachmentWriteOptions extends AttachmentWriteOptions {
|
|
103
121
|
ownership: AttachmentWriteOwnership;
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* never invents a 401 or 429 response, it only returns `auth.response`
|
|
15
15
|
* verbatim on failure.
|
|
16
16
|
*
|
|
17
|
+
* Two body shapes: a `multipart/form-data` batch, parsed whole, and one raw
|
|
18
|
+
* file body named by the `filename` query parameter. A raw body larger than
|
|
19
|
+
* the sniff head streams to `writeAttachmentStream` when the product supplies
|
|
20
|
+
* one: the Worker never holds the file whole, which is what lets a product
|
|
21
|
+
* admit data files of tens of MB inside a 128 MB isolate.
|
|
22
|
+
*
|
|
17
23
|
* Lifted from gtm-agent's `src/routes/api.vault.upload.ts` (the hardening
|
|
18
24
|
* lineage other lifted modules in this vertical cite: gtm#584 binary
|
|
19
25
|
* corruption, gtm#592 sniff gate/caps, gtm#593 batch-atomic writes) and
|
|
@@ -27,14 +33,15 @@
|
|
|
27
33
|
* vs. inline-`data:`-or-ephemeral-sandbox-workspace). See that module's doc
|
|
28
34
|
* comment for the up-to-date framing between the two.
|
|
29
35
|
*/
|
|
30
|
-
import type
|
|
31
|
-
import { type AtomicAttachmentWriter, type WriteAttachmentFn } from './attachment-store';
|
|
36
|
+
import { type ChatAttachmentKind } from './wire';
|
|
37
|
+
import { type AtomicAttachmentWriter, type WriteAttachmentFn, type WriteAttachmentStreamFn } from './attachment-store';
|
|
32
38
|
import { type AttachmentPathCheck } from './attachment-validation';
|
|
33
39
|
/** Stable authorization result for the original writer contract. */
|
|
34
40
|
export type AttachmentUploadAuthorization = {
|
|
35
41
|
ok: true;
|
|
36
42
|
scopeId: string;
|
|
37
43
|
writeAttachment?: WriteAttachmentFn;
|
|
44
|
+
writeAttachmentStream?: WriteAttachmentStreamFn;
|
|
38
45
|
} | {
|
|
39
46
|
ok: false;
|
|
40
47
|
response: Response;
|
|
@@ -64,6 +71,12 @@ interface AttachmentUploadRouteCommonOptions {
|
|
|
64
71
|
maxTextBytes?: number;
|
|
65
72
|
/** Aggregate raw-byte ceiling. Default {@link MAX_ATTACHMENT_TOTAL_BYTES}. */
|
|
66
73
|
maxTotalBytes?: number;
|
|
74
|
+
/** Largest `multipart/form-data` body this route parses. A form body is
|
|
75
|
+
* held whole while it parses, at about three copies at peak, so a
|
|
76
|
+
* product that admits files too large for that sets this and takes them
|
|
77
|
+
* as raw bodies (see {@link createAttachmentUploadRoute}). Default: no
|
|
78
|
+
* limit beyond the per-file and aggregate caps. */
|
|
79
|
+
maxFormBytes?: number;
|
|
67
80
|
};
|
|
68
81
|
/** Attachment kinds this route accepts. Default `['image', 'file']`. */
|
|
69
82
|
allowedKinds?: ChatAttachmentKind[];
|
|
@@ -89,6 +102,11 @@ export interface CreateLegacyAttachmentUploadRouteOptions extends AttachmentUplo
|
|
|
89
102
|
}): Promise<AttachmentUploadAuthorization>;
|
|
90
103
|
/** Stable writer. `authorize` may override it per-request. */
|
|
91
104
|
writeAttachment: WriteAttachmentFn;
|
|
105
|
+
/** Writer for a raw-body upload larger than {@link STREAM_SNIFF_HEAD_BYTES}:
|
|
106
|
+
* the route checks the file while it streams to this writer, so the file
|
|
107
|
+
* is never held whole. Without it, a raw body is read whole and goes
|
|
108
|
+
* through `writeAttachment` under the same caps. */
|
|
109
|
+
writeAttachmentStream?: WriteAttachmentStreamFn;
|
|
92
110
|
}
|
|
93
111
|
/**
|
|
94
112
|
* The original public route-options interface remains available for consumers
|
|
@@ -58,6 +58,9 @@ export declare const MACRO_ENABLED_OOXML_SNIFFED_MIMES: ReadonlySet<string>;
|
|
|
58
58
|
* upload of that format fails the type gate — locked by the round-trip test
|
|
59
59
|
* that sniffs real bytes of every mime listed here. */
|
|
60
60
|
export declare const ALLOWED_ATTACHMENT_SNIFFED_MIMES: ReadonlySet<string>;
|
|
61
|
+
/** The sniffed mime a filename's extension implies, when its format has an
|
|
62
|
+
* unambiguous magic-byte family (`.xlsx` → the spreadsheet mime). */
|
|
63
|
+
export declare function sniffedMimeImpliedByName(fileName: string): string | undefined;
|
|
61
64
|
/** Represent the result of checking an attachment's type with success or specific failure details */
|
|
62
65
|
export type AttachmentTypeCheckResult = {
|
|
63
66
|
succeeded: true;
|
|
@@ -43,3 +43,31 @@ export declare const OOXML_PRESENTATION_MACRO_ENABLED_MIME = "application/vnd.ms
|
|
|
43
43
|
* nothing and does not decode as text is binary with an unknown mime —
|
|
44
44
|
* extension-based guessing happens at the call site, not here. */
|
|
45
45
|
export declare function sniffBinary(bytes: Uint8Array): SniffResult;
|
|
46
|
+
/** Leading bytes a streamed classification keeps. Every magic-byte family
|
|
47
|
+
* except PKZIP is decided within them (the EBML header scan reads 4 KiB),
|
|
48
|
+
* and a file no longer than this is classified exactly as
|
|
49
|
+
* {@link sniffBinary} classifies it. */
|
|
50
|
+
export declare const STREAM_SNIFF_HEAD_BYTES: number;
|
|
51
|
+
/** Trailing bytes a streamed classification keeps for the zip reader. The
|
|
52
|
+
* end-of-central-directory record sits within 64 KiB + 22 bytes of the end,
|
|
53
|
+
* and an Office package's central directory is a few KB. An archive whose
|
|
54
|
+
* directory starts earlier than this window is reported as a plain zip. */
|
|
55
|
+
export declare const STREAM_SNIFF_TAIL_BYTES: number;
|
|
56
|
+
/** Classifies a file from its bytes as they stream past, holding at most
|
|
57
|
+
* {@link STREAM_SNIFF_HEAD_BYTES} + {@link STREAM_SNIFF_TAIL_BYTES} of it. */
|
|
58
|
+
export interface StreamingSniffer {
|
|
59
|
+
/** Feed the next bytes in order. The sniffer keeps references to recent
|
|
60
|
+
* chunks, so a caller must not reuse a chunk's buffer afterwards. */
|
|
61
|
+
push(chunk: Uint8Array): void;
|
|
62
|
+
/** Bytes pushed so far. */
|
|
63
|
+
readonly size: number;
|
|
64
|
+
/** The start of the file, up to {@link STREAM_SNIFF_HEAD_BYTES}. */
|
|
65
|
+
head(): Uint8Array;
|
|
66
|
+
/** Classify everything pushed. The answer is {@link sniffBinary}'s over the
|
|
67
|
+
* whole file, except that a zip whose central directory lies outside the
|
|
68
|
+
* tail window reads as `application/zip`, and the SVG check sees only the
|
|
69
|
+
* head. Text validity (NUL bytes, UTF-8) is checked over every byte. */
|
|
70
|
+
finish(): SniffResult;
|
|
71
|
+
}
|
|
72
|
+
/** Start a {@link StreamingSniffer} for one file. */
|
|
73
|
+
export declare function createStreamingSniffer(): StreamingSniffer;
|
|
@@ -19,14 +19,18 @@ import {
|
|
|
19
19
|
OOXML_SPREADSHEET_MIME,
|
|
20
20
|
OOXML_WORD_MACRO_ENABLED_MIME,
|
|
21
21
|
OOXML_WORD_MIME,
|
|
22
|
+
STREAM_SNIFF_HEAD_BYTES,
|
|
23
|
+
STREAM_SNIFF_TAIL_BYTES,
|
|
22
24
|
attachmentSizeErrorMessage,
|
|
23
25
|
attachmentTotalSizeErrorMessage,
|
|
24
26
|
checkAttachmentType,
|
|
27
|
+
createStreamingSniffer,
|
|
25
28
|
defaultValidateAttachmentPath,
|
|
26
29
|
parseChatAttachmentInputs,
|
|
27
30
|
sanitizeAttachmentFileName,
|
|
28
|
-
sniffBinary
|
|
29
|
-
|
|
31
|
+
sniffBinary,
|
|
32
|
+
sniffedMimeImpliedByName
|
|
33
|
+
} from "../chunk-2ZRXJ3NO.js";
|
|
30
34
|
import {
|
|
31
35
|
DEFAULT_STALE_TURN_LOCK_GRACE_MS,
|
|
32
36
|
DEFAULT_TERMINAL_TURN_LOCK_GRACE_MS,
|
|
@@ -2880,6 +2884,94 @@ function atomicAttachmentFailure(path, cleanupFailures = 0) {
|
|
|
2880
2884
|
path
|
|
2881
2885
|
);
|
|
2882
2886
|
}
|
|
2887
|
+
var MAX_RAW_FILENAME_LENGTH = 255;
|
|
2888
|
+
function rawUploadTarget(request) {
|
|
2889
|
+
const filename = new URL(request.url).searchParams.get("filename");
|
|
2890
|
+
if (!filename || filename.length > MAX_RAW_FILENAME_LENGTH) {
|
|
2891
|
+
return {
|
|
2892
|
+
succeeded: false,
|
|
2893
|
+
response: attachmentUploadError(
|
|
2894
|
+
400,
|
|
2895
|
+
"invalid_upload",
|
|
2896
|
+
"Expected a multipart/form-data body with file fields, or one raw file body named by the filename query parameter"
|
|
2897
|
+
)
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2900
|
+
const uploadLength = request.headers.get("X-Upload-Length");
|
|
2901
|
+
const transportLength = request.headers.get("Content-Length");
|
|
2902
|
+
if (uploadLength !== null && transportLength !== null && uploadLength !== transportLength) {
|
|
2903
|
+
return { succeeded: false, response: attachmentUploadError(400, "invalid_length", "Conflicting upload lengths") };
|
|
2904
|
+
}
|
|
2905
|
+
const declared = uploadLength ?? transportLength;
|
|
2906
|
+
if (declared === null) {
|
|
2907
|
+
return {
|
|
2908
|
+
succeeded: false,
|
|
2909
|
+
response: attachmentUploadError(411, "length_required", "A raw upload declares its size in Content-Length or X-Upload-Length")
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2912
|
+
const declaredBytes = Number(declared);
|
|
2913
|
+
if (!/^(0|[1-9]\d*)$/.test(declared) || !Number.isSafeInteger(declaredBytes)) {
|
|
2914
|
+
return { succeeded: false, response: attachmentUploadError(400, "invalid_length", "Invalid upload length") };
|
|
2915
|
+
}
|
|
2916
|
+
return { succeeded: true, filename, declaredBytes };
|
|
2917
|
+
}
|
|
2918
|
+
var LENGTH_MISMATCH_MESSAGE = "Upload length does not match the file body";
|
|
2919
|
+
async function readRawBody(request, declaredBytes) {
|
|
2920
|
+
const mismatch = { succeeded: false, response: attachmentUploadError(400, "invalid_length", LENGTH_MISMATCH_MESSAGE) };
|
|
2921
|
+
const bytes = new Uint8Array(declaredBytes);
|
|
2922
|
+
if (!request.body) return declaredBytes === 0 ? { succeeded: true, value: bytes } : mismatch;
|
|
2923
|
+
const reader = request.body.getReader();
|
|
2924
|
+
let offset = 0;
|
|
2925
|
+
try {
|
|
2926
|
+
for (; ; ) {
|
|
2927
|
+
const { done, value } = await reader.read();
|
|
2928
|
+
if (done) break;
|
|
2929
|
+
if (offset + value.length > declaredBytes) {
|
|
2930
|
+
await reader.cancel().catch(() => void 0);
|
|
2931
|
+
return mismatch;
|
|
2932
|
+
}
|
|
2933
|
+
bytes.set(value, offset);
|
|
2934
|
+
offset += value.length;
|
|
2935
|
+
}
|
|
2936
|
+
} catch {
|
|
2937
|
+
return { succeeded: false, response: attachmentUploadError(400, "invalid_upload", "The upload body ended early") };
|
|
2938
|
+
}
|
|
2939
|
+
return offset === declaredBytes ? { succeeded: true, value: bytes } : mismatch;
|
|
2940
|
+
}
|
|
2941
|
+
async function readBodyUpTo(request, maxBytes) {
|
|
2942
|
+
const declared = Number(request.headers.get("Content-Length") ?? Number.NaN);
|
|
2943
|
+
if (Number.isFinite(declared) && declared > maxBytes) return { succeeded: false, tooLarge: true };
|
|
2944
|
+
if (!request.body) return { succeeded: true, value: new Uint8Array(0) };
|
|
2945
|
+
const reader = request.body.getReader();
|
|
2946
|
+
const chunks = [];
|
|
2947
|
+
let length = 0;
|
|
2948
|
+
try {
|
|
2949
|
+
for (; ; ) {
|
|
2950
|
+
const { done, value } = await reader.read();
|
|
2951
|
+
if (done) break;
|
|
2952
|
+
length += value.length;
|
|
2953
|
+
if (length > maxBytes) {
|
|
2954
|
+
await reader.cancel().catch(() => void 0);
|
|
2955
|
+
return { succeeded: false, tooLarge: true };
|
|
2956
|
+
}
|
|
2957
|
+
chunks.push(value);
|
|
2958
|
+
}
|
|
2959
|
+
} catch {
|
|
2960
|
+
return { succeeded: false, tooLarge: false };
|
|
2961
|
+
}
|
|
2962
|
+
const bytes = new Uint8Array(length);
|
|
2963
|
+
let offset = 0;
|
|
2964
|
+
for (const chunk of chunks) {
|
|
2965
|
+
bytes.set(chunk, offset);
|
|
2966
|
+
offset += chunk.length;
|
|
2967
|
+
}
|
|
2968
|
+
return { succeeded: true, value: bytes };
|
|
2969
|
+
}
|
|
2970
|
+
function provisionalMediaType(name, head, sniffMime) {
|
|
2971
|
+
if (!head.binary) return sniffMime(name);
|
|
2972
|
+
if (head.mime === "application/zip") return sniffedMimeImpliedByName(name) ?? head.mime;
|
|
2973
|
+
return head.mime ?? "application/octet-stream";
|
|
2974
|
+
}
|
|
2883
2975
|
async function rollbackSuccessfulWrites(writes, scopeId, writer, logger) {
|
|
2884
2976
|
let failures = 0;
|
|
2885
2977
|
for (let index = writes.length - 1; index >= 0; index -= 1) {
|
|
@@ -2933,6 +3025,138 @@ function createAttachmentUploadRoute(options) {
|
|
|
2933
3025
|
return crypto.randomUUID();
|
|
2934
3026
|
}) : void 0;
|
|
2935
3027
|
const logger = options.logger ?? console;
|
|
3028
|
+
const maxFormBytes = options.limits?.maxFormBytes;
|
|
3029
|
+
const streamWriter = atomic ? void 0 : options.writeAttachmentStream;
|
|
3030
|
+
const limitFor = (sniff) => {
|
|
3031
|
+
if (!sniff.binary) return maxTextBytes;
|
|
3032
|
+
return sniff.mime === null ? maxBinaryBytes : maxBytesBySniffedMime?.get(sniff.mime) ?? maxBinaryBytes;
|
|
3033
|
+
};
|
|
3034
|
+
const largestFileBytes = Math.max(maxTextBytes, maxBinaryBytes, ...maxBytesBySniffedMime?.values() ?? []);
|
|
3035
|
+
async function streamRawUpload(input) {
|
|
3036
|
+
const { request, declaredBytes, name, path } = input;
|
|
3037
|
+
if (!request.body) return attachmentUploadError(400, "invalid_length", LENGTH_MISMATCH_MESSAGE);
|
|
3038
|
+
const reader = request.body.getReader();
|
|
3039
|
+
const sniffer = createStreamingSniffer();
|
|
3040
|
+
const head = [];
|
|
3041
|
+
let refusal;
|
|
3042
|
+
const tooLong = () => attachmentUploadError(400, "invalid_length", LENGTH_MISMATCH_MESSAGE);
|
|
3043
|
+
try {
|
|
3044
|
+
while (sniffer.size < STREAM_SNIFF_HEAD_BYTES) {
|
|
3045
|
+
const { done, value } = await reader.read();
|
|
3046
|
+
if (done) break;
|
|
3047
|
+
if (sniffer.size + value.length > declaredBytes) {
|
|
3048
|
+
await reader.cancel().catch(() => void 0);
|
|
3049
|
+
return tooLong();
|
|
3050
|
+
}
|
|
3051
|
+
sniffer.push(value);
|
|
3052
|
+
head.push(value);
|
|
3053
|
+
}
|
|
3054
|
+
} catch {
|
|
3055
|
+
return attachmentUploadError(400, "invalid_upload", "The upload body ended early");
|
|
3056
|
+
}
|
|
3057
|
+
const mediaTypeBeforeEnd = provisionalMediaType(name, sniffBinary(sniffer.head()), sniffMime);
|
|
3058
|
+
let held = head.pop();
|
|
3059
|
+
let finalSniff;
|
|
3060
|
+
const refuse = (controller, response) => {
|
|
3061
|
+
refusal = response;
|
|
3062
|
+
controller.error(new Error("attachment upload refused"));
|
|
3063
|
+
};
|
|
3064
|
+
const content = new ReadableStream({
|
|
3065
|
+
start(controller) {
|
|
3066
|
+
for (const chunk of head) controller.enqueue(chunk);
|
|
3067
|
+
},
|
|
3068
|
+
async pull(controller) {
|
|
3069
|
+
let next;
|
|
3070
|
+
try {
|
|
3071
|
+
next = await reader.read();
|
|
3072
|
+
} catch {
|
|
3073
|
+
refuse(controller, attachmentUploadError(400, "invalid_upload", "The upload body ended early"));
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
if (!next.done) {
|
|
3077
|
+
if (sniffer.size + next.value.length > declaredBytes) {
|
|
3078
|
+
await reader.cancel().catch(() => void 0);
|
|
3079
|
+
refuse(controller, tooLong());
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
sniffer.push(next.value);
|
|
3083
|
+
if (held) controller.enqueue(held);
|
|
3084
|
+
held = next.value;
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
if (sniffer.size !== declaredBytes) {
|
|
3088
|
+
refuse(controller, tooLong());
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
const sniff2 = sniffer.finish();
|
|
3092
|
+
const typeCheck = checkAttachmentType(name, sniff2, allowedSniffedMimes);
|
|
3093
|
+
if (!typeCheck.succeeded) {
|
|
3094
|
+
refuse(controller, attachmentUploadError(
|
|
3095
|
+
typeCheck.code === "attachment_type_mismatch" ? 400 : 415,
|
|
3096
|
+
typeCheck.code,
|
|
3097
|
+
typeCheck.message
|
|
3098
|
+
));
|
|
3099
|
+
return;
|
|
3100
|
+
}
|
|
3101
|
+
const kind = attachmentKindForMime(sniff2.mime ?? "");
|
|
3102
|
+
if (!allowedKinds.includes(kind)) {
|
|
3103
|
+
refuse(controller, attachmentUploadError(
|
|
3104
|
+
415,
|
|
3105
|
+
"attachment_kind_not_allowed",
|
|
3106
|
+
`${name} is a "${kind}" attachment, which this upload route does not accept`
|
|
3107
|
+
));
|
|
3108
|
+
return;
|
|
3109
|
+
}
|
|
3110
|
+
const limit = limitFor(sniff2);
|
|
3111
|
+
if (declaredBytes > limit) {
|
|
3112
|
+
refuse(controller, attachmentUploadError(413, "attachment_too_large", attachmentSizeErrorMessage(name, declaredBytes, limit)));
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
finalSniff = sniff2;
|
|
3116
|
+
if (held) controller.enqueue(held);
|
|
3117
|
+
held = void 0;
|
|
3118
|
+
controller.close();
|
|
3119
|
+
},
|
|
3120
|
+
cancel(reason) {
|
|
3121
|
+
return reader.cancel(reason).catch(() => void 0);
|
|
3122
|
+
}
|
|
3123
|
+
}, { highWaterMark: 0 });
|
|
3124
|
+
let written;
|
|
3125
|
+
try {
|
|
3126
|
+
written = await input.writer(input.scopeId, path, content, {
|
|
3127
|
+
mediaType: mediaTypeBeforeEnd,
|
|
3128
|
+
name,
|
|
3129
|
+
originalName: input.filename,
|
|
3130
|
+
size: declaredBytes
|
|
3131
|
+
});
|
|
3132
|
+
} catch (error) {
|
|
3133
|
+
if (!finalSniff) await reader.cancel().catch(() => void 0);
|
|
3134
|
+
if (refusal) return refusal;
|
|
3135
|
+
logAttachmentUploadError(logger, "[attachment-upload] stream writer failed", { path, error: redactErrorMessage(error) });
|
|
3136
|
+
return attachmentUploadError(503, "attachment_store_unavailable", ATTACHMENT_STORAGE_FAILURE_MESSAGE, path);
|
|
3137
|
+
}
|
|
3138
|
+
if (!finalSniff) await reader.cancel().catch(() => void 0);
|
|
3139
|
+
if (refusal) return refusal;
|
|
3140
|
+
const result = inspectLegacyAttachmentWriteResult(written);
|
|
3141
|
+
if (!result || result.ok && !finalSniff) {
|
|
3142
|
+
logAttachmentUploadError(logger, "[attachment-upload] stream writer returned without a checked file", { path });
|
|
3143
|
+
return attachmentUploadError(503, "attachment_store_unavailable", ATTACHMENT_STORAGE_FAILURE_MESSAGE, path);
|
|
3144
|
+
}
|
|
3145
|
+
if (!result.ok) {
|
|
3146
|
+
const reason = redactErrorMessage(result.reason);
|
|
3147
|
+
logAttachmentUploadError(logger, "[attachment-upload] stream write rejected", { path, error: reason });
|
|
3148
|
+
return attachmentUploadError(413, "attachment_write_failed", reason, path);
|
|
3149
|
+
}
|
|
3150
|
+
const sniff = finalSniff;
|
|
3151
|
+
const uploaded = {
|
|
3152
|
+
path,
|
|
3153
|
+
name,
|
|
3154
|
+
size: declaredBytes,
|
|
3155
|
+
mediaType: sniff.mime ?? sniffMime(name),
|
|
3156
|
+
kind: attachmentKindForMime(sniff.mime ?? "")
|
|
3157
|
+
};
|
|
3158
|
+
return Response.json({ files: [uploaded] });
|
|
3159
|
+
}
|
|
2936
3160
|
return async function attachmentUpload(request) {
|
|
2937
3161
|
const auth = await options.authorize({ request });
|
|
2938
3162
|
if (!auth.ok) return auth.response;
|
|
@@ -2941,16 +3165,55 @@ function createAttachmentUploadRoute(options) {
|
|
|
2941
3165
|
if (atomic && !atomicWriter) {
|
|
2942
3166
|
return attachmentUploadError(503, "attachment_store_unavailable", ATTACHMENT_STORAGE_FAILURE_MESSAGE);
|
|
2943
3167
|
}
|
|
2944
|
-
let form;
|
|
2945
|
-
try {
|
|
2946
|
-
form = await request.formData();
|
|
2947
|
-
} catch {
|
|
2948
|
-
return attachmentUploadError(400, "invalid_upload", "Expected a multipart/form-data body with file fields");
|
|
2949
|
-
}
|
|
2950
3168
|
const files = [];
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
3169
|
+
const contentType = request.headers.get("Content-Type")?.toLowerCase() ?? "";
|
|
3170
|
+
if (!contentType.startsWith("multipart/form-data")) {
|
|
3171
|
+
const target = rawUploadTarget(request);
|
|
3172
|
+
if (!target.succeeded) return target.response;
|
|
3173
|
+
const { filename, declaredBytes } = target;
|
|
3174
|
+
const name = sanitizeAttachmentFileName(filename);
|
|
3175
|
+
if (declaredBytes > largestFileBytes) {
|
|
3176
|
+
return attachmentUploadError(413, "attachment_too_large", attachmentSizeErrorMessage(name, declaredBytes, largestFileBytes));
|
|
3177
|
+
}
|
|
3178
|
+
if (declaredBytes > maxTotalBytes) {
|
|
3179
|
+
return attachmentUploadError(413, "attachments_total_too_large", attachmentTotalSizeErrorMessage(declaredBytes, maxTotalBytes));
|
|
3180
|
+
}
|
|
3181
|
+
if (streamWriter && declaredBytes > STREAM_SNIFF_HEAD_BYTES) {
|
|
3182
|
+
const path = pathFor(name);
|
|
3183
|
+
const pathCheck = validatePath(path);
|
|
3184
|
+
if (!pathCheck.succeeded) return attachmentUploadError(400, "invalid_attachment_path", pathCheck.error, path);
|
|
3185
|
+
const writer = auth.writeAttachmentStream ?? streamWriter;
|
|
3186
|
+
return streamRawUpload({ request, scopeId: auth.scopeId, writer, filename, name, path, declaredBytes });
|
|
3187
|
+
}
|
|
3188
|
+
const body = await readRawBody(request, declaredBytes);
|
|
3189
|
+
if (!body.succeeded) return body.response;
|
|
3190
|
+
files.push(new File([body.value], filename));
|
|
3191
|
+
} else {
|
|
3192
|
+
let formSource = request;
|
|
3193
|
+
if (maxFormBytes !== void 0) {
|
|
3194
|
+
const bounded = await readBodyUpTo(request, maxFormBytes);
|
|
3195
|
+
if (!bounded.succeeded && !bounded.tooLarge) {
|
|
3196
|
+
return attachmentUploadError(400, "invalid_upload", "The upload body ended early");
|
|
3197
|
+
}
|
|
3198
|
+
if (!bounded.succeeded) {
|
|
3199
|
+
return attachmentUploadError(
|
|
3200
|
+
413,
|
|
3201
|
+
"form_upload_too_large",
|
|
3202
|
+
`Form uploads are limited to ${formatBytes(maxFormBytes)}; send a larger file as a raw request body named by the filename query parameter`
|
|
3203
|
+
);
|
|
3204
|
+
}
|
|
3205
|
+
formSource = new Response(bounded.value, { headers: { "Content-Type": request.headers.get("Content-Type") ?? "" } });
|
|
3206
|
+
}
|
|
3207
|
+
let form;
|
|
3208
|
+
try {
|
|
3209
|
+
form = await formSource.formData();
|
|
3210
|
+
} catch {
|
|
3211
|
+
return attachmentUploadError(400, "invalid_upload", "Expected a multipart/form-data body with file fields");
|
|
3212
|
+
}
|
|
3213
|
+
form.forEach((value) => {
|
|
3214
|
+
if (value instanceof File) files.push(value);
|
|
3215
|
+
});
|
|
3216
|
+
}
|
|
2954
3217
|
if (files.length === 0) {
|
|
2955
3218
|
return attachmentUploadError(400, "invalid_upload", "No files in the upload body");
|
|
2956
3219
|
}
|
|
@@ -2992,10 +3255,7 @@ function createAttachmentUploadRoute(options) {
|
|
|
2992
3255
|
`${name} is a "${kind}" attachment, which this upload route does not accept`
|
|
2993
3256
|
);
|
|
2994
3257
|
}
|
|
2995
|
-
|
|
2996
|
-
if (sniff.binary) {
|
|
2997
|
-
limit = sniff.mime === null ? maxBinaryBytes : maxBytesBySniffedMime?.get(sniff.mime) ?? maxBinaryBytes;
|
|
2998
|
-
}
|
|
3258
|
+
const limit = limitFor(sniff);
|
|
2999
3259
|
if (bytes.length > limit) {
|
|
3000
3260
|
return attachmentUploadError(
|
|
3001
3261
|
413,
|
|
@@ -3521,6 +3781,8 @@ export {
|
|
|
3521
3781
|
OOXML_WORD_MIME,
|
|
3522
3782
|
PROMOTE_MAX_FILE_BYTES,
|
|
3523
3783
|
RUNTIME_RESTARTED_ERROR,
|
|
3784
|
+
STREAM_SNIFF_HEAD_BYTES,
|
|
3785
|
+
STREAM_SNIFF_TAIL_BYTES,
|
|
3524
3786
|
TOOL_ABORTED_ERROR,
|
|
3525
3787
|
TURN_INTENT_EVENT,
|
|
3526
3788
|
TURN_PHASE_EVENT,
|
|
@@ -3554,6 +3816,7 @@ export {
|
|
|
3554
3816
|
createRouterTurnProducer,
|
|
3555
3817
|
createSandboxChatProducer,
|
|
3556
3818
|
createSandboxFileIndexRoute,
|
|
3819
|
+
createStreamingSniffer,
|
|
3557
3820
|
createUploadRoute,
|
|
3558
3821
|
defaultValidateAttachmentPath,
|
|
3559
3822
|
fileMentionsToParts,
|
|
@@ -3590,6 +3853,7 @@ export {
|
|
|
3590
3853
|
settleTurnResponse,
|
|
3591
3854
|
sniffBinary,
|
|
3592
3855
|
sniffMimeFromName,
|
|
3856
|
+
sniffedMimeImpliedByName,
|
|
3593
3857
|
storeSupportsDraftPersistence,
|
|
3594
3858
|
streamChatRouteAsSandboxEvents,
|
|
3595
3859
|
streamPlanFollowUpEvents,
|