@venizia/ignis 0.2.0-21 → 0.2.0-23

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.
@@ -1 +1 @@
1
- {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../../../src/components/static-asset/controller/factory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEvE,OAAO,EAAE,UAAU,EAAY,MAAM,6BAA6B,CAAC;AACnE,OAAO,EAAQ,cAAc,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAGL,cAAc,EAIf,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,GAAG,EAAE,MAAM,MAAM,CAAC;AAE3B,OAAO,EAIL,eAAe,EAEf,wBAAwB,EACxB,6BAA6B,EAC7B,uBAAuB,EAGxB,MAAM,WAAW,CAAC;AAGnB,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,6BAA6B,CAAC,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;IAChE,OAAO,EAAE,uBAAuB,CAAC;IACjC,MAAM,EAAE,cAAc,CAAC;IACvB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,OAAO,CAAC,EAAE,wBAAwB,CAAC;CACpC;AAuBD,qBAAa,sBAAuB,SAAQ,UAAU;;IAKpD,MAAM,CAAC,qBAAqB,CAAC,IAAI,EAAE,uBAAuB;;YAkBtD,6FAA6F;2CAC9D;gBAAE,KAAK,EAAE,MAAM,EAAE,CAAA;aAAE,GAAG,IAAI;uBAYrC,cAAc,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAxDiC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAif/E"}
1
+ {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../../../src/components/static-asset/controller/factory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAEvE,OAAO,EAGL,eAAe,EAEf,UAAU,EAEV,cAAc,EAKf,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAQ,cAAc,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,UAAU,EAAY,MAAM,6BAA6B,CAAC;AACnE,OAAO,EAAE,GAAG,EAAE,MAAM,MAAM,CAAC;AAE3B,OAAO,EAIL,kBAAkB,EAElB,eAAe,EAEf,kBAAkB,EAClB,wBAAwB,EACxB,6BAA6B,EAC7B,uBAAuB,EAGxB,MAAM,WAAW,CAAC;AAGnB,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,6BAA6B,CAAC,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC;IAChE,OAAO,EAAE,uBAAuB,CAAC;IACjC,MAAM,EAAE,cAAc,CAAC;IACvB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,OAAO,CAAC,EAAE,wBAAwB,CAAC;IAEnC,qHAAqH;IACrH,iBAAiB,CAAC,EAAE,kBAAkB,CAAC;IAEvC,oGAAoG;IACpG,4IAA4I;IAC5I,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,iBAAiB,CAAC,EAAE,kBAAkB,CAAC;CACxC;AAqBD,qJAAqJ;AACrJ,eAAO,MAAM,eAAe,GAC1B,MAAM,eAAe,GAAG;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB,KACA,MAQF,CAAC;AAcF;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,GAAI,MAAM;IAC7C,MAAM,EAAE,cAAc,CAAC;IACvB,MAAM,EAAE,UAAU,CAAC;CACpB,KAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,OAAO,CAAA;CAS/C,CAAC;AAyDF,qBAAa,sBAAuB,SAAQ,UAAU;;IAKpD,MAAM,CAAC,qBAAqB,CAAC,IAAI,EAAE,uBAAuB;;YAoCtD,6FAA6F;2CAC9D;gBAAE,KAAK,EAAE,MAAM,EAAE,CAAA;aAAE,GAAG,IAAI;uBAYrC,cAAc,CAAC,IAAI,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAxLN,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4tBxC"}
@@ -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,7 +24,77 @@ const readObjectName = rawObjectName => rawObjectName;
24
24
  const encodeObjectPath = objectPath => {
25
25
  return encodeURIComponent(objectPath);
26
26
  };
27
- /** Sets whitelisted metadata headers on the response context. */
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;
44
+ /** Mirrors `BaseStorageHelper.normalizeObjectName`, which is protected: `resolveObjectName` is offered the very name the helper would otherwise have written. */
45
+ const defaultObjectName = ({ file }) => {
46
+ const { originalName, folderPath } = file;
47
+ const normalizedFileName = originalName.toLowerCase().replace(/ /g, '_');
48
+ if (!folderPath) {
49
+ return normalizedFileName;
50
+ }
51
+ return `${folderPath.toLowerCase().replace(/ /g, '_')}/${normalizedFileName}`;
52
+ };
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. */
28
98
  const applyMetadataHeaders = ({ ctx, metadata }) => {
29
99
  Object.entries(metadata).forEach(([key, value]) => {
30
100
  if (!common_2.WHITELIST_HEADERS.includes(key.toLowerCase())) {
@@ -39,10 +109,24 @@ class AssetControllerFactory extends core_1.BaseHelper {
39
109
  }
40
110
  static defineAssetController(opts) {
41
111
  const { controller, helper, options, useMetaLink, metaLink, storage } = opts;
42
- const { name, basePath, routes, isStrict = true } = controller;
112
+ const { resolveObjectName, defineRoutesBefore, defineExtraRoutes } = opts;
113
+ const { name, basePath, routes, isStrict = true, bucket, rawObjectPath = false } = controller;
43
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 });
44
117
  // The mount path may be declared with or without a leading slash; a link must carry exactly one.
45
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;
46
130
  let GeneratedStaticAssetController = class GeneratedStaticAssetController extends controllers_1.BaseRestController {
47
131
  constructor() {
48
132
  super({
@@ -65,79 +149,146 @@ class AssetControllerFactory extends core_1.BaseHelper {
65
149
  }
66
150
  }
67
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
+ }
68
207
  this.bindRoute({
69
- configs: { ...base_definition_1.StaticAssetDefinitions.GET_BUCKETS, ...routes?.getBuckets },
70
- }).to({
71
- handler: async (ctx) => {
72
- const bucket = await helper.getBuckets();
73
- return ctx.json(bucket, common_1.HTTP.ResultCodes.RS_2.Ok);
74
- },
75
- });
76
- this.bindRoute({
77
- configs: { ...base_definition_1.StaticAssetDefinitions.GET_BUCKET_BY_NAME, ...routes?.getBucketByName },
78
- }).to({
79
- handler: async (ctx) => {
80
- const { bucketName } = ctx.req.valid('param');
81
- if (!helper.isValidName(bucketName)) {
82
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
83
- }
84
- const bucket = await helper.getBucket({ name: bucketName });
85
- return ctx.json(bucket, common_1.HTTP.ResultCodes.RS_2.Ok);
86
- },
87
- });
88
- this.bindRoute({
89
- configs: { ...base_definition_1.StaticAssetDefinitions.GET_OBJECT_BY_NAME, ...routes?.getObjectByName },
208
+ configs: { ...definitions.GET_OBJECT_BY_NAME, ...routes?.getObjectByName },
90
209
  }).to({
91
210
  handler: async (ctx) => {
92
- const { bucketName, objectName: rawObjectName } = ctx.req.valid('param');
93
- const objectName = readObjectName(rawObjectName);
94
- 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 } })) {
95
215
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
96
216
  }
97
- if (!helper.isValidPath(objectName, { maxDepth: maxFolderDepth })) {
217
+ if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
98
218
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
99
219
  }
100
- const fileStat = await helper.getStat({ bucket: bucketName, name: objectName });
220
+ const fileStat = await helper.getStat({
221
+ bucket: { name: bucketName },
222
+ object: { key: objectName },
223
+ });
101
224
  const { size, metadata } = fileStat;
102
225
  applyMetadataHeaders({ ctx, metadata });
103
- if (!ctx.res.headers.has(common_1.HTTP.Headers.CONTENT_TYPE)) {
104
- ctx.header(common_1.HTTP.Headers.CONTENT_TYPE, common_1.HTTP.HeaderValues.APPLICATION_OCTET_STREAM);
105
- }
106
- 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);
107
228
  ctx.header('x-content-type-options', 'nosniff');
108
- 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
+ });
109
252
  return new Response(stream, {
110
253
  headers: ctx.res.headers,
111
- 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,
112
255
  });
113
256
  },
114
257
  });
115
258
  this.bindRoute({
116
259
  configs: {
117
- ...base_definition_1.StaticAssetDefinitions.DOWNLOAD_OBJECT_BY_NAME,
260
+ ...definitions.DOWNLOAD_OBJECT_BY_NAME,
118
261
  ...routes?.downloadObjectByName,
119
262
  },
120
263
  }).to({
121
264
  handler: async (ctx) => {
122
- const { bucketName, objectName: rawObjectName } = ctx.req.valid('param');
123
- const objectName = readObjectName(rawObjectName);
124
- 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 } })) {
125
269
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
126
270
  }
127
- if (!helper.isValidPath(objectName, { maxDepth: maxFolderDepth })) {
271
+ if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
128
272
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
129
273
  }
130
- const fileStat = await helper.getStat({ bucket: bucketName, name: objectName });
274
+ const fileStat = await helper.getStat({
275
+ bucket: { name: bucketName },
276
+ object: { key: objectName },
277
+ });
131
278
  const { size, metadata } = fileStat;
132
279
  applyMetadataHeaders({ ctx, metadata });
133
- if (!ctx.res.headers.has(common_1.HTTP.Headers.CONTENT_TYPE)) {
134
- ctx.header(common_1.HTTP.Headers.CONTENT_TYPE, common_1.HTTP.HeaderValues.APPLICATION_OCTET_STREAM);
135
- }
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);
136
283
  ctx.header(common_1.HTTP.Headers.CONTENT_LENGTH, size.toString());
137
284
  const fileName = objectName.split('/').pop() ?? objectName;
138
285
  ctx.header(common_1.HTTP.Headers.CONTENT_DISPOSITION, (0, ignis_helpers_1.createContentDispositionHeader)({ filename: fileName, type: 'attachment' }));
139
286
  ctx.header('x-content-type-options', 'nosniff');
140
- 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
+ });
141
292
  return new Response(stream, {
142
293
  headers: ctx.res.headers,
143
294
  status: common_1.HTTP.ResultCodes.RS_2.Ok,
@@ -145,24 +296,16 @@ class AssetControllerFactory extends core_1.BaseHelper {
145
296
  },
146
297
  });
147
298
  this.bindRoute({
148
- configs: { ...base_definition_1.StaticAssetDefinitions.CREATE_BUCKET, ...routes?.createBucket },
299
+ configs: { ...definitions.UPLOAD, ...routes?.upload },
149
300
  }).to({
150
301
  handler: async (ctx) => {
151
- const { bucketName } = ctx.req.valid('param');
152
- if (!helper.isValidName(bucketName)) {
153
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
154
- }
155
- const createdBucket = await helper.createBucket({ name: bucketName });
156
- return ctx.json(createdBucket, common_1.HTTP.ResultCodes.RS_2.Ok);
157
- },
158
- });
159
- this.bindRoute({
160
- configs: { ...base_definition_1.StaticAssetDefinitions.UPLOAD, ...routes?.upload },
161
- }).to({
162
- handler: async (ctx) => {
163
- 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 });
164
307
  const query = ctx.req.valid('query');
165
- if (!helper.isValidName(bucketName)) {
308
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
166
309
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
167
310
  }
168
311
  const folderPath = query.folderPath;
@@ -179,7 +322,7 @@ class AssetControllerFactory extends core_1.BaseHelper {
179
322
  message: `Folder path exceeds max depth of ${maxFolderDepth}`,
180
323
  });
181
324
  }
182
- const invalidSegmentIndex = folderSegments.findIndex(segment => !helper.isValidName(segment));
325
+ const invalidSegmentIndex = folderSegments.findIndex(segment => !helper.isValidSegment({ segment }));
183
326
  if (invalidSegmentIndex !== -1) {
184
327
  throw (0, core_1.getError)({
185
328
  error: common_2.StaticAssetErrors.FOLDER_SEGMENT_INVALID,
@@ -215,11 +358,25 @@ class AssetControllerFactory extends core_1.BaseHelper {
215
358
  folderPath: folderPath ?? undefined,
216
359
  };
217
360
  });
361
+ // Built per request: the hook is offered the bucket, which only the route knows. No
362
+ // hook leaves `normalizeNameFn` exactly as configured, so the stored name is unchanged.
363
+ const normalizeNameFn = resolveObjectName
364
+ ? (naming) => {
365
+ const defaultKey = options?.normalizeNameFn
366
+ ? options.normalizeNameFn(naming)
367
+ : defaultObjectName(naming);
368
+ return resolveObjectName({
369
+ bucket: { name: bucketName },
370
+ file: naming.file,
371
+ defaultKey,
372
+ });
373
+ }
374
+ : options?.normalizeNameFn;
218
375
  uploaded = await helper.upload({
219
- bucket: bucketName,
376
+ bucket: { name: bucketName },
220
377
  files: modifiedFiles,
221
- normalizeNameFn: options?.normalizeNameFn,
222
- normalizeLinkFn: options?.normalizeLinkFn,
378
+ normalizeNameFn,
379
+ normalizeLinkFn,
223
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.
224
381
  maxFolderDepth,
225
382
  });
@@ -234,8 +391,8 @@ class AssetControllerFactory extends core_1.BaseHelper {
234
391
  for (const uploadResult of uploaded) {
235
392
  try {
236
393
  const fileStat = await helper.getStat({
237
- bucket: uploadResult.bucketName,
238
- name: uploadResult.objectName,
394
+ bucket: uploadResult.bucket,
395
+ object: uploadResult.object,
239
396
  });
240
397
  const { data: createdMetaLink } = metaLink.createMetaLink
241
398
  ? await metaLink.createMetaLink({
@@ -245,10 +402,12 @@ class AssetControllerFactory extends core_1.BaseHelper {
245
402
  })
246
403
  : await metaLink.repository.create({
247
404
  data: {
248
- bucketName: uploadResult.bucketName,
249
- objectName: uploadResult.objectName,
405
+ bucketName: uploadResult.bucket.name,
406
+ objectName: uploadResult.object.key,
250
407
  link: uploadResult.link,
251
- 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 }),
252
411
  size: fileStat.size,
253
412
  etag: fileStat.etag,
254
413
  metadata: fileStat.metadata,
@@ -261,20 +420,19 @@ class AssetControllerFactory extends core_1.BaseHelper {
261
420
  variant: query.variant ? String(query.variant) : undefined,
262
421
  },
263
422
  });
264
- results.push({ ...uploadResult, metaLink: createdMetaLink });
423
+ results.push({ ...uploadResult, metaLink: { data: createdMetaLink } });
265
424
  }
266
425
  catch (error) {
267
426
  this.logger
268
427
  .for('UPLOAD')
269
- .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);
270
429
  results.push({
271
430
  ...uploadResult,
272
- metaLink: null,
273
431
  // A CODE, never the driver text. This handler returns 200, so it bypasses the
274
432
  // error middleware and `database.handler` - the two places that strip
275
433
  // `detail`/`table`/`constraint` - and shipped raw constraint names to the client.
276
434
  // The real error is already logged in full immediately above.
277
- metaLinkError: 'META_LINK_CREATE_FAILED',
435
+ metaLink: { error: 'META_LINK_CREATE_FAILED' },
278
436
  });
279
437
  }
280
438
  }
@@ -282,32 +440,31 @@ class AssetControllerFactory extends core_1.BaseHelper {
282
440
  },
283
441
  });
284
442
  this.bindRoute({
285
- configs: { ...base_definition_1.StaticAssetDefinitions.DELETE_BUCKET, ...routes?.deleteBucket },
286
- }).to({
287
- handler: async (ctx) => {
288
- const { bucketName } = ctx.req.valid('param');
289
- if (!helper.isValidName(bucketName)) {
290
- throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
291
- }
292
- const isRemovedBucket = await helper.removeBucket({
293
- name: bucketName,
294
- });
295
- return ctx.json({ isDeleted: isRemovedBucket }, common_1.HTTP.ResultCodes.RS_2.Ok);
296
- },
297
- });
298
- this.bindRoute({
299
- configs: { ...base_definition_1.StaticAssetDefinitions.DELETE_OBJECT, ...routes?.deleteObject },
443
+ configs: { ...definitions.DELETE_OBJECT, ...routes?.deleteObject },
300
444
  }).to({
301
445
  handler: async (ctx) => {
302
- const { bucketName, objectName: rawObjectName } = ctx.req.valid('param');
303
- const objectName = readObjectName(rawObjectName);
304
- 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 } })) {
305
450
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
306
451
  }
307
- if (!helper.isValidPath(objectName, { maxDepth: maxFolderDepth })) {
452
+ if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
308
453
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
309
454
  }
310
- 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
+ }
311
468
  if (!useMetaLink || !metaLink) {
312
469
  return ctx.json({ success: true }, common_1.HTTP.ResultCodes.RS_2.Ok);
313
470
  }
@@ -332,12 +489,16 @@ class AssetControllerFactory extends core_1.BaseHelper {
332
489
  },
333
490
  });
334
491
  this.bindRoute({
335
- configs: { ...base_definition_1.StaticAssetDefinitions.LIST_OBJECTS, ...routes?.listObjects },
492
+ configs: { ...definitions.LIST_OBJECTS, ...routes?.listObjects },
336
493
  }).to({
337
494
  handler: async (ctx) => {
338
- 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 });
339
500
  const { prefix, recursive, maxKeys } = ctx.req.valid('query');
340
- if (!helper.isValidName(bucketName)) {
501
+ if (!helper.isValidBucketName({ bucket: { name: bucketName } })) {
341
502
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
342
503
  }
343
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.
@@ -352,7 +513,7 @@ class AssetControllerFactory extends core_1.BaseHelper {
352
513
  }
353
514
  }
354
515
  const objects = await helper.listObjects({
355
- bucket: bucketName,
516
+ bucket: { name: bucketName },
356
517
  prefix,
357
518
  useRecursive: recursive === 'true',
358
519
  maxKeys: resolvedMaxKeys,
@@ -362,24 +523,34 @@ class AssetControllerFactory extends core_1.BaseHelper {
362
523
  });
363
524
  if (useMetaLink && metaLink) {
364
525
  this.bindRoute({
365
- configs: { ...base_definition_1.StaticAssetDefinitions.RECREATE_METALINK, ...routes?.recreateMetaLink },
526
+ configs: { ...definitions.RECREATE_METALINK, ...routes?.recreateMetaLink },
366
527
  }).to({
367
528
  handler: async (ctx) => {
368
- const { bucketName, objectName: rawObjectName } = ctx.req.valid('param');
369
- const objectName = readObjectName(rawObjectName);
370
- 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 } })) {
371
533
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.BUCKET_NAME_INVALID });
372
534
  }
373
- if (!helper.isValidPath(objectName, { maxDepth: maxFolderDepth })) {
535
+ if (!helper.isValidObjectKey({ object: { key: objectName }, maxDepth: maxFolderDepth })) {
374
536
  throw (0, core_1.getError)({ error: common_2.StaticAssetErrors.OBJECT_NAME_INVALID });
375
537
  }
376
538
  const fileStat = await helper.getStat({
377
- bucket: bucketName,
378
- name: objectName,
539
+ bucket: { name: bucketName },
540
+ object: { key: objectName },
379
541
  });
380
542
  const link = options?.normalizeLinkFn
381
- ? options.normalizeLinkFn({ bucketName, normalizeName: objectName })
382
- : `${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
+ });
383
554
  const existing = await metaLink.repository.findOne({
384
555
  filter: {
385
556
  where: {
@@ -409,7 +580,7 @@ class AssetControllerFactory extends core_1.BaseHelper {
409
580
  bucketName,
410
581
  objectName,
411
582
  link,
412
- mimetype: fileStat.metadata?.['mimetype'],
583
+ mimetype: fileStat.metadata?.mimetype ?? helper.getMimeType({ filename: objectName }),
413
584
  size: fileStat.size,
414
585
  etag: fileStat.etag,
415
586
  metadata: fileStat.metadata,
@@ -421,6 +592,8 @@ class AssetControllerFactory extends core_1.BaseHelper {
421
592
  },
422
593
  });
423
594
  }
595
+ // Last, so a built-in route always wins a path collision with an application's own.
596
+ defineExtraRoutes?.({ controller: this, helper, basePath: normalizedBasePath });
424
597
  }
425
598
  };
426
599
  GeneratedStaticAssetController = __decorate([