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