@cepseudo/assets 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +204 -0
  3. package/dist/assets_manager.d.ts +643 -0
  4. package/dist/assets_manager.d.ts.map +1 -0
  5. package/dist/assets_manager.js +1217 -0
  6. package/dist/assets_manager.js.map +1 -0
  7. package/dist/assets_openapi.d.ts +9 -0
  8. package/dist/assets_openapi.d.ts.map +1 -0
  9. package/dist/assets_openapi.js +391 -0
  10. package/dist/assets_openapi.js.map +1 -0
  11. package/dist/async_upload.d.ts +20 -0
  12. package/dist/async_upload.d.ts.map +1 -0
  13. package/dist/async_upload.js +10 -0
  14. package/dist/async_upload.js.map +1 -0
  15. package/dist/index.d.ts +18 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +17 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/map_manager.d.ts +61 -0
  20. package/dist/map_manager.d.ts.map +1 -0
  21. package/dist/map_manager.js +217 -0
  22. package/dist/map_manager.js.map +1 -0
  23. package/dist/presigned_upload_service.d.ts +52 -0
  24. package/dist/presigned_upload_service.d.ts.map +1 -0
  25. package/dist/presigned_upload_service.js +152 -0
  26. package/dist/presigned_upload_service.js.map +1 -0
  27. package/dist/tileset_manager.d.ts +128 -0
  28. package/dist/tileset_manager.d.ts.map +1 -0
  29. package/dist/tileset_manager.js +705 -0
  30. package/dist/tileset_manager.js.map +1 -0
  31. package/dist/upload_processor.d.ts +42 -0
  32. package/dist/upload_processor.d.ts.map +1 -0
  33. package/dist/upload_processor.js +138 -0
  34. package/dist/upload_processor.js.map +1 -0
  35. package/dist/upload_reconciler.d.ts +44 -0
  36. package/dist/upload_reconciler.d.ts.map +1 -0
  37. package/dist/upload_reconciler.js +140 -0
  38. package/dist/upload_reconciler.js.map +1 -0
  39. package/dist/utils/zip_utils.d.ts +66 -0
  40. package/dist/utils/zip_utils.d.ts.map +1 -0
  41. package/dist/utils/zip_utils.js +169 -0
  42. package/dist/utils/zip_utils.js.map +1 -0
  43. package/package.json +72 -0
@@ -0,0 +1,1217 @@
1
+ import { successResponse, errorResponse, badRequestResponse, unauthorizedResponse, forbiddenResponse, notFoundResponse, textResponse, fileResponse, multiStatusResponse, HttpStatus, safeAsync, Logger, validateAssetUpdate, validateIdParam, validatePagination, validateData, validateQuery, validateParams } from '@cepseudo/shared';
2
+ import { ApisixAuthParser, AuthMiddleware, UserService } from '@cepseudo/auth';
3
+ import { PresignedUploadService } from './presigned_upload_service.js';
4
+ import { generateAssetsOpenAPISpec } from './assets_openapi.js';
5
+ import fs from 'fs/promises';
6
+ const logger = new Logger('AssetsManager');
7
+ /**
8
+ * Abstract base class for Assets Manager components with authentication and access control.
9
+ *
10
+ * Provides secure file upload, storage, and retrieval capabilities following the Digital Twin framework patterns.
11
+ * Each concrete implementation manages a specific type of asset and creates its own database table.
12
+ *
13
+ * ## Authentication & Authorization
14
+ *
15
+ * - **Write Operations** (POST, PUT, DELETE): Require authentication via Apache APISIX headers
16
+ * - **User Management**: Automatically creates/updates user records from Keycloak data
17
+ * - **Access Control**: Users can only modify/delete their own assets (ownership-based)
18
+ * - **Resource Linking**: Assets are automatically linked to their owners via user_id foreign key
19
+ *
20
+ * ## Required Headers for Authenticated Endpoints
21
+ *
22
+ * - `x-user-id`: Keycloak user UUID (required)
23
+ * - `x-user-roles`: Comma-separated list of user roles (optional)
24
+ *
25
+ * These headers are automatically added by Apache APISIX after successful Keycloak authentication.
26
+ *
27
+ * @abstract
28
+ * @class AssetsManager
29
+ * @implements {Component}
30
+ * @implements {Servable}
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * // Create concrete implementations for different asset types
35
+ * class GLTFAssetsManager extends AssetsManager {
36
+ * getConfiguration() {
37
+ * return { name: 'gltf', description: 'GLTF 3D models manager', ... }
38
+ * }
39
+ * }
40
+ *
41
+ * class PointCloudAssetsManager extends AssetsManager {
42
+ * getConfiguration() {
43
+ * return { name: 'pointcloud', description: 'Point cloud data manager', ... }
44
+ * }
45
+ * }
46
+ *
47
+ * // Usage in engine
48
+ * const gltfManager = new GLTFAssetsManager()
49
+ * gltfManager.setDependencies(database, storage)
50
+ *
51
+ * // Each creates its own table and endpoints:
52
+ * // - GLTFAssetsManager → table 'gltf', endpoints /gltf/*
53
+ * // - PointCloudAssetsManager → table 'pointcloud', endpoints /pointcloud/*
54
+ * ```
55
+ *
56
+ * @remarks
57
+ * Asset metadata is stored as dedicated columns in the database table:
58
+ * - id, name, url, date (standard columns)
59
+ * - description, source, owner_id, filename (asset-specific columns)
60
+ *
61
+ * Each concrete AssetsManager creates its own table based on the configuration name.
62
+ */
63
+ export class AssetsManager {
64
+ /**
65
+ * Injects dependencies into the assets manager.
66
+ *
67
+ * Called by the framework during component initialization.
68
+ *
69
+ * @param {DatabaseAdapter} db - The database adapter for metadata storage
70
+ * @param {StorageService} storage - The storage service for file persistence
71
+ * @param {AuthMiddleware} [authMiddleware] - Optional auth middleware for authentication (created automatically if not provided)
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * // Standard usage (AuthMiddleware created automatically)
76
+ * const assetsManager = new MyAssetsManager()
77
+ * assetsManager.setDependencies(databaseAdapter, storageService)
78
+ *
79
+ * // For testing (inject mock AuthMiddleware)
80
+ * assetsManager.setDependencies(databaseAdapter, storageService, mockAuthMiddleware)
81
+ * ```
82
+ */
83
+ setDependencies(db, storage, authMiddleware) {
84
+ this.db = db;
85
+ this.storage = storage;
86
+ this.authMiddleware = authMiddleware ?? new AuthMiddleware(new UserService(db.getUserRepository()));
87
+ this.presignedService = new PresignedUploadService({
88
+ db: this.db,
89
+ storage: this.storage,
90
+ authMiddleware: this.authMiddleware,
91
+ getConfiguration: () => this.getConfiguration(),
92
+ getAssetById: (id) => this.getAssetById(id),
93
+ validateOwnership: (asset, userId, headers) => this.validateOwnership(asset, userId, headers),
94
+ validateFileExtension: (filename) => this.validateFileExtension(filename)
95
+ });
96
+ }
97
+ /**
98
+ * Validates that a source string is a valid URL.
99
+ *
100
+ * Used internally to ensure data provenance URLs are properly formatted.
101
+ *
102
+ * @private
103
+ * @param {string} source - The source URL to validate
104
+ * @returns {boolean} True if the source is a valid URL, false otherwise
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * this.validateSourceURL('https://example.com/data') // returns true
109
+ * this.validateSourceURL('not-a-url') // returns false
110
+ * ```
111
+ */
112
+ validateSourceURL(source) {
113
+ try {
114
+ new URL(source);
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ /**
122
+ * Validates that a filename has the correct extension as configured.
123
+ *
124
+ * Used internally to ensure uploaded files match the expected extension.
125
+ *
126
+ * @private
127
+ * @param {string} filename - The filename to validate
128
+ * @returns {boolean} True if the filename has the correct extension or no extension is configured, false otherwise
129
+ *
130
+ * @example
131
+ * ```typescript
132
+ * // If config.extension = '.glb'
133
+ * this.validateFileExtension('model.glb') // returns true
134
+ * this.validateFileExtension('model.json') // returns false
135
+ * this.validateFileExtension('model') // returns false
136
+ *
137
+ * // If config.extension is undefined
138
+ * this.validateFileExtension('any-file.ext') // returns true
139
+ * ```
140
+ */
141
+ validateFileExtension(filename) {
142
+ const config = this.getConfiguration();
143
+ // If no extension is configured, allow any file
144
+ if (!config.extension) {
145
+ return true;
146
+ }
147
+ // Ensure the configured extension starts with a dot
148
+ const requiredExtension = config.extension.startsWith('.') ? config.extension : `.${config.extension}`;
149
+ // Check if the filename ends with the required extension (case-insensitive)
150
+ return filename.toLowerCase().endsWith(requiredExtension.toLowerCase());
151
+ }
152
+ /**
153
+ * Validates that a string is valid base64-encoded data.
154
+ *
155
+ * Used internally to ensure file data in batch uploads is properly base64-encoded
156
+ * before attempting to decode it.
157
+ *
158
+ * @private
159
+ * @param {any} data - Data to validate as base64
160
+ * @returns {boolean} True if data is a valid base64 string, false otherwise
161
+ *
162
+ * @example
163
+ * ```typescript
164
+ * this.validateBase64('SGVsbG8gV29ybGQ=') // returns true
165
+ * this.validateBase64('not-base64!@#') // returns false
166
+ * this.validateBase64(123) // returns false (not a string)
167
+ * this.validateBase64('') // returns false (empty string)
168
+ * ```
169
+ */
170
+ validateBase64(data) {
171
+ // Must be a non-empty string
172
+ if (typeof data !== 'string' || data.length === 0) {
173
+ return false;
174
+ }
175
+ // Base64 regex: only A-Z, a-z, 0-9, +, /, and = for padding
176
+ // Padding (=) can only appear at the end, max 2 times
177
+ const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/;
178
+ const trimmed = data.trim();
179
+ // Must match regex
180
+ if (!base64Regex.test(trimmed)) {
181
+ return false;
182
+ }
183
+ // Length must be multiple of 4
184
+ if (trimmed.length % 4 !== 0) {
185
+ return false;
186
+ }
187
+ // Try to decode to verify it's valid base64
188
+ try {
189
+ const decoded = Buffer.from(trimmed, 'base64').toString('base64');
190
+ // Re-encode and compare to ensure no data loss (valid base64)
191
+ return decoded === trimmed;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ }
197
+ // ============================================================================
198
+ // Authentication & Request Processing Helpers
199
+ // ============================================================================
200
+ /**
201
+ * Authenticates a request and returns the user record.
202
+ *
203
+ * This method consolidates the authentication flow:
204
+ * 1. Validates APISIX headers are present
205
+ * 2. Parses authentication headers
206
+ * 3. Finds or creates user record in database
207
+ *
208
+ * @param req - HTTP request object
209
+ * @returns AuthResult with either userRecord on success or DataResponse on failure
210
+ *
211
+ * @example
212
+ * ```typescript
213
+ * const authResult = await this.authenticateRequest(req)
214
+ * if (!authResult.success) {
215
+ * return authResult.response
216
+ * }
217
+ * const userRecord = authResult.userRecord
218
+ * ```
219
+ */
220
+ async authenticateRequest(req) {
221
+ return this.authMiddleware.authenticate(req);
222
+ }
223
+ /**
224
+ * Extracts upload data from multipart form request.
225
+ *
226
+ * @param req - HTTP request object with body and file
227
+ * @returns UploadData object with extracted fields
228
+ */
229
+ extractUploadData(req) {
230
+ const body = req.body;
231
+ return {
232
+ description: body?.description,
233
+ source: body?.source,
234
+ is_public: body?.is_public,
235
+ filePath: req.file?.path,
236
+ fileBuffer: req.file?.buffer,
237
+ filename: req.file?.originalname || body?.filename
238
+ };
239
+ }
240
+ /**
241
+ * Validates required fields for asset upload and returns validated data.
242
+ *
243
+ * @param data - Upload data to validate
244
+ * @returns UploadValidationResult with validated data on success or error response on failure
245
+ */
246
+ validateUploadFields(data) {
247
+ const hasFile = data.filePath || data.fileBuffer;
248
+ if (!hasFile || !data.description || !data.source) {
249
+ return {
250
+ success: false,
251
+ response: badRequestResponse('Missing required fields: description, source, file')
252
+ };
253
+ }
254
+ if (!data.filename) {
255
+ return {
256
+ success: false,
257
+ response: badRequestResponse('Filename could not be determined from uploaded file')
258
+ };
259
+ }
260
+ if (!this.validateFileExtension(data.filename)) {
261
+ const config = this.getConfiguration();
262
+ return {
263
+ success: false,
264
+ response: badRequestResponse(`Invalid file extension. Expected: ${config.extension}`)
265
+ };
266
+ }
267
+ return {
268
+ success: true,
269
+ data: {
270
+ description: data.description,
271
+ source: data.source,
272
+ filePath: data.filePath,
273
+ fileBuffer: data.fileBuffer,
274
+ filename: data.filename,
275
+ is_public: data.is_public !== undefined ? Boolean(data.is_public) : true
276
+ }
277
+ };
278
+ }
279
+ /**
280
+ * Reads file content from temporary upload path.
281
+ *
282
+ * @param filePath - Path to temporary file
283
+ * @returns Buffer with file content
284
+ * @throws Error if file cannot be read
285
+ */
286
+ async readTempFile(filePath) {
287
+ return fs.readFile(filePath);
288
+ }
289
+ /**
290
+ * Cleans up temporary file after processing.
291
+ * Logs cleanup errors but doesn't throw.
292
+ *
293
+ * @param filePath - Path to temporary file
294
+ */
295
+ async cleanupTempFile(filePath) {
296
+ await safeAsync(() => fs.unlink(filePath), `cleanup temp file ${filePath}`, logger);
297
+ }
298
+ /**
299
+ * Validates ownership of an asset.
300
+ *
301
+ * Admins can modify any asset. Regular users can only modify their own assets
302
+ * or assets with no owner (owner_id = null).
303
+ *
304
+ * @param asset - Asset record to check
305
+ * @param userId - User ID to validate against
306
+ * @param headers - HTTP request headers (optional, for admin check)
307
+ * @returns DataResponse with error if not owner/admin, undefined if valid
308
+ */
309
+ validateOwnership(asset, userId, headers) {
310
+ // Admins can modify any asset
311
+ if (headers && ApisixAuthParser.isAdmin(headers)) {
312
+ return undefined;
313
+ }
314
+ // Assets with no owner (null) can be modified by anyone
315
+ if (asset.owner_id === null) {
316
+ return undefined;
317
+ }
318
+ if (asset.owner_id !== userId) {
319
+ return forbiddenResponse('You can only modify your own assets');
320
+ }
321
+ return undefined;
322
+ }
323
+ /**
324
+ * Checks if a user can access a private asset.
325
+ *
326
+ * @param asset - Asset record to check
327
+ * @param req - HTTP request for authentication context
328
+ * @returns DataResponse with error if access denied, undefined if allowed
329
+ */
330
+ async checkPrivateAssetAccess(asset, req) {
331
+ // Public assets are always accessible
332
+ if (asset.is_public) {
333
+ return undefined;
334
+ }
335
+ // Admins can access everything
336
+ if (ApisixAuthParser.isAdmin(req.headers || {})) {
337
+ return undefined;
338
+ }
339
+ // Private asset - require authentication
340
+ const authResult = await this.authMiddleware.authenticate(req);
341
+ if (!authResult.success) {
342
+ return unauthorizedResponse('Authentication required for private assets');
343
+ }
344
+ if (asset.owner_id !== authResult.userRecord.id) {
345
+ return forbiddenResponse('This asset is private');
346
+ }
347
+ return undefined;
348
+ }
349
+ /**
350
+ * Fetches an asset by ID with full access control validation.
351
+ *
352
+ * This method consolidates the common logic for retrieving an asset:
353
+ * 1. Validates that ID is provided
354
+ * 2. Fetches the asset from database
355
+ * 3. Verifies the asset belongs to this component
356
+ * 4. Checks access permissions for private assets
357
+ *
358
+ * @param req - HTTP request with params.id
359
+ * @returns Object with asset on success, or DataResponse on failure
360
+ */
361
+ async fetchAssetWithAccessCheck(req) {
362
+ // Validate ID parameter (ValidationError bubbles up to global handler -> 422)
363
+ const validatedParams = await validateParams(validateIdParam, req.params || {}, 'Asset ID');
364
+ const asset = await this.getAssetById(validatedParams.id.toString());
365
+ if (!asset) {
366
+ return { success: false, response: textResponse(HttpStatus.NOT_FOUND, 'Asset not found') };
367
+ }
368
+ // Verify this asset belongs to our component
369
+ const config = this.getConfiguration();
370
+ if (asset.name !== config.name) {
371
+ return { success: false, response: textResponse(HttpStatus.NOT_FOUND, 'Asset not found') };
372
+ }
373
+ // Check access permissions for private assets
374
+ const accessError = await this.checkPrivateAssetAccess(asset, req);
375
+ if (accessError) {
376
+ return { success: false, response: accessError };
377
+ }
378
+ return { success: true, asset };
379
+ }
380
+ /**
381
+ * Upload a new asset file with metadata.
382
+ *
383
+ * Stores the file using the storage service and saves metadata to the database.
384
+ * Asset metadata is stored as dedicated columns in the database table.
385
+ *
386
+ * @param {CreateAssetRequest} request - The asset upload request
387
+ * @throws {Error} If source URL is invalid
388
+ *
389
+ * @example
390
+ * ```typescript
391
+ * await assetsManager.uploadAsset({
392
+ * description: '3D building model',
393
+ * source: 'https://city-data.example.com/buildings',
394
+ * owner_id: 'user123',
395
+ * filename: 'building.glb',
396
+ * file: fileBuffer,
397
+ * is_public: true
398
+ * })
399
+ * ```
400
+ */
401
+ async uploadAsset(request) {
402
+ if (!this.validateSourceURL(request.source)) {
403
+ throw new Error('Invalid source URL');
404
+ }
405
+ if (!this.validateFileExtension(request.filename)) {
406
+ const config = this.getConfiguration();
407
+ throw new Error(`Invalid file extension. Expected: ${config.extension}`);
408
+ }
409
+ const config = this.getConfiguration();
410
+ const now = new Date();
411
+ // Store file using framework pattern
412
+ const url = await this.storage.save(request.file, config.name, request.filename);
413
+ // Create metadata with all asset-specific fields
414
+ const metadata = {
415
+ name: config.name,
416
+ type: config.contentType,
417
+ url,
418
+ date: now,
419
+ description: request.description,
420
+ source: request.source,
421
+ owner_id: request.owner_id,
422
+ filename: request.filename,
423
+ is_public: request.is_public ?? true // Default to public if not specified
424
+ };
425
+ await this.db.save(metadata);
426
+ }
427
+ /**
428
+ * Retrieve all assets for this component (like other components).
429
+ *
430
+ * Returns a JSON list of all assets with their metadata, following the
431
+ * framework pattern but adapted for assets management.
432
+ *
433
+ * Access control:
434
+ * - Unauthenticated users: Can only see public assets
435
+ * - Authenticated users: Can see public assets + their own private assets
436
+ * - Admin users: Can see all assets (public and private from all users)
437
+ *
438
+ * @returns {Promise<DataResponse>} JSON response with all assets
439
+ */
440
+ async retrieve(req) {
441
+ try {
442
+ // Validate pagination parameters if present (ValidationError bubbles up to global handler -> 422)
443
+ let pagination = {};
444
+ if (req?.query && Object.keys(req.query).length > 0) {
445
+ pagination = await validateQuery(validatePagination, req.query, 'Pagination');
446
+ }
447
+ const assets = await this.getAllAssets();
448
+ const isAdmin = req && ApisixAuthParser.isAdmin(req.headers || {});
449
+ // Admin can see everything
450
+ let visibleAssets;
451
+ if (isAdmin) {
452
+ visibleAssets = assets;
453
+ }
454
+ else {
455
+ // Get authenticated user ID if available
456
+ const authenticatedUserId = await this.getAuthenticatedUserId(req);
457
+ // Filter to visible assets only
458
+ visibleAssets = assets.filter(asset => asset.is_public || (authenticatedUserId !== null && asset.owner_id === authenticatedUserId));
459
+ }
460
+ // Apply pagination
461
+ const offset = pagination.offset ?? 0;
462
+ const limit = pagination.limit ?? visibleAssets.length;
463
+ const paginatedAssets = visibleAssets.slice(offset, offset + limit);
464
+ return successResponse(this.formatAssetsForResponse(paginatedAssets));
465
+ }
466
+ catch (error) {
467
+ return errorResponse(error);
468
+ }
469
+ }
470
+ /**
471
+ * Gets the authenticated user's database ID from request headers.
472
+ *
473
+ * @param req - HTTP request object
474
+ * @returns User ID or null if not authenticated
475
+ */
476
+ async getAuthenticatedUserId(req) {
477
+ if (!req)
478
+ return null;
479
+ const authResult = await this.authMiddleware.authenticate(req);
480
+ if (!authResult.success)
481
+ return null;
482
+ return authResult.userRecord.id || null;
483
+ }
484
+ /**
485
+ * Formats assets for API response with metadata and URLs.
486
+ *
487
+ * @param assets - Array of asset records
488
+ * @returns Formatted assets array ready for JSON serialization
489
+ */
490
+ formatAssetsForResponse(assets) {
491
+ const config = this.getConfiguration();
492
+ return assets.map(asset => ({
493
+ id: asset.id,
494
+ name: asset.name,
495
+ date: asset.date,
496
+ contentType: asset.contentType,
497
+ description: asset.description || '',
498
+ source: asset.source || '',
499
+ owner_id: asset.owner_id || null,
500
+ filename: asset.filename || '',
501
+ is_public: asset.is_public ?? true,
502
+ url: `/${config.endpoint}/${asset.id}`,
503
+ download_url: `/${config.endpoint}/${asset.id}/download`
504
+ }));
505
+ }
506
+ /**
507
+ * Get all assets for this component type.
508
+ *
509
+ * Retrieves all assets managed by this component, with their metadata.
510
+ * Uses a very old start date to get all records.
511
+ *
512
+ * @returns {Promise<DataRecord[]>} Array of all asset records
513
+ *
514
+ * @example
515
+ * ```typescript
516
+ * const allAssets = await assetsManager.getAllAssets()
517
+ * // Returns: [{ id, name, type, url, date, contentType }, ...]
518
+ * ```
519
+ */
520
+ async getAllAssets() {
521
+ const config = this.getConfiguration();
522
+ // Get all assets sorted by date descending (newest first) using SQL ORDER BY
523
+ const veryOldDate = new Date('1970-01-01');
524
+ const farFutureDate = new Date('2099-12-31');
525
+ return this.db.getByDateRange(config.name, veryOldDate, farFutureDate, 1000, 'desc');
526
+ }
527
+ /**
528
+ * Get asset by specific ID.
529
+ *
530
+ * @param {string} id - The asset ID to retrieve
531
+ * @returns {Promise<DataRecord | undefined>} The asset record or undefined if not found
532
+ *
533
+ * @example
534
+ * ```typescript
535
+ * const asset = await assetsManager.getAssetById('123')
536
+ * if (asset) {
537
+ * const fileData = await asset.data()
538
+ * }
539
+ * ```
540
+ */
541
+ async getAssetById(id) {
542
+ return await this.db.getById(id, this.getConfiguration().name);
543
+ }
544
+ /**
545
+ * Update asset metadata by ID.
546
+ *
547
+ * Updates the metadata (description, source, and/or visibility) of a specific asset.
548
+ * Asset metadata is stored as dedicated columns in the database.
549
+ *
550
+ * @param {string} id - The ID of the asset to update
551
+ * @param {UpdateAssetRequest} updates - The metadata updates to apply
552
+ * @throws {Error} If source URL is invalid or asset not found
553
+ *
554
+ * @example
555
+ * ```typescript
556
+ * await assetsManager.updateAssetMetadata('123', {
557
+ * description: 'Updated building model with new textures',
558
+ * source: 'https://updated-source.example.com',
559
+ * is_public: false
560
+ * })
561
+ * ```
562
+ */
563
+ async updateAssetMetadata(id, updates) {
564
+ if (updates.source && !this.validateSourceURL(updates.source)) {
565
+ throw new Error('Invalid source URL');
566
+ }
567
+ const record = await this.db.getById(id, this.getConfiguration().name);
568
+ if (!record) {
569
+ throw new Error(`Asset with ID ${id} not found`);
570
+ }
571
+ // Verify this asset belongs to our component
572
+ const config = this.getConfiguration();
573
+ if (record.name !== config.name) {
574
+ throw new Error(`Asset ${id} does not belong to component ${config.name}`);
575
+ }
576
+ // Apply updates - only include fields that are being updated
577
+ const updateData = {};
578
+ if (updates.description !== undefined) {
579
+ updateData.description = updates.description;
580
+ }
581
+ if (updates.source !== undefined) {
582
+ updateData.source = updates.source;
583
+ }
584
+ if (updates.is_public !== undefined) {
585
+ updateData.is_public = updates.is_public;
586
+ }
587
+ // Use true UPDATE to preserve the record ID
588
+ await this.db.updateAssetMetadata(config.name, parseInt(id), updateData);
589
+ }
590
+ /**
591
+ * Delete asset by ID.
592
+ *
593
+ * Removes a specific asset.
594
+ *
595
+ * @param {string} id - The ID of the asset to delete
596
+ * @throws {Error} If asset not found or doesn't belong to this component
597
+ *
598
+ * @example
599
+ * ```typescript
600
+ * await assetsManager.deleteAssetById('123')
601
+ * ```
602
+ */
603
+ async deleteAssetById(id) {
604
+ const record = await this.db.getById(id, this.getConfiguration().name);
605
+ if (!record) {
606
+ throw new Error(`Asset with ID ${id} not found`);
607
+ }
608
+ // Verify this asset belongs to our component
609
+ const config = this.getConfiguration();
610
+ if (record.name !== config.name) {
611
+ throw new Error(`Asset ${id} does not belong to component ${config.name}`);
612
+ }
613
+ await this.db.delete(id, this.getConfiguration().name);
614
+ }
615
+ /**
616
+ * Delete latest asset (simplified)
617
+ *
618
+ * Removes the most recently uploaded asset for this component type.
619
+ *
620
+ * @throws {Error} If no assets exist to delete
621
+ *
622
+ * @example
623
+ * ```typescript
624
+ * await assetsManager.deleteLatestAsset()
625
+ * ```
626
+ */
627
+ async deleteLatestAsset() {
628
+ const config = this.getConfiguration();
629
+ const record = await this.db.getLatestByName(config.name);
630
+ if (record) {
631
+ await this.db.delete(record.id.toString(), this.getConfiguration().name);
632
+ }
633
+ }
634
+ /**
635
+ * Upload multiple assets in batch for better performance
636
+ *
637
+ * @param {CreateAssetRequest[]} requests - Array of asset upload requests
638
+ * @throws {Error} If any source URL is invalid
639
+ *
640
+ * @example
641
+ * ```typescript
642
+ * await assetsManager.uploadAssetsBatch([
643
+ * { description: 'Model 1', source: 'https://example.com/1', file: buffer1, ... },
644
+ * { description: 'Model 2', source: 'https://example.com/2', file: buffer2, ... }
645
+ * ])
646
+ * ```
647
+ */
648
+ async uploadAssetsBatch(requests) {
649
+ if (requests.length === 0)
650
+ return;
651
+ // Validate all URLs and extensions first
652
+ for (const request of requests) {
653
+ if (!this.validateSourceURL(request.source)) {
654
+ throw new Error(`Invalid source URL: ${request.source}`);
655
+ }
656
+ if (!this.validateFileExtension(request.filename)) {
657
+ const config = this.getConfiguration();
658
+ throw new Error(`Invalid file extension for ${request.filename}. Expected: ${config.extension}`);
659
+ }
660
+ }
661
+ const config = this.getConfiguration();
662
+ const now = new Date();
663
+ const metadataList = [];
664
+ // Store files and prepare metadata
665
+ for (const request of requests) {
666
+ const url = await this.storage.save(request.file, config.name, request.filename);
667
+ const metadata = {
668
+ name: config.name,
669
+ type: config.contentType,
670
+ url,
671
+ date: now,
672
+ description: request.description,
673
+ source: request.source,
674
+ owner_id: request.owner_id,
675
+ filename: request.filename,
676
+ is_public: request.is_public ?? true
677
+ };
678
+ metadataList.push(metadata);
679
+ }
680
+ // Save all metadata individually (compatible with all adapters)
681
+ for (const metadata of metadataList) {
682
+ await this.db.save(metadata);
683
+ }
684
+ }
685
+ /**
686
+ * Delete multiple assets by IDs in batch
687
+ *
688
+ * @param {string[]} ids - Array of asset IDs to delete
689
+ * @throws {Error} If any asset not found or doesn't belong to this component
690
+ */
691
+ async deleteAssetsBatch(ids) {
692
+ if (ids.length === 0)
693
+ return;
694
+ // Delete assets individually (compatible with all adapters)
695
+ for (const id of ids) {
696
+ await this.deleteAssetById(id);
697
+ }
698
+ }
699
+ /**
700
+ * Get endpoints following the framework pattern
701
+ */
702
+ /**
703
+ * Get HTTP endpoints exposed by this assets manager.
704
+ *
705
+ * Returns the standard CRUD endpoints following the framework pattern.
706
+ *
707
+ * @returns {Array} Array of endpoint descriptors with methods, paths, and handlers
708
+ *
709
+ * @example
710
+ * ```typescript
711
+ * // For a manager with assetType: 'gltf', provides:
712
+ * GET /gltf - Get all assets
713
+ * POST /gltf/upload - Upload new asset
714
+ * GET /gltf/123 - Get specific asset
715
+ * PUT /gltf/123 - Update asset metadata
716
+ * GET /gltf/123/download - Download asset
717
+ * DELETE /gltf/123 - Delete asset
718
+ * ```
719
+ */
720
+ getEndpoints() {
721
+ const config = this.getConfiguration();
722
+ return [
723
+ {
724
+ method: 'get',
725
+ path: `/${config.endpoint}`,
726
+ handler: this.retrieve.bind(this),
727
+ responseType: 'application/json'
728
+ },
729
+ {
730
+ method: 'post',
731
+ path: `/${config.endpoint}`,
732
+ handler: this.handleUpload.bind(this),
733
+ responseType: 'application/json'
734
+ },
735
+ {
736
+ method: 'post',
737
+ path: `/${config.endpoint}/upload-request`,
738
+ handler: this.handlePresignedUploadRequest.bind(this),
739
+ responseType: 'application/json'
740
+ },
741
+ {
742
+ method: 'post',
743
+ path: `/${config.endpoint}/confirm/:fileId`,
744
+ handler: this.handleUploadConfirm.bind(this),
745
+ responseType: 'application/json'
746
+ },
747
+ {
748
+ method: 'get',
749
+ path: `/${config.endpoint}/:id`,
750
+ handler: this.handleGetAsset.bind(this),
751
+ responseType: config.contentType
752
+ },
753
+ {
754
+ method: 'put',
755
+ path: `/${config.endpoint}/:id`,
756
+ handler: this.handleUpdate.bind(this),
757
+ responseType: 'application/json'
758
+ },
759
+ {
760
+ method: 'get',
761
+ path: `/${config.endpoint}/:id/download`,
762
+ handler: this.handleDownload.bind(this),
763
+ responseType: config.contentType
764
+ },
765
+ {
766
+ method: 'delete',
767
+ path: `/${config.endpoint}/:id`,
768
+ handler: this.handleDelete.bind(this),
769
+ responseType: 'application/json'
770
+ },
771
+ {
772
+ method: 'post',
773
+ path: `/${config.endpoint}/batch`,
774
+ handler: this.handleUploadBatch.bind(this),
775
+ responseType: 'application/json'
776
+ },
777
+ {
778
+ method: 'delete',
779
+ path: `/${config.endpoint}/batch`,
780
+ handler: this.handleDeleteBatch.bind(this),
781
+ responseType: 'application/json'
782
+ }
783
+ ];
784
+ }
785
+ /**
786
+ * Returns the OpenAPI specification for this assets manager's endpoints.
787
+ *
788
+ * Generates documentation for all CRUD endpoints including batch operations.
789
+ * Can be overridden by subclasses for more detailed specifications.
790
+ *
791
+ * @returns {OpenAPIComponentSpec} OpenAPI paths, tags, and schemas for this assets manager
792
+ */
793
+ getOpenAPISpec() {
794
+ return generateAssetsOpenAPISpec(this.getConfiguration());
795
+ }
796
+ /**
797
+ * Handle presigned upload URL request.
798
+ * Delegates to PresignedUploadService.
799
+ */
800
+ async handlePresignedUploadRequest(req) {
801
+ return this.presignedService.handleUploadRequest(req);
802
+ }
803
+ /**
804
+ * Handle presigned upload confirmation.
805
+ * Delegates to PresignedUploadService.
806
+ */
807
+ async handleUploadConfirm(req) {
808
+ return this.presignedService.handleConfirm(req);
809
+ }
810
+ /**
811
+ * Handle single asset upload via HTTP POST (multipart/form-data).
812
+ */
813
+ async handleUpload(req) {
814
+ try {
815
+ // Validate request structure
816
+ if (!req?.body) {
817
+ return badRequestResponse('Invalid request: missing request body');
818
+ }
819
+ // Authenticate user
820
+ const authResult = await this.authenticateRequest(req);
821
+ if (!authResult.success) {
822
+ return authResult.response;
823
+ }
824
+ const userId = authResult.userRecord.id;
825
+ if (!userId) {
826
+ return errorResponse('Failed to retrieve user information');
827
+ }
828
+ // Extract and validate upload data
829
+ const uploadData = this.extractUploadData(req);
830
+ const validation = this.validateUploadFields(uploadData);
831
+ if (!validation.success) {
832
+ return validation.response;
833
+ }
834
+ const validData = validation.data;
835
+ // Get file buffer from memory or read from temporary location
836
+ let fileBuffer;
837
+ if (validData.fileBuffer) {
838
+ // Memory storage: buffer already available
839
+ fileBuffer = validData.fileBuffer;
840
+ }
841
+ else if (validData.filePath) {
842
+ // Disk storage: read from temp file
843
+ try {
844
+ fileBuffer = await this.readTempFile(validData.filePath);
845
+ }
846
+ catch (error) {
847
+ return errorResponse(`Failed to read uploaded file: ${error instanceof Error ? error.message : 'Unknown error'}`);
848
+ }
849
+ }
850
+ else {
851
+ return badRequestResponse('No file data available');
852
+ }
853
+ // Upload asset and cleanup
854
+ try {
855
+ await this.uploadAsset({
856
+ description: validData.description,
857
+ source: validData.source,
858
+ owner_id: userId,
859
+ filename: validData.filename,
860
+ file: fileBuffer,
861
+ is_public: validData.is_public
862
+ });
863
+ if (validData.filePath) {
864
+ await this.cleanupTempFile(validData.filePath);
865
+ }
866
+ }
867
+ catch (error) {
868
+ if (validData.filePath) {
869
+ await this.cleanupTempFile(validData.filePath);
870
+ }
871
+ throw error;
872
+ }
873
+ return successResponse({ message: 'Asset uploaded successfully' });
874
+ }
875
+ catch (error) {
876
+ return errorResponse(error);
877
+ }
878
+ }
879
+ /**
880
+ * Handle update endpoint (PUT).
881
+ *
882
+ * Updates metadata for a specific asset by ID.
883
+ *
884
+ * @param {any} req - HTTP request object with params.id and body containing updates
885
+ * @returns {Promise<DataResponse>} HTTP response
886
+ *
887
+ * @example
888
+ * ```typescript
889
+ * // PUT /gltf/123
890
+ * // Body: { "description": "Updated model", "source": "https://new-source.com" }
891
+ * ```
892
+ */
893
+ async handleUpdate(req) {
894
+ try {
895
+ if (!req) {
896
+ return badRequestResponse('Invalid request: missing request object');
897
+ }
898
+ // Authenticate user
899
+ const authResult = await this.authenticateRequest(req);
900
+ if (!authResult.success) {
901
+ return authResult.response;
902
+ }
903
+ const userId = authResult.userRecord.id;
904
+ if (!userId) {
905
+ return errorResponse('Failed to retrieve user information');
906
+ }
907
+ // Validate ID parameter (ValidationError bubbles up to global handler -> 422)
908
+ const validatedParams = await validateParams(validateIdParam, req.params || {}, 'Asset ID');
909
+ // Validate request body (ValidationError bubbles up to global handler -> 422)
910
+ const validatedBody = await validateData(validateAssetUpdate, req.body || {}, 'Asset update');
911
+ const { description, source, is_public } = validatedBody;
912
+ if (!description && !source && is_public === undefined) {
913
+ return badRequestResponse('At least one field (description, source, or is_public) must be provided for update');
914
+ }
915
+ // Check if asset exists
916
+ const asset = await this.getAssetById(validatedParams.id.toString());
917
+ if (!asset) {
918
+ return notFoundResponse('Asset not found');
919
+ }
920
+ // Check ownership (admins can modify any asset)
921
+ const ownershipError = this.validateOwnership(asset, userId, req.headers);
922
+ if (ownershipError) {
923
+ return ownershipError;
924
+ }
925
+ // Build and apply updates
926
+ const updates = {};
927
+ if (description !== undefined)
928
+ updates.description = description;
929
+ if (source !== undefined)
930
+ updates.source = source;
931
+ if (is_public !== undefined)
932
+ updates.is_public = Boolean(is_public);
933
+ await this.updateAssetMetadata(validatedParams.id.toString(), updates);
934
+ return successResponse({ message: 'Asset metadata updated successfully' });
935
+ }
936
+ catch (error) {
937
+ return errorResponse(error);
938
+ }
939
+ }
940
+ /**
941
+ * Handle get asset endpoint (GET).
942
+ *
943
+ * Returns the file content of a specific asset by ID for display/use in front-end.
944
+ * No download headers - just the raw file content.
945
+ *
946
+ * Access control:
947
+ * - Public assets: Accessible to everyone
948
+ * - Private assets: Accessible only to owner
949
+ * - Admin users: Can access all assets (public and private)
950
+ *
951
+ * @param {any} req - HTTP request object with params.id
952
+ * @returns {Promise<DataResponse>} HTTP response with file content
953
+ *
954
+ * @example
955
+ * ```typescript
956
+ * // GET /gltf/123
957
+ * // Returns the .glb file content for display in 3D viewer
958
+ * ```
959
+ */
960
+ async handleGetAsset(req) {
961
+ try {
962
+ const result = await this.fetchAssetWithAccessCheck(req);
963
+ if (!result.success) {
964
+ return result.response;
965
+ }
966
+ const fileContent = await result.asset.data();
967
+ return fileResponse(fileContent, this.getConfiguration().contentType);
968
+ }
969
+ catch (error) {
970
+ return errorResponse(error);
971
+ }
972
+ }
973
+ /**
974
+ * Handle download endpoint (GET).
975
+ *
976
+ * Downloads the file content of a specific asset by ID with download headers.
977
+ * Forces browser to download the file rather than display it.
978
+ *
979
+ * Access control:
980
+ * - Public assets: Accessible to everyone
981
+ * - Private assets: Accessible only to owner
982
+ * - Admin users: Can download all assets (public and private)
983
+ *
984
+ * @param {any} req - HTTP request object with params.id
985
+ * @returns {Promise<DataResponse>} HTTP response with file content and download headers
986
+ *
987
+ * @example
988
+ * ```typescript
989
+ * // GET /gltf/123/download
990
+ * // Returns the .glb file with download headers - browser will save it
991
+ * ```
992
+ */
993
+ async handleDownload(req) {
994
+ try {
995
+ const result = await this.fetchAssetWithAccessCheck(req);
996
+ if (!result.success) {
997
+ return result.response;
998
+ }
999
+ const fileContent = await result.asset.data();
1000
+ const filename = result.asset.filename || `asset_${req.params?.id}`;
1001
+ return fileResponse(fileContent, this.getConfiguration().contentType, filename);
1002
+ }
1003
+ catch (error) {
1004
+ return errorResponse(error);
1005
+ }
1006
+ }
1007
+ /**
1008
+ * Handle delete endpoint (DELETE).
1009
+ *
1010
+ * Deletes a specific asset by ID.
1011
+ *
1012
+ * @param {any} req - HTTP request object with params.id
1013
+ * @returns {Promise<DataResponse>} HTTP response
1014
+ *
1015
+ * @example
1016
+ * ```typescript
1017
+ * // DELETE /gltf/123
1018
+ * ```
1019
+ */
1020
+ async handleDelete(req) {
1021
+ try {
1022
+ // Authenticate user
1023
+ const authResult = await this.authenticateRequest(req);
1024
+ if (!authResult.success) {
1025
+ return authResult.response;
1026
+ }
1027
+ const userId = authResult.userRecord.id;
1028
+ if (!userId) {
1029
+ return errorResponse('Failed to retrieve user information');
1030
+ }
1031
+ // Validate ID parameter (ValidationError bubbles up to global handler -> 422)
1032
+ const validatedParams = await validateParams(validateIdParam, req.params || {}, 'Asset ID');
1033
+ // Check if asset exists
1034
+ const asset = await this.getAssetById(validatedParams.id.toString());
1035
+ if (!asset) {
1036
+ return notFoundResponse('Asset not found');
1037
+ }
1038
+ // Check ownership (admins can delete any asset)
1039
+ const ownershipError = this.validateOwnership(asset, userId, req.headers);
1040
+ if (ownershipError) {
1041
+ return ownershipError;
1042
+ }
1043
+ await this.deleteAssetById(validatedParams.id.toString());
1044
+ return successResponse({ message: 'Asset deleted successfully' });
1045
+ }
1046
+ catch (error) {
1047
+ return errorResponse(error);
1048
+ }
1049
+ }
1050
+ /**
1051
+ * Handle batch upload endpoint
1052
+ */
1053
+ async handleUploadBatch(req) {
1054
+ try {
1055
+ if (!req?.body) {
1056
+ return badRequestResponse('Invalid request: missing request body');
1057
+ }
1058
+ // Authenticate user
1059
+ const authResult = await this.authenticateRequest(req);
1060
+ if (!authResult.success) {
1061
+ return authResult.response;
1062
+ }
1063
+ const userId = authResult.userRecord.id;
1064
+ if (!userId) {
1065
+ return errorResponse('Failed to retrieve user information');
1066
+ }
1067
+ const requests = req.body.requests;
1068
+ if (!Array.isArray(requests) || requests.length === 0) {
1069
+ return badRequestResponse('Requests array is required and must not be empty');
1070
+ }
1071
+ // Validate all requests first
1072
+ const validationError = this.validateBatchRequests(requests);
1073
+ if (validationError) {
1074
+ return validationError;
1075
+ }
1076
+ // Process each request
1077
+ const results = await this.processBatchUploads(requests, userId);
1078
+ const successCount = results.filter(r => r.success).length;
1079
+ const failureCount = results.length - successCount;
1080
+ const message = `${successCount}/${requests.length} assets uploaded successfully`;
1081
+ if (failureCount > 0) {
1082
+ return multiStatusResponse(message, results);
1083
+ }
1084
+ return successResponse({ message, results });
1085
+ }
1086
+ catch (error) {
1087
+ return errorResponse(error);
1088
+ }
1089
+ }
1090
+ /**
1091
+ * Validates all requests in a batch upload.
1092
+ *
1093
+ * @param requests - Array of upload requests to validate
1094
+ * @returns DataResponse with error if validation fails, undefined if valid
1095
+ */
1096
+ validateBatchRequests(requests) {
1097
+ const config = this.getConfiguration();
1098
+ for (const request of requests) {
1099
+ if (!request.file || !request.description || !request.source || !request.filename) {
1100
+ return badRequestResponse('Each request must have description, source, filename, and file');
1101
+ }
1102
+ if (!this.validateBase64(request.file)) {
1103
+ return badRequestResponse(`Invalid base64 data for file: ${request.filename}. File must be a valid base64-encoded string.`);
1104
+ }
1105
+ if (!this.validateFileExtension(request.filename)) {
1106
+ return badRequestResponse(`Invalid file extension for ${request.filename}. Expected: ${config.extension}`);
1107
+ }
1108
+ }
1109
+ return undefined;
1110
+ }
1111
+ /**
1112
+ * Processes batch upload requests.
1113
+ *
1114
+ * @param requests - Array of upload requests
1115
+ * @param ownerId - Owner user ID
1116
+ * @returns Array of results for each upload
1117
+ */
1118
+ async processBatchUploads(requests, ownerId) {
1119
+ const results = [];
1120
+ for (const request of requests) {
1121
+ try {
1122
+ await this.uploadAsset({
1123
+ description: request.description,
1124
+ source: request.source,
1125
+ owner_id: ownerId,
1126
+ filename: request.filename,
1127
+ file: Buffer.from(request.file, 'base64'),
1128
+ is_public: request.is_public !== undefined ? Boolean(request.is_public) : true
1129
+ });
1130
+ results.push({ success: true, filename: request.filename });
1131
+ }
1132
+ catch (error) {
1133
+ results.push({
1134
+ success: false,
1135
+ filename: request.filename,
1136
+ error: error instanceof Error ? error.message : 'Unknown error'
1137
+ });
1138
+ }
1139
+ }
1140
+ return results;
1141
+ }
1142
+ /**
1143
+ * Handle batch delete endpoint
1144
+ */
1145
+ async handleDeleteBatch(req) {
1146
+ try {
1147
+ if (!req?.body) {
1148
+ return badRequestResponse('Invalid request: missing request body');
1149
+ }
1150
+ // Authenticate user
1151
+ const authResult = await this.authenticateRequest(req);
1152
+ if (!authResult.success) {
1153
+ return authResult.response;
1154
+ }
1155
+ const userId = authResult.userRecord.id;
1156
+ if (!userId) {
1157
+ return errorResponse('Failed to retrieve user information');
1158
+ }
1159
+ const { ids } = req.body;
1160
+ if (!Array.isArray(ids) || ids.length === 0) {
1161
+ return badRequestResponse('IDs array is required and must not be empty');
1162
+ }
1163
+ // Process deletions (admins can delete any asset)
1164
+ const results = await this.processBatchDeletes(ids, userId, req.headers);
1165
+ const successCount = results.filter(r => r.success).length;
1166
+ const failureCount = results.length - successCount;
1167
+ const message = `${successCount}/${ids.length} assets deleted successfully`;
1168
+ if (failureCount > 0) {
1169
+ return multiStatusResponse(message, results);
1170
+ }
1171
+ return successResponse({ message, results });
1172
+ }
1173
+ catch (error) {
1174
+ return errorResponse(error);
1175
+ }
1176
+ }
1177
+ /**
1178
+ * Processes batch delete requests.
1179
+ *
1180
+ * Admins can delete any asset. Regular users can only delete their own assets
1181
+ * or assets with no owner.
1182
+ *
1183
+ * @param ids - Array of asset IDs to delete
1184
+ * @param userId - User ID for ownership validation
1185
+ * @param headers - HTTP request headers (for admin check)
1186
+ * @returns Array of results for each deletion
1187
+ */
1188
+ async processBatchDeletes(ids, userId, headers) {
1189
+ const results = [];
1190
+ const isAdmin = headers && ApisixAuthParser.isAdmin(headers);
1191
+ for (const id of ids) {
1192
+ try {
1193
+ const asset = await this.getAssetById(id);
1194
+ if (!asset) {
1195
+ results.push({ success: false, id, error: 'Asset not found' });
1196
+ continue;
1197
+ }
1198
+ // Allow deletion if: admin OR owner is the current user OR asset has no owner
1199
+ if (!isAdmin && asset.owner_id !== null && asset.owner_id !== userId) {
1200
+ results.push({ success: false, id, error: 'You can only delete your own assets' });
1201
+ continue;
1202
+ }
1203
+ await this.deleteAssetById(id);
1204
+ results.push({ success: true, id });
1205
+ }
1206
+ catch (error) {
1207
+ results.push({
1208
+ success: false,
1209
+ id,
1210
+ error: error instanceof Error ? error.message : 'Unknown error'
1211
+ });
1212
+ }
1213
+ }
1214
+ return results;
1215
+ }
1216
+ }
1217
+ //# sourceMappingURL=assets_manager.js.map