@venizia/ignis 0.2.0 → 0.2.1-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/base/middlewares/request-spy/request-spy.middleware.d.ts +1 -1
- package/dist/base/middlewares/request-spy/request-spy.middleware.d.ts.map +1 -1
- package/dist/base/middlewares/request-spy/request-spy.middleware.js +7 -2
- package/dist/base/middlewares/request-spy/request-spy.middleware.js.map +1 -1
- package/dist/components/mail/common/constants.d.ts +5 -0
- package/dist/components/mail/common/constants.d.ts.map +1 -1
- package/dist/components/mail/common/constants.js +11 -0
- package/dist/components/mail/common/constants.js.map +1 -1
- package/dist/components/mail/common/errors.d.ts +25 -0
- package/dist/components/mail/common/errors.d.ts.map +1 -1
- package/dist/components/mail/common/errors.js +22 -0
- package/dist/components/mail/common/errors.js.map +1 -1
- package/dist/components/mail/common/types/message.d.ts +2 -1
- package/dist/components/mail/common/types/message.d.ts.map +1 -1
- package/dist/components/mail/common/types/options.d.ts +7 -0
- package/dist/components/mail/common/types/options.d.ts.map +1 -1
- package/dist/components/mail/component.d.ts +2 -0
- package/dist/components/mail/component.d.ts.map +1 -1
- package/dist/components/mail/component.js +57 -17
- package/dist/components/mail/component.js.map +1 -1
- package/dist/components/mail/helpers/transporters/amazon-ses-transporter.helper.d.ts.map +1 -1
- package/dist/components/mail/helpers/transporters/amazon-ses-transporter.helper.js +2 -0
- package/dist/components/mail/helpers/transporters/amazon-ses-transporter.helper.js.map +1 -1
- package/dist/components/mail/helpers/transporters/mailgun-transporter.helper.d.ts.map +1 -1
- package/dist/components/mail/helpers/transporters/mailgun-transporter.helper.js +6 -6
- package/dist/components/mail/helpers/transporters/mailgun-transporter.helper.js.map +1 -1
- package/dist/components/mail/helpers/transporters/nodemail-transporter.helper.d.ts.map +1 -1
- package/dist/components/mail/helpers/transporters/nodemail-transporter.helper.js +12 -1
- package/dist/components/mail/helpers/transporters/nodemail-transporter.helper.js.map +1 -1
- package/dist/components/mail/services/mail.service.d.ts.map +1 -1
- package/dist/components/mail/services/mail.service.js +19 -3
- package/dist/components/mail/services/mail.service.js.map +1 -1
- package/dist/components/mail/utilities/attachment.utility.d.ts +31 -0
- package/dist/components/mail/utilities/attachment.utility.d.ts.map +1 -0
- package/dist/components/mail/utilities/attachment.utility.js +214 -0
- package/dist/components/mail/utilities/attachment.utility.js.map +1 -0
- package/dist/components/mail/utilities/body.utility.d.ts +6 -0
- package/dist/components/mail/utilities/body.utility.d.ts.map +1 -0
- package/dist/components/mail/utilities/body.utility.js +20 -0
- package/dist/components/mail/utilities/body.utility.js.map +1 -0
- package/dist/components/mail/utilities/mime.utility.d.ts.map +1 -1
- package/dist/components/mail/utilities/mime.utility.js +3 -27
- package/dist/components/mail/utilities/mime.utility.js.map +1 -1
- package/dist/components/static-asset/common/constants.d.ts +8 -0
- package/dist/components/static-asset/common/constants.d.ts.map +1 -1
- package/dist/components/static-asset/common/constants.js +11 -1
- package/dist/components/static-asset/common/constants.js.map +1 -1
- package/dist/components/static-asset/common/errors.d.ts +9 -0
- package/dist/components/static-asset/common/errors.d.ts.map +1 -1
- package/dist/components/static-asset/common/errors.js +9 -0
- package/dist/components/static-asset/common/errors.js.map +1 -1
- package/dist/components/static-asset/common/types.d.ts +25 -12
- package/dist/components/static-asset/common/types.d.ts.map +1 -1
- package/dist/components/static-asset/controller/base.definition.d.ts.map +1 -1
- package/dist/components/static-asset/controller/base.definition.js +10 -1
- package/dist/components/static-asset/controller/base.definition.js.map +1 -1
- package/dist/components/static-asset/controller/factory.d.ts.map +1 -1
- package/dist/components/static-asset/controller/factory.js +618 -563
- package/dist/components/static-asset/controller/factory.js.map +1 -1
- package/dist/components/static-asset/controller/key-scope.d.ts +28 -0
- package/dist/components/static-asset/controller/key-scope.d.ts.map +1 -0
- package/dist/components/static-asset/controller/key-scope.js +44 -0
- package/dist/components/static-asset/controller/key-scope.js.map +1 -0
- package/dist/components/static-asset/controller/meta-link.d.ts +37 -0
- package/dist/components/static-asset/controller/meta-link.d.ts.map +1 -0
- package/dist/components/static-asset/controller/meta-link.js +95 -0
- package/dist/components/static-asset/controller/meta-link.js.map +1 -0
- package/dist/components/static-asset/controller/route-options.d.ts +18 -0
- package/dist/components/static-asset/controller/route-options.d.ts.map +1 -0
- package/dist/components/static-asset/controller/route-options.js +37 -0
- package/dist/components/static-asset/controller/route-options.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +19 -0
- package/dist/testing/index.js.map +1 -0
- 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.
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
//
|
|
458
|
-
|
|
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.
|
|
461
|
-
message: `
|
|
436
|
+
error: common_2.StaticAssetErrors.FOLDER_DEPTH_EXCEEDED,
|
|
437
|
+
message: `Folder path exceeds max depth of ${maxFolderDepth}`,
|
|
462
438
|
});
|
|
463
439
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
727
|
-
|
|
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
|
-
|
|
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('
|
|
751
|
-
.error('Failed to create MetaLink | objectName: %s | Error: %s', object.key, error);
|
|
752
|
-
|
|
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
|
-
|
|
569
|
+
const deleteObjectRoute = (0, route_options_1.readRouteOverride)({ route: routes?.deleteObject });
|
|
570
|
+
if (deleteObjectRoute.isEnabled) {
|
|
765
571
|
this.bindRoute({
|
|
766
|
-
configs: { ...definitions.
|
|
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({
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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(
|
|
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
|
}
|