digitaltwin-core 0.13.0 → 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.
- package/dist/components/tileset_manager.d.ts +46 -65
- package/dist/components/tileset_manager.d.ts.map +1 -1
- package/dist/components/tileset_manager.js +415 -652
- package/dist/components/tileset_manager.js.map +1 -1
- package/dist/database/adapters/knex_database_adapter.d.ts.map +1 -1
- package/dist/database/adapters/knex_database_adapter.js +9 -12
- package/dist/database/adapters/knex_database_adapter.js.map +1 -1
- package/dist/engine/upload_processor.d.ts +7 -37
- package/dist/engine/upload_processor.d.ts.map +1 -1
- package/dist/engine/upload_processor.js +45 -92
- package/dist/engine/upload_processor.js.map +1 -1
- package/dist/storage/adapters/local_storage_service.d.ts +14 -0
- package/dist/storage/adapters/local_storage_service.d.ts.map +1 -1
- package/dist/storage/adapters/local_storage_service.js +46 -0
- package/dist/storage/adapters/local_storage_service.js.map +1 -1
- package/dist/storage/adapters/ovh_storage_service.d.ts +15 -0
- package/dist/storage/adapters/ovh_storage_service.d.ts.map +1 -1
- package/dist/storage/adapters/ovh_storage_service.js +54 -2
- package/dist/storage/adapters/ovh_storage_service.js.map +1 -1
- package/dist/storage/storage_service.d.ts +36 -0
- package/dist/storage/storage_service.d.ts.map +1 -1
- package/dist/storage/storage_service.js.map +1 -1
- package/dist/types/data_record.d.ts +22 -15
- package/dist/types/data_record.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/map_to_data_record.d.ts.map +1 -1
- package/dist/utils/map_to_data_record.js +8 -2
- package/dist/utils/map_to_data_record.js.map +1 -1
- package/dist/utils/zip_utils.d.ts +14 -30
- package/dist/utils/zip_utils.d.ts.map +1 -1
- package/dist/utils/zip_utils.js +50 -69
- package/dist/utils/zip_utils.js.map +1 -1
- 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
|
|
10
|
-
* allowing Cesium and other 3D viewers to load tilesets directly via
|
|
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
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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: '
|
|
36
|
+
* name: 'tilesets',
|
|
32
37
|
* description: 'Manage 3D Tiles tilesets',
|
|
33
38
|
* contentType: 'application/json',
|
|
34
39
|
* endpoint: 'api/tilesets',
|
|
35
|
-
*
|
|
40
|
+
* extension: '.zip'
|
|
36
41
|
* }
|
|
37
42
|
* }
|
|
38
43
|
* }
|
|
39
44
|
*
|
|
40
|
-
* // After upload,
|
|
41
|
-
* //
|
|
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,524 +63,240 @@ export class TilesetManager extends AssetsManager {
|
|
|
55
63
|
this.uploadQueue = queue;
|
|
56
64
|
}
|
|
57
65
|
/**
|
|
58
|
-
*
|
|
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
|
|
65
|
-
*
|
|
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
|
|
66
|
+
* Handle tileset upload.
|
|
70
67
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
77
|
-
return
|
|
78
|
-
status: 400,
|
|
79
|
-
content: JSON.stringify({
|
|
80
|
-
error: 'Invalid request: missing request body'
|
|
81
|
-
}),
|
|
82
|
-
headers: { 'Content-Type': 'application/json' }
|
|
83
|
-
};
|
|
73
|
+
if (!req?.body) {
|
|
74
|
+
return badRequestResponse('Invalid request: missing request body');
|
|
84
75
|
}
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
id: AuthConfig.getAnonymousUserId(),
|
|
90
|
-
roles: []
|
|
91
|
-
};
|
|
92
|
-
const userRecord = await this.userService.findOrCreateUser(anonymousUser);
|
|
93
|
-
userId = userRecord.id;
|
|
94
|
-
}
|
|
95
|
-
else {
|
|
96
|
-
if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
|
|
97
|
-
return {
|
|
98
|
-
status: 401,
|
|
99
|
-
content: JSON.stringify({
|
|
100
|
-
error: 'Authentication required'
|
|
101
|
-
}),
|
|
102
|
-
headers: { 'Content-Type': 'application/json' }
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
|
|
106
|
-
if (!authUser) {
|
|
107
|
-
return {
|
|
108
|
-
status: 401,
|
|
109
|
-
content: JSON.stringify({
|
|
110
|
-
error: 'Invalid authentication headers'
|
|
111
|
-
}),
|
|
112
|
-
headers: { 'Content-Type': 'application/json' }
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
const userRecord = await this.userService.findOrCreateUser(authUser);
|
|
116
|
-
if (!userRecord.id) {
|
|
117
|
-
return {
|
|
118
|
-
status: 500,
|
|
119
|
-
content: JSON.stringify({
|
|
120
|
-
error: 'Failed to retrieve user information'
|
|
121
|
-
}),
|
|
122
|
-
headers: { 'Content-Type': 'application/json' }
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
userId = userRecord.id;
|
|
76
|
+
// Authenticate user
|
|
77
|
+
const userId = await this.authenticateUser(req);
|
|
78
|
+
if (typeof userId !== 'number') {
|
|
79
|
+
return userId; // Returns error response
|
|
126
80
|
}
|
|
81
|
+
// Validate request
|
|
127
82
|
const { description } = req.body;
|
|
128
83
|
const filePath = req.file?.path;
|
|
129
84
|
const fileBuffer = req.file?.buffer;
|
|
130
85
|
const filename = req.file?.originalname || req.body.filename;
|
|
131
|
-
|
|
86
|
+
const fileSize = req.file?.size || fileBuffer?.length || 0;
|
|
132
87
|
if (!filePath && !fileBuffer) {
|
|
133
|
-
return
|
|
134
|
-
status: 400,
|
|
135
|
-
content: JSON.stringify({
|
|
136
|
-
error: 'Missing required fields: ZIP file'
|
|
137
|
-
}),
|
|
138
|
-
headers: { 'Content-Type': 'application/json' }
|
|
139
|
-
};
|
|
88
|
+
return badRequestResponse('Missing required field: ZIP file');
|
|
140
89
|
}
|
|
141
90
|
if (!description) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
error: 'Missing required fields: description'
|
|
146
|
-
}),
|
|
147
|
-
headers: { 'Content-Type': 'application/json' }
|
|
148
|
-
};
|
|
91
|
+
if (filePath)
|
|
92
|
+
await fs.unlink(filePath).catch(() => { });
|
|
93
|
+
return badRequestResponse('Missing required field: description');
|
|
149
94
|
}
|
|
150
95
|
if (!filename) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
error: 'Filename could not be determined from uploaded file'
|
|
155
|
-
}),
|
|
156
|
-
headers: { 'Content-Type': 'application/json' }
|
|
157
|
-
};
|
|
96
|
+
if (filePath)
|
|
97
|
+
await fs.unlink(filePath).catch(() => { });
|
|
98
|
+
return badRequestResponse('Filename could not be determined from uploaded file');
|
|
158
99
|
}
|
|
159
|
-
// Validate ZIP file extension
|
|
160
100
|
if (!filename.toLowerCase().endsWith('.zip')) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
error: 'Invalid file extension. Expected: .zip'
|
|
165
|
-
}),
|
|
166
|
-
headers: { 'Content-Type': 'application/json' }
|
|
167
|
-
};
|
|
101
|
+
if (filePath)
|
|
102
|
+
await fs.unlink(filePath).catch(() => { });
|
|
103
|
+
return badRequestResponse('Invalid file extension. Expected: .zip');
|
|
168
104
|
}
|
|
169
105
|
const config = this.getConfiguration();
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
console.log(`[TilesetManager] Using ASYNC mode for ${filename}`);
|
|
175
|
-
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);
|
|
176
110
|
}
|
|
177
|
-
|
|
178
|
-
console.log(`[TilesetManager] Using SYNC mode for ${filename}`);
|
|
179
|
-
return this.handleSyncUpload(req, userId, filePath, fileBuffer, filename, description, config);
|
|
111
|
+
return this.handleSyncUpload(userId, filePath, fileBuffer, filename, description, isPublic, config);
|
|
180
112
|
}
|
|
181
113
|
catch (error) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
error: error instanceof Error ? error.message : 'Unknown error'
|
|
186
|
-
}),
|
|
187
|
-
headers: { 'Content-Type': 'application/json' }
|
|
188
|
-
};
|
|
114
|
+
if (req.file?.path)
|
|
115
|
+
await fs.unlink(req.file.path).catch(() => { });
|
|
116
|
+
return errorResponse(error);
|
|
189
117
|
}
|
|
190
118
|
}
|
|
191
119
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
120
|
+
* Authenticate user from request headers.
|
|
121
|
+
* Returns user ID on success, or error response on failure.
|
|
194
122
|
*/
|
|
195
|
-
async
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
};
|
|
222
|
-
// uploadQueue is guaranteed to exist here (checked in handleUpload)
|
|
223
|
-
let job;
|
|
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');
|
|
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;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Queue upload for background processing. Returns HTTP 202 immediately.
|
|
146
|
+
*/
|
|
147
|
+
async handleAsyncUpload(userId, filePath, filename, description, isPublic, config) {
|
|
148
|
+
let recordId = null;
|
|
224
149
|
try {
|
|
225
|
-
|
|
150
|
+
// Create pending record (url will be updated after extraction)
|
|
151
|
+
const metadata = {
|
|
152
|
+
name: config.name,
|
|
153
|
+
type: 'application/json',
|
|
154
|
+
url: '',
|
|
155
|
+
tileset_url: '',
|
|
156
|
+
date: new Date(),
|
|
157
|
+
description,
|
|
158
|
+
filename,
|
|
159
|
+
owner_id: userId,
|
|
160
|
+
is_public: isPublic,
|
|
161
|
+
upload_status: 'pending'
|
|
162
|
+
};
|
|
163
|
+
const savedRecord = await this.db.save(metadata);
|
|
164
|
+
recordId = savedRecord.id;
|
|
165
|
+
const jobData = {
|
|
166
|
+
type: 'tileset',
|
|
167
|
+
recordId,
|
|
168
|
+
tempFilePath: filePath,
|
|
169
|
+
componentName: config.name,
|
|
170
|
+
userId,
|
|
171
|
+
filename,
|
|
172
|
+
description
|
|
173
|
+
};
|
|
174
|
+
const job = await this.uploadQueue?.add(`tileset-${recordId}`, jobData, {
|
|
226
175
|
jobId: `tileset-upload-${recordId}`
|
|
227
176
|
});
|
|
228
|
-
if (!job)
|
|
177
|
+
if (!job)
|
|
229
178
|
throw new Error('Failed to queue upload job');
|
|
230
|
-
}
|
|
179
|
+
await this.db.updateById(config.name, recordId, { upload_job_id: job.id });
|
|
180
|
+
return {
|
|
181
|
+
status: 202,
|
|
182
|
+
content: JSON.stringify({
|
|
183
|
+
message: 'Tileset upload accepted, processing in background',
|
|
184
|
+
id: recordId,
|
|
185
|
+
job_id: job.id,
|
|
186
|
+
status: 'pending',
|
|
187
|
+
status_url: `/${config.endpoint}/${recordId}/status`
|
|
188
|
+
}),
|
|
189
|
+
headers: { 'Content-Type': 'application/json' }
|
|
190
|
+
};
|
|
231
191
|
}
|
|
232
|
-
catch (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
// Clean up temp file
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (recordId !== null)
|
|
194
|
+
await this.db.delete(String(recordId), config.name).catch(() => { });
|
|
236
195
|
await fs.unlink(filePath).catch(() => { });
|
|
237
|
-
throw
|
|
196
|
+
throw error;
|
|
238
197
|
}
|
|
239
|
-
// Update record with job ID
|
|
240
|
-
await this.db.updateById(config.name, recordId, {
|
|
241
|
-
upload_job_id: job.id
|
|
242
|
-
});
|
|
243
|
-
return {
|
|
244
|
-
status: 202, // Accepted - processing started
|
|
245
|
-
content: JSON.stringify({
|
|
246
|
-
message: 'Tileset upload accepted, processing in background',
|
|
247
|
-
id: recordId,
|
|
248
|
-
job_id: job.id,
|
|
249
|
-
status: 'pending',
|
|
250
|
-
status_url: `/${config.endpoint}/${recordId}/status`
|
|
251
|
-
}),
|
|
252
|
-
headers: { 'Content-Type': 'application/json' }
|
|
253
|
-
};
|
|
254
198
|
}
|
|
255
199
|
/**
|
|
256
|
-
*
|
|
257
|
-
* @private
|
|
200
|
+
* Process upload synchronously.
|
|
258
201
|
*/
|
|
259
|
-
async handleSyncUpload(
|
|
260
|
-
// Read ZIP file content
|
|
202
|
+
async handleSyncUpload(userId, filePath, fileBuffer, filename, description, isPublic, config) {
|
|
261
203
|
let zipBuffer;
|
|
262
204
|
try {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
else if (filePath) {
|
|
267
|
-
zipBuffer = await fs.readFile(filePath);
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
205
|
+
const readBuffer = fileBuffer || (filePath ? await fs.readFile(filePath) : null);
|
|
206
|
+
if (!readBuffer)
|
|
270
207
|
throw new Error('No file data available');
|
|
271
|
-
|
|
208
|
+
zipBuffer = readBuffer;
|
|
272
209
|
}
|
|
273
210
|
catch (error) {
|
|
274
|
-
return {
|
|
275
|
-
status: 500,
|
|
276
|
-
content: JSON.stringify({
|
|
277
|
-
error: `Failed to read uploaded file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
278
|
-
}),
|
|
279
|
-
headers: { 'Content-Type': 'application/json' }
|
|
280
|
-
};
|
|
211
|
+
return errorResponse(`Failed to read uploaded file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
281
212
|
}
|
|
282
213
|
try {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
// Base path for storing extracted files: componentName/tilesetId
|
|
287
|
-
const basePath = `${config.name}/${tilesetId}`;
|
|
288
|
-
// Extract ZIP and store all files individually
|
|
214
|
+
// Generate unique base path using timestamp
|
|
215
|
+
const basePath = `${config.name}/${Date.now()}`;
|
|
216
|
+
// Extract ZIP and upload all files to storage
|
|
289
217
|
const extractResult = await extractAndStoreArchive(zipBuffer, this.storage, basePath);
|
|
290
|
-
// Validate that we found a tileset.json
|
|
291
218
|
if (!extractResult.root_file) {
|
|
292
|
-
// Clean up
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
error: 'Invalid tileset: no tileset.json found in the ZIP archive'
|
|
301
|
-
}),
|
|
302
|
-
headers: { 'Content-Type': 'application/json' }
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
// Create metadata with file_index
|
|
306
|
-
// url stores the base path for all extracted files
|
|
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');
|
|
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)
|
|
307
227
|
const metadata = {
|
|
308
228
|
name: config.name,
|
|
309
|
-
type: 'application/json',
|
|
310
|
-
url: basePath,
|
|
311
|
-
|
|
229
|
+
type: 'application/json',
|
|
230
|
+
url: basePath,
|
|
231
|
+
tileset_url: tilesetUrl,
|
|
232
|
+
date: new Date(),
|
|
312
233
|
description,
|
|
313
|
-
|
|
234
|
+
filename,
|
|
314
235
|
owner_id: userId,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
file_index: {
|
|
318
|
-
files: extractResult.files,
|
|
319
|
-
root_file: extractResult.root_file
|
|
320
|
-
}
|
|
236
|
+
is_public: isPublic,
|
|
237
|
+
upload_status: 'completed'
|
|
321
238
|
};
|
|
322
239
|
const savedRecord = await this.db.save(metadata);
|
|
323
|
-
// Clean up
|
|
324
|
-
if (filePath)
|
|
240
|
+
// Clean up temp file
|
|
241
|
+
if (filePath)
|
|
325
242
|
await fs.unlink(filePath).catch(() => { });
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
file_count: extractResult.file_count,
|
|
333
|
-
root_file: extractResult.root_file,
|
|
334
|
-
tileset_url: `/${config.endpoint}/${savedRecord.id}/files/${extractResult.root_file}`
|
|
335
|
-
}),
|
|
336
|
-
headers: { 'Content-Type': 'application/json' }
|
|
337
|
-
};
|
|
243
|
+
return successResponse({
|
|
244
|
+
message: 'Tileset uploaded successfully',
|
|
245
|
+
id: savedRecord.id,
|
|
246
|
+
tileset_url: tilesetUrl,
|
|
247
|
+
file_count: extractResult.file_count
|
|
248
|
+
});
|
|
338
249
|
}
|
|
339
250
|
catch (error) {
|
|
340
|
-
|
|
341
|
-
if (filePath) {
|
|
251
|
+
if (filePath)
|
|
342
252
|
await fs.unlink(filePath).catch(() => { });
|
|
343
|
-
}
|
|
344
253
|
throw error;
|
|
345
254
|
}
|
|
346
255
|
}
|
|
347
256
|
/**
|
|
348
|
-
* Get
|
|
257
|
+
* Get upload status for async uploads.
|
|
349
258
|
*/
|
|
350
259
|
async handleGetStatus(req) {
|
|
351
260
|
try {
|
|
352
261
|
const { id } = req.params || {};
|
|
353
262
|
if (!id) {
|
|
354
|
-
return
|
|
355
|
-
status: 400,
|
|
356
|
-
content: JSON.stringify({ error: 'Asset ID is required' }),
|
|
357
|
-
headers: { 'Content-Type': 'application/json' }
|
|
358
|
-
};
|
|
263
|
+
return badRequestResponse('Asset ID is required');
|
|
359
264
|
}
|
|
360
265
|
const asset = await this.getAssetById(id);
|
|
361
266
|
if (!asset) {
|
|
362
|
-
return
|
|
363
|
-
status: 404,
|
|
364
|
-
content: JSON.stringify({ error: 'Tileset not found' }),
|
|
365
|
-
headers: { 'Content-Type': 'application/json' }
|
|
366
|
-
};
|
|
267
|
+
return notFoundResponse('Tileset not found');
|
|
367
268
|
}
|
|
368
|
-
const config = this.getConfiguration();
|
|
369
269
|
const record = asset;
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
// - If upload_status is 'completed' OR
|
|
373
|
-
// - If upload_status is null/undefined BUT file_index exists (legacy sync uploads)
|
|
374
|
-
const isCompleted = record.upload_status === 'completed' ||
|
|
375
|
-
(!record.upload_status && fileIndex && fileIndex.files && fileIndex.files.length > 0);
|
|
376
|
-
if (isCompleted) {
|
|
377
|
-
const rootFile = fileIndex?.root_file || 'tileset.json';
|
|
378
|
-
return {
|
|
379
|
-
status: 200,
|
|
380
|
-
content: JSON.stringify({
|
|
381
|
-
id: record.id,
|
|
382
|
-
status: 'completed',
|
|
383
|
-
tileset_url: `/${config.endpoint}/${record.id}/files/${rootFile}`,
|
|
384
|
-
file_count: fileIndex?.files?.length || 0
|
|
385
|
-
}),
|
|
386
|
-
headers: { 'Content-Type': 'application/json' }
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
// If no upload_status and no file_index, it's a broken record
|
|
390
|
-
if (!record.upload_status) {
|
|
391
|
-
return {
|
|
392
|
-
status: 200,
|
|
393
|
-
content: JSON.stringify({
|
|
394
|
-
id: record.id,
|
|
395
|
-
status: 'failed',
|
|
396
|
-
error: 'Upload incomplete - no files found'
|
|
397
|
-
}),
|
|
398
|
-
headers: { 'Content-Type': 'application/json' }
|
|
399
|
-
};
|
|
400
|
-
}
|
|
401
|
-
// Return current status (pending, processing, or failed)
|
|
402
|
-
return {
|
|
403
|
-
status: 200,
|
|
404
|
-
content: JSON.stringify({
|
|
270
|
+
if (record.upload_status === 'completed') {
|
|
271
|
+
return successResponse({
|
|
405
272
|
id: record.id,
|
|
406
|
-
status:
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}),
|
|
410
|
-
headers: { 'Content-Type': 'application/json' }
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
catch (error) {
|
|
414
|
-
return {
|
|
415
|
-
status: 500,
|
|
416
|
-
content: JSON.stringify({
|
|
417
|
-
error: error instanceof Error ? error.message : 'Unknown error'
|
|
418
|
-
}),
|
|
419
|
-
headers: { 'Content-Type': 'application/json' }
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
/**
|
|
424
|
-
* Serve an extracted file from the tileset.
|
|
425
|
-
*
|
|
426
|
-
* This endpoint allows Cesium to load tileset.json and all referenced files
|
|
427
|
-
* (tiles, textures, etc.) using relative paths.
|
|
428
|
-
*
|
|
429
|
-
* @param req - HTTP request with params.id and params['0'] (file path)
|
|
430
|
-
* @returns DataResponse with file content
|
|
431
|
-
*/
|
|
432
|
-
async handleGetFile(req) {
|
|
433
|
-
try {
|
|
434
|
-
const { id } = req.params || {};
|
|
435
|
-
// Express/ultimate-express captures wildcard path in different ways:
|
|
436
|
-
// - Express 4.x: params[0] or params['0']
|
|
437
|
-
// - Express 5.x / ultimate-express: params['*'] or via URL parsing
|
|
438
|
-
let filePath = req.params[0] || req.params['0'] || req.params['*'] || req.params.path || req.params['*0'];
|
|
439
|
-
// Fallback: extract from URL if params didn't capture it
|
|
440
|
-
if (!filePath && req.url) {
|
|
441
|
-
const match = req.url.match(/\/files\/(.+)$/);
|
|
442
|
-
if (match) {
|
|
443
|
-
filePath = decodeURIComponent(match[1]);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
// Another fallback: use originalUrl
|
|
447
|
-
if (!filePath && req.originalUrl) {
|
|
448
|
-
const match = req.originalUrl.match(/\/files\/(.+)$/);
|
|
449
|
-
if (match) {
|
|
450
|
-
filePath = decodeURIComponent(match[1]);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
if (!id) {
|
|
454
|
-
return {
|
|
455
|
-
status: 400,
|
|
456
|
-
content: JSON.stringify({ error: 'Asset ID is required' }),
|
|
457
|
-
headers: { 'Content-Type': 'application/json' }
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
|
-
if (!filePath) {
|
|
461
|
-
return {
|
|
462
|
-
status: 400,
|
|
463
|
-
content: JSON.stringify({ error: 'File path is required' }),
|
|
464
|
-
headers: { 'Content-Type': 'application/json' }
|
|
465
|
-
};
|
|
273
|
+
status: 'completed',
|
|
274
|
+
tileset_url: record.tileset_url
|
|
275
|
+
});
|
|
466
276
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
return {
|
|
480
|
-
status: 404,
|
|
481
|
-
content: JSON.stringify({ error: 'Tileset not found' }),
|
|
482
|
-
headers: { 'Content-Type': 'application/json' }
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
// Check access permissions for private assets
|
|
486
|
-
if (!asset.is_public) {
|
|
487
|
-
if (ApisixAuthParser.isAdmin(req.headers || {})) {
|
|
488
|
-
// Admin can access everything
|
|
489
|
-
}
|
|
490
|
-
else if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
|
|
491
|
-
return {
|
|
492
|
-
status: 401,
|
|
493
|
-
content: JSON.stringify({ error: 'Authentication required for private assets' }),
|
|
494
|
-
headers: { 'Content-Type': 'application/json' }
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
else {
|
|
498
|
-
const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
|
|
499
|
-
if (authUser) {
|
|
500
|
-
const userRecord = await this.userService.findOrCreateUser(authUser);
|
|
501
|
-
if (!userRecord.id || asset.owner_id !== userRecord.id) {
|
|
502
|
-
return {
|
|
503
|
-
status: 403,
|
|
504
|
-
content: JSON.stringify({ error: 'This asset is private' }),
|
|
505
|
-
headers: { 'Content-Type': 'application/json' }
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
// Build storage path using asset.url as base path
|
|
512
|
-
// asset.url contains the base path (e.g., 'tilesets_manager/1733064000000')
|
|
513
|
-
const storagePath = `${asset.url}/${filePath}`;
|
|
514
|
-
// Retrieve file from storage
|
|
515
|
-
let fileContent;
|
|
516
|
-
try {
|
|
517
|
-
fileContent = await this.storage.retrieve(storagePath);
|
|
518
|
-
}
|
|
519
|
-
catch {
|
|
520
|
-
return {
|
|
521
|
-
status: 404,
|
|
522
|
-
content: JSON.stringify({ error: 'File not found' }),
|
|
523
|
-
headers: { 'Content-Type': 'application/json' }
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
// Determine content type based on file extension
|
|
527
|
-
const contentType = this.getContentTypeForFile(filePath);
|
|
528
|
-
return {
|
|
529
|
-
status: 200,
|
|
530
|
-
content: fileContent,
|
|
531
|
-
headers: {
|
|
532
|
-
'Content-Type': contentType,
|
|
533
|
-
'Access-Control-Allow-Origin': '*'
|
|
534
|
-
}
|
|
535
|
-
};
|
|
277
|
+
if (record.upload_status === 'failed') {
|
|
278
|
+
return successResponse({
|
|
279
|
+
id: record.id,
|
|
280
|
+
status: 'failed',
|
|
281
|
+
error: record.upload_error || 'Upload failed'
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return successResponse({
|
|
285
|
+
id: record.id,
|
|
286
|
+
status: record.upload_status || 'unknown',
|
|
287
|
+
job_id: record.upload_job_id
|
|
288
|
+
});
|
|
536
289
|
}
|
|
537
290
|
catch (error) {
|
|
538
|
-
return
|
|
539
|
-
status: 500,
|
|
540
|
-
content: JSON.stringify({
|
|
541
|
-
error: error instanceof Error ? error.message : 'Unknown error'
|
|
542
|
-
}),
|
|
543
|
-
headers: { 'Content-Type': 'application/json' }
|
|
544
|
-
};
|
|
291
|
+
return errorResponse(error);
|
|
545
292
|
}
|
|
546
293
|
}
|
|
547
294
|
/**
|
|
548
|
-
*
|
|
549
|
-
*/
|
|
550
|
-
getContentTypeForFile(filePath) {
|
|
551
|
-
const ext = filePath.toLowerCase().split('.').pop();
|
|
552
|
-
const contentTypes = {
|
|
553
|
-
json: 'application/json',
|
|
554
|
-
b3dm: 'application/octet-stream',
|
|
555
|
-
i3dm: 'application/octet-stream',
|
|
556
|
-
pnts: 'application/octet-stream',
|
|
557
|
-
cmpt: 'application/octet-stream',
|
|
558
|
-
glb: 'model/gltf-binary',
|
|
559
|
-
gltf: 'model/gltf+json',
|
|
560
|
-
bin: 'application/octet-stream',
|
|
561
|
-
png: 'image/png',
|
|
562
|
-
jpg: 'image/jpeg',
|
|
563
|
-
jpeg: 'image/jpeg',
|
|
564
|
-
webp: 'image/webp',
|
|
565
|
-
ktx2: 'image/ktx2'
|
|
566
|
-
};
|
|
567
|
-
return contentTypes[ext || ''] || 'application/octet-stream';
|
|
568
|
-
}
|
|
569
|
-
/**
|
|
570
|
-
* Override retrieve to include tileset.json URL in the response.
|
|
295
|
+
* List all tilesets with their public URLs.
|
|
571
296
|
*/
|
|
572
297
|
async retrieve(req) {
|
|
573
298
|
try {
|
|
574
299
|
const assets = await this.getAllAssets();
|
|
575
|
-
const config = this.getConfiguration();
|
|
576
300
|
const isAdmin = req && ApisixAuthParser.isAdmin(req.headers || {});
|
|
577
301
|
// Get authenticated user ID if available
|
|
578
302
|
let authenticatedUserId = null;
|
|
@@ -587,167 +311,114 @@ export class TilesetManager extends AssetsManager {
|
|
|
587
311
|
const visibleAssets = isAdmin
|
|
588
312
|
? assets
|
|
589
313
|
: assets.filter(asset => asset.is_public || (authenticatedUserId !== null && asset.owner_id === authenticatedUserId));
|
|
590
|
-
// Transform to
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
filename: asset.filename || '',
|
|
603
|
-
is_public: asset.is_public ?? true,
|
|
604
|
-
// Tileset-specific URLs
|
|
605
|
-
file_count: fileIndex?.files?.length || 0,
|
|
606
|
-
root_file: rootFile,
|
|
607
|
-
tileset_url: `/${config.endpoint}/${asset.id}/files/${rootFile}`,
|
|
608
|
-
files_base_url: `/${config.endpoint}/${asset.id}/files`
|
|
609
|
-
};
|
|
610
|
-
});
|
|
611
|
-
return {
|
|
612
|
-
status: 200,
|
|
613
|
-
content: JSON.stringify(assetsWithUrls),
|
|
614
|
-
headers: { 'Content-Type': 'application/json' }
|
|
615
|
-
};
|
|
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);
|
|
616
326
|
}
|
|
617
327
|
catch (error) {
|
|
618
|
-
return
|
|
619
|
-
status: 500,
|
|
620
|
-
content: JSON.stringify({
|
|
621
|
-
error: error instanceof Error ? error.message : 'Unknown error'
|
|
622
|
-
}),
|
|
623
|
-
headers: { 'Content-Type': 'application/json' }
|
|
624
|
-
};
|
|
328
|
+
return errorResponse(error);
|
|
625
329
|
}
|
|
626
330
|
}
|
|
627
331
|
/**
|
|
628
|
-
*
|
|
332
|
+
* Delete tileset and all files from storage.
|
|
629
333
|
*/
|
|
630
334
|
async handleDelete(req) {
|
|
631
335
|
try {
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
if (
|
|
635
|
-
|
|
636
|
-
id: AuthConfig.getAnonymousUserId(),
|
|
637
|
-
roles: []
|
|
638
|
-
};
|
|
639
|
-
const userRecord = await this.userService.findOrCreateUser(anonymousUser);
|
|
640
|
-
userId = userRecord.id;
|
|
641
|
-
}
|
|
642
|
-
else {
|
|
643
|
-
if (!ApisixAuthParser.hasValidAuth(req.headers || {})) {
|
|
644
|
-
return {
|
|
645
|
-
status: 401,
|
|
646
|
-
content: JSON.stringify({ error: 'Authentication required' }),
|
|
647
|
-
headers: { 'Content-Type': 'application/json' }
|
|
648
|
-
};
|
|
649
|
-
}
|
|
650
|
-
const authUser = ApisixAuthParser.parseAuthHeaders(req.headers || {});
|
|
651
|
-
if (!authUser) {
|
|
652
|
-
return {
|
|
653
|
-
status: 401,
|
|
654
|
-
content: JSON.stringify({ error: 'Invalid authentication headers' }),
|
|
655
|
-
headers: { 'Content-Type': 'application/json' }
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
const userRecord = await this.userService.findOrCreateUser(authUser);
|
|
659
|
-
if (!userRecord.id) {
|
|
660
|
-
return {
|
|
661
|
-
status: 500,
|
|
662
|
-
content: JSON.stringify({ error: 'Failed to retrieve user information' }),
|
|
663
|
-
headers: { 'Content-Type': 'application/json' }
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
userId = userRecord.id;
|
|
336
|
+
// Authenticate user
|
|
337
|
+
const userId = await this.authenticateUser(req);
|
|
338
|
+
if (typeof userId !== 'number') {
|
|
339
|
+
return userId;
|
|
667
340
|
}
|
|
668
341
|
const { id } = req.params || {};
|
|
669
342
|
if (!id) {
|
|
670
|
-
return
|
|
671
|
-
status: 400,
|
|
672
|
-
content: JSON.stringify({ error: 'Asset ID is required' }),
|
|
673
|
-
headers: { 'Content-Type': 'application/json' }
|
|
674
|
-
};
|
|
343
|
+
return badRequestResponse('Asset ID is required');
|
|
675
344
|
}
|
|
676
|
-
// Get asset metadata
|
|
677
345
|
const asset = await this.getAssetById(id);
|
|
678
346
|
if (!asset) {
|
|
679
|
-
return
|
|
680
|
-
status: 404,
|
|
681
|
-
content: JSON.stringify({ error: 'Tileset not found' }),
|
|
682
|
-
headers: { 'Content-Type': 'application/json' }
|
|
683
|
-
};
|
|
347
|
+
return notFoundResponse('Tileset not found');
|
|
684
348
|
}
|
|
685
|
-
// Check ownership (admins can delete any
|
|
349
|
+
// Check ownership (admins can delete any)
|
|
686
350
|
const isAdmin = ApisixAuthParser.isAdmin(req.headers || {});
|
|
687
351
|
if (!isAdmin && asset.owner_id !== null && asset.owner_id !== userId) {
|
|
352
|
+
return forbiddenResponse('You can only delete your own assets');
|
|
353
|
+
}
|
|
354
|
+
// Block deletion while upload in progress
|
|
355
|
+
if (asset.upload_status === 'pending' || asset.upload_status === 'processing') {
|
|
688
356
|
return {
|
|
689
|
-
status:
|
|
690
|
-
content: JSON.stringify({ error: '
|
|
357
|
+
status: 409,
|
|
358
|
+
content: JSON.stringify({ error: 'Cannot delete tileset while upload is in progress' }),
|
|
691
359
|
headers: { 'Content-Type': 'application/json' }
|
|
692
360
|
};
|
|
693
361
|
}
|
|
694
|
-
// Delete all
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
362
|
+
// Delete all files from storage
|
|
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}`);
|
|
699
378
|
}
|
|
700
379
|
// Delete database record
|
|
701
380
|
await this.deleteAssetById(id);
|
|
702
|
-
return {
|
|
703
|
-
status: 200,
|
|
704
|
-
content: JSON.stringify({ message: 'Tileset deleted successfully' }),
|
|
705
|
-
headers: { 'Content-Type': 'application/json' }
|
|
706
|
-
};
|
|
381
|
+
return successResponse({ message: 'Tileset deleted successfully' });
|
|
707
382
|
}
|
|
708
383
|
catch (error) {
|
|
709
|
-
return
|
|
710
|
-
status: 500,
|
|
711
|
-
content: JSON.stringify({
|
|
712
|
-
error: error instanceof Error ? error.message : 'Unknown error'
|
|
713
|
-
}),
|
|
714
|
-
headers: { 'Content-Type': 'application/json' }
|
|
715
|
-
};
|
|
384
|
+
return errorResponse(error);
|
|
716
385
|
}
|
|
717
386
|
}
|
|
718
387
|
/**
|
|
719
|
-
*
|
|
388
|
+
* Get HTTP endpoints for this manager.
|
|
720
389
|
*/
|
|
721
390
|
getEndpoints() {
|
|
722
391
|
const config = this.getConfiguration();
|
|
723
|
-
// Get base endpoints from parent but filter out ones we override
|
|
724
|
-
const baseEndpoints = super.getEndpoints().filter(ep =>
|
|
725
|
-
// Keep all except GET /:id (we don't serve the raw asset, only files)
|
|
726
|
-
// and DELETE /:id (we override to clean up files)
|
|
727
|
-
!(ep.method === 'get' && ep.path === `/${config.endpoint}/:id`) &&
|
|
728
|
-
!(ep.method === 'delete' && ep.path === `/${config.endpoint}/:id`) &&
|
|
729
|
-
// Also remove download endpoint - not applicable for extracted tilesets
|
|
730
|
-
!(ep.method === 'get' && ep.path === `/${config.endpoint}/:id/download`));
|
|
731
|
-
// IMPORTANT: More specific routes must come BEFORE less specific ones
|
|
732
|
-
// /:id/files/* and /:id/status must be registered before /:id to avoid incorrect matching
|
|
733
392
|
return [
|
|
734
|
-
// Status endpoint
|
|
393
|
+
// Status endpoint (for async upload polling)
|
|
735
394
|
{
|
|
736
395
|
method: 'get',
|
|
737
396
|
path: `/${config.endpoint}/:id/status`,
|
|
738
397
|
handler: this.handleGetStatus.bind(this),
|
|
739
398
|
responseType: 'application/json'
|
|
740
399
|
},
|
|
741
|
-
//
|
|
400
|
+
// List tilesets
|
|
742
401
|
{
|
|
743
402
|
method: 'get',
|
|
744
|
-
path: `/${config.endpoint}
|
|
745
|
-
handler: this.
|
|
746
|
-
responseType: 'application/
|
|
403
|
+
path: `/${config.endpoint}`,
|
|
404
|
+
handler: this.retrieve.bind(this),
|
|
405
|
+
responseType: 'application/json'
|
|
406
|
+
},
|
|
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'
|
|
747
420
|
},
|
|
748
|
-
//
|
|
749
|
-
...baseEndpoints,
|
|
750
|
-
// Override delete to clean up files (after base to override)
|
|
421
|
+
// Delete tileset
|
|
751
422
|
{
|
|
752
423
|
method: 'delete',
|
|
753
424
|
path: `/${config.endpoint}/:id`,
|
|
@@ -757,99 +428,191 @@ export class TilesetManager extends AssetsManager {
|
|
|
757
428
|
];
|
|
758
429
|
}
|
|
759
430
|
/**
|
|
760
|
-
*
|
|
431
|
+
* Generate OpenAPI specification.
|
|
761
432
|
*/
|
|
762
433
|
getOpenAPISpec() {
|
|
763
|
-
const parentSpec = super.getOpenAPISpec();
|
|
764
434
|
const config = this.getConfiguration();
|
|
765
435
|
const basePath = `/${config.endpoint}`;
|
|
766
436
|
const tagName = config.tags?.[0] || config.name;
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
+
}
|
|
780
455
|
}
|
|
781
456
|
}
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
// Add file serving endpoint
|
|
787
|
-
parentSpec.paths[`${basePath}/{id}/files/{filePath}`] = {
|
|
788
|
-
get: {
|
|
789
|
-
summary: 'Get tileset file',
|
|
790
|
-
description: 'Retrieve an extracted file from the tileset (tileset.json, .b3dm, etc.)',
|
|
791
|
-
tags: [tagName],
|
|
792
|
-
parameters: [
|
|
793
|
-
{
|
|
794
|
-
name: 'id',
|
|
795
|
-
in: 'path',
|
|
796
|
-
required: true,
|
|
797
|
-
schema: { type: 'string' },
|
|
798
|
-
description: 'Tileset ID'
|
|
799
457
|
},
|
|
800
|
-
{
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
+
}
|
|
525
|
+
}
|
|
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
|
+
}
|
|
806
555
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
+
}
|
|
814
574
|
}
|
|
575
|
+
},
|
|
576
|
+
responses: {
|
|
577
|
+
'200': { description: 'Updated successfully' },
|
|
578
|
+
'401': { description: 'Unauthorized' },
|
|
579
|
+
'403': { description: 'Forbidden' },
|
|
580
|
+
'404': { description: 'Not found' }
|
|
815
581
|
}
|
|
816
582
|
},
|
|
817
|
-
|
|
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
|
+
}
|
|
818
613
|
}
|
|
819
614
|
}
|
|
820
615
|
};
|
|
821
|
-
// Update GET list response schema
|
|
822
|
-
if (parentSpec.schemas) {
|
|
823
|
-
parentSpec.schemas.TilesetResponse = {
|
|
824
|
-
type: 'object',
|
|
825
|
-
properties: {
|
|
826
|
-
id: { type: 'integer' },
|
|
827
|
-
name: { type: 'string' },
|
|
828
|
-
date: { type: 'string', format: 'date-time' },
|
|
829
|
-
contentType: { type: 'string' },
|
|
830
|
-
description: { type: 'string' },
|
|
831
|
-
source: { type: 'string' },
|
|
832
|
-
owner_id: { type: 'integer', nullable: true },
|
|
833
|
-
filename: { type: 'string' },
|
|
834
|
-
is_public: { type: 'boolean' },
|
|
835
|
-
file_count: { type: 'integer', description: 'Number of files in tileset' },
|
|
836
|
-
root_file: { type: 'string', description: 'Root tileset.json filename' },
|
|
837
|
-
tileset_url: { type: 'string', description: 'URL to load tileset in Cesium' },
|
|
838
|
-
files_base_url: { type: 'string', description: 'Base URL for all tileset files' }
|
|
839
|
-
}
|
|
840
|
-
};
|
|
841
|
-
}
|
|
842
|
-
if (parentSpec.paths[basePath]?.get?.responses?.['200']?.content?.['application/json']) {
|
|
843
|
-
parentSpec.paths[basePath].get.responses['200'].content['application/json'].schema = {
|
|
844
|
-
type: 'array',
|
|
845
|
-
items: { $ref: '#/components/schemas/TilesetResponse' }
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
// Remove the single asset GET endpoint since we don't serve raw assets
|
|
849
|
-
delete parentSpec.paths[`${basePath}/{id}`]?.get;
|
|
850
|
-
// Remove download endpoint
|
|
851
|
-
delete parentSpec.paths[`${basePath}/{id}/download`];
|
|
852
|
-
return parentSpec;
|
|
853
616
|
}
|
|
854
617
|
}
|
|
855
618
|
//# sourceMappingURL=tileset_manager.js.map
|