digitaltwin-core 0.14.2 → 1.0.0

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