@venizia/ignis 0.2.0-22 → 0.2.0-24

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 (30) hide show
  1. package/dist/components/api-reference/ui/factory.d.ts +1 -1
  2. package/dist/components/api-reference/ui/factory.d.ts.map +1 -1
  3. package/dist/components/api-reference/ui/factory.js +5 -4
  4. package/dist/components/api-reference/ui/factory.js.map +1 -1
  5. package/dist/components/static-asset/common/constants.d.ts +9 -1
  6. package/dist/components/static-asset/common/constants.d.ts.map +1 -1
  7. package/dist/components/static-asset/common/constants.js +25 -2
  8. package/dist/components/static-asset/common/constants.js.map +1 -1
  9. package/dist/components/static-asset/common/types.d.ts +14 -11
  10. package/dist/components/static-asset/common/types.d.ts.map +1 -1
  11. package/dist/components/static-asset/component.d.ts.map +1 -1
  12. package/dist/components/static-asset/component.js +10 -6
  13. package/dist/components/static-asset/component.js.map +1 -1
  14. package/dist/components/static-asset/controller/base.definition.d.ts +25 -554
  15. package/dist/components/static-asset/controller/base.definition.d.ts.map +1 -1
  16. package/dist/components/static-asset/controller/base.definition.js +205 -291
  17. package/dist/components/static-asset/controller/base.definition.js.map +1 -1
  18. package/dist/components/static-asset/controller/factory.d.ts +22 -2
  19. package/dist/components/static-asset/controller/factory.d.ts.map +1 -1
  20. package/dist/components/static-asset/controller/factory.js +265 -117
  21. package/dist/components/static-asset/controller/factory.js.map +1 -1
  22. package/dist/components/static-asset/index.d.ts +1 -0
  23. package/dist/components/static-asset/index.d.ts.map +1 -1
  24. package/dist/components/static-asset/index.js +1 -0
  25. package/dist/components/static-asset/index.js.map +1 -1
  26. package/dist/components/static-asset/ingest.d.ts +58 -0
  27. package/dist/components/static-asset/ingest.d.ts.map +1 -0
  28. package/dist/components/static-asset/ingest.js +82 -0
  29. package/dist/components/static-asset/ingest.js.map +1 -0
  30. package/package.json +4 -4
@@ -9,12 +9,12 @@ var __metadata = (this && this.__metadata) || function (k, v) {
9
9
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.AssetControllerFactory = void 0;
12
+ exports.AssetControllerFactory = exports.resolveServedContentType = exports.buildObjectLink = void 0;
13
13
  const controllers_1 = require("../../../base/controllers");
14
14
  const metadata_1 = require("../../../base/metadata");
15
- const core_1 = require("@venizia/ignis-helpers/core");
16
- const common_1 = require("@venizia/ignis-helpers/common");
17
15
  const ignis_helpers_1 = require("@venizia/ignis-helpers");
16
+ const common_1 = require("@venizia/ignis-helpers/common");
17
+ const core_1 = require("@venizia/ignis-helpers/core");
18
18
  const node_fs_1 = require("node:fs");
19
19
  const common_2 = require("../common");
20
20
  const base_definition_1 = require("./base.definition");
@@ -24,15 +24,77 @@ const readObjectName = rawObjectName => rawObjectName;
24
24
  const encodeObjectPath = objectPath => {
25
25
  return encodeURIComponent(objectPath);
26
26
  };
27
+ /** The bucket one request works on: a configured value wins - called on every request, so an application may read an environment variable lazily - else the path param, which is only registered when there is no configured bucket. */
28
+ const resolveBucket = (opts) => {
29
+ const { configured, param } = opts;
30
+ if (typeof configured === 'function') {
31
+ return configured();
32
+ }
33
+ return configured ?? param ?? '';
34
+ };
35
+ /** The URL of one object, in the shape its controller serves: no `/buckets/<name>` with a configured bucket, and a raw path with `rawObjectPath`. */
36
+ const buildObjectLink = (opts) => {
37
+ const { basePath, bucket, object } = opts;
38
+ const { hasConfiguredBucket = false, rawObjectPath = false } = opts;
39
+ const bucketSegment = hasConfiguredBucket ? '' : `/buckets/${bucket.name}`;
40
+ const objectSegment = rawObjectPath ? object.key : encodeObjectPath(object.key);
41
+ return `${basePath}${bucketSegment}/objects/${objectSegment}`;
42
+ };
43
+ exports.buildObjectLink = buildObjectLink;
27
44
  /** Mirrors `BaseStorageHelper.normalizeObjectName`, which is protected: `resolveObjectName` is offered the very name the helper would otherwise have written. */
28
- const defaultObjectName = ({ originalName, folderPath, }) => {
45
+ const defaultObjectName = ({ file }) => {
46
+ const { originalName, folderPath } = file;
29
47
  const normalizedFileName = originalName.toLowerCase().replace(/ /g, '_');
30
48
  if (!folderPath) {
31
49
  return normalizedFileName;
32
50
  }
33
51
  return `${folderPath.toLowerCase().replace(/ /g, '_')}/${normalizedFileName}`;
34
52
  };
35
- /** Sets whitelisted metadata headers on the response context. */
53
+ /**
54
+ * The served type comes from the object NAME, never the backend or the uploader: a renderable type on
55
+ * the API origin is stored XSS. Exported so a hand-written route makes the same decision - the
56
+ * backend's own content-type is dropped by `WHITELIST_HEADERS`.
57
+ */
58
+ const resolveServedContentType = (opts) => {
59
+ const { helper, object } = opts;
60
+ const candidate = helper.getMimeType({ filename: object.key }).toLowerCase().split(';')[0].trim();
61
+ if (!common_2.RENDERABLE_CONTENT_TYPES.has(candidate)) {
62
+ return { contentType: common_1.HTTP.HeaderValues.APPLICATION_OCTET_STREAM, isRenderable: false };
63
+ }
64
+ return { contentType: candidate, isRenderable: true };
65
+ };
66
+ exports.resolveServedContentType = resolveServedContentType;
67
+ /**
68
+ * Reads one `Range` header. Only a single byte range is honoured - multipart ranges need a multipart
69
+ * body no browser asks for here. An unsatisfiable or malformed range returns `null`, and the caller
70
+ * serves the whole object, which is what RFC 9110 allows.
71
+ */
72
+ const readByteRange = (opts) => {
73
+ const { header, size } = opts;
74
+ if (!header || size <= 0) {
75
+ return null;
76
+ }
77
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
78
+ if (!match) {
79
+ return null;
80
+ }
81
+ const [, rawStart, rawEnd] = match;
82
+ // `bytes=-500` means the LAST 500 bytes, not "from 0 to 500".
83
+ if (!rawStart) {
84
+ const suffixLength = Number(rawEnd);
85
+ if (!Number.isInteger(suffixLength) || suffixLength <= 0) {
86
+ return null;
87
+ }
88
+ return { start: Math.max(size - suffixLength, 0), end: size - 1 };
89
+ }
90
+ const start = Number(rawStart);
91
+ const end = rawEnd ? Number(rawEnd) : size - 1;
92
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start > end || start >= size) {
93
+ return null;
94
+ }
95
+ return { start, end: Math.min(end, size - 1) };
96
+ };
97
+ /** Sets whitelisted metadata headers on the response context. `content-type` is not among them. */
36
98
  const applyMetadataHeaders = ({ ctx, metadata }) => {
37
99
  Object.entries(metadata).forEach(([key, value]) => {
38
100
  if (!common_2.WHITELIST_HEADERS.includes(key.toLowerCase())) {
@@ -47,11 +109,24 @@ class AssetControllerFactory extends core_1.BaseHelper {
47
109
  }
48
110
  static defineAssetController(opts) {
49
111
  const { controller, helper, options, useMetaLink, metaLink, storage } = opts;
50
- const { resolveObjectName, defineExtraRoutes } = opts;
51
- const { name, basePath, routes, isStrict = true } = controller;
112
+ const { resolveObjectName, defineRoutesBefore, defineExtraRoutes } = opts;
113
+ const { name, basePath, routes, isStrict = true, bucket, rawObjectPath = false } = controller;
52
114
  const maxFolderDepth = options?.maxFolderDepth ?? ignis_helpers_1.BaseStorageHelper.DEFAULT_MAX_FOLDER_DEPTH;
115
+ const hasConfiguredBucket = bucket !== undefined;
116
+ const definitions = (0, base_definition_1.buildAssetDefinitions)({ hasConfiguredBucket, rawObjectPath });
53
117
  // The mount path may be declared with or without a leading slash; a link must carry exactly one.
54
118
  const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}`;
119
+ /** Only when the URL shape deviates: with neither option the storage helper's own link is left untouched. */
120
+ const shapedLinkFn = hasConfiguredBucket || rawObjectPath
121
+ ? (linkOptions) => (0, exports.buildObjectLink)({
122
+ basePath: normalizedBasePath,
123
+ bucket: linkOptions.bucket,
124
+ object: linkOptions.object,
125
+ hasConfiguredBucket,
126
+ rawObjectPath,
127
+ })
128
+ : undefined;
129
+ const normalizeLinkFn = options?.normalizeLinkFn ?? shapedLinkFn;
55
130
  let GeneratedStaticAssetController = class GeneratedStaticAssetController extends controllers_1.BaseRestController {
56
131
  constructor() {
57
132
  super({
@@ -74,79 +149,146 @@ class AssetControllerFactory extends core_1.BaseHelper {
74
149
  }
75
150
  }
76
151
  binding() {
152
+ // First, so a literal path can win over the built-in catch-all `rawObjectPath` registers.
153
+ defineRoutesBefore?.({ controller: this, helper, basePath: normalizedBasePath });
154
+ // A single-bucket application has no reason to expose bucket creation or deletion, so the
155
+ // four bucket-management routes are not registered once `bucket` is configured.
156
+ if (!hasConfiguredBucket) {
157
+ this.bindRoute({
158
+ configs: { ...definitions.GET_BUCKETS, ...routes?.getBuckets },
159
+ }).to({
160
+ handler: async (ctx) => {
161
+ const buckets = await helper.getBuckets();
162
+ return ctx.json(buckets, common_1.HTTP.ResultCodes.RS_2.Ok);
163
+ },
164
+ });
165
+ this.bindRoute({
166
+ configs: { ...definitions.GET_BUCKET_BY_NAME, ...routes?.getBucketByName },
167
+ }).to({
168
+ handler: async (ctx) => {
169
+ const params = ctx.req.valid('param');
170
+ const bucketName = resolveBucket({ configured: bucket, param: params.bucketName });
171
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
172
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
173
+ }
174
+ const found = await helper.getBucket({ bucket: { name: bucketName } });
175
+ return ctx.json(found, common_1.HTTP.ResultCodes.RS_2.Ok);
176
+ },
177
+ });
178
+ this.bindRoute({
179
+ configs: { ...definitions.CREATE_BUCKET, ...routes?.createBucket },
180
+ }).to({
181
+ handler: async (ctx) => {
182
+ const params = ctx.req.valid('param');
183
+ const bucketName = resolveBucket({ configured: bucket, param: params.bucketName });
184
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
185
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
186
+ }
187
+ const createdBucket = await helper.createBucket({ bucket: { name: bucketName } });
188
+ return ctx.json(createdBucket, common_1.HTTP.ResultCodes.RS_2.Ok);
189
+ },
190
+ });
191
+ this.bindRoute({
192
+ configs: { ...definitions.DELETE_BUCKET, ...routes?.deleteBucket },
193
+ }).to({
194
+ handler: async (ctx) => {
195
+ const params = ctx.req.valid('param');
196
+ const bucketName = resolveBucket({ configured: bucket, param: params.bucketName });
197
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
198
+ throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
199
+ }
200
+ const isRemovedBucket = await helper.removeBucket({
201
+ bucket: { name: bucketName },
202
+ });
203
+ return ctx.json({ isDeleted: isRemovedBucket }, common_1.HTTP.ResultCodes.RS_2.Ok);
204
+ },
205
+ });
206
+ }
77
207
  this.bindRoute({
78
- configs: { ...base_definition_1.StaticAssetDefinitions.GET_BUCKETS, ...routes?.getBuckets },
79
- }).to({
80
- handler: async (ctx) => {
81
- const bucket = await helper.getBuckets();
82
- return ctx.json(bucket, common_1.HTTP.ResultCodes.RS_2.Ok);
83
- },
84
- });
85
- this.bindRoute({
86
- configs: { ...base_definition_1.StaticAssetDefinitions.GET_BUCKET_BY_NAME, ...routes?.getBucketByName },
87
- }).to({
88
- handler: async (ctx) => {
89
- const { bucketName } = ctx.req.valid('param');
90
- if (!helper.isValidName(bucketName)) {
91
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
92
- }
93
- const bucket = await helper.getBucket({ name: bucketName });
94
- return ctx.json(bucket, common_1.HTTP.ResultCodes.RS_2.Ok);
95
- },
96
- });
97
- this.bindRoute({
98
- configs: { ...base_definition_1.StaticAssetDefinitions.GET_OBJECT_BY_NAME, ...routes?.getObjectByName },
208
+ configs: { ...definitions.GET_OBJECT_BY_NAME, ...routes?.getObjectByName },
99
209
  }).to({
100
210
  handler: async (ctx) => {
101
- const { bucketName, objectName: rawObjectName } = ctx.req.valid('param');
102
- const objectName = readObjectName(rawObjectName);
103
- if (!helper.isValidName(bucketName)) {
211
+ const params = ctx.req.valid('param');
212
+ const bucketName = resolveBucket({ configured: bucket, param: params.bucketName });
213
+ const objectName = readObjectName(params.objectName);
214
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
104
215
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
105
216
  }
106
- if (!helper.isValidPath(objectName, { maxDepth: maxFolderDepth })) {
217
+ if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
107
218
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
108
219
  }
109
- const fileStat = await helper.getStat({ bucket: bucketName, name: objectName });
220
+ const fileStat = await helper.getStat({
221
+ bucket: { name: bucketName },
222
+ object: { key: objectName },
223
+ });
110
224
  const { size, metadata } = fileStat;
111
225
  applyMetadataHeaders({ ctx, metadata });
112
- if (!ctx.res.headers.has(common_1.HTTP.Headers.CONTENT_TYPE)) {
113
- ctx.header(common_1.HTTP.Headers.CONTENT_TYPE, common_1.HTTP.HeaderValues.APPLICATION_OCTET_STREAM);
114
- }
115
- ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, size.toString());
226
+ const served = (0, exports.resolveServedContentType)({ helper, object: { key: objectName } });
227
+ ctx.header(common_1.HTTP.Headers.CONTENT_TYPE, served.contentType);
116
228
  ctx.header('x-content-type-options', 'nosniff');
117
- const stream = await helper.getFile({ bucket: bucketName, name: objectName });
229
+ // Sandboxed even when renderable, so an unforeseen renderable type cannot reach this origin.
230
+ ctx.header(common_1.HTTP.Headers.CONTENT_SECURITY_POLICY, 'sandbox');
231
+ // Advertised unconditionally: a player only offers seeking once it sees this.
232
+ ctx.header('accept-ranges', 'bytes');
233
+ // A type outside the allow-list downloads instead of rendering; that is the XSS boundary.
234
+ if (!served.isRenderable) {
235
+ const inlineName = objectName.split('/').pop() ?? objectName;
236
+ ctx.header(common_1.HTTP.Headers.CONTENT_DISPOSITION, (0, ignis_helpers_1.createContentDispositionHeader)({ filename: inlineName, type: 'attachment' }));
237
+ }
238
+ const range = readByteRange({ header: ctx.req.header('range'), size });
239
+ if (range) {
240
+ ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, String(range.end - range.start + 1));
241
+ ctx.header(common_1.HTTP.Headers.CONTENT_RANGE, `bytes ${range.start}-${range.end}/${size}`);
242
+ }
243
+ else {
244
+ ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, size.toString());
245
+ }
246
+ // The web stream is what a Response body wants, so no Node Readable sits in between.
247
+ const stream = await helper.getObjectStream({
248
+ bucket: { name: bucketName },
249
+ object: { key: objectName },
250
+ ...(range ? { range } : {}),
251
+ });
118
252
  return new Response(stream, {
119
253
  headers: ctx.res.headers,
120
- status: common_1.HTTP.ResultCodes.RS_2.Ok,
254
+ status: range ? common_1.HTTP.ResultCodes.RS_2.PartialContent : common_1.HTTP.ResultCodes.RS_2.Ok,
121
255
  });
122
256
  },
123
257
  });
124
258
  this.bindRoute({
125
259
  configs: {
126
- ...base_definition_1.StaticAssetDefinitions.DOWNLOAD_OBJECT_BY_NAME,
260
+ ...definitions.DOWNLOAD_OBJECT_BY_NAME,
127
261
  ...routes?.downloadObjectByName,
128
262
  },
129
263
  }).to({
130
264
  handler: async (ctx) => {
131
- const { bucketName, objectName: rawObjectName } = ctx.req.valid('param');
132
- const objectName = readObjectName(rawObjectName);
133
- if (!helper.isValidName(bucketName)) {
265
+ const params = ctx.req.valid('param');
266
+ const bucketName = resolveBucket({ configured: bucket, param: params.bucketName });
267
+ const objectName = readObjectName(params.objectName);
268
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
134
269
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
135
270
  }
136
- if (!helper.isValidPath(objectName, { maxDepth: maxFolderDepth })) {
271
+ if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
137
272
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
138
273
  }
139
- const fileStat = await helper.getStat({ bucket: bucketName, name: objectName });
274
+ const fileStat = await helper.getStat({
275
+ bucket: { name: bucketName },
276
+ object: { key: objectName },
277
+ });
140
278
  const { size, metadata } = fileStat;
141
279
  applyMetadataHeaders({ ctx, metadata });
142
- if (!ctx.res.headers.has(common_1.HTTP.Headers.CONTENT_TYPE)) {
143
- ctx.header(common_1.HTTP.Headers.CONTENT_TYPE, common_1.HTTP.HeaderValues.APPLICATION_OCTET_STREAM);
144
- }
280
+ // Download always attaches, so the served type only has to be honest, never renderable.
281
+ const served = (0, exports.resolveServedContentType)({ helper, object: { key: objectName } });
282
+ ctx.header(common_1.HTTP.Headers.CONTENT_TYPE, served.contentType);
145
283
  ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, size.toString());
146
284
  const fileName = objectName.split('/').pop() ?? objectName;
147
285
  ctx.header(common_1.HTTP.Headers.CONTENT_DISPOSITION, (0, ignis_helpers_1.createContentDispositionHeader)({ filename: fileName, type: 'attachment' }));
148
286
  ctx.header('x-content-type-options', 'nosniff');
149
- const stream = await helper.getFile({ bucket: bucketName, name: objectName });
287
+ ctx.header(common_1.HTTP.Headers.CONTENT_SECURITY_POLICY, 'sandbox');
288
+ const stream = await helper.getObjectStream({
289
+ bucket: { name: bucketName },
290
+ object: { key: objectName },
291
+ });
150
292
  return new Response(stream, {
151
293
  headers: ctx.res.headers,
152
294
  status: common_1.HTTP.ResultCodes.RS_2.Ok,
@@ -154,24 +296,16 @@ class AssetControllerFactory extends core_1.BaseHelper {
154
296
  },
155
297
  });
156
298
  this.bindRoute({
157
- configs: { ...base_definition_1.StaticAssetDefinitions.CREATE_BUCKET, ...routes?.createBucket },
299
+ configs: { ...definitions.UPLOAD, ...routes?.upload },
158
300
  }).to({
159
301
  handler: async (ctx) => {
160
- const { bucketName } = ctx.req.valid('param');
161
- if (!helper.isValidName(bucketName)) {
162
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
163
- }
164
- const createdBucket = await helper.createBucket({ name: bucketName });
165
- return ctx.json(createdBucket, common_1.HTTP.ResultCodes.RS_2.Ok);
166
- },
167
- });
168
- this.bindRoute({
169
- configs: { ...base_definition_1.StaticAssetDefinitions.UPLOAD, ...routes?.upload },
170
- }).to({
171
- handler: async (ctx) => {
172
- const { bucketName } = ctx.req.valid('param');
302
+ // A configured bucket drops `params` from this route entirely, so it is read only when it exists.
303
+ const bucketParam = hasConfiguredBucket
304
+ ? undefined
305
+ : ctx.req.valid('param').bucketName;
306
+ const bucketName = resolveBucket({ configured: bucket, param: bucketParam });
173
307
  const query = ctx.req.valid('query');
174
- if (!helper.isValidName(bucketName)) {
308
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
175
309
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
176
310
  }
177
311
  const folderPath = query.folderPath;
@@ -188,7 +322,7 @@ class AssetControllerFactory extends core_1.BaseHelper {
188
322
  message: `Folder path exceeds max depth of ${maxFolderDepth}`,
189
323
  });
190
324
  }
191
- const invalidSegmentIndex = folderSegments.findIndex(segment => !helper.isValidName(segment));
325
+ const invalidSegmentIndex = folderSegments.findIndex(segment => !helper.isValidSegment({ segment }));
192
326
  if (invalidSegmentIndex !== -1) {
193
327
  throw (0, core_1.getError)({
194
328
  error: common_2.StaticAssetErrors.FOLDER_SEGMENT_INVALID,
@@ -227,22 +361,22 @@ class AssetControllerFactory extends core_1.BaseHelper {
227
361
  // Built per request: the hook is offered the bucket, which only the route knows. No
228
362
  // hook leaves `normalizeNameFn` exactly as configured, so the stored name is unchanged.
229
363
  const normalizeNameFn = resolveObjectName
230
- ? (nameOptions) => {
231
- const defaultName = options?.normalizeNameFn
232
- ? options.normalizeNameFn(nameOptions)
233
- : defaultObjectName(nameOptions);
364
+ ? (naming) => {
365
+ const defaultKey = options?.normalizeNameFn
366
+ ? options.normalizeNameFn(naming)
367
+ : defaultObjectName(naming);
234
368
  return resolveObjectName({
235
- originalName: nameOptions.originalName,
236
- defaultName,
237
- bucket: bucketName,
369
+ bucket: { name: bucketName },
370
+ file: naming.file,
371
+ defaultKey,
238
372
  });
239
373
  }
240
374
  : options?.normalizeNameFn;
241
375
  uploaded = await helper.upload({
242
- bucket: bucketName,
376
+ bucket: { name: bucketName },
243
377
  files: modifiedFiles,
244
378
  normalizeNameFn,
245
- normalizeLinkFn: options?.normalizeLinkFn,
379
+ normalizeLinkFn,
246
380
  // 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.
247
381
  maxFolderDepth,
248
382
  });
@@ -257,8 +391,8 @@ class AssetControllerFactory extends core_1.BaseHelper {
257
391
  for (const uploadResult of uploaded) {
258
392
  try {
259
393
  const fileStat = await helper.getStat({
260
- bucket: uploadResult.bucketName,
261
- name: uploadResult.objectName,
394
+ bucket: uploadResult.bucket,
395
+ object: uploadResult.object,
262
396
  });
263
397
  const { data: createdMetaLink } = metaLink.createMetaLink
264
398
  ? await metaLink.createMetaLink({
@@ -268,10 +402,12 @@ class AssetControllerFactory extends core_1.BaseHelper {
268
402
  })
269
403
  : await metaLink.repository.create({
270
404
  data: {
271
- bucketName: uploadResult.bucketName,
272
- objectName: uploadResult.objectName,
405
+ bucketName: uploadResult.bucket.name,
406
+ objectName: uploadResult.object.key,
273
407
  link: uploadResult.link,
274
- mimetype: fileStat.metadata?.['mimetype'],
408
+ // The column is NOT NULL and a backend may report nothing.
409
+ mimetype: fileStat.metadata?.mimetype ??
410
+ helper.getMimeType({ filename: uploadResult.object.key }),
275
411
  size: fileStat.size,
276
412
  etag: fileStat.etag,
277
413
  metadata: fileStat.metadata,
@@ -284,20 +420,19 @@ class AssetControllerFactory extends core_1.BaseHelper {
284
420
  variant: query.variant ? String(query.variant) : undefined,
285
421
  },
286
422
  });
287
- results.push({ ...uploadResult, metaLink: createdMetaLink });
423
+ results.push({ ...uploadResult, metaLink: { data: createdMetaLink } });
288
424
  }
289
425
  catch (error) {
290
426
  this.logger
291
427
  .for('UPLOAD')
292
- .error('Failed to create MetaLink | objectName: %s | Error: %s', uploadResult.objectName, error);
428
+ .error('Failed to create MetaLink | objectName: %s | Error: %s', uploadResult.object.key, error);
293
429
  results.push({
294
430
  ...uploadResult,
295
- metaLink: null,
296
431
  // A CODE, never the driver text. This handler returns 200, so it bypasses the
297
432
  // error middleware and `database.handler` - the two places that strip
298
433
  // `detail`/`table`/`constraint` - and shipped raw constraint names to the client.
299
434
  // The real error is already logged in full immediately above.
300
- metaLinkError: 'META_LINK_CREATE_FAILED',
435
+ metaLink: { error: 'META_LINK_CREATE_FAILED' },
301
436
  });
302
437
  }
303
438
  }
@@ -305,32 +440,31 @@ class AssetControllerFactory extends core_1.BaseHelper {
305
440
  },
306
441
  });
307
442
  this.bindRoute({
308
- configs: { ...base_definition_1.StaticAssetDefinitions.DELETE_BUCKET, ...routes?.deleteBucket },
443
+ configs: { ...definitions.DELETE_OBJECT, ...routes?.deleteObject },
309
444
  }).to({
310
445
  handler: async (ctx) => {
311
- const { bucketName } = ctx.req.valid('param');
312
- if (!helper.isValidName(bucketName)) {
446
+ const params = ctx.req.valid('param');
447
+ const bucketName = resolveBucket({ configured: bucket, param: params.bucketName });
448
+ const objectName = readObjectName(params.objectName);
449
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
313
450
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
314
451
  }
315
- const isRemovedBucket = await helper.removeBucket({
316
- name: bucketName,
317
- });
318
- return ctx.json({ isDeleted: isRemovedBucket }, common_1.HTTP.ResultCodes.RS_2.Ok);
319
- },
320
- });
321
- this.bindRoute({
322
- configs: { ...base_definition_1.StaticAssetDefinitions.DELETE_OBJECT, ...routes?.deleteObject },
323
- }).to({
324
- handler: async (ctx) => {
325
- const { bucketName, objectName: rawObjectName } = ctx.req.valid('param');
326
- const objectName = readObjectName(rawObjectName);
327
- if (!helper.isValidName(bucketName)) {
328
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
329
- }
330
- if (!helper.isValidPath(objectName, { maxDepth: maxFolderDepth })) {
452
+ if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
331
453
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
332
454
  }
333
- await helper.removeObject({ bucket: bucketName, name: objectName });
455
+ // Deliberately idempotent: S3 answers 200 for a key that was never there, and consumers
456
+ // already depend on it. `disk` throws instead, so the two are reconciled here, not below.
457
+ try {
458
+ await helper.removeObject({
459
+ bucket: { name: bucketName },
460
+ object: { key: objectName },
461
+ });
462
+ }
463
+ catch (error) {
464
+ if (!(0, ignis_helpers_1.isNotFoundError)({ error })) {
465
+ throw error;
466
+ }
467
+ }
334
468
  if (!useMetaLink || !metaLink) {
335
469
  return ctx.json({ success: true }, common_1.HTTP.ResultCodes.RS_2.Ok);
336
470
  }
@@ -355,12 +489,16 @@ class AssetControllerFactory extends core_1.BaseHelper {
355
489
  },
356
490
  });
357
491
  this.bindRoute({
358
- configs: { ...base_definition_1.StaticAssetDefinitions.LIST_OBJECTS, ...routes?.listObjects },
492
+ configs: { ...definitions.LIST_OBJECTS, ...routes?.listObjects },
359
493
  }).to({
360
494
  handler: async (ctx) => {
361
- const { bucketName } = ctx.req.valid('param');
495
+ // A configured bucket drops `params` from this route entirely, so it is read only when it exists.
496
+ const bucketParam = hasConfiguredBucket
497
+ ? undefined
498
+ : ctx.req.valid('param').bucketName;
499
+ const bucketName = resolveBucket({ configured: bucket, param: bucketParam });
362
500
  const { prefix, recursive, maxKeys } = ctx.req.valid('query');
363
- if (!helper.isValidName(bucketName)) {
501
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
364
502
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
365
503
  }
366
504
  // A NaN or 0 maxKeys is silently treated as "unlimited" by the storage backends, so an unparsable value must be rejected instead of forwarded.
@@ -375,7 +513,7 @@ class AssetControllerFactory extends core_1.BaseHelper {
375
513
  }
376
514
  }
377
515
  const objects = await helper.listObjects({
378
- bucket: bucketName,
516
+ bucket: { name: bucketName },
379
517
  prefix,
380
518
  useRecursive: recursive === 'true',
381
519
  maxKeys: resolvedMaxKeys,
@@ -385,24 +523,34 @@ class AssetControllerFactory extends core_1.BaseHelper {
385
523
  });
386
524
  if (useMetaLink && metaLink) {
387
525
  this.bindRoute({
388
- configs: { ...base_definition_1.StaticAssetDefinitions.RECREATE_METALINK, ...routes?.recreateMetaLink },
526
+ configs: { ...definitions.RECREATE_METALINK, ...routes?.recreateMetaLink },
389
527
  }).to({
390
528
  handler: async (ctx) => {
391
- const { bucketName, objectName: rawObjectName } = ctx.req.valid('param');
392
- const objectName = readObjectName(rawObjectName);
393
- if (!helper.isValidName(bucketName)) {
529
+ const params = ctx.req.valid('param');
530
+ const bucketName = resolveBucket({ configured: bucket, param: params.bucketName });
531
+ const objectName = readObjectName(params.objectName);
532
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
394
533
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
395
534
  }
396
- if (!helper.isValidPath(objectName, { maxDepth: maxFolderDepth })) {
535
+ if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
397
536
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
398
537
  }
399
538
  const fileStat = await helper.getStat({
400
- bucket: bucketName,
401
- name: objectName,
539
+ bucket: { name: bucketName },
540
+ object: { key: objectName },
402
541
  });
403
542
  const link = options?.normalizeLinkFn
404
- ? options.normalizeLinkFn({ bucketName, normalizeName: objectName })
405
- : `${normalizedBasePath}/buckets/${bucketName}/objects/${encodeObjectPath(objectName)}`;
543
+ ? options.normalizeLinkFn({
544
+ bucket: { name: bucketName },
545
+ object: { key: objectName },
546
+ })
547
+ : (0, exports.buildObjectLink)({
548
+ basePath: normalizedBasePath,
549
+ bucket: { name: bucketName },
550
+ object: { key: objectName },
551
+ hasConfiguredBucket,
552
+ rawObjectPath,
553
+ });
406
554
  const existing = await metaLink.repository.findOne({
407
555
  filter: {
408
556
  where: {
@@ -432,7 +580,7 @@ class AssetControllerFactory extends core_1.BaseHelper {
432
580
  bucketName,
433
581
  objectName,
434
582
  link,
435
- mimetype: fileStat.metadata?.['mimetype'],
583
+ mimetype: fileStat.metadata?.mimetype ?? helper.getMimeType({ filename: objectName }),
436
584
  size: fileStat.size,
437
585
  etag: fileStat.etag,
438
586
  metadata: fileStat.metadata,