digitaltwin-core 0.11.0 → 0.11.2

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 (36) hide show
  1. package/dist/components/assets_manager.d.ts +9 -1
  2. package/dist/components/assets_manager.d.ts.map +1 -1
  3. package/dist/components/assets_manager.js +24 -11
  4. package/dist/components/assets_manager.js.map +1 -1
  5. package/dist/components/tileset_manager.d.ts +84 -31
  6. package/dist/components/tileset_manager.d.ts.map +1 -1
  7. package/dist/components/tileset_manager.js +510 -120
  8. package/dist/components/tileset_manager.js.map +1 -1
  9. package/dist/database/adapters/knex_database_adapter.d.ts.map +1 -1
  10. package/dist/database/adapters/knex_database_adapter.js +17 -0
  11. package/dist/database/adapters/knex_database_adapter.js.map +1 -1
  12. package/dist/storage/adapters/local_storage_service.d.ts +8 -0
  13. package/dist/storage/adapters/local_storage_service.d.ts.map +1 -1
  14. package/dist/storage/adapters/local_storage_service.js +14 -0
  15. package/dist/storage/adapters/local_storage_service.js.map +1 -1
  16. package/dist/storage/adapters/ovh_storage_service.d.ts +8 -0
  17. package/dist/storage/adapters/ovh_storage_service.d.ts.map +1 -1
  18. package/dist/storage/adapters/ovh_storage_service.js +16 -0
  19. package/dist/storage/adapters/ovh_storage_service.js.map +1 -1
  20. package/dist/storage/storage_service.d.ts +20 -0
  21. package/dist/storage/storage_service.d.ts.map +1 -1
  22. package/dist/storage/storage_service.js.map +1 -1
  23. package/dist/types/data_record.d.ts +30 -0
  24. package/dist/types/data_record.d.ts.map +1 -1
  25. package/dist/utils/index.d.ts +2 -1
  26. package/dist/utils/index.d.ts.map +1 -1
  27. package/dist/utils/index.js +1 -1
  28. package/dist/utils/index.js.map +1 -1
  29. package/dist/utils/map_to_data_record.d.ts.map +1 -1
  30. package/dist/utils/map_to_data_record.js +7 -1
  31. package/dist/utils/map_to_data_record.js.map +1 -1
  32. package/dist/utils/zip_utils.d.ts +58 -0
  33. package/dist/utils/zip_utils.d.ts.map +1 -1
  34. package/dist/utils/zip_utils.js +100 -0
  35. package/dist/utils/zip_utils.js.map +1 -1
  36. package/package.json +1 -1
@@ -1,34 +1,60 @@
1
1
  import { AssetsManager } from './assets_manager.js';
2
- import { zipToDict, analyzeTilesetContent } from '../utils/zip_utils.js';
2
+ import { extractAndStoreArchive } from '../utils/zip_utils.js';
3
3
  import { ApisixAuthParser } from '../auth/apisix_parser.js';
4
+ import { AuthConfig } from '../auth/auth_config.js';
4
5
  import fs from 'fs/promises';
5
6
  /**
6
- * Specialized Assets Manager for handling tileset ZIP files.
7
+ * Specialized Assets Manager for handling 3D Tiles tilesets.
7
8
  *
8
- * Extends the base AssetsManager with specialized logic for:
9
- * - Processing ZIP files containing tileset data
10
- * - Extracting and analyzing tileset metadata
11
- * - Storing tileset-specific information
9
+ * This manager extracts uploaded ZIP files and stores each file individually,
10
+ * allowing Cesium and other 3D viewers to load tilesets directly via URL.
12
11
  *
13
- * Inherits all CRUD endpoints from AssetsManager:
14
- * - GET /{name} - List all tilesets
15
- * - POST /{name}/upload - Upload tileset ZIP (overridden)
16
- * - GET /{name}/:id - Get tileset file
17
- * - PUT /{name}/:id - Update tileset metadata
18
- * - DELETE /{name}/:id - Delete tileset
19
- * - GET /{name}/:id/download - Download tileset ZIP
12
+ * Key features:
13
+ * - Extracts ZIP archives on upload (no ZIP stored, only extracted files)
14
+ * - Detects and tracks the root tileset.json file
15
+ * - Serves individual files via dedicated endpoint
16
+ * - Returns tileset.json URL for direct Cesium loading
17
+ *
18
+ * Endpoints:
19
+ * - GET /{endpoint} - List all tilesets with tileset.json URLs
20
+ * - POST /{endpoint} - Upload tileset ZIP (extracts and stores files)
21
+ * - GET /{endpoint}/:id - Get tileset metadata
22
+ * - GET /{endpoint}/:id/files/* - Serve extracted files (tileset.json, .b3dm, etc.)
23
+ * - PUT /{endpoint}/:id - Update tileset metadata
24
+ * - DELETE /{endpoint}/:id - Delete tileset and all extracted files
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * class MyTilesetManager extends TilesetManager {
29
+ * getConfiguration() {
30
+ * return {
31
+ * name: 'tilesets_manager',
32
+ * description: 'Manage 3D Tiles tilesets',
33
+ * contentType: 'application/json',
34
+ * endpoint: 'api/tilesets',
35
+ * tags: ['Tileset']
36
+ * }
37
+ * }
38
+ * }
39
+ *
40
+ * // After upload, Cesium can load:
41
+ * // Cesium3DTileset.fromUrl('/api/tilesets/123/files/tileset.json')
42
+ * ```
20
43
  */
21
44
  export class TilesetManager extends AssetsManager {
22
45
  /**
23
- * Override the upload handler to process ZIP files containing tilesets.
46
+ * Override the upload handler to extract ZIP files and store individual files.
24
47
  *
25
- * Processes the uploaded ZIP file:
26
- * 1. Extracts and analyzes the ZIP content
27
- * 2. Stores tileset-specific metadata
28
- * 3. Saves the ZIP file using the storage service
48
+ * Flow:
49
+ * 1. Validates request and authentication
50
+ * 2. Reads uploaded ZIP file
51
+ * 3. Extracts all files and stores them individually
52
+ * 4. Detects the root tileset.json file
53
+ * 5. Saves metadata with file_index containing all file paths
54
+ * 6. Deletes the temporary ZIP file
29
55
  *
30
56
  * @param req - HTTP request with ZIP file upload
31
- * @returns DataResponse with upload result
57
+ * @returns DataResponse with upload result including tileset.json URL
32
58
  */
33
59
  async handleUpload(req) {
34
60
  try {
@@ -41,46 +67,67 @@ export class TilesetManager extends AssetsManager {
41
67
  headers: { 'Content-Type': 'application/json' }
42
68
  };
43
69
  }
44
- // Check authentication
45
- if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
46
- return {
47
- status: 401,
48
- content: JSON.stringify({
49
- error: 'Authentication required'
50
- }),
51
- headers: { 'Content-Type': 'application/json' }
70
+ // Check authentication (skip if auth is disabled)
71
+ let userId;
72
+ if (AuthConfig.isAuthDisabled()) {
73
+ const anonymousUser = {
74
+ id: AuthConfig.getAnonymousUserId(),
75
+ roles: []
52
76
  };
77
+ const userRecord = await this.userService.findOrCreateUser(anonymousUser);
78
+ userId = userRecord.id;
53
79
  }
54
- // Parse authenticated user
55
- const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
56
- if (!authUser) {
57
- return {
58
- status: 401,
59
- content: JSON.stringify({
60
- error: 'Invalid authentication headers'
61
- }),
62
- headers: { 'Content-Type': 'application/json' }
63
- };
80
+ else {
81
+ if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
82
+ return {
83
+ status: 401,
84
+ content: JSON.stringify({
85
+ error: 'Authentication required'
86
+ }),
87
+ headers: { 'Content-Type': 'application/json' }
88
+ };
89
+ }
90
+ const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
91
+ if (!authUser) {
92
+ return {
93
+ status: 401,
94
+ content: JSON.stringify({
95
+ error: 'Invalid authentication headers'
96
+ }),
97
+ headers: { 'Content-Type': 'application/json' }
98
+ };
99
+ }
100
+ const userRecord = await this.userService.findOrCreateUser(authUser);
101
+ if (!userRecord.id) {
102
+ return {
103
+ status: 500,
104
+ content: JSON.stringify({
105
+ error: 'Failed to retrieve user information'
106
+ }),
107
+ headers: { 'Content-Type': 'application/json' }
108
+ };
109
+ }
110
+ userId = userRecord.id;
64
111
  }
65
- // Find or create user in database
66
- const userRecord = await this.userService.findOrCreateUser(authUser);
67
- if (!userRecord.id) {
112
+ const { description } = req.body;
113
+ const filePath = req.file?.path;
114
+ const fileBuffer = req.file?.buffer;
115
+ const filename = req.file?.originalname || req.body.filename;
116
+ // Support both disk storage (filePath) and memory storage (fileBuffer)
117
+ if (!filePath && !fileBuffer) {
68
118
  return {
69
- status: 500,
119
+ status: 400,
70
120
  content: JSON.stringify({
71
- error: 'Failed to retrieve user information'
121
+ error: 'Missing required fields: ZIP file'
72
122
  }),
73
123
  headers: { 'Content-Type': 'application/json' }
74
124
  };
75
125
  }
76
- const { description } = req.body;
77
- const filePath = req.file?.path;
78
- const filename = req.file?.originalname || req.body.filename;
79
- if (!filePath || !description) {
126
+ if (!description) {
80
127
  return {
81
128
  status: 400,
82
129
  content: JSON.stringify({
83
- error: 'Missing required fields: description, ZIP file'
130
+ error: 'Missing required fields: description'
84
131
  }),
85
132
  headers: { 'Content-Type': 'application/json' }
86
133
  };
@@ -104,10 +151,15 @@ export class TilesetManager extends AssetsManager {
104
151
  headers: { 'Content-Type': 'application/json' }
105
152
  };
106
153
  }
107
- // Read and process ZIP file
108
- let fileBuffer;
154
+ // Read ZIP file content
155
+ let zipBuffer;
109
156
  try {
110
- fileBuffer = await fs.readFile(filePath);
157
+ if (fileBuffer) {
158
+ zipBuffer = fileBuffer;
159
+ }
160
+ else {
161
+ zipBuffer = await fs.readFile(filePath);
162
+ }
111
163
  }
112
164
  catch (error) {
113
165
  return {
@@ -119,49 +171,67 @@ export class TilesetManager extends AssetsManager {
119
171
  };
120
172
  }
121
173
  try {
122
- // Extract and analyze tileset content
123
- const zipDict = await zipToDict(fileBuffer);
124
- const tilesetInfo = analyzeTilesetContent(zipDict);
125
- // Use the analyzed name or fallback to filename
126
- const tilesetName = tilesetInfo.name || filename.replace('.zip', '');
127
174
  const config = this.getConfiguration();
128
175
  const now = new Date();
129
- // Store ZIP file using framework pattern
130
- const url = await this.storage.save(fileBuffer, config.name, filename);
131
- // Create extended metadata with tileset-specific fields
176
+ // Generate unique ID for this tileset (timestamp-based)
177
+ const tilesetId = now.getTime().toString();
178
+ // Base path for storing extracted files: componentName/tilesetId
179
+ const basePath = `${config.name}/${tilesetId}`;
180
+ // Extract ZIP and store all files individually
181
+ const extractResult = await extractAndStoreArchive(zipBuffer, this.storage, basePath);
182
+ // Validate that we found a tileset.json
183
+ if (!extractResult.root_file) {
184
+ // Clean up stored files if no tileset.json found
185
+ for (const file of extractResult.files) {
186
+ await this.storage.delete(file.path).catch(() => { });
187
+ }
188
+ return {
189
+ status: 400,
190
+ content: JSON.stringify({
191
+ error: 'Invalid tileset: no tileset.json found in the ZIP archive'
192
+ }),
193
+ headers: { 'Content-Type': 'application/json' }
194
+ };
195
+ }
196
+ // Create metadata with file_index
197
+ // url stores the base path for all extracted files
132
198
  const metadata = {
133
199
  name: config.name,
134
- type: config.contentType || 'application/zip',
135
- url,
200
+ type: 'application/json', // tileset.json content type
201
+ url: basePath, // Base path for all files (componentName/timestamp)
136
202
  date: now,
137
203
  description,
138
- source: req.body.source || 'uploaded',
139
- owner_id: userRecord.id,
140
- filename,
141
- // Tileset-specific metadata
142
- tileset_name: tilesetName,
143
- file_count: tilesetInfo.fileCount,
144
- has_metadata: tilesetInfo.hasMetadata,
145
- main_files: tilesetInfo.mainFiles.join(',')
204
+ source: req.body.source || '',
205
+ owner_id: userId,
206
+ filename: filename,
207
+ is_public: req.body.is_public !== undefined ? Boolean(req.body.is_public) : true,
208
+ file_index: {
209
+ files: extractResult.files,
210
+ root_file: extractResult.root_file
211
+ }
146
212
  };
147
- await this.db.save(metadata);
213
+ const savedRecord = await this.db.save(metadata);
148
214
  // Clean up temporary file after successful upload
149
- await fs.unlink(filePath).catch(() => {
150
- // Ignore cleanup errors
151
- });
215
+ if (filePath) {
216
+ await fs.unlink(filePath).catch(() => { });
217
+ }
152
218
  return {
153
219
  status: 200,
154
220
  content: JSON.stringify({
155
- message: 'Tileset uploaded successfully',
156
- tileset_name: tilesetName,
157
- file_count: tilesetInfo.fileCount
221
+ message: 'Tileset uploaded and extracted successfully',
222
+ id: savedRecord.id,
223
+ file_count: extractResult.file_count,
224
+ root_file: extractResult.root_file,
225
+ tileset_url: `/${config.endpoint}/${savedRecord.id}/files/${extractResult.root_file}`
158
226
  }),
159
227
  headers: { 'Content-Type': 'application/json' }
160
228
  };
161
229
  }
162
230
  catch (error) {
163
231
  // Clean up temporary file even on error
164
- await fs.unlink(filePath).catch(() => { });
232
+ if (filePath) {
233
+ await fs.unlink(filePath).catch(() => { });
234
+ }
165
235
  throw error;
166
236
  }
167
237
  }
@@ -176,34 +246,196 @@ export class TilesetManager extends AssetsManager {
176
246
  }
177
247
  }
178
248
  /**
179
- * Override retrieve to include tileset-specific metadata in the response
249
+ * Serve an extracted file from the tileset.
250
+ *
251
+ * This endpoint allows Cesium to load tileset.json and all referenced files
252
+ * (tiles, textures, etc.) using relative paths.
253
+ *
254
+ * @param req - HTTP request with params.id and params['0'] (file path)
255
+ * @returns DataResponse with file content
256
+ */
257
+ async handleGetFile(req) {
258
+ try {
259
+ const { id } = req.params || {};
260
+ // Express/ultimate-express captures wildcard path in different ways:
261
+ // - Express 4.x: params[0] or params['0']
262
+ // - Express 5.x / ultimate-express: params['*'] or via URL parsing
263
+ let filePath = req.params[0] || req.params['0'] || req.params['*'] || req.params.path || req.params['*0'];
264
+ // Fallback: extract from URL if params didn't capture it
265
+ if (!filePath && req.url) {
266
+ const match = req.url.match(/\/files\/(.+)$/);
267
+ if (match) {
268
+ filePath = decodeURIComponent(match[1]);
269
+ }
270
+ }
271
+ // Another fallback: use originalUrl
272
+ if (!filePath && req.originalUrl) {
273
+ const match = req.originalUrl.match(/\/files\/(.+)$/);
274
+ if (match) {
275
+ filePath = decodeURIComponent(match[1]);
276
+ }
277
+ }
278
+ if (!id) {
279
+ return {
280
+ status: 400,
281
+ content: JSON.stringify({ error: 'Asset ID is required' }),
282
+ headers: { 'Content-Type': 'application/json' }
283
+ };
284
+ }
285
+ if (!filePath) {
286
+ return {
287
+ status: 400,
288
+ content: JSON.stringify({ error: 'File path is required' }),
289
+ headers: { 'Content-Type': 'application/json' }
290
+ };
291
+ }
292
+ // Get asset metadata
293
+ const asset = await this.getAssetById(id);
294
+ if (!asset) {
295
+ return {
296
+ status: 404,
297
+ content: JSON.stringify({ error: 'Tileset not found' }),
298
+ headers: { 'Content-Type': 'application/json' }
299
+ };
300
+ }
301
+ // Verify this asset belongs to our component
302
+ const config = this.getConfiguration();
303
+ if (asset.name !== config.name) {
304
+ return {
305
+ status: 404,
306
+ content: JSON.stringify({ error: 'Tileset not found' }),
307
+ headers: { 'Content-Type': 'application/json' }
308
+ };
309
+ }
310
+ // Check access permissions for private assets
311
+ if (!asset.is_public) {
312
+ if (ApisixAuthParser.isAdmin(req.headers || {})) {
313
+ // Admin can access everything
314
+ }
315
+ else if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
316
+ return {
317
+ status: 401,
318
+ content: JSON.stringify({ error: 'Authentication required for private assets' }),
319
+ headers: { 'Content-Type': 'application/json' }
320
+ };
321
+ }
322
+ else {
323
+ const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
324
+ if (authUser) {
325
+ const userRecord = await this.userService.findOrCreateUser(authUser);
326
+ if (!userRecord.id || asset.owner_id !== userRecord.id) {
327
+ return {
328
+ status: 403,
329
+ content: JSON.stringify({ error: 'This asset is private' }),
330
+ headers: { 'Content-Type': 'application/json' }
331
+ };
332
+ }
333
+ }
334
+ }
335
+ }
336
+ // Build storage path using asset.url as base path
337
+ // asset.url contains the base path (e.g., 'tilesets_manager/1733064000000')
338
+ const storagePath = `${asset.url}/${filePath}`;
339
+ // Retrieve file from storage
340
+ let fileContent;
341
+ try {
342
+ fileContent = await this.storage.retrieve(storagePath);
343
+ }
344
+ catch {
345
+ return {
346
+ status: 404,
347
+ content: JSON.stringify({ error: 'File not found' }),
348
+ headers: { 'Content-Type': 'application/json' }
349
+ };
350
+ }
351
+ // Determine content type based on file extension
352
+ const contentType = this.getContentTypeForFile(filePath);
353
+ return {
354
+ status: 200,
355
+ content: fileContent,
356
+ headers: {
357
+ 'Content-Type': contentType,
358
+ 'Access-Control-Allow-Origin': '*'
359
+ }
360
+ };
361
+ }
362
+ catch (error) {
363
+ return {
364
+ status: 500,
365
+ content: JSON.stringify({
366
+ error: error instanceof Error ? error.message : 'Unknown error'
367
+ }),
368
+ headers: { 'Content-Type': 'application/json' }
369
+ };
370
+ }
371
+ }
372
+ /**
373
+ * Determines the MIME content type based on file extension.
374
+ */
375
+ getContentTypeForFile(filePath) {
376
+ const ext = filePath.toLowerCase().split('.').pop();
377
+ const contentTypes = {
378
+ json: 'application/json',
379
+ b3dm: 'application/octet-stream',
380
+ i3dm: 'application/octet-stream',
381
+ pnts: 'application/octet-stream',
382
+ cmpt: 'application/octet-stream',
383
+ glb: 'model/gltf-binary',
384
+ gltf: 'model/gltf+json',
385
+ bin: 'application/octet-stream',
386
+ png: 'image/png',
387
+ jpg: 'image/jpeg',
388
+ jpeg: 'image/jpeg',
389
+ webp: 'image/webp',
390
+ ktx2: 'image/ktx2'
391
+ };
392
+ return contentTypes[ext || ''] || 'application/octet-stream';
393
+ }
394
+ /**
395
+ * Override retrieve to include tileset.json URL in the response.
180
396
  */
181
- async retrieve() {
397
+ async retrieve(req) {
182
398
  try {
183
399
  const assets = await this.getAllAssets();
184
400
  const config = this.getConfiguration();
185
- // Transform to include tileset metadata
186
- const assetsWithMetadata = assets.map(asset => ({
187
- id: asset.id,
188
- name: asset.name,
189
- date: asset.date,
190
- contentType: asset.contentType,
191
- description: asset.description || '',
192
- source: asset.source || '',
193
- owner_id: asset.owner_id || null,
194
- filename: asset.filename || '',
195
- // Tileset-specific fields
196
- tileset_name: asset.tileset_name || '',
197
- file_count: asset.file_count || 0,
198
- has_metadata: asset.has_metadata || false,
199
- main_files: asset.main_files ? asset.main_files.split(',') : [],
200
- // URLs for frontend
201
- url: `/${config.endpoint}/${asset.id}`,
202
- download_url: `/${config.endpoint}/${asset.id}/download`
203
- }));
401
+ const isAdmin = req && ApisixAuthParser.isAdmin(req.headers || {});
402
+ // Get authenticated user ID if available
403
+ let authenticatedUserId = null;
404
+ if (req && ApisixAuthParser.hasValidAuth(req.headers || {})) {
405
+ const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
406
+ if (authUser) {
407
+ const userRecord = await this.userService.findOrCreateUser(authUser);
408
+ authenticatedUserId = userRecord.id || null;
409
+ }
410
+ }
411
+ // Filter to visible assets only (unless admin)
412
+ const visibleAssets = isAdmin
413
+ ? assets
414
+ : assets.filter(asset => asset.is_public || (authenticatedUserId !== null && asset.owner_id === authenticatedUserId));
415
+ // Transform to include tileset URLs
416
+ const assetsWithUrls = visibleAssets.map(asset => {
417
+ const fileIndex = asset.file_index;
418
+ const rootFile = fileIndex?.root_file || 'tileset.json';
419
+ return {
420
+ id: asset.id,
421
+ name: asset.name,
422
+ date: asset.date,
423
+ contentType: asset.contentType,
424
+ description: asset.description || '',
425
+ source: asset.source || '',
426
+ owner_id: asset.owner_id || null,
427
+ filename: asset.filename || '',
428
+ is_public: asset.is_public ?? true,
429
+ // Tileset-specific URLs
430
+ file_count: fileIndex?.files?.length || 0,
431
+ root_file: rootFile,
432
+ tileset_url: `/${config.endpoint}/${asset.id}/files/${rootFile}`,
433
+ files_base_url: `/${config.endpoint}/${asset.id}/files`
434
+ };
435
+ });
204
436
  return {
205
437
  status: 200,
206
- content: JSON.stringify(assetsWithMetadata),
438
+ content: JSON.stringify(assetsWithUrls),
207
439
  headers: { 'Content-Type': 'application/json' }
208
440
  };
209
441
  }
@@ -218,36 +450,196 @@ export class TilesetManager extends AssetsManager {
218
450
  }
219
451
  }
220
452
  /**
221
- * Override OpenAPI specification to include tileset-specific fields.
222
- *
223
- * Extends the parent AssetsManager spec with additional properties
224
- * specific to tileset uploads (ZIP file analysis metadata).
225
- *
226
- * @returns {OpenAPIComponentSpec} OpenAPI spec with tileset extensions
453
+ * Override delete to clean up all extracted files.
454
+ */
455
+ async handleDelete(req) {
456
+ try {
457
+ // Check authentication (skip if auth is disabled)
458
+ let userId;
459
+ if (AuthConfig.isAuthDisabled()) {
460
+ const anonymousUser = {
461
+ id: AuthConfig.getAnonymousUserId(),
462
+ roles: []
463
+ };
464
+ const userRecord = await this.userService.findOrCreateUser(anonymousUser);
465
+ userId = userRecord.id;
466
+ }
467
+ else {
468
+ if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
469
+ return {
470
+ status: 401,
471
+ content: JSON.stringify({ error: 'Authentication required' }),
472
+ headers: { 'Content-Type': 'application/json' }
473
+ };
474
+ }
475
+ const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
476
+ if (!authUser) {
477
+ return {
478
+ status: 401,
479
+ content: JSON.stringify({ error: 'Invalid authentication headers' }),
480
+ headers: { 'Content-Type': 'application/json' }
481
+ };
482
+ }
483
+ const userRecord = await this.userService.findOrCreateUser(authUser);
484
+ if (!userRecord.id) {
485
+ return {
486
+ status: 500,
487
+ content: JSON.stringify({ error: 'Failed to retrieve user information' }),
488
+ headers: { 'Content-Type': 'application/json' }
489
+ };
490
+ }
491
+ userId = userRecord.id;
492
+ }
493
+ const { id } = req.params || {};
494
+ if (!id) {
495
+ return {
496
+ status: 400,
497
+ content: JSON.stringify({ error: 'Asset ID is required' }),
498
+ headers: { 'Content-Type': 'application/json' }
499
+ };
500
+ }
501
+ // Get asset metadata
502
+ const asset = await this.getAssetById(id);
503
+ if (!asset) {
504
+ return {
505
+ status: 404,
506
+ content: JSON.stringify({ error: 'Tileset not found' }),
507
+ headers: { 'Content-Type': 'application/json' }
508
+ };
509
+ }
510
+ // Check ownership (admins can delete any asset)
511
+ const isAdmin = ApisixAuthParser.isAdmin(req.headers || {});
512
+ if (!isAdmin && asset.owner_id !== null && asset.owner_id !== userId) {
513
+ return {
514
+ status: 403,
515
+ content: JSON.stringify({ error: 'You can only delete your own assets' }),
516
+ headers: { 'Content-Type': 'application/json' }
517
+ };
518
+ }
519
+ // Delete all extracted files from storage
520
+ const fileIndex = asset.file_index;
521
+ if (fileIndex?.files) {
522
+ for (const file of fileIndex.files) {
523
+ await this.storage.delete(file.path).catch(() => {
524
+ // Ignore individual file deletion errors
525
+ });
526
+ }
527
+ }
528
+ // Delete database record
529
+ await this.deleteAssetById(id);
530
+ return {
531
+ status: 200,
532
+ content: JSON.stringify({ message: 'Tileset deleted successfully' }),
533
+ headers: { 'Content-Type': 'application/json' }
534
+ };
535
+ }
536
+ catch (error) {
537
+ return {
538
+ status: 500,
539
+ content: JSON.stringify({
540
+ error: error instanceof Error ? error.message : 'Unknown error'
541
+ }),
542
+ headers: { 'Content-Type': 'application/json' }
543
+ };
544
+ }
545
+ }
546
+ /**
547
+ * Override getEndpoints to add the file serving endpoint.
548
+ */
549
+ getEndpoints() {
550
+ const config = this.getConfiguration();
551
+ // Get base endpoints from parent but filter out ones we override
552
+ const baseEndpoints = super.getEndpoints().filter(ep =>
553
+ // Keep all except GET /:id (we don't serve the raw asset, only files)
554
+ // and DELETE /:id (we override to clean up files)
555
+ !(ep.method === 'get' && ep.path === `/${config.endpoint}/:id`) &&
556
+ !(ep.method === 'delete' && ep.path === `/${config.endpoint}/:id`) &&
557
+ // Also remove download endpoint - not applicable for extracted tilesets
558
+ !(ep.method === 'get' && ep.path === `/${config.endpoint}/:id/download`));
559
+ // IMPORTANT: More specific routes must come BEFORE less specific ones
560
+ // /:id/files/* must be registered before /:id to avoid incorrect matching
561
+ return [
562
+ // File serving endpoint FIRST (most specific)
563
+ {
564
+ method: 'get',
565
+ path: `/${config.endpoint}/:id/files/*`,
566
+ handler: this.handleGetFile.bind(this),
567
+ responseType: 'application/octet-stream'
568
+ },
569
+ // Then base endpoints (list, create, update, etc.)
570
+ ...baseEndpoints,
571
+ // Override delete to clean up files (after base to override)
572
+ {
573
+ method: 'delete',
574
+ path: `/${config.endpoint}/:id`,
575
+ handler: this.handleDelete.bind(this),
576
+ responseType: 'application/json'
577
+ }
578
+ ];
579
+ }
580
+ /**
581
+ * Override OpenAPI specification to include tileset-specific endpoints.
227
582
  */
228
583
  getOpenAPISpec() {
229
584
  const parentSpec = super.getOpenAPISpec();
230
585
  const config = this.getConfiguration();
231
586
  const basePath = `/${config.endpoint}`;
232
- // Extend the POST response to include tileset-specific fields
587
+ const tagName = config.tags?.[0] || config.name;
588
+ // Update POST response
233
589
  if (parentSpec.paths[basePath]?.post?.responses?.['200']) {
234
590
  parentSpec.paths[basePath].post.responses['200'] = {
235
- description: 'Tileset uploaded successfully',
591
+ description: 'Tileset uploaded and extracted successfully',
236
592
  content: {
237
593
  'application/json': {
238
594
  schema: {
239
595
  type: 'object',
240
596
  properties: {
241
597
  message: { type: 'string' },
242
- tileset_name: { type: 'string', description: 'Extracted tileset name' },
243
- file_count: { type: 'integer', description: 'Number of files in the tileset' }
598
+ file_count: { type: 'integer', description: 'Number of files extracted' },
599
+ root_file: { type: 'string', description: 'Root tileset.json path' },
600
+ tileset_url: { type: 'string', description: 'URL to load in Cesium' }
244
601
  }
245
602
  }
246
603
  }
247
604
  }
248
605
  };
249
606
  }
250
- // Add tileset-specific schema extending the base AssetResponse
607
+ // Add file serving endpoint
608
+ parentSpec.paths[`${basePath}/{id}/files/{filePath}`] = {
609
+ get: {
610
+ summary: 'Get tileset file',
611
+ description: 'Retrieve an extracted file from the tileset (tileset.json, .b3dm, etc.)',
612
+ tags: [tagName],
613
+ parameters: [
614
+ {
615
+ name: 'id',
616
+ in: 'path',
617
+ required: true,
618
+ schema: { type: 'string' },
619
+ description: 'Tileset ID'
620
+ },
621
+ {
622
+ name: 'filePath',
623
+ in: 'path',
624
+ required: true,
625
+ schema: { type: 'string' },
626
+ description: 'Path to file within tileset (e.g., tileset.json, tiles/tile_0.b3dm)'
627
+ }
628
+ ],
629
+ responses: {
630
+ '200': {
631
+ description: 'File content',
632
+ content: {
633
+ 'application/octet-stream': {
634
+ schema: { type: 'string', format: 'binary' }
635
+ }
636
+ }
637
+ },
638
+ '404': { description: 'Tileset or file not found' }
639
+ }
640
+ }
641
+ };
642
+ // Update GET list response schema
251
643
  if (parentSpec.schemas) {
252
644
  parentSpec.schemas.TilesetResponse = {
253
645
  type: 'object',
@@ -260,26 +652,24 @@ export class TilesetManager extends AssetsManager {
260
652
  source: { type: 'string' },
261
653
  owner_id: { type: 'integer', nullable: true },
262
654
  filename: { type: 'string' },
263
- tileset_name: { type: 'string', description: 'Extracted tileset name from ZIP' },
264
- file_count: { type: 'integer', description: 'Number of files in the tileset' },
265
- has_metadata: { type: 'boolean', description: 'Whether tileset has metadata file' },
266
- main_files: {
267
- type: 'array',
268
- items: { type: 'string' },
269
- description: 'Main tileset files (e.g., tileset.json)'
270
- },
271
- url: { type: 'string' },
272
- download_url: { type: 'string' }
655
+ is_public: { type: 'boolean' },
656
+ file_count: { type: 'integer', description: 'Number of files in tileset' },
657
+ root_file: { type: 'string', description: 'Root tileset.json filename' },
658
+ tileset_url: { type: 'string', description: 'URL to load tileset in Cesium' },
659
+ files_base_url: { type: 'string', description: 'Base URL for all tileset files' }
273
660
  }
274
661
  };
275
662
  }
276
- // Update the GET list response to use tileset schema
277
663
  if (parentSpec.paths[basePath]?.get?.responses?.['200']?.content?.['application/json']) {
278
664
  parentSpec.paths[basePath].get.responses['200'].content['application/json'].schema = {
279
665
  type: 'array',
280
666
  items: { $ref: '#/components/schemas/TilesetResponse' }
281
667
  };
282
668
  }
669
+ // Remove the single asset GET endpoint since we don't serve raw assets
670
+ delete parentSpec.paths[`${basePath}/{id}`]?.get;
671
+ // Remove download endpoint
672
+ delete parentSpec.paths[`${basePath}/{id}/download`];
283
673
  return parentSpec;
284
674
  }
285
675
  }