@venizia/ignis 0.2.0 → 0.2.1-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 (76) hide show
  1. package/dist/base/middlewares/request-spy/request-spy.middleware.d.ts +1 -1
  2. package/dist/base/middlewares/request-spy/request-spy.middleware.d.ts.map +1 -1
  3. package/dist/base/middlewares/request-spy/request-spy.middleware.js +7 -2
  4. package/dist/base/middlewares/request-spy/request-spy.middleware.js.map +1 -1
  5. package/dist/components/mail/common/constants.d.ts +5 -0
  6. package/dist/components/mail/common/constants.d.ts.map +1 -1
  7. package/dist/components/mail/common/constants.js +11 -0
  8. package/dist/components/mail/common/constants.js.map +1 -1
  9. package/dist/components/mail/common/errors.d.ts +25 -0
  10. package/dist/components/mail/common/errors.d.ts.map +1 -1
  11. package/dist/components/mail/common/errors.js +22 -0
  12. package/dist/components/mail/common/errors.js.map +1 -1
  13. package/dist/components/mail/common/types/message.d.ts +2 -1
  14. package/dist/components/mail/common/types/message.d.ts.map +1 -1
  15. package/dist/components/mail/common/types/options.d.ts +7 -0
  16. package/dist/components/mail/common/types/options.d.ts.map +1 -1
  17. package/dist/components/mail/component.d.ts +2 -0
  18. package/dist/components/mail/component.d.ts.map +1 -1
  19. package/dist/components/mail/component.js +57 -17
  20. package/dist/components/mail/component.js.map +1 -1
  21. package/dist/components/mail/helpers/transporters/amazon-ses-transporter.helper.d.ts.map +1 -1
  22. package/dist/components/mail/helpers/transporters/amazon-ses-transporter.helper.js +2 -0
  23. package/dist/components/mail/helpers/transporters/amazon-ses-transporter.helper.js.map +1 -1
  24. package/dist/components/mail/helpers/transporters/mailgun-transporter.helper.d.ts.map +1 -1
  25. package/dist/components/mail/helpers/transporters/mailgun-transporter.helper.js +6 -6
  26. package/dist/components/mail/helpers/transporters/mailgun-transporter.helper.js.map +1 -1
  27. package/dist/components/mail/helpers/transporters/nodemail-transporter.helper.d.ts.map +1 -1
  28. package/dist/components/mail/helpers/transporters/nodemail-transporter.helper.js +12 -1
  29. package/dist/components/mail/helpers/transporters/nodemail-transporter.helper.js.map +1 -1
  30. package/dist/components/mail/services/mail.service.d.ts.map +1 -1
  31. package/dist/components/mail/services/mail.service.js +19 -3
  32. package/dist/components/mail/services/mail.service.js.map +1 -1
  33. package/dist/components/mail/utilities/attachment.utility.d.ts +31 -0
  34. package/dist/components/mail/utilities/attachment.utility.d.ts.map +1 -0
  35. package/dist/components/mail/utilities/attachment.utility.js +214 -0
  36. package/dist/components/mail/utilities/attachment.utility.js.map +1 -0
  37. package/dist/components/mail/utilities/body.utility.d.ts +6 -0
  38. package/dist/components/mail/utilities/body.utility.d.ts.map +1 -0
  39. package/dist/components/mail/utilities/body.utility.js +20 -0
  40. package/dist/components/mail/utilities/body.utility.js.map +1 -0
  41. package/dist/components/mail/utilities/mime.utility.d.ts.map +1 -1
  42. package/dist/components/mail/utilities/mime.utility.js +3 -27
  43. package/dist/components/mail/utilities/mime.utility.js.map +1 -1
  44. package/dist/components/static-asset/common/constants.d.ts +8 -0
  45. package/dist/components/static-asset/common/constants.d.ts.map +1 -1
  46. package/dist/components/static-asset/common/constants.js +11 -1
  47. package/dist/components/static-asset/common/constants.js.map +1 -1
  48. package/dist/components/static-asset/common/errors.d.ts +9 -0
  49. package/dist/components/static-asset/common/errors.d.ts.map +1 -1
  50. package/dist/components/static-asset/common/errors.js +9 -0
  51. package/dist/components/static-asset/common/errors.js.map +1 -1
  52. package/dist/components/static-asset/common/types.d.ts +25 -12
  53. package/dist/components/static-asset/common/types.d.ts.map +1 -1
  54. package/dist/components/static-asset/controller/base.definition.d.ts.map +1 -1
  55. package/dist/components/static-asset/controller/base.definition.js +10 -1
  56. package/dist/components/static-asset/controller/base.definition.js.map +1 -1
  57. package/dist/components/static-asset/controller/factory.d.ts.map +1 -1
  58. package/dist/components/static-asset/controller/factory.js +618 -563
  59. package/dist/components/static-asset/controller/factory.js.map +1 -1
  60. package/dist/components/static-asset/controller/key-scope.d.ts +28 -0
  61. package/dist/components/static-asset/controller/key-scope.d.ts.map +1 -0
  62. package/dist/components/static-asset/controller/key-scope.js +44 -0
  63. package/dist/components/static-asset/controller/key-scope.js.map +1 -0
  64. package/dist/components/static-asset/controller/meta-link.d.ts +37 -0
  65. package/dist/components/static-asset/controller/meta-link.d.ts.map +1 -0
  66. package/dist/components/static-asset/controller/meta-link.js +95 -0
  67. package/dist/components/static-asset/controller/meta-link.js.map +1 -0
  68. package/dist/components/static-asset/controller/route-options.d.ts +18 -0
  69. package/dist/components/static-asset/controller/route-options.d.ts.map +1 -0
  70. package/dist/components/static-asset/controller/route-options.js +37 -0
  71. package/dist/components/static-asset/controller/route-options.js.map +1 -0
  72. package/dist/testing/index.d.ts +3 -0
  73. package/dist/testing/index.d.ts.map +1 -0
  74. package/dist/testing/index.js +19 -0
  75. package/dist/testing/index.js.map +1 -0
  76. package/package.json +9 -5
@@ -19,6 +19,9 @@ const node_fs_1 = require("node:fs");
19
19
  const common_2 = require("../common");
20
20
  const common_3 = require("../common");
21
21
  const base_definition_1 = require("./base.definition");
22
+ const key_scope_1 = require("./key-scope");
23
+ const meta_link_1 = require("./meta-link");
24
+ const route_options_1 = require("./route-options");
22
25
  /** Hono ALREADY percent-decodes path params - a second decodeURIComponent throws on `report_100%.pdf` and turns `a%2Fb.png` into a DIFFERENT object; `isValidName`/`isValidPath` still run on this value, so traversal is still rejected. */
23
26
  /** The upload labels, read off the multipart form. Every value arrives as text, so `sequence` is the one that needs converting. */
24
27
  /** One list: the reader below and the stale-caller warning must agree. */
@@ -144,37 +147,6 @@ const applyMetadataHeaders = ({ ctx, metadata }) => {
144
147
  ctx.header(key.toLowerCase(), String(value).replace(/[\r\n]/g, ''));
145
148
  });
146
149
  };
147
- /** The MetaLink row for one uploaded object. Both upload paths go through here, so a direct upload lands exactly the row an ordinary one does. */
148
- const createMetaLinkRow = async (opts) => {
149
- const { metaLink, helper, storage, uploadResult, fileStat, query } = opts;
150
- if (metaLink.createMetaLink) {
151
- const created = await metaLink.createMetaLink({ uploadResult, fileStat, query });
152
- return created.data;
153
- }
154
- // Resolved per call so an application that swaps the repository is not pinned to the first one.
155
- const repository = await (0, common_1.resolveValueAsync)({ value: metaLink.repository });
156
- const created = await repository.create({
157
- data: {
158
- bucketName: uploadResult.bucket.name,
159
- objectName: uploadResult.object.key,
160
- link: uploadResult.link,
161
- // The column is NOT NULL and a backend may report nothing.
162
- mimetype: fileStat.metadata?.mimetype ?? helper.getMimeType({ filename: uploadResult.object.key }),
163
- size: fileStat.size,
164
- etag: fileStat.etag,
165
- metadata: fileStat.metadata,
166
- storageType: storage,
167
- isSynced: true,
168
- principalId: query.principalId ? String(query.principalId) : undefined,
169
- principalType: query.principalType ? String(query.principalType) : undefined,
170
- variant: query.variant ? String(query.variant) : undefined,
171
- // Absent leaves the column default rather than writing 0, so a row the caller never
172
- // ordered is indistinguishable from a legacy one.
173
- sequence: query.sequence === undefined ? undefined : Number(query.sequence),
174
- },
175
- });
176
- return created.data;
177
- };
178
150
  class AssetControllerFactory extends core_1.BaseHelper {
179
151
  constructor() {
180
152
  super({ scope: AssetControllerFactory.name });
@@ -187,6 +159,20 @@ class AssetControllerFactory extends core_1.BaseHelper {
187
159
  const maxFolderDepth = options?.maxFolderDepth ?? ignis_helpers_1.BaseStorageHelper.DEFAULT_MAX_FOLDER_DEPTH;
188
160
  const maxBytes = options?.maxBytes;
189
161
  const hasConfiguredBucket = bucket !== undefined;
162
+ // With the bucket in the URL, the bucket routes stay unscoped and the scope spans every bucket.
163
+ if (controller.keyPrefix !== undefined && !hasConfiguredBucket) {
164
+ throw (0, core_1.getError)({
165
+ message: `[${name}] keyPrefix needs controller.bucket | a bucket in the URL is a bucket the caller chooses, and the bucket routes cannot be scoped`,
166
+ });
167
+ }
168
+ const scope = controller.keyPrefix === undefined
169
+ ? undefined
170
+ : (0, key_scope_1.normalizeKeyPrefix)({ keyPrefix: controller.keyPrefix, helper, scope: name });
171
+ const keyPrefix = scope?.keyPrefix;
172
+ // `maxFolderDepth` counts the caller's folders; the scope's own are configuration, not input.
173
+ const objectKeyMaxDepth = maxFolderDepth + (scope?.depth ?? 0);
174
+ // The component's own name under a scope: the original name must stay one segment, as without one.
175
+ const isScopedDefaultNaming = keyPrefix !== undefined && !resolveObjectName && !options?.normalizeNameFn;
190
176
  const definitions = (0, base_definition_1.buildAssetDefinitions)({ hasConfiguredBucket, rawObjectPath });
191
177
  // The mount path may be declared with or without a leading slash; a link must carry exactly one.
192
178
  const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}`;
@@ -228,16 +214,81 @@ class AssetControllerFactory extends core_1.BaseHelper {
228
214
  // A single-bucket application has no reason to expose bucket creation or deletion, so the
229
215
  // four bucket-management routes are not registered once `bucket` is configured.
230
216
  if (!hasConfiguredBucket) {
217
+ const getBucketsRoute = (0, route_options_1.readRouteOverride)({ route: routes?.getBuckets });
218
+ if (getBucketsRoute.isEnabled) {
219
+ this.bindRoute({
220
+ configs: { ...definitions.GET_BUCKETS, ...getBucketsRoute.override },
221
+ }).to({
222
+ handler: async (ctx) => {
223
+ const buckets = await helper.getBuckets();
224
+ return ctx.json(buckets, common_1.HTTP.ResultCodes.RS_2.Ok);
225
+ },
226
+ });
227
+ }
228
+ const getBucketByNameRoute = (0, route_options_1.readRouteOverride)({ route: routes?.getBucketByName });
229
+ if (getBucketByNameRoute.isEnabled) {
230
+ this.bindRoute({
231
+ configs: { ...definitions.GET_BUCKET_BY_NAME, ...getBucketByNameRoute.override },
232
+ }).to({
233
+ handler: async (ctx) => {
234
+ const params = ctx.req.valid('param');
235
+ const bucketName = await resolveBucket({
236
+ configured: bucket,
237
+ param: params.bucketName,
238
+ });
239
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
240
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
241
+ }
242
+ const found = await helper.getBucket({ bucket: { name: bucketName } });
243
+ return ctx.json(found, common_1.HTTP.ResultCodes.RS_2.Ok);
244
+ },
245
+ });
246
+ }
247
+ const createBucketRoute = (0, route_options_1.readRouteOverride)({ route: routes?.createBucket });
248
+ if (createBucketRoute.isEnabled) {
249
+ this.bindRoute({
250
+ configs: { ...definitions.CREATE_BUCKET, ...createBucketRoute.override },
251
+ }).to({
252
+ handler: async (ctx) => {
253
+ const params = ctx.req.valid('param');
254
+ const bucketName = await resolveBucket({
255
+ configured: bucket,
256
+ param: params.bucketName,
257
+ });
258
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
259
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
260
+ }
261
+ const createdBucket = await helper.createBucket({ bucket: { name: bucketName } });
262
+ return ctx.json(createdBucket, common_1.HTTP.ResultCodes.RS_2.Ok);
263
+ },
264
+ });
265
+ }
266
+ const deleteBucketRoute = (0, route_options_1.readRouteOverride)({ route: routes?.deleteBucket });
267
+ if (deleteBucketRoute.isEnabled) {
268
+ this.bindRoute({
269
+ configs: { ...definitions.DELETE_BUCKET, ...deleteBucketRoute.override },
270
+ }).to({
271
+ handler: async (ctx) => {
272
+ const params = ctx.req.valid('param');
273
+ const bucketName = await resolveBucket({
274
+ configured: bucket,
275
+ param: params.bucketName,
276
+ });
277
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
278
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
279
+ }
280
+ const isRemovedBucket = await helper.removeBucket({
281
+ bucket: { name: bucketName },
282
+ });
283
+ return ctx.json({ isDeleted: isRemovedBucket }, common_1.HTTP.ResultCodes.RS_2.Ok);
284
+ },
285
+ });
286
+ }
287
+ }
288
+ const getObjectByNameRoute = (0, route_options_1.readRouteOverride)({ route: routes?.getObjectByName });
289
+ if (getObjectByNameRoute.isEnabled) {
231
290
  this.bindRoute({
232
- configs: { ...definitions.GET_BUCKETS, ...routes?.getBuckets },
233
- }).to({
234
- handler: async (ctx) => {
235
- const buckets = await helper.getBuckets();
236
- return ctx.json(buckets, common_1.HTTP.ResultCodes.RS_2.Ok);
237
- },
238
- });
239
- this.bindRoute({
240
- configs: { ...definitions.GET_BUCKET_BY_NAME, ...routes?.getBucketByName },
291
+ configs: { ...definitions.GET_OBJECT_BY_NAME, ...getObjectByNameRoute.override },
241
292
  }).to({
242
293
  handler: async (ctx) => {
243
294
  const params = ctx.req.valid('param');
@@ -245,31 +296,66 @@ class AssetControllerFactory extends core_1.BaseHelper {
245
296
  configured: bucket,
246
297
  param: params.bucketName,
247
298
  });
299
+ const objectName = readObjectName(params.objectName);
248
300
  if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
249
301
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
250
302
  }
251
- const found = await helper.getBucket({ bucket: { name: bucketName } });
252
- return ctx.json(found, common_1.HTTP.ResultCodes.RS_2.Ok);
253
- },
254
- });
255
- this.bindRoute({
256
- configs: { ...definitions.CREATE_BUCKET, ...routes?.createBucket },
257
- }).to({
258
- handler: async (ctx) => {
259
- const params = ctx.req.valid('param');
260
- const bucketName = await resolveBucket({
261
- configured: bucket,
262
- param: params.bucketName,
303
+ if (!helper.isValidObjectKey({
304
+ object: { key: objectName },
305
+ maxDepth: objectKeyMaxDepth,
306
+ })) {
307
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
308
+ }
309
+ // Before any storage call: a key outside the scope must not even be probed.
310
+ (0, key_scope_1.assertKeyInScope)({ key: objectName, keyPrefix });
311
+ const fileStat = await helper.getStat({
312
+ bucket: { name: bucketName },
313
+ object: { key: objectName },
263
314
  });
264
- if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
265
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
315
+ const { size, metadata } = fileStat;
316
+ applyMetadataHeaders({ ctx, metadata });
317
+ const served = (0, exports.resolveServedContentType)({ helper, object: { key: objectName } });
318
+ ctx.header(common_1.HTTP.Headers.CONTENT_TYPE, served.contentType);
319
+ ctx.header('x-content-type-options', 'nosniff');
320
+ // Sandboxed even when renderable, so an unforeseen renderable type cannot reach this origin.
321
+ ctx.header(common_1.HTTP.Headers.CONTENT_SECURITY_POLICY, 'sandbox');
322
+ // Advertised unconditionally: a player only offers seeking once it sees this.
323
+ ctx.header('accept-ranges', 'bytes');
324
+ // A type outside the allow-list downloads instead of rendering; that is the XSS boundary.
325
+ if (!served.isRenderable) {
326
+ const inlineName = objectName.split('/').pop() ?? objectName;
327
+ ctx.header(common_1.HTTP.Headers.CONTENT_DISPOSITION, (0, ignis_helpers_1.createContentDispositionHeader)({ filename: inlineName, type: 'attachment' }));
328
+ }
329
+ const range = readByteRange({ header: ctx.req.header('range'), size });
330
+ if (range) {
331
+ ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, String(range.end - range.start + 1));
332
+ ctx.header(common_1.HTTP.Headers.CONTENT_RANGE, `bytes ${range.start}-${range.end}/${size}`);
266
333
  }
267
- const createdBucket = await helper.createBucket({ bucket: { name: bucketName } });
268
- return ctx.json(createdBucket, common_1.HTTP.ResultCodes.RS_2.Ok);
334
+ else {
335
+ ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, size.toString());
336
+ }
337
+ // The web stream is what a Response body wants, so no Node Readable sits in between.
338
+ const stream = await helper.getObjectStream({
339
+ bucket: { name: bucketName },
340
+ object: { key: objectName },
341
+ ...(range ? { range } : {}),
342
+ });
343
+ return new Response(stream, {
344
+ headers: ctx.res.headers,
345
+ status: range ? common_1.HTTP.ResultCodes.RS_2.PartialContent : common_1.HTTP.ResultCodes.RS_2.Ok,
346
+ });
269
347
  },
270
348
  });
349
+ }
350
+ const downloadObjectByNameRoute = (0, route_options_1.readRouteOverride)({
351
+ route: routes?.downloadObjectByName,
352
+ });
353
+ if (downloadObjectByNameRoute.isEnabled) {
271
354
  this.bindRoute({
272
- configs: { ...definitions.DELETE_BUCKET, ...routes?.deleteBucket },
355
+ configs: {
356
+ ...definitions.DOWNLOAD_OBJECT_BY_NAME,
357
+ ...downloadObjectByNameRoute.override,
358
+ },
273
359
  }).to({
274
360
  handler: async (ctx) => {
275
361
  const params = ctx.req.valid('param');
@@ -277,500 +363,215 @@ class AssetControllerFactory extends core_1.BaseHelper {
277
363
  configured: bucket,
278
364
  param: params.bucketName,
279
365
  });
366
+ const objectName = readObjectName(params.objectName);
280
367
  if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
281
368
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
282
369
  }
283
- const isRemovedBucket = await helper.removeBucket({
370
+ if (!helper.isValidObjectKey({
371
+ object: { key: objectName },
372
+ maxDepth: objectKeyMaxDepth,
373
+ })) {
374
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
375
+ }
376
+ // Before any storage call: a key outside the scope must not even be probed.
377
+ (0, key_scope_1.assertKeyInScope)({ key: objectName, keyPrefix });
378
+ const fileStat = await helper.getStat({
284
379
  bucket: { name: bucketName },
380
+ object: { key: objectName },
381
+ });
382
+ const { size, metadata } = fileStat;
383
+ applyMetadataHeaders({ ctx, metadata });
384
+ // Download always attaches, so the served type only has to be honest, never renderable.
385
+ const served = (0, exports.resolveServedContentType)({ helper, object: { key: objectName } });
386
+ ctx.header(common_1.HTTP.Headers.CONTENT_TYPE, served.contentType);
387
+ ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, size.toString());
388
+ const fileName = objectName.split('/').pop() ?? objectName;
389
+ ctx.header(common_1.HTTP.Headers.CONTENT_DISPOSITION, (0, ignis_helpers_1.createContentDispositionHeader)({ filename: fileName, type: 'attachment' }));
390
+ ctx.header('x-content-type-options', 'nosniff');
391
+ ctx.header(common_1.HTTP.Headers.CONTENT_SECURITY_POLICY, 'sandbox');
392
+ const stream = await helper.getObjectStream({
393
+ bucket: { name: bucketName },
394
+ object: { key: objectName },
395
+ });
396
+ return new Response(stream, {
397
+ headers: ctx.res.headers,
398
+ status: common_1.HTTP.ResultCodes.RS_2.Ok,
285
399
  });
286
- return ctx.json({ isDeleted: isRemovedBucket }, common_1.HTTP.ResultCodes.RS_2.Ok);
287
400
  },
288
401
  });
289
402
  }
290
- this.bindRoute({
291
- configs: { ...definitions.GET_OBJECT_BY_NAME, ...routes?.getObjectByName },
292
- }).to({
293
- handler: async (ctx) => {
294
- const params = ctx.req.valid('param');
295
- const bucketName = await resolveBucket({
296
- configured: bucket,
297
- param: params.bucketName,
298
- });
299
- const objectName = readObjectName(params.objectName);
300
- if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
301
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
302
- }
303
- if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
304
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
305
- }
306
- const fileStat = await helper.getStat({
307
- bucket: { name: bucketName },
308
- object: { key: objectName },
309
- });
310
- const { size, metadata } = fileStat;
311
- applyMetadataHeaders({ ctx, metadata });
312
- const served = (0, exports.resolveServedContentType)({ helper, object: { key: objectName } });
313
- ctx.header(common_1.HTTP.Headers.CONTENT_TYPE, served.contentType);
314
- ctx.header('x-content-type-options', 'nosniff');
315
- // Sandboxed even when renderable, so an unforeseen renderable type cannot reach this origin.
316
- ctx.header(common_1.HTTP.Headers.CONTENT_SECURITY_POLICY, 'sandbox');
317
- // Advertised unconditionally: a player only offers seeking once it sees this.
318
- ctx.header('accept-ranges', 'bytes');
319
- // A type outside the allow-list downloads instead of rendering; that is the XSS boundary.
320
- if (!served.isRenderable) {
321
- const inlineName = objectName.split('/').pop() ?? objectName;
322
- ctx.header(common_1.HTTP.Headers.CONTENT_DISPOSITION, (0, ignis_helpers_1.createContentDispositionHeader)({ filename: inlineName, type: 'attachment' }));
323
- }
324
- const range = readByteRange({ header: ctx.req.header('range'), size });
325
- if (range) {
326
- ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, String(range.end - range.start + 1));
327
- ctx.header(common_1.HTTP.Headers.CONTENT_RANGE, `bytes ${range.start}-${range.end}/${size}`);
328
- }
329
- else {
330
- ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, size.toString());
331
- }
332
- // The web stream is what a Response body wants, so no Node Readable sits in between.
333
- const stream = await helper.getObjectStream({
334
- bucket: { name: bucketName },
335
- object: { key: objectName },
336
- ...(range ? { range } : {}),
337
- });
338
- return new Response(stream, {
339
- headers: ctx.res.headers,
340
- status: range ? common_1.HTTP.ResultCodes.RS_2.PartialContent : common_1.HTTP.ResultCodes.RS_2.Ok,
341
- });
342
- },
343
- });
344
- this.bindRoute({
345
- configs: {
346
- ...definitions.DOWNLOAD_OBJECT_BY_NAME,
347
- ...routes?.downloadObjectByName,
348
- },
349
- }).to({
350
- handler: async (ctx) => {
351
- const params = ctx.req.valid('param');
352
- const bucketName = await resolveBucket({
353
- configured: bucket,
354
- param: params.bucketName,
355
- });
356
- const objectName = readObjectName(params.objectName);
357
- if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
358
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
359
- }
360
- if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
361
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
362
- }
363
- const fileStat = await helper.getStat({
364
- bucket: { name: bucketName },
365
- object: { key: objectName },
366
- });
367
- const { size, metadata } = fileStat;
368
- applyMetadataHeaders({ ctx, metadata });
369
- // Download always attaches, so the served type only has to be honest, never renderable.
370
- const served = (0, exports.resolveServedContentType)({ helper, object: { key: objectName } });
371
- ctx.header(common_1.HTTP.Headers.CONTENT_TYPE, served.contentType);
372
- ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, size.toString());
373
- const fileName = objectName.split('/').pop() ?? objectName;
374
- ctx.header(common_1.HTTP.Headers.CONTENT_DISPOSITION, (0, ignis_helpers_1.createContentDispositionHeader)({ filename: fileName, type: 'attachment' }));
375
- ctx.header('x-content-type-options', 'nosniff');
376
- ctx.header(common_1.HTTP.Headers.CONTENT_SECURITY_POLICY, 'sandbox');
377
- const stream = await helper.getObjectStream({
378
- bucket: { name: bucketName },
379
- object: { key: objectName },
380
- });
381
- return new Response(stream, {
382
- headers: ctx.res.headers,
383
- status: common_1.HTTP.ResultCodes.RS_2.Ok,
403
+ const uploadRoute = (0, route_options_1.readRouteOverride)({ route: routes?.upload });
404
+ if (uploadRoute.isEnabled) {
405
+ const uploadMiddleware = maxBytes === undefined
406
+ ? uploadRoute.override.middleware
407
+ : (0, route_options_1.appendDeclaredLengthGuard)({
408
+ middleware: uploadRoute.override.middleware,
409
+ maxBytes,
384
410
  });
385
- },
386
- });
387
- this.bindRoute({
388
- configs: { ...definitions.UPLOAD, ...routes?.upload },
389
- }).to({
390
- handler: async (ctx) => {
391
- // A configured bucket drops `params` from this route entirely, so it is read only when it exists.
392
- const bucketParam = hasConfiguredBucket
393
- ? undefined
394
- : ctx.req.valid('param').bucketName;
395
- const bucketName = await resolveBucket({ configured: bucket, param: bucketParam });
396
- if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
397
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
398
- }
399
- const validateFolderPath = (folderPath) => {
400
- const normalizedFolder = folderPath.replace(/^\/+|\/+$/g, '');
401
- if (!normalizedFolder) {
402
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.FOLDER_PATH_INVALID });
403
- }
404
- // maxFolderDepth excludes the filename, so it is compared directly against segment count.
405
- const folderSegments = normalizedFolder.split('/');
406
- if (folderSegments.length > maxFolderDepth) {
407
- throw (0, core_1.getError)({
408
- error: common_2.StaticAssetErrors.FOLDER_DEPTH_EXCEEDED,
409
- message: `Folder path exceeds max depth of ${maxFolderDepth}`,
410
- });
411
- }
412
- const invalidSegmentIndex = folderSegments.findIndex(segment => !helper.isValidSegment({ segment }));
413
- if (invalidSegmentIndex !== -1) {
414
- throw (0, core_1.getError)({
415
- error: common_2.StaticAssetErrors.FOLDER_SEGMENT_INVALID,
416
- message: `Invalid folder path segment: ${folderSegments[invalidSegmentIndex]}`,
417
- });
418
- }
419
- };
420
- refuseLabelsInQuery({ query: ctx.req.query() });
421
- // The early exit, before the body is spooled. Not authoritative: a multipart envelope is
422
- // larger than its files, so the per-file check below is the one that decides.
423
- if (maxBytes !== undefined) {
424
- const declaredLength = Number(ctx.req.header(common_1.HTTP.Headers.CONTENT_LENGTH));
425
- if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
426
- throw (0, core_1.getError)({
427
- error: common_2.StaticAssetErrors.UPLOAD_TOO_LARGE,
428
- message: `Request body of ${declaredLength} bytes exceeds the maximum of ${maxBytes}`,
429
- });
411
+ this.bindRoute({
412
+ configs: {
413
+ ...definitions.UPLOAD,
414
+ ...uploadRoute.override,
415
+ middleware: uploadMiddleware,
416
+ },
417
+ }).to({
418
+ handler: async (ctx) => {
419
+ // A configured bucket drops `params` from this route entirely, so it is read only when it exists.
420
+ const bucketParam = hasConfiguredBucket
421
+ ? undefined
422
+ : ctx.req.valid('param').bucketName;
423
+ const bucketName = await resolveBucket({ configured: bucket, param: bucketParam });
424
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
425
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
430
426
  }
431
- }
432
- const { files: filesArray, fields } = await (0, ignis_helpers_1.parseMultipartBody)({
433
- context: ctx,
434
- storage: options?.parseMultipartBody?.storage,
435
- uploadDir: options?.parseMultipartBody?.uploadDir,
436
- });
437
- // Labels travel with the payload, not the URL, so the folder check waits for the parse.
438
- const query = readUploadLabels({ fields });
439
- const folderPath = query.folderPath;
440
- if (folderPath) {
441
- validateFolderPath(folderPath);
442
- }
443
- const spoolPaths = filesArray
444
- .map(file => file.path)
445
- .filter((filePath) => Boolean(filePath));
446
- let uploaded;
447
- try {
448
- // `storage: 'disk'` spools the payload to `uploadDir` and returns `path` instead of `buffer`; the storage helpers only ever write `buffer`, so it must be read back.
449
- const modifiedFiles = filesArray.map(file => {
450
- const buffer = file.buffer ?? (file.path ? (0, node_fs_1.readFileSync)(file.path) : undefined);
451
- if (!buffer?.length) {
452
- throw (0, core_1.getError)({
453
- error: common_2.StaticAssetErrors.FILE_EMPTY,
454
- message: `Empty file content | name: ${file.originalname}`,
455
- });
427
+ const validateFolderPath = (folderPath) => {
428
+ const normalizedFolder = folderPath.replace(/^\/+|\/+$/g, '');
429
+ if (!normalizedFolder) {
430
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.FOLDER_PATH_INVALID });
456
431
  }
457
- // Authoritative: `buffer.length`, not the client-declared `size`.
458
- if (maxBytes !== undefined && buffer.length > maxBytes) {
432
+ // maxFolderDepth excludes the filename, so it is compared directly against segment count.
433
+ const folderSegments = normalizedFolder.split('/');
434
+ if (folderSegments.length > maxFolderDepth) {
459
435
  throw (0, core_1.getError)({
460
- error: common_2.StaticAssetErrors.UPLOAD_TOO_LARGE,
461
- message: `File ${file.originalname} of ${buffer.length} bytes exceeds the maximum of ${maxBytes}`,
436
+ error: common_2.StaticAssetErrors.FOLDER_DEPTH_EXCEEDED,
437
+ message: `Folder path exceeds max depth of ${maxFolderDepth}`,
462
438
  });
463
439
  }
464
- return {
465
- originalName: file.originalname,
466
- mimetype: file.mimetype,
467
- buffer,
468
- size: file.size,
469
- encoding: file.encoding,
470
- folderPath: folderPath ?? undefined,
471
- };
472
- });
473
- // Built per request: the hook is offered the bucket, which only the route knows. No
474
- // hook leaves `normalizeNameFn` exactly as configured, so the stored name is unchanged.
475
- const normalizeNameFn = resolveObjectName
476
- ? (naming) => {
477
- const defaultKey = options?.normalizeNameFn
478
- ? options.normalizeNameFn(naming)
479
- : defaultObjectName(naming);
480
- return resolveObjectName({
481
- bucket: { name: bucketName },
482
- file: naming.file,
483
- defaultKey,
440
+ const invalidSegmentIndex = folderSegments.findIndex(segment => !helper.isValidSegment({ segment }));
441
+ if (invalidSegmentIndex !== -1) {
442
+ throw (0, core_1.getError)({
443
+ error: common_2.StaticAssetErrors.FOLDER_SEGMENT_INVALID,
444
+ message: `Invalid folder path segment: ${folderSegments[invalidSegmentIndex]}`,
484
445
  });
485
446
  }
486
- : options?.normalizeNameFn;
487
- uploaded = await helper.upload({
488
- bucket: { name: bucketName },
489
- files: modifiedFiles,
490
- normalizeNameFn,
491
- normalizeLinkFn,
492
- // Without this the helper re-validates against its own hard default of 2, so an app configured for a deeper tree spools the body and only then fails inside the helper.
493
- maxFolderDepth,
447
+ };
448
+ refuseLabelsInQuery({ query: ctx.req.query() });
449
+ const { files: filesArray, fields } = await (0, ignis_helpers_1.parseMultipartBody)({
450
+ context: ctx,
451
+ storage: options?.parseMultipartBody?.storage,
452
+ uploadDir: options?.parseMultipartBody?.uploadDir,
494
453
  });
495
- }
496
- finally {
497
- this.removeSpoolFiles({ paths: spoolPaths });
498
- }
499
- if (!useMetaLink || !metaLink) {
500
- return ctx.json(uploaded, common_1.HTTP.ResultCodes.RS_2.Ok);
501
- }
502
- const results = [];
503
- for (const uploadResult of uploaded) {
454
+ // Labels travel with the payload, not the URL, so the folder check waits for the parse.
455
+ const query = readUploadLabels({ fields });
456
+ const folderPath = query.folderPath;
457
+ if (folderPath) {
458
+ validateFolderPath(folderPath);
459
+ }
460
+ const spoolPaths = filesArray
461
+ .map(file => file.path)
462
+ .filter((filePath) => Boolean(filePath));
463
+ let uploaded;
504
464
  try {
505
- const fileStat = await helper.getStat({
506
- bucket: uploadResult.bucket,
507
- object: uploadResult.object,
508
- });
509
- const createdMetaLink = await createMetaLinkRow({
510
- metaLink,
511
- helper,
512
- storage,
513
- uploadResult,
514
- fileStat,
515
- query,
465
+ // `storage: 'disk'` spools the payload to `uploadDir` and returns `path` instead of `buffer`; the storage helpers only ever write `buffer`, so it must be read back.
466
+ const modifiedFiles = filesArray.map(file => {
467
+ const buffer = file.buffer ?? (file.path ? (0, node_fs_1.readFileSync)(file.path) : undefined);
468
+ if (!buffer?.length) {
469
+ throw (0, core_1.getError)({
470
+ error: common_2.StaticAssetErrors.FILE_EMPTY,
471
+ message: `Empty file content | name: ${file.originalname}`,
472
+ });
473
+ }
474
+ // Authoritative: `buffer.length`, not the client-declared `size`.
475
+ if (maxBytes !== undefined && buffer.length > maxBytes) {
476
+ throw (0, core_1.getError)({
477
+ error: common_2.StaticAssetErrors.UPLOAD_TOO_LARGE,
478
+ message: `File ${file.originalname} of ${buffer.length} bytes exceeds the maximum of ${maxBytes}`,
479
+ });
480
+ }
481
+ return {
482
+ originalName: file.originalname,
483
+ mimetype: file.mimetype,
484
+ buffer,
485
+ size: file.size,
486
+ encoding: file.encoding,
487
+ folderPath: folderPath ?? undefined,
488
+ };
516
489
  });
517
- results.push({ ...uploadResult, metaLink: { data: createdMetaLink } });
518
- }
519
- catch (error) {
520
- this.logger
521
- .for('UPLOAD')
522
- .error('Failed to create MetaLink | objectName: %s | Error: %s', uploadResult.object.key, error);
523
- results.push({
524
- ...uploadResult,
525
- // A CODE, never the driver text. This handler returns 200, so it bypasses the
526
- // error middleware and `database.handler` - the two places that strip
527
- // `detail`/`table`/`constraint` - and shipped raw constraint names to the client.
528
- // The real error is already logged in full immediately above.
529
- metaLink: { error: common_2.META_LINK_CREATE_FAILED },
490
+ // Built per request: the hook is offered the bucket, which only the route knows. With
491
+ // neither hook nor scope, `normalizeNameFn` stays exactly as configured. Under a scope,
492
+ // the component's own default is placed inside it; an application hook's key is
493
+ // checked, never rewritten, because the application may record that key elsewhere.
494
+ const normalizeNameFn = resolveObjectName || keyPrefix !== undefined
495
+ ? (naming) => {
496
+ if (isScopedDefaultNaming &&
497
+ !helper.isValidSegment({ segment: naming.file.originalName })) {
498
+ throw (0, core_1.getError)({ message: '[upload] Invalid original file name' });
499
+ }
500
+ const defaultKey = options?.normalizeNameFn
501
+ ? options.normalizeNameFn(naming)
502
+ : `${keyPrefix ?? ''}${defaultObjectName(naming)}`;
503
+ const key = resolveObjectName
504
+ ? resolveObjectName({
505
+ bucket: { name: bucketName },
506
+ file: naming.file,
507
+ defaultKey,
508
+ })
509
+ : defaultKey;
510
+ if (!(0, key_scope_1.isKeyInScope)({ key, keyPrefix })) {
511
+ throw (0, core_1.getError)({
512
+ error: common_2.StaticAssetErrors.OBJECT_KEY_OUT_OF_SCOPE,
513
+ message: `Object key is outside the key prefix of this controller | key: ${key} | keyPrefix: ${keyPrefix}`,
514
+ });
515
+ }
516
+ return key;
517
+ }
518
+ : options?.normalizeNameFn;
519
+ uploaded = await helper.upload({
520
+ bucket: { name: bucketName },
521
+ files: modifiedFiles,
522
+ normalizeNameFn,
523
+ normalizeLinkFn,
524
+ // Without this the helper re-validates against its own hard default of 2, so an app configured for a deeper tree spools the body and only then fails inside the helper.
525
+ maxFolderDepth: objectKeyMaxDepth,
530
526
  });
531
527
  }
532
- }
533
- return ctx.json(results, common_1.HTTP.ResultCodes.RS_2.Ok);
534
- },
535
- });
536
- this.bindRoute({
537
- configs: { ...definitions.DELETE_OBJECT, ...routes?.deleteObject },
538
- }).to({
539
- handler: async (ctx) => {
540
- const params = ctx.req.valid('param');
541
- const bucketName = await resolveBucket({
542
- configured: bucket,
543
- param: params.bucketName,
544
- });
545
- const objectName = readObjectName(params.objectName);
546
- if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
547
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
548
- }
549
- if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
550
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
551
- }
552
- // Deliberately idempotent: S3 answers 200 for a key that was never there, and consumers
553
- // already depend on it. `disk` throws instead, so the two are reconciled here, not below.
554
- try {
555
- await helper.removeObject({
556
- bucket: { name: bucketName },
557
- object: { key: objectName },
558
- });
559
- }
560
- catch (error) {
561
- if (!(0, ignis_helpers_1.isNotFoundError)({ error })) {
562
- throw error;
528
+ finally {
529
+ this.removeSpoolFiles({ paths: spoolPaths });
563
530
  }
564
- }
565
- if (!useMetaLink || !metaLink) {
566
- return ctx.json({ success: true }, common_1.HTTP.ResultCodes.RS_2.Ok);
567
- }
568
- const deleteRepository = await (0, common_1.resolveValueAsync)({ value: metaLink.repository });
569
- deleteRepository
570
- .deleteAll({
571
- where: {
572
- bucketName,
573
- objectName,
574
- },
575
- })
576
- .then(() => {
577
- this.logger
578
- .for('DELETE_OBJECT')
579
- .info('Successfully to delete MetaLink for %s/%s', bucketName, objectName);
580
- })
581
- .catch(error => {
582
- this.logger
583
- .for('DELETE_OBJECT')
584
- .error('Failed to delete MetaLink | bucket: %s | objectName: %s | Error: %s', bucketName, objectName, error);
585
- });
586
- return ctx.json({ success: true }, common_1.HTTP.ResultCodes.RS_2.Ok);
587
- },
588
- });
589
- this.bindRoute({
590
- configs: { ...definitions.LIST_OBJECTS, ...routes?.listObjects },
591
- }).to({
592
- handler: async (ctx) => {
593
- // A configured bucket drops `params` from this route entirely, so it is read only when it exists.
594
- const bucketParam = hasConfiguredBucket
595
- ? undefined
596
- : ctx.req.valid('param').bucketName;
597
- const bucketName = await resolveBucket({ configured: bucket, param: bucketParam });
598
- const { prefix, recursive, maxKeys } = ctx.req.valid('query');
599
- if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
600
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
601
- }
602
- // A NaN or 0 maxKeys is silently treated as "unlimited" by the storage backends, so an unparsable value must be rejected instead of forwarded.
603
- let resolvedMaxKeys;
604
- if (maxKeys !== undefined) {
605
- resolvedMaxKeys = Number(maxKeys);
606
- if (!Number.isInteger(resolvedMaxKeys) || resolvedMaxKeys < 1) {
607
- throw (0, core_1.getError)({
608
- error: common_2.StaticAssetErrors.MAX_KEYS_INVALID,
609
- message: `Invalid maxKeys | Expected a positive integer | value: ${maxKeys}`,
610
- });
531
+ if (!useMetaLink || !metaLink) {
532
+ return ctx.json(uploaded, common_1.HTTP.ResultCodes.RS_2.Ok);
611
533
  }
612
- }
613
- const objects = await helper.listObjects({
614
- bucket: { name: bucketName },
615
- prefix,
616
- useRecursive: recursive === 'true',
617
- maxKeys: resolvedMaxKeys,
618
- });
619
- return ctx.json(objects, common_1.HTTP.ResultCodes.RS_2.Ok);
620
- },
621
- });
622
- if (directUpload) {
623
- if (!hasConfiguredBucket) {
624
- throw (0, core_1.getError)({
625
- message: `[${name}] directUpload needs controller.bucket | a policy names one bucket, and a bucket in the URL is a bucket the caller chooses`,
626
- });
627
- }
628
- const pendingPrefix = directUpload.pendingPrefix ?? common_2.DEFAULT_PENDING_PREFIX;
629
- this.bindRoute({
630
- configs: { ...definitions.UPLOAD_POLICY, ...routes?.uploadPolicy },
631
- }).to({
632
- handler: async (ctx) => {
633
- if (!(await directUpload.authorize({ context: ctx }))) {
634
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.UPLOAD_NOT_AUTHORIZED });
635
- }
636
- const { files } = ctx.req.valid('json');
637
- const bucketName = await resolveBucket({ configured: bucket });
638
- const policies = [];
639
- for (const file of files) {
640
- if (file.size !== undefined && file.size > directUpload.maxBytes) {
641
- throw (0, core_1.getError)({
642
- error: common_2.StaticAssetErrors.UPLOAD_TOO_LARGE,
643
- message: `File '${file.fileName}' is ${file.size} bytes, over the ${directUpload.maxBytes} the policy allows`,
644
- });
645
- }
646
- // Generated, never taken from the caller: a key the caller picks is a key the caller
647
- // can collide with, and the pending object is the one thing a policy authorizes.
648
- const objectName = `${pendingPrefix}${core_1.UuidHelper.getInstance().v4()}/${file.fileName}`;
649
- const policy = await helper.presignPost({
650
- bucket: { name: bucketName },
651
- keyPrefix: pendingPrefix,
652
- maxBytes: directUpload.maxBytes,
653
- contentType: file.contentType,
654
- expiresIn: directUpload.expiresIn,
655
- });
656
- policies.push({
657
- ...policy,
658
- objectName,
659
- commitToken: (0, common_3.buildCommitToken)({
660
- payload: {
661
- bucket: bucketName,
662
- key: objectName,
663
- expiresAt: new Date(policy.expiresAt).getTime(),
664
- },
665
- secretKey: directUpload.secretKey,
666
- }),
667
- });
668
- }
669
- return ctx.json(policies, common_1.HTTP.ResultCodes.RS_2.Ok);
670
- },
671
- });
672
- this.bindRoute({
673
- configs: { ...definitions.UPLOAD_COMMIT, ...routes?.uploadCommit },
674
- }).to({
675
- handler: async (ctx) => {
676
- const commitQuery = ctx.req.query();
677
- refuseLabelsInQuery({ query: commitQuery, names: COMMIT_LABEL_NAMES });
678
- refuseLabelsInQuery({
679
- query: commitQuery,
680
- names: ['folderPath'],
681
- reason: 'folderPath was decided when the upload policy was issued; the commit route cannot move the object',
682
- });
683
- const { commitToken, ...labels } = ctx.req.valid('json');
684
- // Verified BEFORE any storage call. That is what keeps this route from being a name
685
- // prober: a forged token fails without a round trip, so timing says nothing.
686
- const payload = (0, common_3.readCommitToken)({
687
- token: commitToken,
688
- secretKey: directUpload.secretKey,
689
- });
690
- // Labels, not authorization - the token deliberately carries no business fields, so
691
- // they travel beside it rather than inside it.
692
- const query = readUploadLabels({ fields: labels });
693
- const bucketRef = { name: payload.bucket };
694
- const pendingObject = { key: payload.key };
695
- const object = { key: payload.key.slice(pendingPrefix.length) };
696
- // Before the copy, so a hook that throws leaves the object under the pending prefix
697
- // for a lifecycle rule, rather than stranding it at the final key.
698
- await directUpload.onCommit?.({ bucket: bucketRef, pendingObject, object });
699
- await helper.copyObject({
700
- bucket: bucketRef,
701
- source: pendingObject,
702
- destination: object,
703
- });
704
- // The copy IS the commit; removing the temporary object is cleanup. A failure here
705
- // leaves one object for the lifecycle rule, and failing the request would tell the
706
- // caller their upload did not work when it did.
707
- await helper
708
- .removeObject({ bucket: bucketRef, object: pendingObject })
709
- .catch(error => {
710
- this.logger
711
- .for('UPLOAD_COMMIT')
712
- .warn('Committed, but the pending object remains | key: %s | error: %s', pendingObject.key, error);
713
- });
714
- const link = (0, exports.buildObjectLink)({
715
- basePath: normalizedBasePath,
716
- bucket: bucketRef,
717
- object,
718
- hasConfiguredBucket,
719
- rawObjectPath,
720
- });
721
- // The two paths have to agree: without this a client reading `metaLink` off the
722
- // response renders an empty state with no error - the failure nobody reports.
723
- let metaLinkResult;
724
- if (useMetaLink && metaLink) {
534
+ const results = [];
535
+ for (const uploadResult of uploaded) {
725
536
  try {
726
- const fileStat = await helper.getStat({ bucket: bucketRef, object });
727
- const data = await createMetaLinkRow({
537
+ const fileStat = await helper.getStat({
538
+ bucket: uploadResult.bucket,
539
+ object: uploadResult.object,
540
+ });
541
+ const createdMetaLink = await (0, meta_link_1.createMetaLinkRow)({
728
542
  metaLink,
729
543
  helper,
730
544
  storage,
731
- uploadResult: {
732
- bucket: bucketRef,
733
- object: {
734
- key: object.key,
735
- size: fileStat.size,
736
- contentType: fileStat.metadata?.mimetype ??
737
- helper.getMimeType({ filename: object.key }),
738
- },
739
- link,
740
- },
545
+ uploadResult,
741
546
  fileStat,
742
547
  query,
743
548
  });
744
- metaLinkResult = { data };
549
+ results.push({ ...uploadResult, metaLink: { data: createdMetaLink } });
745
550
  }
746
551
  catch (error) {
747
- // The object IS committed by now. A 500 would send the caller back with a token
748
- // whose source no longer exists, so this rides the body as a code, like UPLOAD.
749
552
  this.logger
750
- .for('UPLOAD_COMMIT')
751
- .error('Failed to create MetaLink | objectName: %s | Error: %s', object.key, error);
752
- metaLinkResult = { error: common_2.META_LINK_CREATE_FAILED };
553
+ .for('UPLOAD')
554
+ .error('Failed to create MetaLink | objectName: %s | Error: %s', uploadResult.object.key, error);
555
+ results.push({
556
+ ...uploadResult,
557
+ // A CODE, never the driver text. This handler returns 200, so it bypasses the
558
+ // error middleware and `database.handler` - the two places that strip
559
+ // `detail`/`table`/`constraint` - and shipped raw constraint names to the client.
560
+ // The real error is already logged in full immediately above.
561
+ metaLink: { error: common_2.META_LINK_CREATE_FAILED },
562
+ });
753
563
  }
754
564
  }
755
- return ctx.json({
756
- bucket: bucketRef,
757
- object,
758
- link,
759
- ...(metaLinkResult === undefined ? {} : { metaLink: metaLinkResult }),
760
- }, common_1.HTTP.ResultCodes.RS_2.Ok);
565
+ return ctx.json(results, common_1.HTTP.ResultCodes.RS_2.Ok);
761
566
  },
762
567
  });
763
568
  }
764
- if (useMetaLink && metaLink) {
569
+ const deleteObjectRoute = (0, route_options_1.readRouteOverride)({ route: routes?.deleteObject });
570
+ if (deleteObjectRoute.isEnabled) {
765
571
  this.bindRoute({
766
- configs: { ...definitions.RECREATE_METALINK, ...routes?.recreateMetaLink },
572
+ configs: { ...definitions.DELETE_OBJECT, ...deleteObjectRoute.override },
767
573
  }).to({
768
574
  handler: async (ctx) => {
769
- // This route takes no labels anywhere: one in the query would land an unlabelled row.
770
- refuseLabelsInQuery({
771
- query: ctx.req.query(),
772
- reason: 'This route recreates a MetaLink for an existing object and takes no labels',
773
- });
774
575
  const params = ctx.req.valid('param');
775
576
  const bucketName = await resolveBucket({
776
577
  configured: bucket,
@@ -780,72 +581,326 @@ class AssetControllerFactory extends core_1.BaseHelper {
780
581
  if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
781
582
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
782
583
  }
783
- if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
584
+ if (!helper.isValidObjectKey({
585
+ object: { key: objectName },
586
+ maxDepth: objectKeyMaxDepth,
587
+ })) {
784
588
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
785
589
  }
786
- const fileStat = await helper.getStat({
787
- bucket: { name: bucketName },
788
- object: { key: objectName },
789
- });
790
- const link = options?.normalizeLinkFn
791
- ? options.normalizeLinkFn({
792
- bucket: { name: bucketName },
793
- object: { key: objectName },
794
- })
795
- : (0, exports.buildObjectLink)({
796
- basePath: normalizedBasePath,
590
+ // Before any storage call: a key outside the scope must not even be probed.
591
+ (0, key_scope_1.assertKeyInScope)({ key: objectName, keyPrefix });
592
+ // Deliberately idempotent: S3 answers 200 for a key that was never there, and consumers
593
+ // already depend on it. `disk` throws instead, so the two are reconciled here, not below.
594
+ try {
595
+ await helper.removeObject({
797
596
  bucket: { name: bucketName },
798
597
  object: { key: objectName },
799
- hasConfiguredBucket,
800
- rawObjectPath,
801
- });
802
- // Read per call, never once per handler: an application may rebind the repository
803
- // between two of these, and a copy held across them would answer from the old binding.
804
- const findRepository = await (0, common_1.resolveValueAsync)({ value: metaLink.repository });
805
- const existing = await findRepository.findOne({
806
- filter: {
807
- where: {
808
- bucketName,
809
- objectName,
810
- },
811
- },
812
- });
813
- if (existing) {
814
- const updateRepository = await (0, common_1.resolveValueAsync)({ value: metaLink.repository });
815
- await updateRepository.updateById({
816
- id: existing.id,
817
- data: {
818
- link,
819
- mimetype: fileStat.metadata?.['mimetype'],
820
- size: fileStat.size,
821
- etag: fileStat.etag,
822
- metadata: fileStat.metadata,
823
- storageType: storage,
824
- isSynced: true,
825
- },
826
598
  });
827
- const readRepository = await (0, common_1.resolveValueAsync)({ value: metaLink.repository });
828
- const updatedMetaLink = await readRepository.findById({ id: existing.id });
829
- return ctx.json({ success: true, metaLink: updatedMetaLink }, common_1.HTTP.ResultCodes.RS_2.Ok);
830
599
  }
831
- const createRepository = await (0, common_1.resolveValueAsync)({ value: metaLink.repository });
832
- const createdMetaLink = await createRepository.create({
833
- data: {
600
+ catch (error) {
601
+ if (!(0, ignis_helpers_1.isNotFoundError)({ error })) {
602
+ throw error;
603
+ }
604
+ }
605
+ if (!useMetaLink || !metaLink) {
606
+ return ctx.json({ success: true }, common_1.HTTP.ResultCodes.RS_2.Ok);
607
+ }
608
+ const deleteRepository = await (0, common_1.resolveValueAsync)({ value: metaLink.repository });
609
+ deleteRepository
610
+ .deleteAll({
611
+ where: {
834
612
  bucketName,
835
613
  objectName,
836
- link,
837
- mimetype: fileStat.metadata?.mimetype ?? helper.getMimeType({ filename: objectName }),
838
- size: fileStat.size,
839
- etag: fileStat.etag,
840
- metadata: fileStat.metadata,
841
- storageType: storage,
842
- isSynced: true,
843
614
  },
615
+ })
616
+ .then(() => {
617
+ this.logger
618
+ .for('DELETE_OBJECT')
619
+ .info('Successfully to delete MetaLink for %s/%s', bucketName, objectName);
620
+ })
621
+ .catch(error => {
622
+ this.logger
623
+ .for('DELETE_OBJECT')
624
+ .error('Failed to delete MetaLink | bucket: %s | objectName: %s | Error: %s', bucketName, objectName, error);
625
+ });
626
+ return ctx.json({ success: true }, common_1.HTTP.ResultCodes.RS_2.Ok);
627
+ },
628
+ });
629
+ }
630
+ const listObjectsRoute = (0, route_options_1.readRouteOverride)({ route: routes?.listObjects });
631
+ if (listObjectsRoute.isEnabled) {
632
+ this.bindRoute({
633
+ configs: { ...definitions.LIST_OBJECTS, ...listObjectsRoute.override },
634
+ }).to({
635
+ handler: async (ctx) => {
636
+ // A configured bucket drops `params` from this route entirely, so it is read only when it exists.
637
+ const bucketParam = hasConfiguredBucket
638
+ ? undefined
639
+ : ctx.req.valid('param').bucketName;
640
+ const bucketName = await resolveBucket({ configured: bucket, param: bucketParam });
641
+ const { prefix, recursive, maxKeys } = ctx.req.valid('query');
642
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
643
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
644
+ }
645
+ // A NaN or 0 maxKeys is silently treated as "unlimited" by the storage backends, so an unparsable value must be rejected instead of forwarded.
646
+ let resolvedMaxKeys;
647
+ if (maxKeys !== undefined) {
648
+ resolvedMaxKeys = Number(maxKeys);
649
+ if (!Number.isInteger(resolvedMaxKeys) || resolvedMaxKeys < 1) {
650
+ throw (0, core_1.getError)({
651
+ error: common_2.StaticAssetErrors.MAX_KEYS_INVALID,
652
+ message: `Invalid maxKeys | Expected a positive integer | value: ${maxKeys}`,
653
+ });
654
+ }
655
+ }
656
+ const scoped = (0, key_scope_1.resolveScopedListPrefix)({ prefix, keyPrefix });
657
+ if (scoped.isOutOfScope) {
658
+ return ctx.json([], common_1.HTTP.ResultCodes.RS_2.Ok);
659
+ }
660
+ const objects = await helper.listObjects({
661
+ bucket: { name: bucketName },
662
+ prefix: scoped.prefix,
663
+ useRecursive: recursive === 'true',
664
+ maxKeys: resolvedMaxKeys,
844
665
  });
845
- return ctx.json({ success: true, metaLink: createdMetaLink.data }, common_1.HTTP.ResultCodes.RS_2.Ok);
666
+ return ctx.json(objects, common_1.HTTP.ResultCodes.RS_2.Ok);
846
667
  },
847
668
  });
848
669
  }
670
+ if (directUpload) {
671
+ if (!hasConfiguredBucket) {
672
+ throw (0, core_1.getError)({
673
+ message: `[${name}] directUpload needs controller.bucket | a policy names one bucket, and a bucket in the URL is a bucket the caller chooses`,
674
+ });
675
+ }
676
+ const pendingPrefix = directUpload.pendingPrefix ?? common_2.DEFAULT_PENDING_PREFIX;
677
+ // The scope rides inside the pending key, so the commit, which strips `pendingPrefix`, lands inside it.
678
+ const policyKeyPrefix = `${pendingPrefix}${keyPrefix ?? ''}`;
679
+ const uploadPolicyRoute = (0, route_options_1.readRouteOverride)({ route: routes?.uploadPolicy });
680
+ if (uploadPolicyRoute.isEnabled) {
681
+ this.bindRoute({
682
+ configs: { ...definitions.UPLOAD_POLICY, ...uploadPolicyRoute.override },
683
+ }).to({
684
+ handler: async (ctx) => {
685
+ if (!(await directUpload.authorize({ context: ctx }))) {
686
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.UPLOAD_NOT_AUTHORIZED });
687
+ }
688
+ const { files } = ctx.req.valid('json');
689
+ const bucketName = await resolveBucket({ configured: bucket });
690
+ const policies = [];
691
+ for (const file of files) {
692
+ if (file.size !== undefined && file.size > directUpload.maxBytes) {
693
+ throw (0, core_1.getError)({
694
+ error: common_2.StaticAssetErrors.UPLOAD_TOO_LARGE,
695
+ message: `File '${file.fileName}' is ${file.size} bytes, over the ${directUpload.maxBytes} the policy allows`,
696
+ });
697
+ }
698
+ // Generated, never taken from the caller: a key the caller picks is a key the caller
699
+ // can collide with, and the pending object is the one thing a policy authorizes.
700
+ const objectName = `${policyKeyPrefix}${core_1.UuidHelper.getInstance().v4()}/${file.fileName}`;
701
+ const policy = await helper.presignPost({
702
+ bucket: { name: bucketName },
703
+ keyPrefix: policyKeyPrefix,
704
+ maxBytes: directUpload.maxBytes,
705
+ contentType: file.contentType,
706
+ expiresIn: directUpload.expiresIn,
707
+ });
708
+ policies.push({
709
+ ...policy,
710
+ objectName,
711
+ commitToken: (0, common_3.buildCommitToken)({
712
+ payload: {
713
+ bucket: bucketName,
714
+ key: objectName,
715
+ expiresAt: new Date(policy.expiresAt).getTime(),
716
+ },
717
+ secretKey: directUpload.secretKey,
718
+ }),
719
+ });
720
+ }
721
+ return ctx.json(policies, common_1.HTTP.ResultCodes.RS_2.Ok);
722
+ },
723
+ });
724
+ }
725
+ const uploadCommitRoute = (0, route_options_1.readRouteOverride)({ route: routes?.uploadCommit });
726
+ if (uploadCommitRoute.isEnabled) {
727
+ this.bindRoute({
728
+ configs: { ...definitions.UPLOAD_COMMIT, ...uploadCommitRoute.override },
729
+ }).to({
730
+ handler: async (ctx) => {
731
+ const commitQuery = ctx.req.query();
732
+ refuseLabelsInQuery({ query: commitQuery, names: COMMIT_LABEL_NAMES });
733
+ refuseLabelsInQuery({
734
+ query: commitQuery,
735
+ names: ['folderPath'],
736
+ reason: 'folderPath was decided when the upload policy was issued; the commit route cannot move the object',
737
+ });
738
+ const { commitToken, ...labels } = ctx.req.valid('json');
739
+ // Verified BEFORE any storage call. That is what keeps this route from being a name
740
+ // prober: a forged token fails without a round trip, so timing says nothing.
741
+ const payload = (0, common_3.readCommitToken)({
742
+ token: commitToken,
743
+ secretKey: directUpload.secretKey,
744
+ });
745
+ // Labels, not authorization - the token deliberately carries no business fields, so
746
+ // they travel beside it rather than inside it.
747
+ const query = readUploadLabels({ fields: labels });
748
+ const bucketRef = { name: payload.bucket };
749
+ const pendingObject = { key: payload.key };
750
+ const object = { key: payload.key.slice(pendingPrefix.length) };
751
+ // A token another controller signed with the same secret must not land outside this scope.
752
+ (0, key_scope_1.assertKeyInScope)({ key: object.key, keyPrefix });
753
+ // Before the copy, so a hook that throws leaves the object under the pending prefix
754
+ // for a lifecycle rule, rather than stranding it at the final key.
755
+ await directUpload.onCommit?.({ bucket: bucketRef, pendingObject, object });
756
+ await helper.copyObject({
757
+ bucket: bucketRef,
758
+ source: pendingObject,
759
+ destination: object,
760
+ });
761
+ // The copy IS the commit; removing the temporary object is cleanup. A failure here
762
+ // leaves one object for the lifecycle rule, and failing the request would tell the
763
+ // caller their upload did not work when it did.
764
+ await helper
765
+ .removeObject({ bucket: bucketRef, object: pendingObject })
766
+ .catch(error => {
767
+ this.logger
768
+ .for('UPLOAD_COMMIT')
769
+ .warn('Committed, but the pending object remains | key: %s | error: %s', pendingObject.key, error);
770
+ });
771
+ const link = (0, exports.buildObjectLink)({
772
+ basePath: normalizedBasePath,
773
+ bucket: bucketRef,
774
+ object,
775
+ hasConfiguredBucket,
776
+ rawObjectPath,
777
+ });
778
+ // The two paths have to agree: without this a client reading `metaLink` off the
779
+ // response renders an empty state with no error - the failure nobody reports.
780
+ let metaLinkResult;
781
+ if (useMetaLink && metaLink) {
782
+ try {
783
+ const fileStat = await helper.getStat({ bucket: bucketRef, object });
784
+ const data = await (0, meta_link_1.createMetaLinkRow)({
785
+ metaLink,
786
+ helper,
787
+ storage,
788
+ uploadResult: (0, meta_link_1.buildStoredUploadResult)({
789
+ helper,
790
+ bucket: bucketRef,
791
+ object,
792
+ link,
793
+ fileStat,
794
+ }),
795
+ fileStat,
796
+ query,
797
+ });
798
+ metaLinkResult = { data };
799
+ }
800
+ catch (error) {
801
+ // The object IS committed by now. A 500 would send the caller back with a token
802
+ // whose source no longer exists, so this rides the body as a code, like UPLOAD.
803
+ this.logger
804
+ .for('UPLOAD_COMMIT')
805
+ .error('Failed to create MetaLink | objectName: %s | Error: %s', object.key, error);
806
+ metaLinkResult = { error: common_2.META_LINK_CREATE_FAILED };
807
+ }
808
+ }
809
+ return ctx.json({
810
+ bucket: bucketRef,
811
+ object,
812
+ link,
813
+ ...(metaLinkResult === undefined ? {} : { metaLink: metaLinkResult }),
814
+ }, common_1.HTTP.ResultCodes.RS_2.Ok);
815
+ },
816
+ });
817
+ }
818
+ }
819
+ if (useMetaLink && metaLink) {
820
+ const recreateMetaLinkRoute = (0, route_options_1.readRouteOverride)({ route: routes?.recreateMetaLink });
821
+ if (recreateMetaLinkRoute.isEnabled) {
822
+ this.bindRoute({
823
+ configs: { ...definitions.RECREATE_METALINK, ...recreateMetaLinkRoute.override },
824
+ }).to({
825
+ handler: async (ctx) => {
826
+ // This route takes no labels anywhere: one in the query would land an unlabelled row.
827
+ refuseLabelsInQuery({
828
+ query: ctx.req.query(),
829
+ reason: 'This route recreates a MetaLink for an existing object and takes no labels',
830
+ });
831
+ const params = ctx.req.valid('param');
832
+ const bucketName = await resolveBucket({
833
+ configured: bucket,
834
+ param: params.bucketName,
835
+ });
836
+ const objectName = readObjectName(params.objectName);
837
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
838
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
839
+ }
840
+ if (!helper.isValidObjectKey({
841
+ object: { key: objectName },
842
+ maxDepth: objectKeyMaxDepth,
843
+ })) {
844
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
845
+ }
846
+ // Before any storage call: a key outside the scope must not even be probed.
847
+ (0, key_scope_1.assertKeyInScope)({ key: objectName, keyPrefix });
848
+ const bucketRef = { name: bucketName };
849
+ const object = { key: objectName };
850
+ const fileStat = await helper.getStat({ bucket: bucketRef, object });
851
+ const link = options?.normalizeLinkFn
852
+ ? options.normalizeLinkFn({ bucket: bucketRef, object })
853
+ : (0, exports.buildObjectLink)({
854
+ basePath: normalizedBasePath,
855
+ bucket: bucketRef,
856
+ object,
857
+ hasConfiguredBucket,
858
+ rawObjectPath,
859
+ });
860
+ const refreshed = await (0, meta_link_1.refreshMetaLinkRows)({
861
+ metaLink,
862
+ helper,
863
+ bucket: bucketRef,
864
+ object,
865
+ link,
866
+ fileStat,
867
+ });
868
+ if (refreshed.count > 0) {
869
+ return ctx.json({
870
+ success: true,
871
+ action: common_3.MetaLinkRecreateActions.REFRESHED,
872
+ count: refreshed.count,
873
+ metaLink: refreshed.data[0],
874
+ metaLinks: refreshed.data,
875
+ }, common_1.HTTP.ResultCodes.RS_2.Ok);
876
+ }
877
+ // Not atomic with the refresh above: two concurrent calls may both create, which the
878
+ // table allows - rows per pair are deliberately not unique.
879
+ const created = await (0, meta_link_1.createMetaLinkRow)({
880
+ metaLink,
881
+ helper,
882
+ storage,
883
+ uploadResult: (0, meta_link_1.buildStoredUploadResult)({
884
+ helper,
885
+ bucket: bucketRef,
886
+ object,
887
+ link,
888
+ fileStat,
889
+ }),
890
+ fileStat,
891
+ query: {},
892
+ });
893
+ return ctx.json({
894
+ success: true,
895
+ action: common_3.MetaLinkRecreateActions.CREATED,
896
+ count: 1,
897
+ metaLink: created,
898
+ metaLinks: [created],
899
+ }, common_1.HTTP.ResultCodes.RS_2.Ok);
900
+ },
901
+ });
902
+ }
903
+ }
849
904
  // Last, so a built-in route always wins a path collision with an application's own.
850
905
  defineExtraRoutes?.({ controller: this, helper, basePath: normalizedBasePath });
851
906
  }