@agent-native/core 0.168.5 → 0.168.6
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/corpus/README.md +1 -1
- package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +147 -24
- package/corpus/templates/slides/server/handlers/uploads-chunked.ts +317 -0
- package/corpus/templates/slides/server/handlers/uploads.ts +3 -4
- package/corpus/templates/slides/server/lib/chunked-upload-session.ts +54 -0
- package/corpus/templates/slides/server/routes/api/uploads-chunked/[sessionId]/chunk.post.ts +1 -0
- package/corpus/templates/slides/server/routes/api/uploads-chunked/start.post.ts +1 -0
- package/corpus/templates/slides/shared/upload-types.ts +1 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/builder.js +24 -0
- package/dist/file-upload/index.d.ts +2 -2
- package/dist/file-upload/index.js +1 -1
- package/dist/file-upload/registry.d.ts +2 -1
- package/dist/file-upload/registry.js +8 -0
- package/dist/file-upload/s3.js +59 -1
- package/dist/file-upload/types.d.ts +6 -0
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/private-blob/registry.d.ts +0 -5
- package/dist/private-blob/registry.js +25 -18
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/routes.d.ts +6 -6
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -14,7 +14,10 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
|
|
14
14
|
import { createPortal } from "react-dom";
|
|
15
15
|
import { toast } from "sonner";
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
MAX_REFERENCE_FILE_BYTES,
|
|
19
|
+
MAX_REFERENCE_FILES,
|
|
20
|
+
} from "../../../shared/upload-types";
|
|
18
21
|
import { Button } from "../ui/button";
|
|
19
22
|
import { Input } from "../ui/input";
|
|
20
23
|
import { GoogleDocImportHint } from "./GoogleDocImportHint";
|
|
@@ -29,39 +32,159 @@ export interface UploadedFile {
|
|
|
29
32
|
size: number;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
35
|
-
|
|
35
|
+
// Netlify functions cap request bodies well under what a real PPTX/PDF
|
|
36
|
+
// needs, so any file above this size streams through the chunked upload
|
|
37
|
+
// endpoints (sub-4 MB slices, reassembled server-side) instead of one
|
|
38
|
+
// multipart POST.
|
|
39
|
+
const CHUNK_UPLOAD_THRESHOLD_BYTES = 4 * 1024 * 1024;
|
|
40
|
+
const CHUNK_SIZE_BYTES = 4 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
async function readUploadJson(response: Response): Promise<unknown> {
|
|
43
|
+
try {
|
|
44
|
+
return await response.json();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new Error(`Upload returned invalid JSON (${response.status})`, {
|
|
47
|
+
cause: error,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractErrorMessage(data: unknown): string | null {
|
|
53
|
+
if (
|
|
54
|
+
data &&
|
|
55
|
+
typeof data === "object" &&
|
|
56
|
+
"error" in data &&
|
|
57
|
+
typeof (data as { error: unknown }).error === "string" &&
|
|
58
|
+
(data as { error: string }).error.trim()
|
|
59
|
+
) {
|
|
60
|
+
return (data as { error: string }).error;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function uploadFilesMultipart(files: File[]): Promise<UploadedFile[]> {
|
|
36
66
|
const formData = new FormData();
|
|
37
67
|
files.forEach((file) => formData.append("files", file));
|
|
38
|
-
ensureEmbedAuthFetchInterceptor();
|
|
39
68
|
const response = await fetch(`${appBasePath()}/api/uploads`, {
|
|
40
69
|
method: "POST",
|
|
41
70
|
body: formData,
|
|
42
71
|
credentials: "include",
|
|
43
72
|
});
|
|
73
|
+
const data = await readUploadJson(response);
|
|
44
74
|
if (!response.ok) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
throw new Error(
|
|
76
|
+
extractErrorMessage(data) || `Upload failed (${response.status})`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(data)) {
|
|
80
|
+
throw new Error("Upload failed: invalid response");
|
|
81
|
+
}
|
|
82
|
+
return data as UploadedFile[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function uploadFileChunked(file: File): Promise<UploadedFile> {
|
|
86
|
+
const startResponse = await fetch(
|
|
87
|
+
`${appBasePath()}/api/uploads-chunked/start`,
|
|
88
|
+
{
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
credentials: "include",
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
filename: file.name,
|
|
94
|
+
mimetype: file.type || "application/octet-stream",
|
|
95
|
+
declaredSize: file.size,
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
const startData = await readUploadJson(startResponse);
|
|
100
|
+
if (!startResponse.ok) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
extractErrorMessage(startData) ||
|
|
103
|
+
`Upload failed (${startResponse.status})`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
startData &&
|
|
108
|
+
typeof startData === "object" &&
|
|
109
|
+
(startData as { uploadMode?: unknown }).uploadMode === "multipart"
|
|
110
|
+
) {
|
|
111
|
+
const [uploaded] = await uploadFilesMultipart([file]);
|
|
112
|
+
if (!uploaded) throw new Error("Upload failed: no file returned");
|
|
113
|
+
return uploaded;
|
|
114
|
+
}
|
|
115
|
+
const sessionId =
|
|
116
|
+
startData && typeof startData === "object"
|
|
117
|
+
? (startData as { sessionId?: unknown }).sessionId
|
|
118
|
+
: undefined;
|
|
119
|
+
if (typeof sessionId !== "string" || !sessionId) {
|
|
120
|
+
throw new Error("Upload failed: session ID missing");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE_BYTES));
|
|
124
|
+
for (let index = 0; index < totalChunks; index++) {
|
|
125
|
+
const start = index * CHUNK_SIZE_BYTES;
|
|
126
|
+
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
|
|
127
|
+
const isFinal = index === totalChunks - 1;
|
|
128
|
+
const chunkResponse = await fetch(
|
|
129
|
+
`${appBasePath()}/api/uploads-chunked/${sessionId}/chunk?index=${index}&isFinal=${
|
|
130
|
+
isFinal ? "1" : "0"
|
|
131
|
+
}`,
|
|
132
|
+
{
|
|
133
|
+
method: "POST",
|
|
134
|
+
credentials: "include",
|
|
135
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
136
|
+
body: file.slice(start, end),
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
const chunkData = await readUploadJson(chunkResponse);
|
|
140
|
+
if (!chunkResponse.ok) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
extractErrorMessage(chunkData) ||
|
|
143
|
+
`Upload failed (${chunkResponse.status})`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (isFinal) {
|
|
147
|
+
const result = Array.isArray(chunkData)
|
|
148
|
+
? (chunkData[0] as UploadedFile)
|
|
149
|
+
: undefined;
|
|
150
|
+
if (!result) throw new Error("Upload failed: no file returned");
|
|
151
|
+
return result;
|
|
61
152
|
}
|
|
62
|
-
throw new Error(message);
|
|
63
153
|
}
|
|
64
|
-
|
|
154
|
+
throw new Error("Upload failed: no final chunk response");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function uploadPromptFiles(
|
|
158
|
+
files: File[],
|
|
159
|
+
): Promise<UploadedFile[]> {
|
|
160
|
+
if (files.length === 0) return [];
|
|
161
|
+
if (files.length > MAX_REFERENCE_FILES) {
|
|
162
|
+
throw new Error(`Too many files (max ${MAX_REFERENCE_FILES})`);
|
|
163
|
+
}
|
|
164
|
+
ensureEmbedAuthFetchInterceptor();
|
|
165
|
+
const smallIndices = files.flatMap((file, index) =>
|
|
166
|
+
file.size <= CHUNK_UPLOAD_THRESHOLD_BYTES ? [index] : [],
|
|
167
|
+
);
|
|
168
|
+
const largeIndices = files.flatMap((file, index) =>
|
|
169
|
+
file.size > CHUNK_UPLOAD_THRESHOLD_BYTES ? [index] : [],
|
|
170
|
+
);
|
|
171
|
+
const [smallUploads, largeUploads] = await Promise.all([
|
|
172
|
+
smallIndices.length > 0
|
|
173
|
+
? uploadFilesMultipart(smallIndices.map((index) => files[index]))
|
|
174
|
+
: [],
|
|
175
|
+
Promise.all(largeIndices.map((index) => uploadFileChunked(files[index]))),
|
|
176
|
+
]);
|
|
177
|
+
if (smallUploads.length !== smallIndices.length) {
|
|
178
|
+
throw new Error("Upload failed: response file count did not match request");
|
|
179
|
+
}
|
|
180
|
+
const uploads = new Array<UploadedFile>(files.length);
|
|
181
|
+
smallIndices.forEach((fileIndex, resultIndex) => {
|
|
182
|
+
uploads[fileIndex] = smallUploads[resultIndex];
|
|
183
|
+
});
|
|
184
|
+
largeIndices.forEach((fileIndex, resultIndex) => {
|
|
185
|
+
uploads[fileIndex] = largeUploads[resultIndex];
|
|
186
|
+
});
|
|
187
|
+
return uploads;
|
|
65
188
|
}
|
|
66
189
|
|
|
67
190
|
/**
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deletePrivateBlob,
|
|
3
|
+
putPrivateBlob,
|
|
4
|
+
readPrivateBlob,
|
|
5
|
+
} from "@agent-native/core/private-blob";
|
|
6
|
+
import {
|
|
7
|
+
defineEventHandler,
|
|
8
|
+
getHeader,
|
|
9
|
+
getQuery,
|
|
10
|
+
getRouterParam,
|
|
11
|
+
readBody,
|
|
12
|
+
readRawBody,
|
|
13
|
+
setResponseStatus,
|
|
14
|
+
} from "h3";
|
|
15
|
+
import { nanoid } from "nanoid";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
createChunkedUploadSession,
|
|
19
|
+
deleteChunkedUploadSession,
|
|
20
|
+
getChunkedUploadSession,
|
|
21
|
+
listChunkedUploadSessions,
|
|
22
|
+
type ChunkedUploadSession,
|
|
23
|
+
} from "../lib/chunked-upload-session.js";
|
|
24
|
+
import { isHostedSlidesRuntime } from "../lib/tenant-files.js";
|
|
25
|
+
import {
|
|
26
|
+
resolveSlidesRequestAuth,
|
|
27
|
+
withSlidesRequestContext,
|
|
28
|
+
} from "./request-auth-context.js";
|
|
29
|
+
import { maxReferenceFileBytes, saveUploadedReferenceFile } from "./uploads.js";
|
|
30
|
+
|
|
31
|
+
const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
32
|
+
const MAX_CHUNKS = 128;
|
|
33
|
+
const SESSION_TTL_MS = 60 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
interface StartBody {
|
|
36
|
+
filename?: unknown;
|
|
37
|
+
mimetype?: unknown;
|
|
38
|
+
declaredSize?: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function deleteChunk(handle: ChunkedUploadSession["chunks"][string]) {
|
|
42
|
+
return (await deletePrivateBlob(handle)).deleted;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function cleanupChunks(session: ChunkedUploadSession): Promise<boolean> {
|
|
46
|
+
const results = await Promise.all(
|
|
47
|
+
Object.values(session.chunks).map(deleteChunk),
|
|
48
|
+
);
|
|
49
|
+
return results.every(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function discardSession(
|
|
53
|
+
sessionId: string,
|
|
54
|
+
session: ChunkedUploadSession,
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
const cleaned = await cleanupChunks(session);
|
|
57
|
+
if (cleaned) await deleteChunkedUploadSession(sessionId);
|
|
58
|
+
return cleaned;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function reapExpiredChunkedUploads(): Promise<void> {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const sessions = await listChunkedUploadSessions();
|
|
64
|
+
await Promise.all(
|
|
65
|
+
sessions.map(async ({ sessionId, session }) => {
|
|
66
|
+
const expiresAt = Date.parse(session.expiresAt);
|
|
67
|
+
if (Number.isFinite(expiresAt) && expiresAt > now) return;
|
|
68
|
+
try {
|
|
69
|
+
const cleaned = await discardSession(sessionId, session);
|
|
70
|
+
if (!cleaned) {
|
|
71
|
+
console.warn("[slides-upload] expired session cleanup incomplete", {
|
|
72
|
+
sessionId,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.warn("[slides-upload] expired session cleanup failed", {
|
|
77
|
+
sessionId,
|
|
78
|
+
error: error instanceof Error ? error.message : String(error),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function cleanupCommittedSession(
|
|
86
|
+
sessionId: string,
|
|
87
|
+
session: ChunkedUploadSession,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
try {
|
|
90
|
+
const cleaned = await discardSession(sessionId, session);
|
|
91
|
+
if (!cleaned) {
|
|
92
|
+
console.warn("[slides-upload] committed session cleanup incomplete", {
|
|
93
|
+
sessionId,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.warn("[slides-upload] committed session cleanup failed", {
|
|
98
|
+
sessionId,
|
|
99
|
+
error: error instanceof Error ? error.message : String(error),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const startChunkedUpload = defineEventHandler(async (event) => {
|
|
105
|
+
const auth = await resolveSlidesRequestAuth(event);
|
|
106
|
+
if (!auth.ok) {
|
|
107
|
+
setResponseStatus(event, auth.statusCode);
|
|
108
|
+
return { error: auth.error };
|
|
109
|
+
}
|
|
110
|
+
const authContext = auth.context;
|
|
111
|
+
if (!authContext.email) {
|
|
112
|
+
setResponseStatus(event, 401);
|
|
113
|
+
return { error: "Unauthorized" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return withSlidesRequestContext(
|
|
117
|
+
event,
|
|
118
|
+
async () => {
|
|
119
|
+
if (!isHostedSlidesRuntime()) {
|
|
120
|
+
return { uploadMode: "multipart" as const };
|
|
121
|
+
}
|
|
122
|
+
await reapExpiredChunkedUploads();
|
|
123
|
+
const body = (await readBody(event).catch(
|
|
124
|
+
() => null,
|
|
125
|
+
)) as StartBody | null;
|
|
126
|
+
const filename =
|
|
127
|
+
typeof body?.filename === "string" ? body.filename.trim() : "";
|
|
128
|
+
const mimetype =
|
|
129
|
+
typeof body?.mimetype === "string" && body.mimetype.trim()
|
|
130
|
+
? body.mimetype.trim()
|
|
131
|
+
: "application/octet-stream";
|
|
132
|
+
const declaredSize = Number(body?.declaredSize);
|
|
133
|
+
if (!filename) {
|
|
134
|
+
setResponseStatus(event, 400);
|
|
135
|
+
return { error: "filename is required" };
|
|
136
|
+
}
|
|
137
|
+
if (!Number.isSafeInteger(declaredSize) || declaredSize <= 0) {
|
|
138
|
+
setResponseStatus(event, 400);
|
|
139
|
+
return { error: "declaredSize must be a positive integer" };
|
|
140
|
+
}
|
|
141
|
+
const limit = maxReferenceFileBytes(filename);
|
|
142
|
+
if (declaredSize > limit) {
|
|
143
|
+
setResponseStatus(event, 413);
|
|
144
|
+
return {
|
|
145
|
+
error: `File too large (max ${Math.round(limit / 1024 / 1024)} MB)`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const sessionId = nanoid();
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
await createChunkedUploadSession(sessionId, {
|
|
152
|
+
filename,
|
|
153
|
+
mimeType: mimetype,
|
|
154
|
+
declaredSize,
|
|
155
|
+
chunks: {},
|
|
156
|
+
chunkSizes: {},
|
|
157
|
+
createdAt: new Date(now).toISOString(),
|
|
158
|
+
expiresAt: new Date(now + SESSION_TTL_MS).toISOString(),
|
|
159
|
+
});
|
|
160
|
+
return { sessionId, maxChunkBytes: MAX_CHUNK_BYTES };
|
|
161
|
+
},
|
|
162
|
+
authContext,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
export const uploadChunkedChunk = defineEventHandler(async (event) => {
|
|
167
|
+
const auth = await resolveSlidesRequestAuth(event);
|
|
168
|
+
if (!auth.ok) {
|
|
169
|
+
setResponseStatus(event, auth.statusCode);
|
|
170
|
+
return { error: auth.error };
|
|
171
|
+
}
|
|
172
|
+
const authContext = auth.context;
|
|
173
|
+
const email = authContext.email;
|
|
174
|
+
if (!email) {
|
|
175
|
+
setResponseStatus(event, 401);
|
|
176
|
+
return { error: "Unauthorized" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return withSlidesRequestContext(
|
|
180
|
+
event,
|
|
181
|
+
async ({ orgId }) => {
|
|
182
|
+
const sessionId = getRouterParam(event, "sessionId");
|
|
183
|
+
if (!sessionId) {
|
|
184
|
+
setResponseStatus(event, 400);
|
|
185
|
+
return { error: "Missing sessionId" };
|
|
186
|
+
}
|
|
187
|
+
const session = await getChunkedUploadSession(sessionId);
|
|
188
|
+
if (!session) {
|
|
189
|
+
setResponseStatus(event, 404);
|
|
190
|
+
return { error: "Upload session not found or expired" };
|
|
191
|
+
}
|
|
192
|
+
if (Date.parse(session.expiresAt) <= Date.now()) {
|
|
193
|
+
await discardSession(sessionId, session);
|
|
194
|
+
setResponseStatus(event, 410);
|
|
195
|
+
return { error: "Upload session expired" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const query = getQuery(event);
|
|
199
|
+
const index = Number(query.index ?? 0);
|
|
200
|
+
const isFinal = query.isFinal === "1" || query.isFinal === "true";
|
|
201
|
+
if (!Number.isInteger(index) || index < 0 || index >= MAX_CHUNKS) {
|
|
202
|
+
setResponseStatus(event, 400);
|
|
203
|
+
return { error: "Invalid chunk index" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const contentLengthHeader = getHeader(event, "content-length");
|
|
207
|
+
if (!contentLengthHeader || !/^\d+$/.test(contentLengthHeader)) {
|
|
208
|
+
setResponseStatus(event, 411);
|
|
209
|
+
return { error: "Valid Content-Length header required" };
|
|
210
|
+
}
|
|
211
|
+
const contentLength = Number(contentLengthHeader);
|
|
212
|
+
if (contentLength <= 0) {
|
|
213
|
+
setResponseStatus(event, 400);
|
|
214
|
+
return { error: "Empty chunk body" };
|
|
215
|
+
}
|
|
216
|
+
if (contentLength > MAX_CHUNK_BYTES) {
|
|
217
|
+
setResponseStatus(event, 413);
|
|
218
|
+
return { error: "Chunk too large" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const chunkKey = String(index);
|
|
222
|
+
const previousSize = session.chunkSizes[chunkKey] ?? 0;
|
|
223
|
+
const receivedBefore = Object.values(session.chunkSizes).reduce(
|
|
224
|
+
(total, size) => total + size,
|
|
225
|
+
0,
|
|
226
|
+
);
|
|
227
|
+
const nextSize = receivedBefore - previousSize + contentLength;
|
|
228
|
+
const fileLimit = maxReferenceFileBytes(session.filename);
|
|
229
|
+
if (nextSize > session.declaredSize || nextSize > fileLimit) {
|
|
230
|
+
await discardSession(sessionId, session);
|
|
231
|
+
setResponseStatus(event, 413);
|
|
232
|
+
return { error: "Uploaded bytes exceed the declared file size" };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const raw = await readRawBody(event, false);
|
|
236
|
+
const bytes = raw ?? new Uint8Array(0);
|
|
237
|
+
if (bytes.byteLength !== contentLength) {
|
|
238
|
+
setResponseStatus(event, 400);
|
|
239
|
+
return { error: "Chunk size does not match Content-Length" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const previousHandle = session.chunks[chunkKey];
|
|
243
|
+
if (previousHandle && !(await deleteChunk(previousHandle))) {
|
|
244
|
+
setResponseStatus(event, 503);
|
|
245
|
+
return { error: "Could not replace the previously uploaded chunk" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const handle = await putPrivateBlob({
|
|
249
|
+
data: bytes,
|
|
250
|
+
filename: `${sessionId}-${index}`,
|
|
251
|
+
mimeType: "application/octet-stream",
|
|
252
|
+
ownerEmail: email,
|
|
253
|
+
});
|
|
254
|
+
if (!handle) {
|
|
255
|
+
setResponseStatus(event, 503);
|
|
256
|
+
return { error: "Upload storage is not available" };
|
|
257
|
+
}
|
|
258
|
+
session.chunks[chunkKey] = handle;
|
|
259
|
+
session.chunkSizes[chunkKey] = bytes.byteLength;
|
|
260
|
+
await createChunkedUploadSession(sessionId, session);
|
|
261
|
+
|
|
262
|
+
if (!isFinal) return { ok: true };
|
|
263
|
+
|
|
264
|
+
const orderedIndices = Object.keys(session.chunks)
|
|
265
|
+
.map(Number)
|
|
266
|
+
.sort((a, b) => a - b);
|
|
267
|
+
const missing = orderedIndices.some((value, i) => value !== i);
|
|
268
|
+
const receivedSize = Object.values(session.chunkSizes).reduce(
|
|
269
|
+
(total, size) => total + size,
|
|
270
|
+
0,
|
|
271
|
+
);
|
|
272
|
+
if (
|
|
273
|
+
missing ||
|
|
274
|
+
orderedIndices.length === 0 ||
|
|
275
|
+
receivedSize !== session.declaredSize
|
|
276
|
+
) {
|
|
277
|
+
await discardSession(sessionId, session);
|
|
278
|
+
setResponseStatus(event, 400);
|
|
279
|
+
return { error: "Upload is incomplete or has an invalid size" };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let result;
|
|
283
|
+
try {
|
|
284
|
+
const parts = await Promise.all(
|
|
285
|
+
orderedIndices.map(async (chunkIndex) => {
|
|
286
|
+
const chunkHandle = session.chunks[String(chunkIndex)];
|
|
287
|
+
const read = await readPrivateBlob(chunkHandle);
|
|
288
|
+
return Buffer.from(read.data);
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
const combined = Buffer.concat(parts);
|
|
292
|
+
if (combined.byteLength !== session.declaredSize) {
|
|
293
|
+
throw new Error("Assembled upload size does not match declaredSize");
|
|
294
|
+
}
|
|
295
|
+
result = await saveUploadedReferenceFile({
|
|
296
|
+
email,
|
|
297
|
+
orgId,
|
|
298
|
+
originalName: session.filename,
|
|
299
|
+
data: combined,
|
|
300
|
+
type: session.mimeType,
|
|
301
|
+
});
|
|
302
|
+
} catch (err) {
|
|
303
|
+
await discardSession(sessionId, session);
|
|
304
|
+
const statusCode =
|
|
305
|
+
typeof (err as { statusCode?: unknown })?.statusCode === "number"
|
|
306
|
+
? (err as { statusCode: number }).statusCode
|
|
307
|
+
: 400;
|
|
308
|
+
setResponseStatus(event, statusCode);
|
|
309
|
+
return { error: err instanceof Error ? err.message : "Invalid upload" };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await cleanupCommittedSession(sessionId, session);
|
|
313
|
+
return [result];
|
|
314
|
+
},
|
|
315
|
+
authContext,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
@@ -11,6 +11,7 @@ import { nanoid } from "nanoid";
|
|
|
11
11
|
import {
|
|
12
12
|
MAX_FIG_REFERENCE_FILE_BYTES,
|
|
13
13
|
MAX_REFERENCE_FILE_BYTES,
|
|
14
|
+
MAX_REFERENCE_FILES,
|
|
14
15
|
SLIDES_REFERENCE_FILE_ERROR_LABEL,
|
|
15
16
|
isSlidesReferenceFileExtension,
|
|
16
17
|
} from "../../shared/upload-types.js";
|
|
@@ -237,11 +238,9 @@ export const uploadFiles = defineEventHandler(async (event) => {
|
|
|
237
238
|
return { error: "No files uploaded" };
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (fileParts.length > MAX_FILES) {
|
|
241
|
+
if (fileParts.length > MAX_REFERENCE_FILES) {
|
|
243
242
|
setResponseStatus(event, 413);
|
|
244
|
-
return { error: `Too many files (max ${
|
|
243
|
+
return { error: `Too many files (max ${MAX_REFERENCE_FILES})` };
|
|
245
244
|
}
|
|
246
245
|
|
|
247
246
|
const oversized = fileParts.find(
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deleteAppState,
|
|
3
|
+
listAppState,
|
|
4
|
+
readAppState,
|
|
5
|
+
writeAppState,
|
|
6
|
+
} from "@agent-native/core/application-state";
|
|
7
|
+
import type { PrivateBlobHandle } from "@agent-native/core/private-blob";
|
|
8
|
+
|
|
9
|
+
export interface ChunkedUploadSession {
|
|
10
|
+
filename: string;
|
|
11
|
+
mimeType: string;
|
|
12
|
+
declaredSize: number;
|
|
13
|
+
chunks: Record<string, PrivateBlobHandle>;
|
|
14
|
+
chunkSizes: Record<string, number>;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PREFIX = "slides-upload-chunks-";
|
|
20
|
+
const key = (sessionId: string) => `${PREFIX}${sessionId}`;
|
|
21
|
+
|
|
22
|
+
export async function createChunkedUploadSession(
|
|
23
|
+
sessionId: string,
|
|
24
|
+
session: ChunkedUploadSession,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
await writeAppState(
|
|
27
|
+
key(sessionId),
|
|
28
|
+
session as unknown as Record<string, unknown>,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getChunkedUploadSession(
|
|
33
|
+
sessionId: string,
|
|
34
|
+
): Promise<ChunkedUploadSession | null> {
|
|
35
|
+
const raw = await readAppState(key(sessionId));
|
|
36
|
+
if (!raw || typeof raw !== "object") return null;
|
|
37
|
+
return raw as unknown as ChunkedUploadSession;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function listChunkedUploadSessions(): Promise<
|
|
41
|
+
Array<{ sessionId: string; session: ChunkedUploadSession }>
|
|
42
|
+
> {
|
|
43
|
+
const entries = await listAppState(PREFIX);
|
|
44
|
+
return entries.map(({ key: entryKey, value }) => ({
|
|
45
|
+
sessionId: entryKey.slice(PREFIX.length),
|
|
46
|
+
session: value as unknown as ChunkedUploadSession,
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function deleteChunkedUploadSession(
|
|
51
|
+
sessionId: string,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
await deleteAppState(key(sessionId));
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { uploadChunkedChunk as default } from "../../../../handlers/uploads-chunked";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { startChunkedUpload as default } from "../../../handlers/uploads-chunked";
|
|
@@ -25,6 +25,7 @@ export const SLIDES_REFERENCE_FILE_LABEL =
|
|
|
25
25
|
export const SLIDES_REFERENCE_FILE_ERROR_LABEL =
|
|
26
26
|
"pptx, docx, pdf, fig, text, Markdown, JSON, CSV, and images including SVG";
|
|
27
27
|
|
|
28
|
+
export const MAX_REFERENCE_FILES = 20;
|
|
28
29
|
export const MAX_REFERENCE_FILE_BYTES = 50 * 1024 * 1024;
|
|
29
30
|
export const MAX_FIG_REFERENCE_FILE_BYTES = 200 * 1024 * 1024;
|
|
30
31
|
|
|
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
|
|
|
62
62
|
error: string;
|
|
63
63
|
states?: undefined;
|
|
64
64
|
} | {
|
|
65
|
-
error?: undefined;
|
|
66
65
|
states: {
|
|
67
66
|
clientId: number;
|
|
68
67
|
state: string;
|
|
69
68
|
}[];
|
|
69
|
+
error?: undefined;
|
|
70
70
|
}>>;
|
|
71
71
|
/**
|
|
72
72
|
* GET /_agent-native/collab/:docId/users
|
|
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
|
|
|
77
77
|
error: string;
|
|
78
78
|
users?: undefined;
|
|
79
79
|
} | {
|
|
80
|
-
error?: undefined;
|
|
81
80
|
users: {
|
|
82
81
|
clientId: number;
|
|
83
82
|
lastSeen: number;
|
|
84
83
|
}[];
|
|
84
|
+
error?: undefined;
|
|
85
85
|
}>>;
|
package/dist/collab/routes.d.ts
CHANGED
|
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
|
|
|
26
26
|
* Body: { update: string (base64), requestSource?: string }
|
|
27
27
|
*/
|
|
28
28
|
export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
29
|
-
ok?: undefined;
|
|
30
29
|
error: string;
|
|
30
|
+
ok?: undefined;
|
|
31
31
|
} | {
|
|
32
32
|
error?: undefined;
|
|
33
33
|
ok: boolean;
|
|
@@ -199,6 +199,30 @@ export const builderFileUploadProvider = {
|
|
|
199
199
|
console.log(`[builder-upload] done: ${json.url}`);
|
|
200
200
|
return { url: json.url, id: json.id, provider: "builder" };
|
|
201
201
|
},
|
|
202
|
+
delete: async ({ url }) => {
|
|
203
|
+
const assetUrl = new URL(url);
|
|
204
|
+
if (assetUrl.hostname !== "cdn.builder.io")
|
|
205
|
+
return false;
|
|
206
|
+
assetUrl.search = "";
|
|
207
|
+
assetUrl.hash = "";
|
|
208
|
+
const { resolveBuilderCredentials } = await import("../server/credential-provider.js");
|
|
209
|
+
const credentials = await resolveBuilderCredentials();
|
|
210
|
+
if (!credentials.privateKey || !credentials.publicKey)
|
|
211
|
+
return false;
|
|
212
|
+
const deleteUrl = new URL("/api/v1/assets/by-url", "https://cdn.builder.io");
|
|
213
|
+
deleteUrl.searchParams.set("url", assetUrl.toString());
|
|
214
|
+
deleteUrl.searchParams.set("apiKey", credentials.publicKey);
|
|
215
|
+
const response = await fetchWithTimeout(deleteUrl.toString(), {
|
|
216
|
+
method: "DELETE",
|
|
217
|
+
headers: { Authorization: `Bearer ${credentials.privateKey}` },
|
|
218
|
+
});
|
|
219
|
+
if (response.ok)
|
|
220
|
+
return true;
|
|
221
|
+
if (response.status === 404)
|
|
222
|
+
return false;
|
|
223
|
+
await assertOk(response, "Builder.io asset delete failed");
|
|
224
|
+
return false;
|
|
225
|
+
},
|
|
202
226
|
resumable: {
|
|
203
227
|
async startSession(filename, mimeType, maxBytes) {
|
|
204
228
|
const { resolveBuilderPrivateKey } = await import("../server/credential-provider.js");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { FileUploadInput, FileUploadProvider, FileUploadResult, ResumableUploadSession, ResumableChunkResult, } from "./types.js";
|
|
2
|
-
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, uploadFile, } from "./registry.js";
|
|
1
|
+
export type { FileUploadDeleteInput, FileUploadInput, FileUploadProvider, FileUploadResult, ResumableUploadSession, ResumableChunkResult, } from "./types.js";
|
|
2
|
+
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, deleteUploadedFile, uploadFile, } from "./registry.js";
|
|
3
3
|
export { builderFileUploadProvider } from "./builder.js";
|
|
4
4
|
export { preUploadImageAttachments, preUploadAttachments, isFileUploadProviderConfigured, type PreUploadAttachmentsResult, type PreUploadedImageAttachment, type PreUploadedFileAttachment, } from "./pre-upload-attachments.js";
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, uploadFile, } from "./registry.js";
|
|
1
|
+
export { registerFileUploadProvider, unregisterFileUploadProvider, listFileUploadProviders, getActiveFileUploadProvider, getActiveFileUploadProviderForRequest, deleteUploadedFile, uploadFile, } from "./registry.js";
|
|
2
2
|
export { builderFileUploadProvider } from "./builder.js";
|
|
3
3
|
export { preUploadImageAttachments, preUploadAttachments, isFileUploadProviderConfigured, } from "./pre-upload-attachments.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FileUploadInput, FileUploadProvider, FileUploadResult } from "./types.js";
|
|
1
|
+
import type { FileUploadDeleteInput, FileUploadInput, FileUploadProvider, FileUploadResult } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Register a file upload provider. Call from a server plugin or app
|
|
4
4
|
* bootstrap. Idempotent per id — later calls with the same id replace.
|
|
@@ -19,4 +19,5 @@ export declare function getActiveFileUploadProviderForRequest(): Promise<FileUpl
|
|
|
19
19
|
* configured. `null` is an explicit storage-setup state: callers must not
|
|
20
20
|
* turn the input into a base64 SQL fallback.
|
|
21
21
|
*/
|
|
22
|
+
export declare function deleteUploadedFile(providerId: string, input: FileUploadDeleteInput): Promise<boolean>;
|
|
22
23
|
export declare function uploadFile(input: FileUploadInput): Promise<FileUploadResult | null>;
|
|
@@ -59,6 +59,14 @@ export async function getActiveFileUploadProviderForRequest() {
|
|
|
59
59
|
* configured. `null` is an explicit storage-setup state: callers must not
|
|
60
60
|
* turn the input into a base64 SQL fallback.
|
|
61
61
|
*/
|
|
62
|
+
export async function deleteUploadedFile(providerId, input) {
|
|
63
|
+
const provider = providerId === builderFileUploadProvider.id
|
|
64
|
+
? builderFileUploadProvider
|
|
65
|
+
: providers.get(providerId);
|
|
66
|
+
if (!provider?.delete)
|
|
67
|
+
return false;
|
|
68
|
+
return provider.delete(input);
|
|
69
|
+
}
|
|
62
70
|
export async function uploadFile(input) {
|
|
63
71
|
const provider = await getActiveFileUploadProviderForRequest();
|
|
64
72
|
// User-registered providers (S3, etc.) may be configured by sync runtime
|
package/dist/file-upload/s3.js
CHANGED
|
@@ -146,6 +146,56 @@ async function putObject(config, key, data, contentType) {
|
|
|
146
146
|
}
|
|
147
147
|
return `${config.publicBaseUrl}/${key.split("/").map(encodePathSegment).join("/")}`;
|
|
148
148
|
}
|
|
149
|
+
async function deleteObject(config, key) {
|
|
150
|
+
const now = new Date();
|
|
151
|
+
const amzDate = now
|
|
152
|
+
.toISOString()
|
|
153
|
+
.replace(/[:-]|\.\d{3}/g, "")
|
|
154
|
+
.slice(0, 15) + "Z";
|
|
155
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
156
|
+
const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`;
|
|
157
|
+
const host = new URL(config.endpoint).host;
|
|
158
|
+
const canonicalUri = objectPath(config, key);
|
|
159
|
+
const payloadHash = await sha256(new Uint8Array(0));
|
|
160
|
+
const headers = {
|
|
161
|
+
host,
|
|
162
|
+
"x-amz-content-sha256": payloadHash,
|
|
163
|
+
"x-amz-date": amzDate,
|
|
164
|
+
};
|
|
165
|
+
const signedHeaderKeys = Object.keys(headers).sort();
|
|
166
|
+
const signedHeaders = signedHeaderKeys.join(";");
|
|
167
|
+
const canonicalHeaders = signedHeaderKeys
|
|
168
|
+
.map((header) => `${header}:${headers[header]}`)
|
|
169
|
+
.join("\n") + "\n";
|
|
170
|
+
const canonicalRequest = [
|
|
171
|
+
"DELETE",
|
|
172
|
+
canonicalUri,
|
|
173
|
+
"",
|
|
174
|
+
canonicalHeaders,
|
|
175
|
+
signedHeaders,
|
|
176
|
+
payloadHash,
|
|
177
|
+
].join("\n");
|
|
178
|
+
const requestHash = await sha256(new TextEncoder().encode(canonicalRequest));
|
|
179
|
+
const stringToSign = [
|
|
180
|
+
"AWS4-HMAC-SHA256",
|
|
181
|
+
amzDate,
|
|
182
|
+
credentialScope,
|
|
183
|
+
requestHash,
|
|
184
|
+
].join("\n");
|
|
185
|
+
const signature = toHex(await hmac(await signingKey(config.secretAccessKey, dateStamp, config.region), stringToSign));
|
|
186
|
+
const authorization = `AWS4-HMAC-SHA256 Credential=${config.accessKeyId}/${credentialScope}, ` +
|
|
187
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
188
|
+
const response = await fetch(`${config.endpoint}${canonicalUri}`, {
|
|
189
|
+
method: "DELETE",
|
|
190
|
+
headers: { ...headers, Authorization: authorization },
|
|
191
|
+
});
|
|
192
|
+
if (response.ok)
|
|
193
|
+
return true;
|
|
194
|
+
if (response.status === 404)
|
|
195
|
+
return false;
|
|
196
|
+
const detail = await response.text();
|
|
197
|
+
throw new Error(`S3 DeleteObject failed (${response.status}): ${detail || response.statusText}`);
|
|
198
|
+
}
|
|
149
199
|
function safeFilename(filename) {
|
|
150
200
|
const basename = filename?.split(/[\\/]/).pop()?.trim() || "attachment";
|
|
151
201
|
return (basename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160) || "attachment");
|
|
@@ -163,6 +213,14 @@ export const s3FileUploadProvider = {
|
|
|
163
213
|
const key = `uploads/${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${safeFilename(filename)}`;
|
|
164
214
|
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
165
215
|
const url = await putObject(config, key, bytes, mimeType || "application/octet-stream");
|
|
166
|
-
return { url, provider: "s3" };
|
|
216
|
+
return { url, id: key, provider: "s3" };
|
|
217
|
+
},
|
|
218
|
+
delete: async ({ id }) => {
|
|
219
|
+
if (!id)
|
|
220
|
+
return false;
|
|
221
|
+
const config = await readRequestConfig();
|
|
222
|
+
if (!config)
|
|
223
|
+
return false;
|
|
224
|
+
return deleteObject(config, id);
|
|
167
225
|
},
|
|
168
226
|
};
|
|
@@ -28,6 +28,10 @@ export interface FileUploadResult {
|
|
|
28
28
|
/** The provider that handled the upload. */
|
|
29
29
|
provider: string;
|
|
30
30
|
}
|
|
31
|
+
export interface FileUploadDeleteInput {
|
|
32
|
+
url: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
}
|
|
31
35
|
/** Opaque session handle returned by {@link FileUploadProvider.resumable.startSession}.
|
|
32
36
|
* `sessionId` is provider-specific (GCS Location URI, S3 UploadId, etc.).
|
|
33
37
|
* `meta` holds any provider state needed for subsequent relay and complete calls. */
|
|
@@ -56,6 +60,8 @@ export interface FileUploadProvider {
|
|
|
56
60
|
isConfiguredForRequest?: () => Promise<boolean>;
|
|
57
61
|
/** Upload a file and return a URL. Throw on failure. */
|
|
58
62
|
upload: (input: FileUploadInput) => Promise<FileUploadResult>;
|
|
63
|
+
/** Delete a previously uploaded file when the provider supports it. */
|
|
64
|
+
delete?: (input: FileUploadDeleteInput) => Promise<boolean>;
|
|
59
65
|
/**
|
|
60
66
|
* Optional resumable/streaming upload capability.
|
|
61
67
|
* When present, create-recording will initialise a session and stream chunks
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
* DELETE /_agent-native/notifications/:id — delete
|
|
12
12
|
*/
|
|
13
13
|
export declare function createNotificationsHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<"" | import("./types.js").Notification[] | {
|
|
14
|
-
error?: undefined;
|
|
15
14
|
count: number;
|
|
16
15
|
updated?: undefined;
|
|
16
|
+
error?: undefined;
|
|
17
17
|
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
|
-
error?: undefined;
|
|
20
19
|
count?: undefined;
|
|
21
20
|
updated: number;
|
|
21
|
+
error?: undefined;
|
|
22
22
|
ok?: undefined;
|
|
23
23
|
} | {
|
|
24
24
|
count?: undefined;
|
|
@@ -26,8 +26,8 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
|
|
|
26
26
|
error: string;
|
|
27
27
|
ok?: undefined;
|
|
28
28
|
} | {
|
|
29
|
-
error?: undefined;
|
|
30
29
|
count?: undefined;
|
|
31
30
|
updated?: undefined;
|
|
32
31
|
ok: boolean;
|
|
32
|
+
error?: undefined;
|
|
33
33
|
}>>;
|
|
@@ -3,11 +3,6 @@ export declare function registerPrivateBlobProvider(provider: PrivateBlobProvide
|
|
|
3
3
|
export declare function unregisterPrivateBlobProvider(id: string): void;
|
|
4
4
|
export declare function listPrivateBlobProviders(): PrivateBlobProvider[];
|
|
5
5
|
export declare function getActivePrivateBlobProvider(): PrivateBlobProvider | null;
|
|
6
|
-
/**
|
|
7
|
-
* @deprecated Use `defineAppConfig({ privateBlob: { publicUploadFallback } })`
|
|
8
|
-
* instead. This writes the same value into the deprecated layer of the config
|
|
9
|
-
* ladder, so an explicit `defineAppConfig` call now wins over it.
|
|
10
|
-
*/
|
|
11
6
|
export declare function setPrivateBlobPublicUploadFallbackEnabled(enabled: boolean): void;
|
|
12
7
|
export declare function putPrivateBlob(input: PrivateBlobPutInput): Promise<PrivateBlobHandle | null>;
|
|
13
8
|
export declare function readPrivateBlob(handle: PrivateBlobHandle): Promise<PrivateBlobReadResult>;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
2
|
import { getAppConfig } from "../app-config/index.js";
|
|
3
|
-
import {
|
|
4
|
-
import { uploadFile } from "../file-upload/index.js";
|
|
3
|
+
import { deleteUploadedFile, uploadFile } from "../file-upload/index.js";
|
|
5
4
|
import { decryptSecretValue, encryptSecretValue, getSecretEncryptionKey, } from "../secrets/crypto.js";
|
|
6
5
|
const PUBLIC_UPLOAD_HANDLE_PREFIX = "public-upload:v1:";
|
|
7
6
|
const PUBLIC_UPLOAD_READ_RETRY_DELAYS_MS = [100, 250, 500];
|
|
8
7
|
const globals = globalThis;
|
|
9
8
|
const providers = (globals.__agentNativePrivateBlobProviders ??= new Map());
|
|
9
|
+
const publicUploadFallbackRef = (globals.__agentNativePrivateBlobPublicUploadFallback ??= {
|
|
10
|
+
enabled: true,
|
|
11
|
+
});
|
|
10
12
|
function toBytes(data) {
|
|
11
13
|
return data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
12
14
|
}
|
|
@@ -170,13 +172,16 @@ export function listPrivateBlobProviders() {
|
|
|
170
172
|
return [...providers.values()];
|
|
171
173
|
}
|
|
172
174
|
export function getActivePrivateBlobProvider() {
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
175
|
-
const
|
|
176
|
-
if (!
|
|
177
|
-
throw new Error(`Private blob
|
|
175
|
+
const selectedId = getAppConfig().privateBlob.provider;
|
|
176
|
+
if (selectedId) {
|
|
177
|
+
const selected = providers.get(selectedId);
|
|
178
|
+
if (!selected) {
|
|
179
|
+
throw new Error(`Private blob config selects '${selectedId}', but no provider with that id is registered`);
|
|
178
180
|
}
|
|
179
|
-
|
|
181
|
+
if (!selected.isConfigured()) {
|
|
182
|
+
throw new Error(`Private blob provider '${selectedId}' is selected but not configured`);
|
|
183
|
+
}
|
|
184
|
+
return selected;
|
|
180
185
|
}
|
|
181
186
|
for (const provider of providers.values()) {
|
|
182
187
|
if (provider.isConfigured())
|
|
@@ -184,20 +189,15 @@ export function getActivePrivateBlobProvider() {
|
|
|
184
189
|
}
|
|
185
190
|
return null;
|
|
186
191
|
}
|
|
187
|
-
/**
|
|
188
|
-
* @deprecated Use `defineAppConfig({ privateBlob: { publicUploadFallback } })`
|
|
189
|
-
* instead. This writes the same value into the deprecated layer of the config
|
|
190
|
-
* ladder, so an explicit `defineAppConfig` call now wins over it.
|
|
191
|
-
*/
|
|
192
192
|
export function setPrivateBlobPublicUploadFallbackEnabled(enabled) {
|
|
193
|
-
|
|
194
|
-
privateBlob: { publicUploadFallback: enabled },
|
|
195
|
-
});
|
|
193
|
+
publicUploadFallbackRef.enabled = enabled;
|
|
196
194
|
}
|
|
197
195
|
export async function putPrivateBlob(input) {
|
|
198
196
|
const provider = getActivePrivateBlobProvider();
|
|
199
197
|
if (provider)
|
|
200
198
|
return provider.put(input);
|
|
199
|
+
if (!publicUploadFallbackRef.enabled)
|
|
200
|
+
return null;
|
|
201
201
|
if (!getAppConfig().privateBlob.publicUploadFallback)
|
|
202
202
|
return null;
|
|
203
203
|
return putViaEncryptedPublicUpload(input);
|
|
@@ -216,10 +216,17 @@ export async function deletePrivateBlob(handle) {
|
|
|
216
216
|
if (provider)
|
|
217
217
|
return provider.delete(handle);
|
|
218
218
|
if (isPublicUploadFallbackHandle(handle)) {
|
|
219
|
+
const descriptor = decodePublicUploadDescriptor(handle.id);
|
|
220
|
+
const deleted = await deleteUploadedFile(descriptor.uploadProvider, {
|
|
221
|
+
url: descriptor.url,
|
|
222
|
+
id: descriptor.uploadId,
|
|
223
|
+
});
|
|
219
224
|
return {
|
|
220
|
-
deleted
|
|
225
|
+
deleted,
|
|
221
226
|
provider: handle.provider,
|
|
222
|
-
|
|
227
|
+
...(deleted
|
|
228
|
+
? {}
|
|
229
|
+
: { reason: "backing upload provider could not delete the asset" }),
|
|
223
230
|
};
|
|
224
231
|
}
|
|
225
232
|
throw new Error(`No private blob provider registered for ${handle.provider}`);
|
|
@@ -51,8 +51,8 @@ export declare function handleDeleteResource(event: any): Promise<{
|
|
|
51
51
|
error: string;
|
|
52
52
|
ok?: undefined;
|
|
53
53
|
} | {
|
|
54
|
-
error?: undefined;
|
|
55
54
|
ok: boolean;
|
|
55
|
+
error?: undefined;
|
|
56
56
|
}>;
|
|
57
57
|
/** POST /_agent-native/resources/upload — upload a file as a resource */
|
|
58
58
|
export declare function handleUploadResource(event: any): Promise<import("./store.js").Resource | {
|
|
@@ -73,10 +73,10 @@ export declare function handleUploadResource(event: any): Promise<import("./stor
|
|
|
73
73
|
runId: string | null;
|
|
74
74
|
expiresAt: number | null;
|
|
75
75
|
metadata: string | null;
|
|
76
|
-
error?: undefined;
|
|
77
76
|
url: string;
|
|
78
77
|
provider: string;
|
|
79
78
|
storageSetupRequired?: undefined;
|
|
79
|
+
error?: undefined;
|
|
80
80
|
} | {
|
|
81
81
|
error: string;
|
|
82
82
|
storageSetupRequired: boolean;
|
package/dist/secrets/routes.d.ts
CHANGED
|
@@ -37,17 +37,17 @@ export declare function createWriteSecretHandler(): import("h3").EventHandlerWit
|
|
|
37
37
|
ok?: undefined;
|
|
38
38
|
status?: undefined;
|
|
39
39
|
} | {
|
|
40
|
+
error?: undefined;
|
|
40
41
|
ok: boolean;
|
|
41
42
|
status: string;
|
|
42
|
-
error?: undefined;
|
|
43
43
|
} | {
|
|
44
44
|
ok?: undefined;
|
|
45
45
|
error: string;
|
|
46
46
|
removed?: undefined;
|
|
47
47
|
} | {
|
|
48
|
+
error?: undefined;
|
|
48
49
|
ok: boolean;
|
|
49
50
|
removed: boolean;
|
|
50
|
-
error?: undefined;
|
|
51
51
|
}>>;
|
|
52
52
|
/**
|
|
53
53
|
* POST /_agent-native/secrets/:key/test — validate an optional candidate value
|
|
@@ -58,13 +58,13 @@ export declare function createTestSecretHandler(): import("h3").EventHandlerWith
|
|
|
58
58
|
error: string;
|
|
59
59
|
note?: undefined;
|
|
60
60
|
} | {
|
|
61
|
+
error?: undefined;
|
|
61
62
|
ok: boolean;
|
|
62
63
|
note?: undefined;
|
|
63
|
-
error?: undefined;
|
|
64
64
|
} | {
|
|
65
|
+
error?: undefined;
|
|
65
66
|
ok: boolean;
|
|
66
67
|
note: string;
|
|
67
|
-
error?: undefined;
|
|
68
68
|
} | {
|
|
69
69
|
note?: undefined;
|
|
70
70
|
ok: boolean;
|
|
@@ -95,11 +95,11 @@ export interface AdHocSecretPayload {
|
|
|
95
95
|
export declare function createAdHocSecretHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<AdHocSecretPayload[] | {
|
|
96
96
|
error: string;
|
|
97
97
|
} | {
|
|
98
|
+
error?: undefined;
|
|
98
99
|
ok: boolean;
|
|
99
100
|
key: string;
|
|
100
|
-
error?: undefined;
|
|
101
101
|
} | {
|
|
102
|
+
error?: undefined;
|
|
102
103
|
ok: boolean;
|
|
103
104
|
removed: boolean;
|
|
104
|
-
error?: undefined;
|
|
105
105
|
}>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.168.
|
|
3
|
+
"version": "0.168.6",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|