digitaltwin-core 0.13.1 → 0.13.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/tileset_manager.d.ts +45 -56
  2. package/dist/components/tileset_manager.d.ts.map +1 -1
  3. package/dist/components/tileset_manager.js +370 -497
  4. package/dist/components/tileset_manager.js.map +1 -1
  5. package/dist/database/adapters/knex_database_adapter.d.ts.map +1 -1
  6. package/dist/database/adapters/knex_database_adapter.js +9 -12
  7. package/dist/database/adapters/knex_database_adapter.js.map +1 -1
  8. package/dist/engine/upload_processor.d.ts +6 -0
  9. package/dist/engine/upload_processor.d.ts.map +1 -1
  10. package/dist/engine/upload_processor.js +35 -10
  11. package/dist/engine/upload_processor.js.map +1 -1
  12. package/dist/storage/adapters/local_storage_service.d.ts +14 -0
  13. package/dist/storage/adapters/local_storage_service.d.ts.map +1 -1
  14. package/dist/storage/adapters/local_storage_service.js +46 -0
  15. package/dist/storage/adapters/local_storage_service.js.map +1 -1
  16. package/dist/storage/adapters/ovh_storage_service.d.ts +15 -0
  17. package/dist/storage/adapters/ovh_storage_service.d.ts.map +1 -1
  18. package/dist/storage/adapters/ovh_storage_service.js +54 -2
  19. package/dist/storage/adapters/ovh_storage_service.js.map +1 -1
  20. package/dist/storage/storage_service.d.ts +36 -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 +6 -15
  24. package/dist/types/data_record.d.ts.map +1 -1
  25. package/dist/utils/index.d.ts +1 -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 +3 -1
  31. package/dist/utils/map_to_data_record.js.map +1 -1
  32. package/dist/utils/zip_utils.d.ts +14 -30
  33. package/dist/utils/zip_utils.d.ts.map +1 -1
  34. package/dist/utils/zip_utils.js +25 -63
  35. package/dist/utils/zip_utils.js.map +1 -1
  36. package/package.json +1 -1
@@ -3,42 +3,50 @@ import { extractAndStoreArchive } from '../utils/zip_utils.js';
3
3
  import { ApisixAuthParser } from '../auth/apisix_parser.js';
4
4
  import { AuthConfig } from '../auth/auth_config.js';
5
5
  import fs from 'fs/promises';
6
+ import { successResponse, errorResponse, badRequestResponse, unauthorizedResponse, notFoundResponse, forbiddenResponse } from '../utils/http_responses.js';
7
+ /** Threshold for async upload (50MB) */
8
+ const ASYNC_UPLOAD_THRESHOLD = 50 * 1024 * 1024;
6
9
  /**
7
10
  * Specialized Assets Manager for handling 3D Tiles tilesets.
8
11
  *
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
+ * This manager extracts uploaded ZIP files and stores each file in cloud storage (OVH S3),
13
+ * allowing Cesium and other 3D viewers to load tilesets directly via public URLs.
11
14
  *
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
15
+ * ## How it works
17
16
  *
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.)
17
+ * 1. User uploads a ZIP containing a 3D Tiles tileset
18
+ * 2. ZIP is extracted and all files are stored in OVH with public-read ACL
19
+ * 3. Database stores only the tileset.json URL and base path
20
+ * 4. Cesium loads tileset.json directly from OVH
21
+ * 5. Cesium fetches tiles using relative paths in tileset.json (directly from OVH)
22
+ *
23
+ * ## Endpoints
24
+ *
25
+ * - GET /{endpoint} - List all tilesets with their public URLs
26
+ * - POST /{endpoint} - Upload tileset ZIP (sync < 50MB, async >= 50MB)
27
+ * - GET /{endpoint}/:id/status - Poll async upload status
23
28
  * - PUT /{endpoint}/:id - Update tileset metadata
24
- * - DELETE /{endpoint}/:id - Delete tileset and all extracted files
29
+ * - DELETE /{endpoint}/:id - Delete tileset and all files from storage
25
30
  *
26
31
  * @example
27
32
  * ```typescript
28
33
  * class MyTilesetManager extends TilesetManager {
29
34
  * getConfiguration() {
30
35
  * return {
31
- * name: 'tilesets_manager',
36
+ * name: 'tilesets',
32
37
  * description: 'Manage 3D Tiles tilesets',
33
38
  * contentType: 'application/json',
34
39
  * endpoint: 'api/tilesets',
35
- * tags: ['Tileset']
40
+ * extension: '.zip'
36
41
  * }
37
42
  * }
38
43
  * }
39
44
  *
40
- * // After upload, Cesium can load:
41
- * // Cesium3DTileset.fromUrl('/api/tilesets/123/files/tileset.json')
45
+ * // After upload, response contains:
46
+ * // { tileset_url: 'https://bucket.s3.../tilesets/123/tileset.json' }
47
+ * //
48
+ * // Cesium loads directly:
49
+ * // Cesium.Cesium3DTileset.fromUrl(tileset_url)
42
50
  * ```
43
51
  */
44
52
  export class TilesetManager extends AssetsManager {
@@ -55,123 +63,101 @@ export class TilesetManager extends AssetsManager {
55
63
  this.uploadQueue = queue;
56
64
  }
57
65
  /**
58
- * Override the upload handler to support async processing for large files.
59
- *
60
- * When uploadQueue is available (async mode):
61
- * 1. Validates request and authentication
62
- * 2. Creates a database record with status 'pending'
63
- * 3. Queues a job for background processing
64
- * 4. Returns immediately with job ID
66
+ * Handle tileset upload.
65
67
  *
66
- * When uploadQueue is not available (sync mode - fallback):
67
- * 1. Validates request and authentication
68
- * 2. Extracts ZIP and stores files synchronously
69
- * 3. Returns with completed tileset info
70
- *
71
- * @param req - HTTP request with ZIP file upload
72
- * @returns DataResponse with upload result (async: job info, sync: tileset info)
68
+ * - Files < 50MB: Synchronous extraction and upload
69
+ * - Files >= 50MB: Queued for async processing (returns 202)
73
70
  */
74
71
  async handleUpload(req) {
75
72
  try {
76
- if (!req || !req.body) {
77
- return {
78
- status: 400,
79
- content: JSON.stringify({ error: 'Invalid request: missing request body' }),
80
- headers: { 'Content-Type': 'application/json' }
81
- };
73
+ if (!req?.body) {
74
+ return badRequestResponse('Invalid request: missing request body');
82
75
  }
83
76
  // Authenticate user
84
- let userId;
85
- if (AuthConfig.isAuthDisabled()) {
86
- const userRecord = await this.userService.findOrCreateUser({
87
- id: AuthConfig.getAnonymousUserId(),
88
- roles: []
89
- });
90
- userId = userRecord.id;
91
- }
92
- else {
93
- if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
94
- return {
95
- status: 401,
96
- content: JSON.stringify({ error: 'Authentication required' }),
97
- headers: { 'Content-Type': 'application/json' }
98
- };
99
- }
100
- const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
101
- if (!authUser) {
102
- return {
103
- status: 401,
104
- content: JSON.stringify({ error: 'Invalid authentication headers' }),
105
- headers: { 'Content-Type': 'application/json' }
106
- };
107
- }
108
- const userRecord = await this.userService.findOrCreateUser(authUser);
109
- if (!userRecord.id) {
110
- return {
111
- status: 500,
112
- content: JSON.stringify({ error: 'Failed to retrieve user information' }),
113
- headers: { 'Content-Type': 'application/json' }
114
- };
115
- }
116
- userId = userRecord.id;
77
+ const userId = await this.authenticateUser(req);
78
+ if (typeof userId !== 'number') {
79
+ return userId; // Returns error response
117
80
  }
81
+ // Validate request
118
82
  const { description } = req.body;
119
83
  const filePath = req.file?.path;
120
84
  const fileBuffer = req.file?.buffer;
121
85
  const filename = req.file?.originalname || req.body.filename;
122
- // Cleanup temp file and return error response
123
- const cleanupAndReturn = async (status, error) => {
86
+ const fileSize = req.file?.size || fileBuffer?.length || 0;
87
+ if (!filePath && !fileBuffer) {
88
+ return badRequestResponse('Missing required field: ZIP file');
89
+ }
90
+ if (!description) {
124
91
  if (filePath)
125
92
  await fs.unlink(filePath).catch(() => { });
126
- return { status, content: JSON.stringify({ error }), headers: { 'Content-Type': 'application/json' } };
127
- };
128
- if (!filePath && !fileBuffer) {
129
- return {
130
- status: 400,
131
- content: JSON.stringify({ error: 'Missing required fields: ZIP file' }),
132
- headers: { 'Content-Type': 'application/json' }
133
- };
93
+ return badRequestResponse('Missing required field: description');
94
+ }
95
+ if (!filename) {
96
+ if (filePath)
97
+ await fs.unlink(filePath).catch(() => { });
98
+ return badRequestResponse('Filename could not be determined from uploaded file');
99
+ }
100
+ if (!filename.toLowerCase().endsWith('.zip')) {
101
+ if (filePath)
102
+ await fs.unlink(filePath).catch(() => { });
103
+ return badRequestResponse('Invalid file extension. Expected: .zip');
134
104
  }
135
- if (!description)
136
- return cleanupAndReturn(400, 'Missing required fields: description');
137
- if (!filename)
138
- return cleanupAndReturn(400, 'Filename could not be determined from uploaded file');
139
- if (!filename.toLowerCase().endsWith('.zip'))
140
- return cleanupAndReturn(400, 'Invalid file extension. Expected: .zip');
141
105
  const config = this.getConfiguration();
142
- // Route to async or sync handler based on queue availability
143
- if (this.uploadQueue && filePath) {
144
- return this.handleAsyncUpload(req, userId, filePath, filename, description, config);
106
+ const isPublic = req.body.is_public !== undefined ? Boolean(req.body.is_public) : true;
107
+ // Route to async or sync based on file size and queue availability
108
+ if (this.uploadQueue && filePath && fileSize >= ASYNC_UPLOAD_THRESHOLD) {
109
+ return this.handleAsyncUpload(userId, filePath, filename, description, isPublic, config);
145
110
  }
146
- return this.handleSyncUpload(req, userId, filePath, fileBuffer, filename, description, config);
111
+ return this.handleSyncUpload(userId, filePath, fileBuffer, filename, description, isPublic, config);
147
112
  }
148
113
  catch (error) {
149
114
  if (req.file?.path)
150
115
  await fs.unlink(req.file.path).catch(() => { });
151
- return {
152
- status: 500,
153
- content: JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }),
154
- headers: { 'Content-Type': 'application/json' }
155
- };
116
+ return errorResponse(error);
117
+ }
118
+ }
119
+ /**
120
+ * Authenticate user from request headers.
121
+ * Returns user ID on success, or error response on failure.
122
+ */
123
+ async authenticateUser(req) {
124
+ if (AuthConfig.isAuthDisabled()) {
125
+ const userRecord = await this.userService.findOrCreateUser({
126
+ id: AuthConfig.getAnonymousUserId(),
127
+ roles: []
128
+ });
129
+ return userRecord.id;
130
+ }
131
+ if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
132
+ return unauthorizedResponse();
133
+ }
134
+ const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
135
+ if (!authUser) {
136
+ return unauthorizedResponse('Invalid authentication headers');
156
137
  }
138
+ const userRecord = await this.userService.findOrCreateUser(authUser);
139
+ if (!userRecord.id) {
140
+ return errorResponse('Failed to retrieve user information');
141
+ }
142
+ return userRecord.id;
157
143
  }
158
144
  /**
159
145
  * Queue upload for background processing. Returns HTTP 202 immediately.
160
146
  */
161
- async handleAsyncUpload(req, userId, filePath, filename, description, config) {
147
+ async handleAsyncUpload(userId, filePath, filename, description, isPublic, config) {
162
148
  let recordId = null;
163
149
  try {
164
- // Create pending record - will be updated by worker when complete
150
+ // Create pending record (url will be updated after extraction)
165
151
  const metadata = {
166
152
  name: config.name,
167
153
  type: 'application/json',
168
154
  url: '',
155
+ tileset_url: '',
169
156
  date: new Date(),
170
157
  description,
171
- source: req.body.source || '',
172
- owner_id: userId,
173
158
  filename,
174
- is_public: req.body.is_public !== undefined ? Boolean(req.body.is_public) : true,
159
+ owner_id: userId,
160
+ is_public: isPublic,
175
161
  upload_status: 'pending'
176
162
  };
177
163
  const savedRecord = await this.db.save(metadata);
@@ -204,7 +190,6 @@ export class TilesetManager extends AssetsManager {
204
190
  };
205
191
  }
206
192
  catch (error) {
207
- // Cleanup on failure: remove DB record and temp file
208
193
  if (recordId !== null)
209
194
  await this.db.delete(String(recordId), config.name).catch(() => { });
210
195
  await fs.unlink(filePath).catch(() => { });
@@ -212,64 +197,55 @@ export class TilesetManager extends AssetsManager {
212
197
  }
213
198
  }
214
199
  /**
215
- * Process upload synchronously (fallback when queue unavailable).
200
+ * Process upload synchronously.
216
201
  */
217
- async handleSyncUpload(req, userId, filePath, fileBuffer, filename, description, config) {
202
+ async handleSyncUpload(userId, filePath, fileBuffer, filename, description, isPublic, config) {
218
203
  let zipBuffer;
219
204
  try {
220
- zipBuffer = fileBuffer || (filePath ? await fs.readFile(filePath) : null);
221
- if (!zipBuffer)
205
+ const readBuffer = fileBuffer || (filePath ? await fs.readFile(filePath) : null);
206
+ if (!readBuffer)
222
207
  throw new Error('No file data available');
208
+ zipBuffer = readBuffer;
223
209
  }
224
210
  catch (error) {
225
- return {
226
- status: 500,
227
- content: JSON.stringify({
228
- error: `Failed to read uploaded file: ${error instanceof Error ? error.message : 'Unknown error'}`
229
- }),
230
- headers: { 'Content-Type': 'application/json' }
231
- };
211
+ return errorResponse(`Failed to read uploaded file: ${error instanceof Error ? error.message : 'Unknown error'}`);
232
212
  }
233
213
  try {
234
- const now = new Date();
235
- const basePath = `${config.name}/${now.getTime()}`;
214
+ // Generate unique base path using timestamp
215
+ const basePath = `${config.name}/${Date.now()}`;
216
+ // Extract ZIP and upload all files to storage
236
217
  const extractResult = await extractAndStoreArchive(zipBuffer, this.storage, basePath);
237
218
  if (!extractResult.root_file) {
238
- if (extractResult.files.length > 0) {
239
- await this.storage.deleteBatch(extractResult.files.map(f => f.path));
240
- }
241
- return {
242
- status: 400,
243
- content: JSON.stringify({ error: 'Invalid tileset: no tileset.json found in the ZIP archive' }),
244
- headers: { 'Content-Type': 'application/json' }
245
- };
219
+ // Clean up uploaded files
220
+ await this.storage.deleteByPrefix(basePath).catch(() => { });
221
+ return badRequestResponse('Invalid tileset: no tileset.json found in the ZIP archive');
246
222
  }
223
+ // Build the public URL for tileset.json
224
+ const tilesetPath = `${basePath}/${extractResult.root_file}`;
225
+ const tilesetUrl = this.storage.getPublicUrl(tilesetPath);
226
+ // Save metadata to database (url = basePath for deletion)
247
227
  const metadata = {
248
228
  name: config.name,
249
229
  type: 'application/json',
250
230
  url: basePath,
251
- date: now,
231
+ tileset_url: tilesetUrl,
232
+ date: new Date(),
252
233
  description,
253
- source: req.body.source || '',
254
- owner_id: userId,
255
234
  filename,
256
- is_public: req.body.is_public !== undefined ? Boolean(req.body.is_public) : true,
257
- file_index: { files: extractResult.files, root_file: extractResult.root_file }
235
+ owner_id: userId,
236
+ is_public: isPublic,
237
+ upload_status: 'completed'
258
238
  };
259
239
  const savedRecord = await this.db.save(metadata);
240
+ // Clean up temp file
260
241
  if (filePath)
261
242
  await fs.unlink(filePath).catch(() => { });
262
- return {
263
- status: 200,
264
- content: JSON.stringify({
265
- message: 'Tileset uploaded and extracted successfully',
266
- id: savedRecord.id,
267
- file_count: extractResult.file_count,
268
- root_file: extractResult.root_file,
269
- tileset_url: `/${config.endpoint}/${savedRecord.id}/files/${extractResult.root_file}`
270
- }),
271
- headers: { 'Content-Type': 'application/json' }
272
- };
243
+ return successResponse({
244
+ message: 'Tileset uploaded successfully',
245
+ id: savedRecord.id,
246
+ tileset_url: tilesetUrl,
247
+ file_count: extractResult.file_count
248
+ });
273
249
  }
274
250
  catch (error) {
275
251
  if (filePath)
@@ -278,190 +254,49 @@ export class TilesetManager extends AssetsManager {
278
254
  }
279
255
  }
280
256
  /**
281
- * Get upload status for polling. Returns: pending | processing | completed | failed
257
+ * Get upload status for async uploads.
282
258
  */
283
259
  async handleGetStatus(req) {
284
260
  try {
285
261
  const { id } = req.params || {};
286
262
  if (!id) {
287
- return {
288
- status: 400,
289
- content: JSON.stringify({ error: 'Asset ID is required' }),
290
- headers: { 'Content-Type': 'application/json' }
291
- };
263
+ return badRequestResponse('Asset ID is required');
292
264
  }
293
265
  const asset = await this.getAssetById(id);
294
266
  if (!asset) {
295
- return {
296
- status: 404,
297
- content: JSON.stringify({ error: 'Tileset not found' }),
298
- headers: { 'Content-Type': 'application/json' }
299
- };
267
+ return notFoundResponse('Tileset not found');
300
268
  }
301
- const config = this.getConfiguration();
302
269
  const record = asset;
303
- const fileIndex = record.file_index;
304
- // Completed if upload_status says so, or legacy sync upload with file_index
305
- const isCompleted = record.upload_status === 'completed' ||
306
- (!record.upload_status && fileIndex?.files && fileIndex.files.length > 0);
307
- if (isCompleted) {
308
- return {
309
- status: 200,
310
- content: JSON.stringify({
311
- id: record.id,
312
- status: 'completed',
313
- tileset_url: `/${config.endpoint}/${record.id}/files/${fileIndex?.root_file || 'tileset.json'}`,
314
- file_count: fileIndex?.files?.length || 0
315
- }),
316
- headers: { 'Content-Type': 'application/json' }
317
- };
318
- }
319
- // Broken record: no status and no files
320
- if (!record.upload_status) {
321
- return {
322
- status: 200,
323
- content: JSON.stringify({
324
- id: record.id,
325
- status: 'failed',
326
- error: 'Upload incomplete - no files found'
327
- }),
328
- headers: { 'Content-Type': 'application/json' }
329
- };
330
- }
331
- return {
332
- status: 200,
333
- content: JSON.stringify({
270
+ if (record.upload_status === 'completed') {
271
+ return successResponse({
334
272
  id: record.id,
335
- status: record.upload_status,
336
- job_id: record.upload_job_id,
337
- error: record.upload_error || undefined
338
- }),
339
- headers: { 'Content-Type': 'application/json' }
340
- };
341
- }
342
- catch (error) {
343
- return {
344
- status: 500,
345
- content: JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }),
346
- headers: { 'Content-Type': 'application/json' }
347
- };
348
- }
349
- }
350
- /**
351
- * Serve extracted tileset file (tileset.json, .b3dm, textures, etc.)
352
- */
353
- async handleGetFile(req) {
354
- try {
355
- const { id } = req.params || {};
356
- // Extract file path from wildcard param (Express 4.x/5.x compatibility)
357
- let filePath = req.params[0] || req.params['0'] || req.params['*'] || req.params.path || req.params['*0'];
358
- if (!filePath && req.url) {
359
- const match = req.url.match(/\/files\/(.+)$/);
360
- if (match)
361
- filePath = decodeURIComponent(match[1]);
362
- }
363
- if (!filePath && req.originalUrl) {
364
- const match = req.originalUrl.match(/\/files\/(.+)$/);
365
- if (match)
366
- filePath = decodeURIComponent(match[1]);
367
- }
368
- if (!id)
369
- return {
370
- status: 400,
371
- content: JSON.stringify({ error: 'Asset ID is required' }),
372
- headers: { 'Content-Type': 'application/json' }
373
- };
374
- if (!filePath)
375
- return {
376
- status: 400,
377
- content: JSON.stringify({ error: 'File path is required' }),
378
- headers: { 'Content-Type': 'application/json' }
379
- };
380
- const asset = await this.getAssetById(id);
381
- const config = this.getConfiguration();
382
- if (!asset || asset.name !== config.name) {
383
- return {
384
- status: 404,
385
- content: JSON.stringify({ error: 'Tileset not found' }),
386
- headers: { 'Content-Type': 'application/json' }
387
- };
388
- }
389
- // Check access for private assets
390
- if (!asset.is_public && !ApisixAuthParser.isAdmin(req.headers || {})) {
391
- if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
392
- return {
393
- status: 401,
394
- content: JSON.stringify({ error: 'Authentication required for private assets' }),
395
- headers: { 'Content-Type': 'application/json' }
396
- };
397
- }
398
- const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
399
- if (authUser) {
400
- const userRecord = await this.userService.findOrCreateUser(authUser);
401
- if (!userRecord.id || asset.owner_id !== userRecord.id) {
402
- return {
403
- status: 403,
404
- content: JSON.stringify({ error: 'This asset is private' }),
405
- headers: { 'Content-Type': 'application/json' }
406
- };
407
- }
408
- }
409
- }
410
- const storagePath = `${asset.url}/${filePath}`;
411
- let fileContent;
412
- try {
413
- fileContent = await this.storage.retrieve(storagePath);
273
+ status: 'completed',
274
+ tileset_url: record.tileset_url
275
+ });
414
276
  }
415
- catch {
416
- return {
417
- status: 404,
418
- content: JSON.stringify({ error: 'File not found' }),
419
- headers: { 'Content-Type': 'application/json' }
420
- };
277
+ if (record.upload_status === 'failed') {
278
+ return successResponse({
279
+ id: record.id,
280
+ status: 'failed',
281
+ error: record.upload_error || 'Upload failed'
282
+ });
421
283
  }
422
- return {
423
- status: 200,
424
- content: fileContent,
425
- headers: { 'Content-Type': this.getContentTypeForFile(filePath), 'Access-Control-Allow-Origin': '*' }
426
- };
284
+ return successResponse({
285
+ id: record.id,
286
+ status: record.upload_status || 'unknown',
287
+ job_id: record.upload_job_id
288
+ });
427
289
  }
428
290
  catch (error) {
429
- return {
430
- status: 500,
431
- content: JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }),
432
- headers: { 'Content-Type': 'application/json' }
433
- };
291
+ return errorResponse(error);
434
292
  }
435
293
  }
436
294
  /**
437
- * Determines the MIME content type based on file extension.
438
- */
439
- getContentTypeForFile(filePath) {
440
- const ext = filePath.toLowerCase().split('.').pop();
441
- const contentTypes = {
442
- json: 'application/json',
443
- b3dm: 'application/octet-stream',
444
- i3dm: 'application/octet-stream',
445
- pnts: 'application/octet-stream',
446
- cmpt: 'application/octet-stream',
447
- glb: 'model/gltf-binary',
448
- gltf: 'model/gltf+json',
449
- bin: 'application/octet-stream',
450
- png: 'image/png',
451
- jpg: 'image/jpeg',
452
- jpeg: 'image/jpeg',
453
- webp: 'image/webp',
454
- ktx2: 'image/ktx2'
455
- };
456
- return contentTypes[ext || ''] || 'application/octet-stream';
457
- }
458
- /**
459
- * Override retrieve to include tileset.json URL in the response.
295
+ * List all tilesets with their public URLs.
460
296
  */
461
297
  async retrieve(req) {
462
298
  try {
463
299
  const assets = await this.getAllAssets();
464
- const config = this.getConfiguration();
465
300
  const isAdmin = req && ApisixAuthParser.isAdmin(req.headers || {});
466
301
  // Get authenticated user ID if available
467
302
  let authenticatedUserId = null;
@@ -476,109 +311,48 @@ export class TilesetManager extends AssetsManager {
476
311
  const visibleAssets = isAdmin
477
312
  ? assets
478
313
  : assets.filter(asset => asset.is_public || (authenticatedUserId !== null && asset.owner_id === authenticatedUserId));
479
- // Transform to include tileset URLs
480
- const assetsWithUrls = visibleAssets.map(asset => {
481
- const fileIndex = asset.file_index;
482
- const rootFile = fileIndex?.root_file || 'tileset.json';
483
- return {
484
- id: asset.id,
485
- name: asset.name,
486
- date: asset.date,
487
- contentType: asset.contentType,
488
- description: asset.description || '',
489
- source: asset.source || '',
490
- owner_id: asset.owner_id || null,
491
- filename: asset.filename || '',
492
- is_public: asset.is_public ?? true,
493
- // Tileset-specific URLs
494
- file_count: fileIndex?.files?.length || 0,
495
- root_file: rootFile,
496
- tileset_url: `/${config.endpoint}/${asset.id}/files/${rootFile}`,
497
- files_base_url: `/${config.endpoint}/${asset.id}/files`
498
- };
499
- });
500
- return {
501
- status: 200,
502
- content: JSON.stringify(assetsWithUrls),
503
- headers: { 'Content-Type': 'application/json' }
504
- };
314
+ // Transform to response format
315
+ const response = visibleAssets.map(asset => ({
316
+ id: asset.id,
317
+ description: asset.description || '',
318
+ filename: asset.filename || '',
319
+ date: asset.date,
320
+ owner_id: asset.owner_id || null,
321
+ is_public: asset.is_public ?? true,
322
+ tileset_url: asset.tileset_url || '',
323
+ upload_status: asset.upload_status || 'completed'
324
+ }));
325
+ return successResponse(response);
505
326
  }
506
327
  catch (error) {
507
- return {
508
- status: 500,
509
- content: JSON.stringify({
510
- error: error instanceof Error ? error.message : 'Unknown error'
511
- }),
512
- headers: { 'Content-Type': 'application/json' }
513
- };
328
+ return errorResponse(error);
514
329
  }
515
330
  }
516
331
  /**
517
- * Delete tileset and all extracted files from storage.
332
+ * Delete tileset and all files from storage.
518
333
  */
519
334
  async handleDelete(req) {
520
335
  try {
521
336
  // Authenticate user
522
- let userId;
523
- if (AuthConfig.isAuthDisabled()) {
524
- const userRecord = await this.userService.findOrCreateUser({
525
- id: AuthConfig.getAnonymousUserId(),
526
- roles: []
527
- });
528
- userId = userRecord.id;
529
- }
530
- else {
531
- if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
532
- return {
533
- status: 401,
534
- content: JSON.stringify({ error: 'Authentication required' }),
535
- headers: { 'Content-Type': 'application/json' }
536
- };
537
- }
538
- const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
539
- if (!authUser) {
540
- return {
541
- status: 401,
542
- content: JSON.stringify({ error: 'Invalid authentication headers' }),
543
- headers: { 'Content-Type': 'application/json' }
544
- };
545
- }
546
- const userRecord = await this.userService.findOrCreateUser(authUser);
547
- if (!userRecord.id) {
548
- return {
549
- status: 500,
550
- content: JSON.stringify({ error: 'Failed to retrieve user information' }),
551
- headers: { 'Content-Type': 'application/json' }
552
- };
553
- }
554
- userId = userRecord.id;
337
+ const userId = await this.authenticateUser(req);
338
+ if (typeof userId !== 'number') {
339
+ return userId;
555
340
  }
556
341
  const { id } = req.params || {};
557
- if (!id)
558
- return {
559
- status: 400,
560
- content: JSON.stringify({ error: 'Asset ID is required' }),
561
- headers: { 'Content-Type': 'application/json' }
562
- };
342
+ if (!id) {
343
+ return badRequestResponse('Asset ID is required');
344
+ }
563
345
  const asset = await this.getAssetById(id);
564
- if (!asset)
565
- return {
566
- status: 404,
567
- content: JSON.stringify({ error: 'Tileset not found' }),
568
- headers: { 'Content-Type': 'application/json' }
569
- };
346
+ if (!asset) {
347
+ return notFoundResponse('Tileset not found');
348
+ }
570
349
  // Check ownership (admins can delete any)
571
350
  const isAdmin = ApisixAuthParser.isAdmin(req.headers || {});
572
351
  if (!isAdmin && asset.owner_id !== null && asset.owner_id !== userId) {
573
- return {
574
- status: 403,
575
- content: JSON.stringify({ error: 'You can only delete your own assets' }),
576
- headers: { 'Content-Type': 'application/json' }
577
- };
352
+ return forbiddenResponse('You can only delete your own assets');
578
353
  }
579
- // Block deletion while upload in progress to prevent orphan files
580
- const record = asset;
581
- if (record.upload_status === 'pending' || record.upload_status === 'processing') {
354
+ // Block deletion while upload in progress
355
+ if (asset.upload_status === 'pending' || asset.upload_status === 'processing') {
582
356
  return {
583
357
  status: 409,
584
358
  content: JSON.stringify({ error: 'Cannot delete tileset while upload is in progress' }),
@@ -586,58 +360,65 @@ export class TilesetManager extends AssetsManager {
586
360
  };
587
361
  }
588
362
  // Delete all files from storage
589
- const fileIndex = asset.file_index;
590
- if (fileIndex?.files?.length) {
591
- await this.storage.deleteBatch(fileIndex.files.map(f => f.path));
363
+ // Support both new format (url = basePath) and legacy format (file_index.files)
364
+ const legacyFileIndex = asset.file_index;
365
+ if (legacyFileIndex?.files && legacyFileIndex.files.length > 0) {
366
+ // Legacy format: delete individual files from file_index
367
+ console.log(`[TilesetManager] Deleting ${legacyFileIndex.files.length} files (legacy format)`);
368
+ for (const file of legacyFileIndex.files) {
369
+ await this.storage.delete(file.path).catch(() => {
370
+ // Ignore individual file deletion errors
371
+ });
372
+ }
373
+ }
374
+ else if (asset.url) {
375
+ // New format: url contains basePath, use deleteByPrefix
376
+ const deletedCount = await this.storage.deleteByPrefix(asset.url);
377
+ console.log(`[TilesetManager] Deleted ${deletedCount} files from ${asset.url}`);
592
378
  }
379
+ // Delete database record
593
380
  await this.deleteAssetById(id);
594
- return {
595
- status: 200,
596
- content: JSON.stringify({ message: 'Tileset deleted successfully' }),
597
- headers: { 'Content-Type': 'application/json' }
598
- };
381
+ return successResponse({ message: 'Tileset deleted successfully' });
599
382
  }
600
383
  catch (error) {
601
- return {
602
- status: 500,
603
- content: JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }),
604
- headers: { 'Content-Type': 'application/json' }
605
- };
384
+ return errorResponse(error);
606
385
  }
607
386
  }
608
387
  /**
609
- * Override getEndpoints to add the file serving endpoint.
388
+ * Get HTTP endpoints for this manager.
610
389
  */
611
390
  getEndpoints() {
612
391
  const config = this.getConfiguration();
613
- // Get base endpoints from parent but filter out ones we override
614
- const baseEndpoints = super.getEndpoints().filter(ep =>
615
- // Keep all except GET /:id (we don't serve the raw asset, only files)
616
- // and DELETE /:id (we override to clean up files)
617
- !(ep.method === 'get' && ep.path === `/${config.endpoint}/:id`) &&
618
- !(ep.method === 'delete' && ep.path === `/${config.endpoint}/:id`) &&
619
- // Also remove download endpoint - not applicable for extracted tilesets
620
- !(ep.method === 'get' && ep.path === `/${config.endpoint}/:id/download`));
621
- // IMPORTANT: More specific routes must come BEFORE less specific ones
622
- // /:id/files/* and /:id/status must be registered before /:id to avoid incorrect matching
623
392
  return [
624
- // Status endpoint FIRST (most specific)
393
+ // Status endpoint (for async upload polling)
625
394
  {
626
395
  method: 'get',
627
396
  path: `/${config.endpoint}/:id/status`,
628
397
  handler: this.handleGetStatus.bind(this),
629
398
  responseType: 'application/json'
630
399
  },
631
- // File serving endpoint (also specific)
400
+ // List tilesets
632
401
  {
633
402
  method: 'get',
634
- path: `/${config.endpoint}/:id/files/*`,
635
- handler: this.handleGetFile.bind(this),
636
- responseType: 'application/octet-stream'
403
+ path: `/${config.endpoint}`,
404
+ handler: this.retrieve.bind(this),
405
+ responseType: 'application/json'
637
406
  },
638
- // Then base endpoints (list, create, update, etc.)
639
- ...baseEndpoints,
640
- // Override delete to clean up files (after base to override)
407
+ // Upload tileset
408
+ {
409
+ method: 'post',
410
+ path: `/${config.endpoint}`,
411
+ handler: this.handleUpload.bind(this),
412
+ responseType: 'application/json'
413
+ },
414
+ // Update metadata
415
+ {
416
+ method: 'put',
417
+ path: `/${config.endpoint}/:id`,
418
+ handler: this.handleUpdate.bind(this),
419
+ responseType: 'application/json'
420
+ },
421
+ // Delete tileset
641
422
  {
642
423
  method: 'delete',
643
424
  path: `/${config.endpoint}/:id`,
@@ -647,99 +428,191 @@ export class TilesetManager extends AssetsManager {
647
428
  ];
648
429
  }
649
430
  /**
650
- * Override OpenAPI specification to include tileset-specific endpoints.
431
+ * Generate OpenAPI specification.
651
432
  */
652
433
  getOpenAPISpec() {
653
- const parentSpec = super.getOpenAPISpec();
654
434
  const config = this.getConfiguration();
655
435
  const basePath = `/${config.endpoint}`;
656
436
  const tagName = config.tags?.[0] || config.name;
657
- // Update POST response
658
- if (parentSpec.paths[basePath]?.post?.responses?.['200']) {
659
- parentSpec.paths[basePath].post.responses['200'] = {
660
- description: 'Tileset uploaded and extracted successfully',
661
- content: {
662
- 'application/json': {
663
- schema: {
664
- type: 'object',
665
- properties: {
666
- message: { type: 'string' },
667
- file_count: { type: 'integer', description: 'Number of files extracted' },
668
- root_file: { type: 'string', description: 'Root tileset.json path' },
669
- tileset_url: { type: 'string', description: 'URL to load in Cesium' }
437
+ return {
438
+ paths: {
439
+ [basePath]: {
440
+ get: {
441
+ summary: 'List all tilesets',
442
+ description: 'Returns all tilesets with their public URLs for Cesium loading',
443
+ tags: [tagName],
444
+ responses: {
445
+ '200': {
446
+ description: 'List of tilesets',
447
+ content: {
448
+ 'application/json': {
449
+ schema: {
450
+ type: 'array',
451
+ items: { $ref: '#/components/schemas/TilesetResponse' }
452
+ }
453
+ }
454
+ }
670
455
  }
671
456
  }
672
- }
673
- }
674
- };
675
- }
676
- // Add file serving endpoint
677
- parentSpec.paths[`${basePath}/{id}/files/{filePath}`] = {
678
- get: {
679
- summary: 'Get tileset file',
680
- description: 'Retrieve an extracted file from the tileset (tileset.json, .b3dm, etc.)',
681
- tags: [tagName],
682
- parameters: [
683
- {
684
- name: 'id',
685
- in: 'path',
686
- required: true,
687
- schema: { type: 'string' },
688
- description: 'Tileset ID'
689
457
  },
690
- {
691
- name: 'filePath',
692
- in: 'path',
693
- required: true,
694
- schema: { type: 'string' },
695
- description: 'Path to file within tileset (e.g., tileset.json, tiles/tile_0.b3dm)'
458
+ post: {
459
+ summary: 'Upload a tileset',
460
+ description: 'Upload a ZIP file containing a 3D Tiles tileset. Files < 50MB are processed synchronously, larger files are queued.',
461
+ tags: [tagName],
462
+ security: [{ ApiKeyAuth: [] }],
463
+ requestBody: {
464
+ required: true,
465
+ content: {
466
+ 'multipart/form-data': {
467
+ schema: {
468
+ type: 'object',
469
+ required: ['file', 'description'],
470
+ properties: {
471
+ file: {
472
+ type: 'string',
473
+ format: 'binary',
474
+ description: 'ZIP file containing tileset'
475
+ },
476
+ description: { type: 'string', description: 'Tileset description' },
477
+ is_public: {
478
+ type: 'boolean',
479
+ description: 'Whether tileset is public (default: true)'
480
+ }
481
+ }
482
+ }
483
+ }
484
+ }
485
+ },
486
+ responses: {
487
+ '200': {
488
+ description: 'Tileset uploaded successfully (sync)',
489
+ content: {
490
+ 'application/json': {
491
+ schema: {
492
+ type: 'object',
493
+ properties: {
494
+ message: { type: 'string' },
495
+ id: { type: 'integer' },
496
+ tileset_url: {
497
+ type: 'string',
498
+ description: 'Public URL to load in Cesium'
499
+ },
500
+ file_count: { type: 'integer' }
501
+ }
502
+ }
503
+ }
504
+ }
505
+ },
506
+ '202': {
507
+ description: 'Upload accepted for async processing',
508
+ content: {
509
+ 'application/json': {
510
+ schema: {
511
+ type: 'object',
512
+ properties: {
513
+ message: { type: 'string' },
514
+ id: { type: 'integer' },
515
+ status: { type: 'string' },
516
+ status_url: { type: 'string' }
517
+ }
518
+ }
519
+ }
520
+ }
521
+ },
522
+ '400': { description: 'Bad request - missing fields or invalid file' },
523
+ '401': { description: 'Unauthorized' }
524
+ }
696
525
  }
697
- ],
698
- responses: {
699
- '200': {
700
- description: 'File content',
701
- content: {
702
- 'application/octet-stream': {
703
- schema: { type: 'string', format: 'binary' }
526
+ },
527
+ [`${basePath}/{id}/status`]: {
528
+ get: {
529
+ summary: 'Get upload status',
530
+ description: 'Poll the status of an async upload',
531
+ tags: [tagName],
532
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
533
+ responses: {
534
+ '200': {
535
+ description: 'Upload status',
536
+ content: {
537
+ 'application/json': {
538
+ schema: {
539
+ type: 'object',
540
+ properties: {
541
+ id: { type: 'integer' },
542
+ status: {
543
+ type: 'string',
544
+ enum: ['pending', 'processing', 'completed', 'failed']
545
+ },
546
+ tileset_url: { type: 'string' },
547
+ error: { type: 'string' }
548
+ }
549
+ }
550
+ }
551
+ }
552
+ },
553
+ '404': { description: 'Tileset not found' }
554
+ }
555
+ }
556
+ },
557
+ [`${basePath}/{id}`]: {
558
+ put: {
559
+ summary: 'Update tileset metadata',
560
+ tags: [tagName],
561
+ security: [{ ApiKeyAuth: [] }],
562
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
563
+ requestBody: {
564
+ content: {
565
+ 'application/json': {
566
+ schema: {
567
+ type: 'object',
568
+ properties: {
569
+ description: { type: 'string' },
570
+ is_public: { type: 'boolean' }
571
+ }
572
+ }
573
+ }
704
574
  }
575
+ },
576
+ responses: {
577
+ '200': { description: 'Updated successfully' },
578
+ '401': { description: 'Unauthorized' },
579
+ '403': { description: 'Forbidden' },
580
+ '404': { description: 'Not found' }
705
581
  }
706
582
  },
707
- '404': { description: 'Tileset or file not found' }
583
+ delete: {
584
+ summary: 'Delete tileset',
585
+ description: 'Delete tileset and all files from storage',
586
+ tags: [tagName],
587
+ security: [{ ApiKeyAuth: [] }],
588
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
589
+ responses: {
590
+ '200': { description: 'Deleted successfully' },
591
+ '401': { description: 'Unauthorized' },
592
+ '403': { description: 'Forbidden' },
593
+ '404': { description: 'Not found' },
594
+ '409': { description: 'Upload in progress' }
595
+ }
596
+ }
597
+ }
598
+ },
599
+ tags: [{ name: tagName, description: config.description }],
600
+ schemas: {
601
+ TilesetResponse: {
602
+ type: 'object',
603
+ properties: {
604
+ id: { type: 'integer' },
605
+ description: { type: 'string' },
606
+ filename: { type: 'string' },
607
+ date: { type: 'string', format: 'date-time' },
608
+ owner_id: { type: 'integer', nullable: true },
609
+ is_public: { type: 'boolean' },
610
+ tileset_url: { type: 'string', description: 'Public URL to load in Cesium' },
611
+ upload_status: { type: 'string', enum: ['pending', 'processing', 'completed', 'failed'] }
612
+ }
708
613
  }
709
614
  }
710
615
  };
711
- // Update GET list response schema
712
- if (parentSpec.schemas) {
713
- parentSpec.schemas.TilesetResponse = {
714
- type: 'object',
715
- properties: {
716
- id: { type: 'integer' },
717
- name: { type: 'string' },
718
- date: { type: 'string', format: 'date-time' },
719
- contentType: { type: 'string' },
720
- description: { type: 'string' },
721
- source: { type: 'string' },
722
- owner_id: { type: 'integer', nullable: true },
723
- filename: { type: 'string' },
724
- is_public: { type: 'boolean' },
725
- file_count: { type: 'integer', description: 'Number of files in tileset' },
726
- root_file: { type: 'string', description: 'Root tileset.json filename' },
727
- tileset_url: { type: 'string', description: 'URL to load tileset in Cesium' },
728
- files_base_url: { type: 'string', description: 'Base URL for all tileset files' }
729
- }
730
- };
731
- }
732
- if (parentSpec.paths[basePath]?.get?.responses?.['200']?.content?.['application/json']) {
733
- parentSpec.paths[basePath].get.responses['200'].content['application/json'].schema = {
734
- type: 'array',
735
- items: { $ref: '#/components/schemas/TilesetResponse' }
736
- };
737
- }
738
- // Remove the single asset GET endpoint since we don't serve raw assets
739
- delete parentSpec.paths[`${basePath}/{id}`]?.get;
740
- // Remove download endpoint
741
- delete parentSpec.paths[`${basePath}/{id}/download`];
742
- return parentSpec;
743
616
  }
744
617
  }
745
618
  //# sourceMappingURL=tileset_manager.js.map