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