@promptbook/cli 0.112.0-102 → 0.112.0-104
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/apps/agents-server/README.md +0 -6
- package/apps/agents-server/src/app/AddAgentButton.tsx +0 -5
- package/apps/agents-server/src/app/actions.ts +50 -0
- package/apps/agents-server/src/app/admin/image-generator-test/ImageAttachmentsEditor.tsx +11 -6
- package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +13 -15
- package/apps/agents-server/src/app/admin/servers/useCreateServerWizard.ts +13 -14
- package/apps/agents-server/src/app/agents/[agentName]/AgentProfileChat.tsx +3 -4
- package/apps/agents-server/src/app/api/health/route.ts +18 -0
- package/apps/agents-server/src/app/api/upload/route.ts +110 -383
- package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +1 -4
- package/apps/agents-server/src/components/Header/Header.tsx +0 -11
- package/apps/agents-server/src/components/Header/useHeaderAgentMenus.tsx +0 -5
- package/apps/agents-server/src/components/NewAgentDialog/useNewAgentDialog.tsx +39 -16
- package/apps/agents-server/src/constants/defaultAgentAvatarVisual.ts +1 -1
- package/apps/agents-server/src/database/migrations/2026-06-0200-default-agent-avatar-visual-octopus3d3.sql +16 -0
- package/apps/agents-server/src/middleware.ts +2 -1
- package/apps/agents-server/src/tools/$provideCdnForServer.ts +87 -49
- package/apps/agents-server/src/utils/agentRouting/resolveAgentRouteTarget.ts +27 -4
- package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +17 -49
- package/apps/agents-server/src/utils/cdn/classes/TrackedFilesStorage.ts +5 -2
- package/apps/agents-server/src/utils/cdn/interfaces/IFilesStorage.ts +5 -0
- package/apps/agents-server/src/utils/defaultAgents/defaultAgents.ts +168 -0
- package/apps/agents-server/src/utils/defaultAgents/installDefaultAgents.ts +139 -0
- package/apps/agents-server/src/utils/shareTargetPayloads.ts +15 -63
- package/apps/agents-server/src/utils/upload/createBookEditorUploadHandler.ts +23 -150
- package/apps/agents-server/src/utils/upload/uploadFileToServer.ts +113 -0
- package/esm/index.es.js +711 -41
- package/esm/index.es.js.map +1 -1
- package/esm/scripts/run-codex-prompts/common/waitForPause.d.ts +12 -0
- package/esm/scripts/run-codex-prompts/main/runPromptRound.d.ts +2 -1
- package/esm/scripts/run-codex-prompts/ui/buildCoderRunUiFrame.d.ts +1 -0
- package/esm/scripts/run-codex-prompts/ui/buildRunUiFrameShared.d.ts +1 -1
- package/esm/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
- package/esm/src/avatars/visuals/octopus3d3AvatarVisual.d.ts +7 -0
- package/esm/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/avatars/types/AvatarVisualDefinition.ts +1 -0
- package/src/avatars/visuals/avatarVisualRegistry.ts +2 -0
- package/src/avatars/visuals/octopus3d3AvatarVisual.ts +903 -0
- package/src/book-components/Chat/MarkdownContent/MarkdownContent.tsx +1 -3
- package/src/other/templates/getTemplatesPipelineCollection.ts +799 -809
- package/src/utils/agents/resolveAgentAvatarImageUrl.ts +1 -1
- package/src/version.ts +2 -2
- package/src/versions.txt +1 -0
- package/umd/index.umd.js +711 -41
- package/umd/index.umd.js.map +1 -1
- package/umd/scripts/run-codex-prompts/common/waitForPause.d.ts +12 -0
- package/umd/scripts/run-codex-prompts/main/runPromptRound.d.ts +2 -1
- package/umd/scripts/run-codex-prompts/ui/buildCoderRunUiFrame.d.ts +1 -0
- package/umd/scripts/run-codex-prompts/ui/buildRunUiFrameShared.d.ts +1 -1
- package/umd/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
- package/umd/src/avatars/visuals/octopus3d3AvatarVisual.d.ts +7 -0
- package/umd/src/version.d.ts +1 -1
- package/apps/agents-server/src/utils/cdn/resolveCdnStorageProvider.ts +0 -40
|
@@ -1,36 +1,18 @@
|
|
|
1
|
-
import { $getTableName } from '@/src/database/$getTableName';
|
|
2
|
-
import { $provideSupabase } from '@/src/database/$provideSupabase';
|
|
3
|
-
import { $provideUntrackedCdnForServer } from '@/src/tools/$provideCdnForServer';
|
|
4
|
-
import { getSafeCdnPath } from '@/src/utils/cdn/utils/getSafeCdnPath';
|
|
5
1
|
import { serializeError } from '@promptbook-local/utils';
|
|
6
|
-
import type { PostgrestSingleResponse, SupabaseClient } from '@supabase/supabase-js';
|
|
7
|
-
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
|
|
8
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import { spaceTrim } from 'spacetrim';
|
|
9
4
|
import { assertsError } from '../../../../../../src/errors/assertsError';
|
|
10
|
-
import { DatabaseError } from '../../../../../../src/errors/DatabaseError';
|
|
11
5
|
import { LimitReachedError } from '../../../../../../src/errors/LimitReachedError';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
6
|
+
import { UnexpectedError } from '../../../../../../src/errors/UnexpectedError';
|
|
7
|
+
import { $getTableName } from '../../../database/$getTableName';
|
|
8
|
+
import { $provideSupabase } from '../../../database/$provideSupabase';
|
|
14
9
|
import type { AgentsServerDatabase } from '../../../database/schema';
|
|
10
|
+
import { $provideCdnForServer } from '../../../tools/$provideCdnForServer';
|
|
11
|
+
import { getSafeCdnPath } from '../../../utils/cdn/utils/getSafeCdnPath';
|
|
15
12
|
import { FILE_SECURITY_CHECKERS } from '../../../file-security-checkers';
|
|
13
|
+
import { getUserIdFromRequest } from '../../../utils/getUserIdFromRequest';
|
|
16
14
|
import { getMaxFileUploadSizeBytes } from '../../../utils/serverLimits';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Additional metadata accepted from the client-side upload helper.
|
|
20
|
-
*
|
|
21
|
-
* @private
|
|
22
|
-
*/
|
|
23
|
-
type UploadClientPayload = {
|
|
24
|
-
purpose?: unknown;
|
|
25
|
-
contentType?: unknown;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Generic object used for safe JSON parsing in upload payloads.
|
|
30
|
-
*
|
|
31
|
-
* @private
|
|
32
|
-
*/
|
|
33
|
-
type JsonRecord = Record<string, unknown>;
|
|
15
|
+
import { validateMimeType } from '../../../utils/validators/validateMimeType';
|
|
34
16
|
|
|
35
17
|
/**
|
|
36
18
|
* Default purpose used for uploads when the client does not provide one.
|
|
@@ -47,75 +29,30 @@ const DEFAULT_UPLOAD_PURPOSE = 'GENERIC_UPLOAD';
|
|
|
47
29
|
const DEFAULT_UPLOAD_CONTENT_TYPE = 'application/octet-stream';
|
|
48
30
|
|
|
49
31
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* @private
|
|
53
|
-
*/
|
|
54
|
-
const MIME_TYPE_PATTERN = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Form-data field containing the uploaded file for S3-backed server uploads.
|
|
58
|
-
*
|
|
59
|
-
* @private
|
|
60
|
-
*/
|
|
61
|
-
const SERVER_ROUTED_UPLOAD_FILE_FIELD = 'file';
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Form-data field containing the requested CDN object key for S3-backed server uploads.
|
|
65
|
-
*
|
|
66
|
-
* @private
|
|
67
|
-
*/
|
|
68
|
-
const SERVER_ROUTED_UPLOAD_PATHNAME_FIELD = 'pathname';
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Form-data field containing upload purpose for S3-backed server uploads.
|
|
72
|
-
*
|
|
73
|
-
* @private
|
|
74
|
-
*/
|
|
75
|
-
const SERVER_ROUTED_UPLOAD_PURPOSE_FIELD = 'purpose';
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Form-data field containing content type for S3-backed server uploads.
|
|
79
|
-
*
|
|
80
|
-
* @private
|
|
81
|
-
*/
|
|
82
|
-
const SERVER_ROUTED_UPLOAD_CONTENT_TYPE_FIELD = 'contentType';
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Multipart content type prefix used by server-routed S3 uploads.
|
|
32
|
+
* Regular expression for path segments that are safe to keep as public object keys.
|
|
86
33
|
*
|
|
87
34
|
* @private
|
|
88
35
|
*/
|
|
89
|
-
const
|
|
36
|
+
const SAFE_UPLOAD_PATH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~!$&'()*+,;=:@/-]*$/;
|
|
90
37
|
|
|
91
38
|
/**
|
|
92
|
-
*
|
|
39
|
+
* Parsed upload request.
|
|
93
40
|
*
|
|
94
41
|
* @private
|
|
95
42
|
*/
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const parsed = JSON.parse(rawJson);
|
|
103
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
104
|
-
return {};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return parsed as JsonRecord;
|
|
108
|
-
} catch {
|
|
109
|
-
return {};
|
|
110
|
-
}
|
|
111
|
-
}
|
|
43
|
+
type ParsedUploadRequest = {
|
|
44
|
+
file: File;
|
|
45
|
+
pathname: string;
|
|
46
|
+
purpose: string;
|
|
47
|
+
contentType: string;
|
|
48
|
+
};
|
|
112
49
|
|
|
113
50
|
/**
|
|
114
51
|
* Normalizes upload purpose to a non-empty string.
|
|
115
52
|
*
|
|
116
53
|
* @private
|
|
117
54
|
*/
|
|
118
|
-
function normalizeUploadPurpose(value:
|
|
55
|
+
function normalizeUploadPurpose(value: FormDataEntryValue | null): string {
|
|
119
56
|
if (typeof value !== 'string') {
|
|
120
57
|
return DEFAULT_UPLOAD_PURPOSE;
|
|
121
58
|
}
|
|
@@ -129,93 +66,84 @@ function normalizeUploadPurpose(value: unknown): string {
|
|
|
129
66
|
*
|
|
130
67
|
* @private
|
|
131
68
|
*/
|
|
132
|
-
function normalizeUploadContentType(value:
|
|
133
|
-
|
|
134
|
-
return DEFAULT_UPLOAD_CONTENT_TYPE;
|
|
135
|
-
}
|
|
69
|
+
function normalizeUploadContentType(value: FormDataEntryValue | null, fallbackContentType: string): string {
|
|
70
|
+
const candidate = typeof value === 'string' && value.trim() ? value.trim() : fallbackContentType;
|
|
136
71
|
|
|
137
|
-
|
|
138
|
-
|
|
72
|
+
try {
|
|
73
|
+
return validateMimeType(candidate || DEFAULT_UPLOAD_CONTENT_TYPE);
|
|
74
|
+
} catch {
|
|
139
75
|
return DEFAULT_UPLOAD_CONTENT_TYPE;
|
|
140
76
|
}
|
|
141
|
-
|
|
142
|
-
return normalizedContentType;
|
|
143
77
|
}
|
|
144
78
|
|
|
145
79
|
/**
|
|
146
|
-
*
|
|
80
|
+
* Resolves and validates the storage key requested by the browser.
|
|
147
81
|
*
|
|
148
82
|
* @private
|
|
149
83
|
*/
|
|
150
|
-
function
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
const payload = parseJsonRecord(clientPayload) as UploadClientPayload;
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
purpose: normalizeUploadPurpose(payload.purpose),
|
|
158
|
-
contentType: normalizeUploadContentType(payload.contentType),
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Checks whether the upload request uses the server-routed multipart protocol.
|
|
164
|
-
*
|
|
165
|
-
* @private
|
|
166
|
-
*/
|
|
167
|
-
function isServerRoutedUploadRequest(request: NextRequest): boolean {
|
|
168
|
-
return (request.headers.get('content-type') || '').toLowerCase().includes(MULTIPART_FORM_DATA_CONTENT_TYPE);
|
|
169
|
-
}
|
|
84
|
+
function resolveUploadPathname(value: FormDataEntryValue | null): string {
|
|
85
|
+
if (typeof value !== 'string') {
|
|
86
|
+
throw new UnexpectedError('Upload request is missing `pathname`.');
|
|
87
|
+
}
|
|
170
88
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
89
|
+
const pathname = value.trim().replace(/\\/g, '/').replace(/^\/+/, '');
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
pathname === '' ||
|
|
93
|
+
pathname.includes('/../') ||
|
|
94
|
+
pathname.startsWith('../') ||
|
|
95
|
+
pathname.endsWith('/..') ||
|
|
96
|
+
!SAFE_UPLOAD_PATH_PATTERN.test(pathname)
|
|
97
|
+
) {
|
|
98
|
+
throw new UnexpectedError(
|
|
99
|
+
spaceTrim(`
|
|
100
|
+
Upload request contains an invalid \`pathname\`.
|
|
101
|
+
|
|
102
|
+
The upload key must be a relative CDN path without parent-directory segments.
|
|
103
|
+
`),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
178
106
|
|
|
179
|
-
return
|
|
107
|
+
return getSafeCdnPath({
|
|
108
|
+
pathname,
|
|
109
|
+
pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX,
|
|
110
|
+
});
|
|
180
111
|
}
|
|
181
112
|
|
|
182
113
|
/**
|
|
183
|
-
*
|
|
114
|
+
* Parses the multipart request accepted by `/api/upload`.
|
|
184
115
|
*
|
|
185
116
|
* @private
|
|
186
117
|
*/
|
|
187
|
-
function
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
.trim()
|
|
191
|
-
.replace(/\\/g, '/')
|
|
192
|
-
.replace(/^\/+/g, '')
|
|
193
|
-
.replace(/\/+/g, '/');
|
|
118
|
+
async function parseUploadRequest(request: NextRequest): Promise<ParsedUploadRequest> {
|
|
119
|
+
const formData = await request.formData();
|
|
120
|
+
const file = formData.get('file');
|
|
194
121
|
|
|
195
|
-
if (!
|
|
196
|
-
throw new
|
|
122
|
+
if (!(file instanceof File)) {
|
|
123
|
+
throw new UnexpectedError('Upload request is missing `file`.');
|
|
197
124
|
}
|
|
198
125
|
|
|
199
|
-
return
|
|
126
|
+
return {
|
|
127
|
+
file,
|
|
128
|
+
pathname: resolveUploadPathname(formData.get('pathname')),
|
|
129
|
+
purpose: normalizeUploadPurpose(formData.get('purpose')),
|
|
130
|
+
contentType: normalizeUploadContentType(formData.get('contentType'), file.type),
|
|
131
|
+
};
|
|
200
132
|
}
|
|
201
133
|
|
|
202
134
|
/**
|
|
203
|
-
* Runs configured file-security
|
|
135
|
+
* Runs all configured file-security checkers against the uploaded public URL.
|
|
204
136
|
*
|
|
205
137
|
* @private
|
|
206
138
|
*/
|
|
207
139
|
async function checkUploadedFileSecurity(storageUrl: string): Promise<Record<string, unknown>> {
|
|
208
140
|
const securityResults: Record<string, unknown> = {};
|
|
209
141
|
|
|
210
|
-
for (const checkerId
|
|
142
|
+
for (const checkerId of Object.keys(FILE_SECURITY_CHECKERS)) {
|
|
211
143
|
try {
|
|
212
144
|
const checker = FILE_SECURITY_CHECKERS[checkerId]!;
|
|
213
|
-
|
|
214
|
-
const result = await checker.checkFile(storageUrl);
|
|
215
|
-
securityResults[checkerId] = result;
|
|
216
|
-
console.info(`🛡️ Security check result from ${checker.title}:`, result.status);
|
|
145
|
+
securityResults[checkerId] = await checker.checkFile(storageUrl);
|
|
217
146
|
} catch (error) {
|
|
218
|
-
console.error(`🛡️ Security check failed for ${checkerId}:`, error);
|
|
219
147
|
securityResults[checkerId] = {
|
|
220
148
|
isSafe: false,
|
|
221
149
|
status: 'ERROR',
|
|
@@ -229,269 +157,75 @@ async function checkUploadedFileSecurity(storageUrl: string): Promise<Record<str
|
|
|
229
157
|
}
|
|
230
158
|
|
|
231
159
|
/**
|
|
232
|
-
*
|
|
160
|
+
* Stores security results for the file row created by `TrackedFilesStorage`.
|
|
233
161
|
*
|
|
234
162
|
* @private
|
|
235
163
|
*/
|
|
236
|
-
async function
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
storageUrl: string;
|
|
243
|
-
purpose: string;
|
|
244
|
-
securityResult: AgentsServerDatabase['public']['Tables']['File']['Insert']['securityResult'];
|
|
245
|
-
}): Promise<number | null> {
|
|
246
|
-
const {
|
|
247
|
-
supabase,
|
|
248
|
-
userId,
|
|
249
|
-
fileName,
|
|
250
|
-
fileSize,
|
|
251
|
-
fileType,
|
|
252
|
-
storageUrl,
|
|
253
|
-
purpose,
|
|
254
|
-
securityResult,
|
|
255
|
-
} = options;
|
|
256
|
-
const { data, error }: PostgrestSingleResponse<Pick<AgentsServerDatabase['public']['Tables']['File']['Row'], 'id'>> =
|
|
257
|
-
await supabase
|
|
258
|
-
.from(await $getTableName('File'))
|
|
259
|
-
.insert({
|
|
260
|
-
userId,
|
|
261
|
-
fileName,
|
|
262
|
-
fileSize,
|
|
263
|
-
fileType,
|
|
264
|
-
storageUrl,
|
|
265
|
-
shortUrl: null,
|
|
266
|
-
purpose,
|
|
267
|
-
status: 'COMPLETED',
|
|
268
|
-
securityResult,
|
|
269
|
-
})
|
|
270
|
-
.select('id')
|
|
271
|
-
.single();
|
|
272
|
-
|
|
273
|
-
if (error) {
|
|
274
|
-
throw new DatabaseError(`Failed to create completed file record: ${error.message}`);
|
|
164
|
+
async function updateUploadedFileSecurityResult(
|
|
165
|
+
storageUrl: string,
|
|
166
|
+
securityResult: Record<string, unknown>,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
if (Object.keys(securityResult).length === 0) {
|
|
169
|
+
return;
|
|
275
170
|
}
|
|
276
171
|
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Handles direct multipart uploads for S3-compatible storage backends.
|
|
282
|
-
*
|
|
283
|
-
* @private
|
|
284
|
-
*/
|
|
285
|
-
async function handleServerRoutedUpload(request: NextRequest): Promise<NextResponse> {
|
|
286
|
-
const formData = await request.formData();
|
|
287
|
-
const uploadFile = formData.get(SERVER_ROUTED_UPLOAD_FILE_FIELD);
|
|
288
|
-
|
|
289
|
-
if (!(uploadFile instanceof File)) {
|
|
290
|
-
throw new NotAllowed('Upload request must contain a file.');
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const userId = await getUserIdFromRequest(request);
|
|
294
|
-
const purpose = normalizeUploadPurpose(getFormDataString(formData, SERVER_ROUTED_UPLOAD_PURPOSE_FIELD));
|
|
295
|
-
const contentType = normalizeUploadContentType(
|
|
296
|
-
getFormDataString(formData, SERVER_ROUTED_UPLOAD_CONTENT_TYPE_FIELD) || uploadFile.type,
|
|
297
|
-
);
|
|
298
|
-
const pathname = normalizeServerUploadPathname(
|
|
299
|
-
getFormDataString(formData, SERVER_ROUTED_UPLOAD_PATHNAME_FIELD),
|
|
300
|
-
uploadFile.name,
|
|
301
|
-
);
|
|
302
|
-
const maxFileSize = await getMaxFileUploadSizeBytes();
|
|
303
|
-
|
|
304
|
-
if (uploadFile.size > maxFileSize) {
|
|
305
|
-
throw new LimitReachedError(`Uploaded file exceeds the configured maximum size of ${maxFileSize} bytes.`);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const buffer = Buffer.from(await uploadFile.arrayBuffer());
|
|
309
|
-
if (buffer.byteLength > maxFileSize) {
|
|
310
|
-
throw new LimitReachedError(`Uploaded file exceeds the configured maximum size of ${maxFileSize} bytes.`);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const cdn = $provideUntrackedCdnForServer();
|
|
314
|
-
await cdn.setItem(pathname, {
|
|
315
|
-
type: contentType,
|
|
316
|
-
data: buffer,
|
|
317
|
-
userId: userId || undefined,
|
|
318
|
-
purpose,
|
|
319
|
-
fileSize: buffer.byteLength,
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
const storageUrl = cdn.getItemUrl(pathname).href;
|
|
323
|
-
const securityResults = await checkUploadedFileSecurity(storageUrl);
|
|
172
|
+
const supabase = $provideSupabase();
|
|
324
173
|
const securityResultForDatabase =
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
fileName: pathname,
|
|
331
|
-
fileSize: buffer.byteLength,
|
|
332
|
-
fileType: contentType,
|
|
333
|
-
storageUrl,
|
|
334
|
-
purpose,
|
|
335
|
-
securityResult: securityResultForDatabase,
|
|
336
|
-
});
|
|
174
|
+
securityResult as AgentsServerDatabase['public']['Tables']['File']['Update']['securityResult'];
|
|
175
|
+
const { error } = await supabase
|
|
176
|
+
.from(await $getTableName('File'))
|
|
177
|
+
.update({ securityResult: securityResultForDatabase })
|
|
178
|
+
.eq('storageUrl', storageUrl);
|
|
337
179
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
fileId,
|
|
342
|
-
contentType,
|
|
343
|
-
size: buffer.byteLength,
|
|
344
|
-
});
|
|
180
|
+
if (error) {
|
|
181
|
+
console.error('Failed to update uploaded file security result:', error);
|
|
182
|
+
}
|
|
345
183
|
}
|
|
346
184
|
|
|
347
185
|
/**
|
|
348
|
-
* Handles
|
|
186
|
+
* Handles file upload requests.
|
|
349
187
|
*/
|
|
350
188
|
export async function POST(request: NextRequest) {
|
|
351
189
|
try {
|
|
352
|
-
|
|
353
|
-
|
|
190
|
+
const { file, pathname, purpose, contentType } = await parseUploadRequest(request);
|
|
191
|
+
const fileBuffer = Buffer.from(await file.arrayBuffer());
|
|
192
|
+
const maxFileSize = await getMaxFileUploadSizeBytes();
|
|
193
|
+
|
|
194
|
+
if (fileBuffer.byteLength > maxFileSize) {
|
|
195
|
+
throw new LimitReachedError(
|
|
196
|
+
spaceTrim(`
|
|
197
|
+
Uploaded file \`${file.name}\` exceeds the configured upload limit.
|
|
198
|
+
|
|
199
|
+
Maximum supported size: **${maxFileSize} bytes**
|
|
200
|
+
`),
|
|
201
|
+
);
|
|
354
202
|
}
|
|
355
203
|
|
|
356
|
-
const
|
|
204
|
+
const cdn = $provideCdnForServer();
|
|
205
|
+
const storageUrl = cdn.getItemUrl(pathname).href;
|
|
357
206
|
const userId = await getUserIdFromRequest(request);
|
|
358
|
-
const supabase: SupabaseClient<AgentsServerDatabase> = $provideSupabase();
|
|
359
|
-
|
|
360
|
-
// Handle Vercel Blob client upload protocol
|
|
361
|
-
const jsonResponse = await handleUpload({
|
|
362
|
-
body,
|
|
363
|
-
request,
|
|
364
|
-
token: process.env.VERCEL_BLOB_READ_WRITE_TOKEN!,
|
|
365
|
-
onBeforeGenerateToken: async (pathname, clientPayload) => {
|
|
366
|
-
// Authenticate user and validate upload
|
|
367
|
-
|
|
368
|
-
// Parse client payload for additional metadata
|
|
369
|
-
const { purpose, contentType } = resolveUploadClientPayload(clientPayload);
|
|
370
207
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
// Create a DB record at the start of the upload to track it
|
|
379
|
-
const uploadPurpose = purpose;
|
|
380
|
-
const {
|
|
381
|
-
data: insertedFile,
|
|
382
|
-
error: insertError,
|
|
383
|
-
}: PostgrestSingleResponse<Pick<AgentsServerDatabase['public']['Tables']['File']['Row'], 'id'>> =
|
|
384
|
-
await supabase
|
|
385
|
-
.from(await $getTableName('File'))
|
|
386
|
-
.insert({
|
|
387
|
-
userId: userId || null,
|
|
388
|
-
fileName: pathname,
|
|
389
|
-
fileSize: 0, // <- Will be updated when upload completes
|
|
390
|
-
fileType: contentType,
|
|
391
|
-
storageUrl: null, // <- To be updated on completion
|
|
392
|
-
shortUrl: null, // <- To be updated on completion
|
|
393
|
-
purpose: uploadPurpose,
|
|
394
|
-
status: 'UPLOADING',
|
|
395
|
-
})
|
|
396
|
-
.select('id')
|
|
397
|
-
.single();
|
|
398
|
-
|
|
399
|
-
if (insertError) {
|
|
400
|
-
console.error('🔼 Failed to create file record:', insertError);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
console.info('🔼 Upload started, tracking file:', {
|
|
404
|
-
pathname,
|
|
405
|
-
fileId: insertedFile?.id,
|
|
406
|
-
purpose: uploadPurpose,
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
return {
|
|
410
|
-
maximumSizeInBytes: maxFileSize,
|
|
411
|
-
addRandomSuffix: true, // Add random suffix to avoid filename collisions since we can't hash content
|
|
412
|
-
tokenPayload: JSON.stringify({
|
|
413
|
-
userId: userId || null,
|
|
414
|
-
purpose: uploadPurpose,
|
|
415
|
-
fileId: insertedFile?.id || null,
|
|
416
|
-
uploadPath: pathname,
|
|
417
|
-
pathPrefix,
|
|
418
|
-
}),
|
|
419
|
-
};
|
|
420
|
-
},
|
|
421
|
-
onUploadCompleted: async ({ blob, tokenPayload }) => {
|
|
422
|
-
// !!!!
|
|
423
|
-
// ⚠️ IMPORTANT: This callback is a WEBHOOK called by Vercel's servers AFTER the upload completes
|
|
424
|
-
// - It runs in a DIFFERENT request context (not the original user request)
|
|
425
|
-
// - It WON'T work in local development (Vercel can't reach localhost)
|
|
426
|
-
// - All data must come from tokenPayload (userId, fileId, etc.)
|
|
427
|
-
// - Need to create a fresh supabase client here
|
|
428
|
-
console.info('🔼 Upload completed (webhook callback):', { blob, tokenPayload });
|
|
429
|
-
|
|
430
|
-
try {
|
|
431
|
-
const payload = parseJsonRecord(tokenPayload);
|
|
432
|
-
const fileId = typeof payload.fileId === 'number' ? payload.fileId : null;
|
|
433
|
-
const tokenUserId = typeof payload.userId === 'number' ? payload.userId : null;
|
|
434
|
-
const tokenPurpose = normalizeUploadPurpose(payload.purpose);
|
|
435
|
-
const uploadPath = typeof payload.uploadPath === 'string' ? payload.uploadPath : null;
|
|
436
|
-
|
|
437
|
-
// Create fresh supabase client for this webhook context
|
|
438
|
-
const supabase = $provideSupabase();
|
|
439
|
-
|
|
440
|
-
// Security checks
|
|
441
|
-
const securityResults = await checkUploadedFileSecurity(blob.url);
|
|
442
|
-
const securityResultForDatabase =
|
|
443
|
-
securityResults as AgentsServerDatabase['public']['Tables']['File']['Update']['securityResult'];
|
|
444
|
-
|
|
445
|
-
if (fileId) {
|
|
446
|
-
// Update the existing record by ID
|
|
447
|
-
const { error: updateError } = await supabase
|
|
448
|
-
.from(await $getTableName('File'))
|
|
449
|
-
.update({
|
|
450
|
-
userId: tokenUserId || null,
|
|
451
|
-
fileSize: 0, // <- !!!!
|
|
452
|
-
fileType: blob.contentType,
|
|
453
|
-
storageUrl: blob.url,
|
|
454
|
-
// <- TODO: !!!! Split between storageUrl and shortUrl
|
|
455
|
-
purpose: tokenPurpose,
|
|
456
|
-
status: 'COMPLETED',
|
|
457
|
-
securityResult: securityResultForDatabase,
|
|
458
|
-
})
|
|
459
|
-
.eq('id', fileId);
|
|
208
|
+
await cdn.setItem(pathname, {
|
|
209
|
+
type: contentType,
|
|
210
|
+
data: fileBuffer,
|
|
211
|
+
purpose,
|
|
212
|
+
userId: userId || undefined,
|
|
213
|
+
fileSize: fileBuffer.byteLength,
|
|
214
|
+
});
|
|
460
215
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
} else {
|
|
464
|
-
console.info('🔼 File record updated successfully:', { fileId, shortUrl: blob.url });
|
|
465
|
-
}
|
|
466
|
-
} else if (uploadPath) {
|
|
467
|
-
// Fallback: Update by uploadPath if fileId is not available
|
|
468
|
-
const { error: updateError } = await supabase
|
|
469
|
-
.from(await $getTableName('File'))
|
|
470
|
-
.update({
|
|
471
|
-
fileSize: 0, // <- !!!!
|
|
472
|
-
fileType: blob.contentType,
|
|
473
|
-
storageUrl: blob.url,
|
|
474
|
-
status: 'COMPLETED',
|
|
475
|
-
securityResult: securityResultForDatabase,
|
|
476
|
-
})
|
|
477
|
-
.eq('fileName', uploadPath)
|
|
478
|
-
.eq('status', 'UPLOADING');
|
|
216
|
+
const securityResult = await checkUploadedFileSecurity(storageUrl);
|
|
217
|
+
await updateUploadedFileSecurityResult(storageUrl, securityResult);
|
|
479
218
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
console.error('🔼 Error in onUploadCompleted:', error);
|
|
486
|
-
}
|
|
487
|
-
},
|
|
219
|
+
return NextResponse.json({
|
|
220
|
+
url: storageUrl,
|
|
221
|
+
pathname,
|
|
222
|
+
contentType,
|
|
223
|
+
size: fileBuffer.byteLength,
|
|
488
224
|
});
|
|
489
|
-
|
|
490
|
-
return NextResponse.json(jsonResponse);
|
|
491
225
|
} catch (error) {
|
|
492
226
|
assertsError(error);
|
|
493
227
|
|
|
494
|
-
console.error('
|
|
228
|
+
console.error('Upload failed:', error);
|
|
495
229
|
|
|
496
230
|
return new Response(
|
|
497
231
|
JSON.stringify(
|
|
@@ -508,10 +242,3 @@ export async function POST(request: NextRequest) {
|
|
|
508
242
|
);
|
|
509
243
|
}
|
|
510
244
|
}
|
|
511
|
-
|
|
512
|
-
// TODO: !!!! Change uploaded URLs from `storageUrl` to `shortUrl`
|
|
513
|
-
// TODO: !!!! Record both `storageUrl` (actual storage location) and `shortUrl` in `File` table
|
|
514
|
-
// TODO: !!!! Record `purpose` in `File` table
|
|
515
|
-
// TODO: !!!! Record `userId` in `File` table
|
|
516
|
-
// TODO: !!!! Record all things into `File` table
|
|
517
|
-
// TODO: !!!! File type (mime type) of `.book` files should be `application/book` <- [🧠] !!!! Best mime type?!
|
|
@@ -186,10 +186,7 @@ export function AgentProfile(props: AgentProfileProps) {
|
|
|
186
186
|
) : (
|
|
187
187
|
<div className="flex h-full w-full items-center justify-center overflow-hidden p-4 md:p-8">
|
|
188
188
|
{/* Keep built-in visuals inside a centered square stage so different avatar renderers fit the tall profile card consistently. */}
|
|
189
|
-
<div
|
|
190
|
-
className="flex h-full w-full max-h-[80%] max-w-[80%] items-center justify-center"
|
|
191
|
-
style={{ aspectRatio: '1 / 1' }}
|
|
192
|
-
>
|
|
189
|
+
<div className="flex aspect-square w-[80%] items-center justify-center">
|
|
193
190
|
<AgentAvatar
|
|
194
191
|
agent={agent}
|
|
195
192
|
baseUrl={publicUrl}
|
|
@@ -9,7 +9,6 @@ import { HamburgerMenu } from '../../../../../src/book-components/_common/Hambur
|
|
|
9
9
|
import { useMenuHoisting } from '../../../../../src/book-components/_common/MenuHoisting/MenuHoistingContext';
|
|
10
10
|
import { just } from '../../../../../src/utils/organization/just';
|
|
11
11
|
import { getVisibleCommitmentDefinitions } from '../../utils/getVisibleCommitmentDefinitions';
|
|
12
|
-
import { pushWithHeadless, useIsHeadless } from '../_utils/headlessParam';
|
|
13
12
|
import { useInstallPromptState, type AgentContextMenuRenamePayload } from '../AgentContextMenu/AgentContextMenu';
|
|
14
13
|
import { useAgentNaming } from '../AgentNaming/AgentNamingContext';
|
|
15
14
|
import { QrCodeModal } from '../AgentProfile/QrCodeModal';
|
|
@@ -58,11 +57,9 @@ export function Header(props: HeaderProps) {
|
|
|
58
57
|
feedbackMode = 'stars',
|
|
59
58
|
shibbolethAuthenticationStatus,
|
|
60
59
|
} = props;
|
|
61
|
-
|
|
62
60
|
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
|
63
61
|
const router = useRouter();
|
|
64
62
|
const pathname = usePathname();
|
|
65
|
-
const isHeadless = useIsHeadless();
|
|
66
63
|
const menuHoisting = useMenuHoisting();
|
|
67
64
|
const mobileMenuHoisting = useMobileMenuHoisting();
|
|
68
65
|
const { naming } = useAgentNaming();
|
|
@@ -73,13 +70,6 @@ export function Header(props: HeaderProps) {
|
|
|
73
70
|
() => buildDocumentationDropdownItems(visibleDocumentationCommitments, t),
|
|
74
71
|
[t, visibleDocumentationCommitments],
|
|
75
72
|
);
|
|
76
|
-
|
|
77
|
-
const fallbackNavigateToHref = useCallback(
|
|
78
|
-
(href: string) => {
|
|
79
|
-
pushWithHeadless(router, href, isHeadless);
|
|
80
|
-
},
|
|
81
|
-
[isHeadless, router],
|
|
82
|
-
);
|
|
83
73
|
const hoistedMobileMenuItems = mobileMenuHoisting?.menuItems || EMPTY_HOISTED_MOBILE_MENU_ITEMS;
|
|
84
74
|
const {
|
|
85
75
|
cancelMenuClose,
|
|
@@ -201,7 +191,6 @@ export function Header(props: HeaderProps) {
|
|
|
201
191
|
isAdmin,
|
|
202
192
|
isAuthenticated: Boolean(currentUser),
|
|
203
193
|
isInstalled,
|
|
204
|
-
navigateToHref: fallbackNavigateToHref,
|
|
205
194
|
namingPlural: naming.plural,
|
|
206
195
|
namingSingular: naming.singular,
|
|
207
196
|
onAgentRenamed: handleAgentRenamedFromHeader,
|