@appsemble/node-utils 0.38.1 → 0.39.0
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/README.md +3 -3
- package/assetCssURL.d.ts +5 -1
- package/assetCssURL.js +176 -109
- package/assets.d.ts +2 -2
- package/assets.js +3 -2
- package/package.json +6 -5
- package/readAsset.js +1 -1
- package/s3.d.ts +38 -7
- package/s3.js +170 -85
- package/testFixtures.js +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
#  Appsemble Node Utilities
|
|
2
2
|
|
|
3
3
|
> NodeJS utilities used by Appsemble internally.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@appsemble/node-utils)
|
|
6
|
-
[](https://gitlab.com/appsemble/appsemble/-/releases/0.39.0)
|
|
7
7
|
[](https://prettier.io)
|
|
8
8
|
|
|
9
9
|
## Table of Contents
|
|
@@ -26,5 +26,5 @@ compatibility is not guaranteed.
|
|
|
26
26
|
|
|
27
27
|
## License
|
|
28
28
|
|
|
29
|
-
[LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.
|
|
29
|
+
[LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.39.0/LICENSE.md) ©
|
|
30
30
|
[Appsemble](https://appsemble.com)
|
package/assetCssURL.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve `asset()`
|
|
2
|
+
* Resolve `asset()` utilities and app asset URLs in CSS to absolute app asset URLs.
|
|
3
|
+
*
|
|
4
|
+
* An `asset()` utility takes a single string, holding either an asset reference or an app asset
|
|
5
|
+
* endpoint path. Both it and `url()` values addressing an app asset endpoint are replaced with a
|
|
6
|
+
* `url()` value addressing the given app on the given host. All other values are left untouched.
|
|
3
7
|
*
|
|
4
8
|
* @param css The CSS to process.
|
|
5
9
|
* @param appId The id of the app the assets belong to.
|
package/assetCssURL.js
CHANGED
|
@@ -1,96 +1,194 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
if (decodedValue.includes('/') || decodedValue.includes('\\')) {
|
|
20
|
-
return true;
|
|
21
|
-
}
|
|
22
|
-
currentValue = decodedValue;
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
return true;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return false;
|
|
1
|
+
import { normalized, StyleValidationError, uuid4Pattern } from '@appsemble/utils';
|
|
2
|
+
import { parse, walk } from 'css-tree';
|
|
3
|
+
const appIdPattern = /^\d+$/;
|
|
4
|
+
/**
|
|
5
|
+
* Create a replacement of the source a node was parsed from.
|
|
6
|
+
*
|
|
7
|
+
* @param node The node whose source to replace. It must have been parsed with positions.
|
|
8
|
+
* @param offset The offset of the source the node was parsed from within the stylesheet.
|
|
9
|
+
* @param value The value to replace the source of the node with.
|
|
10
|
+
* @returns The replacement.
|
|
11
|
+
*/
|
|
12
|
+
function replaceNode(node, offset, value) {
|
|
13
|
+
return {
|
|
14
|
+
start: offset + node.loc.start.offset,
|
|
15
|
+
end: offset + node.loc.end.offset,
|
|
16
|
+
value,
|
|
17
|
+
};
|
|
29
18
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
return String(new URL(`/api/apps/${appId}/assets/${appAssetPath}`, host));
|
|
42
|
-
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse the value of a declaration whose value the stylesheet parser left raw.
|
|
21
|
+
*
|
|
22
|
+
* Custom properties hold an arbitrary token stream, so their value is never parsed as part of the
|
|
23
|
+
* stylesheet.
|
|
24
|
+
*
|
|
25
|
+
* @param value The raw value to parse.
|
|
26
|
+
* @returns The parsed value, or `null` if it is not a valid declaration value.
|
|
27
|
+
*/
|
|
28
|
+
function parseRawValue(value) {
|
|
43
29
|
try {
|
|
44
|
-
|
|
45
|
-
const appAssetPathMatch = appAssetPathMatcher.exec(parsedUrl.pathname);
|
|
46
|
-
if (!appAssetPathMatch) {
|
|
47
|
-
return trimmedUrl;
|
|
48
|
-
}
|
|
49
|
-
const appAssetPath = appAssetPathMatch[1];
|
|
50
|
-
if (hasRejectedTokens(appAssetPath)) {
|
|
51
|
-
return trimmedUrl;
|
|
52
|
-
}
|
|
53
|
-
const rewrittenUrl = new URL(`/api/apps/${appId}/assets/${appAssetPath}`, host);
|
|
54
|
-
rewrittenUrl.search = parsedUrl.search;
|
|
55
|
-
rewrittenUrl.hash = parsedUrl.hash;
|
|
56
|
-
return String(rewrittenUrl);
|
|
30
|
+
return parse(value, { context: 'value', positions: true });
|
|
57
31
|
}
|
|
58
32
|
catch {
|
|
59
|
-
return
|
|
33
|
+
return null;
|
|
60
34
|
}
|
|
61
35
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
36
|
+
function isValidAssetReference(reference) {
|
|
37
|
+
return normalized.test(reference) || uuid4Pattern.test(reference);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Split a URL value into the part that addresses a resource and the query and fragment that follow.
|
|
41
|
+
*
|
|
42
|
+
* @param value The URL value to split.
|
|
43
|
+
* @returns The path and the query and fragment suffix.
|
|
44
|
+
*/
|
|
45
|
+
function splitURLParts(value) {
|
|
46
|
+
const suffixIndex = Math.min(...['?', '#'].map((separator) => {
|
|
47
|
+
const index = value.indexOf(separator);
|
|
48
|
+
return index === -1 ? value.length : index;
|
|
49
|
+
}));
|
|
50
|
+
return { path: value.slice(0, suffixIndex), suffix: value.slice(suffixIndex) };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Extract the asset reference addressed by an app asset endpoint path.
|
|
54
|
+
*
|
|
55
|
+
* @param path The path to inspect.
|
|
56
|
+
* @returns The asset reference, or `null` if the path is not an app asset endpoint path.
|
|
57
|
+
*/
|
|
58
|
+
function getAppAssetPathReference(path) {
|
|
59
|
+
const [empty, api, apps, appIdSegment, assets, reference, ...rest] = path.split('/');
|
|
60
|
+
if (rest.length || empty !== '' || api !== 'api' || apps !== 'apps' || assets !== 'assets') {
|
|
61
|
+
return null;
|
|
65
62
|
}
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
if (!appIdPattern.test(appIdSegment) || !isValidAssetReference(reference)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return reference;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Create the app asset URL addressing an asset, to be embedded in a single quoted CSS string.
|
|
70
|
+
*
|
|
71
|
+
* The query and fragment are taken from the reference the asset was addressed with, so they may
|
|
72
|
+
* hold characters that end the CSS string. `URL` strips or encodes all of those except the quote
|
|
73
|
+
* and the backslash, which are percent encoded here.
|
|
74
|
+
*
|
|
75
|
+
* @param reference The reference of the asset to address.
|
|
76
|
+
* @param suffix The query and fragment to append to the URL.
|
|
77
|
+
* @param appId The id of the app the asset belongs to.
|
|
78
|
+
* @param host The host on which the app asset endpoints are available.
|
|
79
|
+
* @returns The app asset URL.
|
|
80
|
+
*/
|
|
81
|
+
function createAppAssetURL(reference, suffix, appId, host) {
|
|
82
|
+
const path = `/api/apps/${appId}/assets/${encodeURIComponent(reference)}${suffix}`;
|
|
83
|
+
return String(new URL(path, host)).replaceAll('\\', '%5C').replaceAll("'", '%27');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the contents of an `asset()` utility to an app asset URL.
|
|
87
|
+
*
|
|
88
|
+
* @param value The asset reference or app asset endpoint path the utility was called with.
|
|
89
|
+
* @param appId The id of the app the asset belongs to.
|
|
90
|
+
* @param host The host on which the app asset endpoints are available.
|
|
91
|
+
* @returns The app asset URL the utility resolves to.
|
|
92
|
+
*/
|
|
93
|
+
function resolveAssetReference(value, appId, host) {
|
|
94
|
+
if (value.startsWith('/')) {
|
|
95
|
+
const { path, suffix } = splitURLParts(value);
|
|
96
|
+
const reference = getAppAssetPathReference(path);
|
|
97
|
+
if (reference) {
|
|
98
|
+
return createAppAssetURL(reference, suffix, appId, host);
|
|
70
99
|
}
|
|
71
|
-
return rewriteAppAssetURL(assetId, appId, host);
|
|
72
100
|
}
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
101
|
+
else if (isValidAssetReference(value)) {
|
|
102
|
+
return createAppAssetURL(value, '', appId, host);
|
|
103
|
+
}
|
|
104
|
+
throw new StyleValidationError(`Invalid asset reference: ${value}`);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Rewrite an app asset URL so it addresses the given app on the given host.
|
|
108
|
+
*
|
|
109
|
+
* Stylesheets are stored with their asset references already resolved to absolute URLs, so a
|
|
110
|
+
* stylesheet copied from another app addresses that app on this host. Such URLs are rewritten as
|
|
111
|
+
* well, unlike URLs addressing another host.
|
|
112
|
+
*
|
|
113
|
+
* @param value The URL to rewrite.
|
|
114
|
+
* @param appId The id of the app the asset belongs to.
|
|
115
|
+
* @param host The host on which the app asset endpoints are available.
|
|
116
|
+
* @returns The rewritten URL, or `null` if the value does not address an app asset.
|
|
117
|
+
*/
|
|
118
|
+
function rewriteAppAssetURL(value, appId, host) {
|
|
119
|
+
let path;
|
|
120
|
+
let suffix;
|
|
121
|
+
if (value.startsWith('/')) {
|
|
122
|
+
({ path, suffix } = splitURLParts(value));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
if (!URL.canParse(value)) {
|
|
126
|
+
return null;
|
|
81
127
|
}
|
|
82
|
-
|
|
128
|
+
const url = new URL(value);
|
|
129
|
+
if (url.origin !== new URL(host).origin) {
|
|
83
130
|
return null;
|
|
84
131
|
}
|
|
85
|
-
|
|
132
|
+
path = url.pathname;
|
|
133
|
+
suffix = `${url.search}${url.hash}`;
|
|
86
134
|
}
|
|
87
|
-
|
|
88
|
-
|
|
135
|
+
const reference = getAppAssetPathReference(path);
|
|
136
|
+
return reference ? createAppAssetURL(reference, suffix, appId, host) : null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Resolve all app asset references in CSS to absolute app asset URLs.
|
|
140
|
+
*
|
|
141
|
+
* @param css The CSS to process.
|
|
142
|
+
* @param appId The id of the app the assets belong to.
|
|
143
|
+
* @param host The host on which the app asset endpoints are available.
|
|
144
|
+
* @returns The CSS with all app asset references resolved.
|
|
145
|
+
*/
|
|
146
|
+
function resolveAssetReferences(css, appId, host) {
|
|
147
|
+
const replacements = [];
|
|
148
|
+
/**
|
|
149
|
+
* Collect the replacements of all app asset references in a tree.
|
|
150
|
+
*
|
|
151
|
+
* @param node The tree to collect replacements from.
|
|
152
|
+
* @param offset The offset of the source the tree was parsed from within the stylesheet.
|
|
153
|
+
*/
|
|
154
|
+
function collectReplacements(node, offset) {
|
|
155
|
+
walk(node, (child) => {
|
|
156
|
+
if (child.type === 'Function' && child.name.toLowerCase() === 'asset') {
|
|
157
|
+
const [argument, ...rest] = child.children;
|
|
158
|
+
if (rest.length || argument?.type !== 'String') {
|
|
159
|
+
throw new StyleValidationError('The asset utility takes a single string');
|
|
160
|
+
}
|
|
161
|
+
replacements.push(replaceNode(child, offset, `url('${resolveAssetReference(argument.value, appId, host)}')`));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (child.type === 'Url') {
|
|
165
|
+
const rewrittenURL = rewriteAppAssetURL(child.value, appId, host);
|
|
166
|
+
if (rewrittenURL) {
|
|
167
|
+
replacements.push(replaceNode(child, offset, `url('${rewrittenURL}')`));
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (child.type === 'Raw') {
|
|
172
|
+
const value = parseRawValue(child.value);
|
|
173
|
+
if (value) {
|
|
174
|
+
collectReplacements(value, offset + child.loc.start.offset);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
collectReplacements(parse(css, { positions: true }), 0);
|
|
180
|
+
let result = css;
|
|
181
|
+
for (const replacement of replacements.reverse()) {
|
|
182
|
+
result = result.slice(0, replacement.start) + replacement.value + result.slice(replacement.end);
|
|
89
183
|
}
|
|
90
|
-
return
|
|
184
|
+
return result;
|
|
91
185
|
}
|
|
92
186
|
/**
|
|
93
|
-
* Resolve `asset()`
|
|
187
|
+
* Resolve `asset()` utilities and app asset URLs in CSS to absolute app asset URLs.
|
|
188
|
+
*
|
|
189
|
+
* An `asset()` utility takes a single string, holding either an asset reference or an app asset
|
|
190
|
+
* endpoint path. Both it and `url()` values addressing an app asset endpoint are replaced with a
|
|
191
|
+
* `url()` value addressing the given app on the given host. All other values are left untouched.
|
|
94
192
|
*
|
|
95
193
|
* @param css The CSS to process.
|
|
96
194
|
* @param appId The id of the app the assets belong to.
|
|
@@ -98,37 +196,6 @@ function resolveAssetURL(appId, assetId, host) {
|
|
|
98
196
|
* @returns The CSS with all app asset references resolved.
|
|
99
197
|
*/
|
|
100
198
|
export function replaceAssetFunctions(css, appId, host) {
|
|
101
|
-
|
|
102
|
-
return css;
|
|
103
|
-
}
|
|
104
|
-
const replaceAssetFunction = (match, ...args) => {
|
|
105
|
-
const quotedAssetId = args[1];
|
|
106
|
-
const unquotedAssetId = args[2];
|
|
107
|
-
const assetId = (quotedAssetId || unquotedAssetId)?.trim();
|
|
108
|
-
if (!assetId) {
|
|
109
|
-
return match;
|
|
110
|
-
}
|
|
111
|
-
const resolvedAssetUrl = resolveAssetURL(appId, assetId, host);
|
|
112
|
-
if (!resolvedAssetUrl) {
|
|
113
|
-
return match;
|
|
114
|
-
}
|
|
115
|
-
return `url('${resolvedAssetUrl}')`;
|
|
116
|
-
};
|
|
117
|
-
const cssWithResolvedAssetFunctionURLs = css
|
|
118
|
-
.replaceAll(assetUrlMatcher, replaceAssetFunction)
|
|
119
|
-
.replaceAll(standaloneAssetMatcher, replaceAssetFunction);
|
|
120
|
-
return cssWithResolvedAssetFunctionURLs.replaceAll(urlMatcher, (match, ...args) => {
|
|
121
|
-
const quotedUrl = args[1];
|
|
122
|
-
const unquotedUrl = args[2];
|
|
123
|
-
const url = (quotedUrl || unquotedUrl)?.trim();
|
|
124
|
-
if (!url) {
|
|
125
|
-
return match;
|
|
126
|
-
}
|
|
127
|
-
const rewrittenUrl = rewriteAppAssetURL(url, appId, host);
|
|
128
|
-
if (rewrittenUrl === url) {
|
|
129
|
-
return match;
|
|
130
|
-
}
|
|
131
|
-
return `url('${rewrittenUrl}')`;
|
|
132
|
-
});
|
|
199
|
+
return appId ? resolveAssetReferences(css, appId, host) : css;
|
|
133
200
|
}
|
|
134
201
|
//# sourceMappingURL=assetCssURL.js.map
|
package/assets.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Context } from 'koa';
|
|
2
|
-
import { type
|
|
3
|
-
export declare function setAssetHeaders(ctx: Context, mime: string, filename: string | null, stats?:
|
|
2
|
+
import { type S3FileStats } from './s3.js';
|
|
3
|
+
export declare function setAssetHeaders(ctx: Context, mime: string, filename: string | null, stats?: S3FileStats): void;
|
|
4
4
|
interface FileMeta {
|
|
5
5
|
filename?: string;
|
|
6
6
|
mime: string;
|
package/assets.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createReadStream } from 'node:fs';
|
|
2
2
|
import { stat } from 'node:fs/promises';
|
|
3
3
|
import { logger } from './logger.js';
|
|
4
|
-
import { uploadS3File } from './s3.js';
|
|
4
|
+
import { getAppAssetLocation, uploadS3File } from './s3.js';
|
|
5
5
|
import { removeUploads } from './uploads.js';
|
|
6
6
|
export function setAssetHeaders(ctx, mime, filename, stats) {
|
|
7
7
|
ctx.set('content-type', mime || 'application/octet-stream');
|
|
@@ -24,7 +24,8 @@ export async function uploadAsset(appId, asset) {
|
|
|
24
24
|
try {
|
|
25
25
|
const stats = await stat(path);
|
|
26
26
|
const stream = createReadStream(path);
|
|
27
|
-
|
|
27
|
+
const { bucket, key } = getAppAssetLocation(appId, id);
|
|
28
|
+
await uploadS3File(bucket, key, stream, stats.size);
|
|
28
29
|
}
|
|
29
30
|
catch (error) {
|
|
30
31
|
logger.error(error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appsemble/node-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "NodeJS utilities used by Appsemble internally.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"app",
|
|
@@ -40,9 +40,11 @@
|
|
|
40
40
|
"test": "vitest"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@appsemble/lang-sdk": "0.
|
|
44
|
-
"@appsemble/types": "0.
|
|
45
|
-
"@appsemble/utils": "0.
|
|
43
|
+
"@appsemble/lang-sdk": "0.39.0",
|
|
44
|
+
"@appsemble/types": "0.39.0",
|
|
45
|
+
"@appsemble/utils": "0.39.0",
|
|
46
|
+
"@aws-sdk/client-s3": "3.1130.0",
|
|
47
|
+
"@aws-sdk/lib-storage": "3.1130.0",
|
|
46
48
|
"@formatjs/fast-memoize": "^2.0.0",
|
|
47
49
|
"@fortawesome/fontawesome-free": "^6.0.0",
|
|
48
50
|
"@inquirer/prompts": "^8.0.0",
|
|
@@ -85,7 +87,6 @@
|
|
|
85
87
|
"logform": "^2.0.0",
|
|
86
88
|
"memfs": "4.68.2",
|
|
87
89
|
"mime-types": "^2.0.0",
|
|
88
|
-
"minio": "^8.0.7",
|
|
89
90
|
"mustache": "^4.0.0",
|
|
90
91
|
"openapi-types": "^12.1.3",
|
|
91
92
|
"parse-duration": "^1.0.0",
|
package/readAsset.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
export const assetDir = new URL('assets/', import.meta.url);
|
|
3
3
|
export function readAsset(filename, encoding) {
|
|
4
|
-
return readFile(new URL(filename, assetDir), encoding);
|
|
4
|
+
return readFile(new URL(filename, assetDir), encoding ?? null);
|
|
5
5
|
}
|
|
6
6
|
//# sourceMappingURL=readAsset.js.map
|
package/s3.d.ts
CHANGED
|
@@ -1,27 +1,58 @@
|
|
|
1
1
|
import { Readable } from 'node:stream';
|
|
2
|
-
|
|
3
|
-
export interface
|
|
4
|
-
|
|
2
|
+
export declare const blockAssetsBucketName = "appsemble-block-assets";
|
|
3
|
+
export interface S3Location {
|
|
4
|
+
bucket: string;
|
|
5
5
|
key: string;
|
|
6
|
+
}
|
|
7
|
+
export interface S3FileStats {
|
|
8
|
+
etag: string;
|
|
6
9
|
lastModified: Date;
|
|
7
|
-
metadata:
|
|
10
|
+
metadata: Record<string, string>;
|
|
8
11
|
size: number;
|
|
9
12
|
}
|
|
13
|
+
export interface S3FileReference extends S3FileStats {
|
|
14
|
+
key: string;
|
|
15
|
+
}
|
|
10
16
|
export interface InitS3ClientParams {
|
|
11
17
|
endPoint: string;
|
|
12
18
|
port?: number;
|
|
13
19
|
useSSL?: boolean;
|
|
14
20
|
accessKey: string;
|
|
15
21
|
secretKey: string;
|
|
22
|
+
region?: string;
|
|
23
|
+
pathStyle?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The single, pre-provisioned bucket that holds all objects.
|
|
26
|
+
*
|
|
27
|
+
* When set, app assets live under `apps/<appId>/` and block assets under `blocks/` in this
|
|
28
|
+
* bucket, and buckets are never created or listed. When unset, every app gets its own
|
|
29
|
+
* `app-<appId>` bucket and block assets live in the `appsemble-block-assets` bucket, both created
|
|
30
|
+
* on demand.
|
|
31
|
+
*/
|
|
32
|
+
bucket?: string;
|
|
16
33
|
}
|
|
17
|
-
export declare function initS3Client({ accessKey, endPoint, port, secretKey, useSSL, }: InitS3ClientParams): void;
|
|
34
|
+
export declare function initS3Client({ accessKey, bucket, endPoint, pathStyle, port, region, secretKey, useSSL, }: InitS3ClientParams): void;
|
|
35
|
+
/**
|
|
36
|
+
* @returns The configured single bucket, or `undefined` in the bucket-per-app layout.
|
|
37
|
+
*/
|
|
38
|
+
export declare function getS3Bucket(): string | undefined;
|
|
39
|
+
export declare function getAppAssetLocation(appId: number, assetId: string): S3Location;
|
|
40
|
+
export declare function getBlockAssetLocation(storageKey: string): S3Location;
|
|
41
|
+
export declare function isS3ErrorCode(error: unknown, code: string): boolean;
|
|
18
42
|
export declare function uploadS3File(bucket: string, key: string, content: Buffer | Readable | string, size?: number, metadata?: Record<string, string>): Promise<void>;
|
|
19
43
|
export declare function uploadS3FileFromPath(bucket: string, key: string, path: string): Promise<void>;
|
|
20
44
|
export declare function getS3File(bucket: string, key: string): Promise<Readable>;
|
|
21
45
|
export declare function getS3FileBuffer(bucket: string, key: string): Promise<Buffer>;
|
|
22
|
-
export declare function getS3FileStats(bucket: string, key: string): Promise<
|
|
23
|
-
export declare function listS3Files(bucket: string): Promise<S3FileReference[]>;
|
|
46
|
+
export declare function getS3FileStats(bucket: string, key: string): Promise<S3FileStats>;
|
|
47
|
+
export declare function listS3Files(bucket: string, prefix?: string): Promise<S3FileReference[]>;
|
|
24
48
|
export declare function setS3BucketPolicy(bucket: string, policy: string): Promise<void>;
|
|
25
49
|
export declare function deleteS3Files(bucket: string, keys: string[]): Promise<void>;
|
|
26
50
|
export declare function deleteS3File(bucket: string, key: string): Promise<void>;
|
|
51
|
+
export declare function deleteAppAssetObjects(appId: number, assetIds: string[]): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Remove every object this client can reach.
|
|
54
|
+
*
|
|
55
|
+
* In the single-bucket layout this empties the configured bucket. In the bucket-per-app layout
|
|
56
|
+
* this empties and removes every bucket.
|
|
57
|
+
*/
|
|
27
58
|
export declare function clearAllS3Buckets(): Promise<void>;
|
package/s3.js
CHANGED
|
@@ -1,85 +1,149 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
1
3
|
import { Readable } from 'node:stream';
|
|
2
4
|
import { buffer as streamToBuffer } from 'node:stream/consumers';
|
|
3
|
-
import {
|
|
5
|
+
import { CreateBucketCommand, DeleteBucketCommand, DeleteObjectsCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListBucketsCommand, ListObjectsV2Command, PutBucketPolicyCommand, PutObjectCommand, S3Client, S3ServiceException, } from '@aws-sdk/client-s3';
|
|
6
|
+
import { Upload } from '@aws-sdk/lib-storage';
|
|
4
7
|
import { logger } from './logger.js';
|
|
5
8
|
let s3Client;
|
|
6
|
-
|
|
9
|
+
let s3Bucket;
|
|
10
|
+
let s3Region;
|
|
11
|
+
export const blockAssetsBucketName = 'appsemble-block-assets';
|
|
12
|
+
// S3 limits the number of keys in a single DeleteObjects request.
|
|
13
|
+
const deleteBatchSize = 1000;
|
|
14
|
+
export function initS3Client({ accessKey, bucket, endPoint, pathStyle = true, port = 9000, region = 'us-east-1', secretKey, useSSL = true, }) {
|
|
7
15
|
try {
|
|
8
|
-
s3Client = new
|
|
9
|
-
endPoint
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
accessKey,
|
|
13
|
-
|
|
16
|
+
s3Client = new S3Client({
|
|
17
|
+
endpoint: `${useSSL ? 'https' : 'http'}://${endPoint}:${port}`,
|
|
18
|
+
region,
|
|
19
|
+
forcePathStyle: pathStyle,
|
|
20
|
+
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
|
21
|
+
// Only add checksums where S3 requires them, so requests stay compatible with
|
|
22
|
+
// S3-compatible stores that do not implement flexible checksums.
|
|
23
|
+
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
24
|
+
responseChecksumValidation: 'WHEN_REQUIRED',
|
|
14
25
|
});
|
|
26
|
+
s3Bucket = bucket || undefined;
|
|
27
|
+
s3Region = region;
|
|
15
28
|
}
|
|
16
29
|
catch (error) {
|
|
17
30
|
logger.error(error);
|
|
18
31
|
throw error;
|
|
19
32
|
}
|
|
20
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* @returns The configured single bucket, or `undefined` in the bucket-per-app layout.
|
|
36
|
+
*/
|
|
37
|
+
export function getS3Bucket() {
|
|
38
|
+
return s3Bucket;
|
|
39
|
+
}
|
|
40
|
+
function getAppAssetsBucket(appId) {
|
|
41
|
+
return s3Bucket ?? `app-${appId}`;
|
|
42
|
+
}
|
|
43
|
+
export function getAppAssetLocation(appId, assetId) {
|
|
44
|
+
return {
|
|
45
|
+
bucket: getAppAssetsBucket(appId),
|
|
46
|
+
key: s3Bucket ? `apps/${appId}/${assetId}` : assetId,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function getBlockAssetLocation(storageKey) {
|
|
50
|
+
return s3Bucket
|
|
51
|
+
? { bucket: s3Bucket, key: `blocks/${storageKey}` }
|
|
52
|
+
: { bucket: blockAssetsBucketName, key: storageKey };
|
|
53
|
+
}
|
|
54
|
+
export function isS3ErrorCode(error, code) {
|
|
55
|
+
return error instanceof S3ServiceException && error.name === code;
|
|
56
|
+
}
|
|
21
57
|
async function ensureBucket(name) {
|
|
58
|
+
if (s3Bucket) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
22
61
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
await s3Client.makeBucket(name);
|
|
27
|
-
}
|
|
28
|
-
catch (makeBucketError) {
|
|
29
|
-
if (makeBucketError instanceof S3Error &&
|
|
30
|
-
makeBucketError.code === 'BucketAlreadyOwnedByYou') {
|
|
31
|
-
logger.warn(makeBucketError);
|
|
32
|
-
logger.info('This was probably called in an asynchronous batch upload.');
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
throw makeBucketError;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
62
|
+
await s3Client.send(new HeadBucketCommand({ Bucket: name }));
|
|
63
|
+
return;
|
|
39
64
|
}
|
|
40
65
|
catch (error) {
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
if (!isS3ErrorCode(error, 'NotFound')) {
|
|
67
|
+
logger.error(error);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
43
70
|
}
|
|
44
|
-
}
|
|
45
|
-
function isS3ErrorCode(error, code) {
|
|
46
|
-
return error instanceof S3Error && error.code === code;
|
|
47
|
-
}
|
|
48
|
-
export async function uploadS3File(bucket, key, content, size, metadata) {
|
|
49
71
|
try {
|
|
50
|
-
await
|
|
51
|
-
|
|
72
|
+
await s3Client.send(new CreateBucketCommand({
|
|
73
|
+
Bucket: name,
|
|
74
|
+
// S3 rejects a location constraint for its default region.
|
|
75
|
+
...(s3Region === 'us-east-1'
|
|
76
|
+
? {}
|
|
77
|
+
: {
|
|
78
|
+
CreateBucketConfiguration: {
|
|
79
|
+
LocationConstraint: s3Region,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
}));
|
|
52
83
|
}
|
|
53
84
|
catch (error) {
|
|
54
|
-
if (isS3ErrorCode(error, '
|
|
85
|
+
if (isS3ErrorCode(error, 'BucketAlreadyOwnedByYou')) {
|
|
55
86
|
logger.warn(error);
|
|
56
|
-
|
|
57
|
-
await s3Client.putObject(bucket, key, content, size, metadata);
|
|
87
|
+
logger.info('This was probably called in an asynchronous batch upload.');
|
|
58
88
|
return;
|
|
59
89
|
}
|
|
60
90
|
logger.error(error);
|
|
61
91
|
throw error;
|
|
62
92
|
}
|
|
63
93
|
}
|
|
64
|
-
|
|
94
|
+
function splitMetadata(metadata = {}) {
|
|
95
|
+
const result = { Metadata: {} };
|
|
96
|
+
for (const [name, value] of Object.entries(metadata)) {
|
|
97
|
+
switch (name.toLowerCase()) {
|
|
98
|
+
case 'cache-control':
|
|
99
|
+
result.CacheControl = value;
|
|
100
|
+
break;
|
|
101
|
+
case 'content-type':
|
|
102
|
+
result.ContentType = value;
|
|
103
|
+
break;
|
|
104
|
+
default:
|
|
105
|
+
result.Metadata[name] = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
async function putObject(bucket, key, content, size, metadata) {
|
|
111
|
+
const params = { Bucket: bucket, Key: key, Body: content, ...splitMetadata(metadata) };
|
|
112
|
+
if (content instanceof Readable && size == null) {
|
|
113
|
+
// S3 needs the object size up front, so a stream of unknown length goes through a multipart
|
|
114
|
+
// upload.
|
|
115
|
+
await new Upload({ client: s3Client, params }).done();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await s3Client.send(new PutObjectCommand({
|
|
119
|
+
...params,
|
|
120
|
+
ContentLength: size ?? Buffer.byteLength(content),
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
export async function uploadS3File(bucket, key, content, size, metadata) {
|
|
65
124
|
try {
|
|
66
125
|
await ensureBucket(bucket);
|
|
67
|
-
await
|
|
126
|
+
await putObject(bucket, key, content, size, metadata);
|
|
68
127
|
}
|
|
69
128
|
catch (error) {
|
|
70
|
-
if (isS3ErrorCode(error, 'NoSuchBucket')) {
|
|
129
|
+
if (isS3ErrorCode(error, 'NoSuchBucket') && !s3Bucket && !(content instanceof Readable)) {
|
|
71
130
|
logger.warn(error);
|
|
72
131
|
await ensureBucket(bucket);
|
|
73
|
-
await
|
|
132
|
+
await putObject(bucket, key, content, size, metadata);
|
|
74
133
|
return;
|
|
75
134
|
}
|
|
76
135
|
logger.error(error);
|
|
77
136
|
throw error;
|
|
78
137
|
}
|
|
79
138
|
}
|
|
139
|
+
export async function uploadS3FileFromPath(bucket, key, path) {
|
|
140
|
+
const { size } = await stat(path);
|
|
141
|
+
await uploadS3File(bucket, key, createReadStream(path), size);
|
|
142
|
+
}
|
|
80
143
|
export async function getS3File(bucket, key) {
|
|
81
144
|
try {
|
|
82
|
-
|
|
145
|
+
const { Body } = await s3Client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
146
|
+
return Body;
|
|
83
147
|
}
|
|
84
148
|
catch (error) {
|
|
85
149
|
logger.error(error);
|
|
@@ -101,52 +165,72 @@ export async function getS3FileBuffer(bucket, key) {
|
|
|
101
165
|
}
|
|
102
166
|
export async function getS3FileStats(bucket, key) {
|
|
103
167
|
try {
|
|
104
|
-
|
|
168
|
+
const { CacheControl, ContentLength, ContentType, ETag, LastModified, Metadata } = await s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
|
169
|
+
return {
|
|
170
|
+
etag: ETag,
|
|
171
|
+
lastModified: LastModified,
|
|
172
|
+
metadata: {
|
|
173
|
+
...(CacheControl && { 'cache-control': CacheControl }),
|
|
174
|
+
...(ContentType && { 'content-type': ContentType }),
|
|
175
|
+
...Metadata,
|
|
176
|
+
},
|
|
177
|
+
size: ContentLength,
|
|
178
|
+
};
|
|
105
179
|
}
|
|
106
180
|
catch (error) {
|
|
107
181
|
logger.error(error);
|
|
108
182
|
throw error;
|
|
109
183
|
}
|
|
110
184
|
}
|
|
111
|
-
|
|
112
|
-
const keys =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
185
|
+
async function listS3Keys(bucket, prefix) {
|
|
186
|
+
const keys = [];
|
|
187
|
+
let continuationToken;
|
|
188
|
+
do {
|
|
189
|
+
const { Contents, NextContinuationToken } = await s3Client.send(new ListObjectsV2Command({
|
|
190
|
+
Bucket: bucket,
|
|
191
|
+
Prefix: prefix,
|
|
192
|
+
ContinuationToken: continuationToken,
|
|
193
|
+
}));
|
|
194
|
+
for (const { Key } of Contents ?? []) {
|
|
195
|
+
if (Key) {
|
|
196
|
+
keys.push(Key);
|
|
118
197
|
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
key,
|
|
128
|
-
lastModified: stats.lastModified,
|
|
129
|
-
metadata: stats.metaData,
|
|
130
|
-
size: stats.size,
|
|
131
|
-
};
|
|
132
|
-
}));
|
|
198
|
+
}
|
|
199
|
+
continuationToken = NextContinuationToken;
|
|
200
|
+
} while (continuationToken);
|
|
201
|
+
return keys;
|
|
202
|
+
}
|
|
203
|
+
export async function listS3Files(bucket, prefix) {
|
|
204
|
+
const keys = await listS3Keys(bucket, prefix);
|
|
205
|
+
return Promise.all(keys.map(async (key) => ({ key, ...(await getS3FileStats(bucket, key)) })));
|
|
133
206
|
}
|
|
134
207
|
export async function setS3BucketPolicy(bucket, policy) {
|
|
135
208
|
try {
|
|
136
209
|
await ensureBucket(bucket);
|
|
137
|
-
await s3Client.
|
|
210
|
+
await s3Client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: policy }));
|
|
138
211
|
}
|
|
139
212
|
catch (error) {
|
|
140
213
|
logger.error(error);
|
|
141
214
|
throw error;
|
|
142
215
|
}
|
|
143
216
|
}
|
|
217
|
+
async function deleteObjects(bucket, keys) {
|
|
218
|
+
for (let index = 0; index < keys.length; index += deleteBatchSize) {
|
|
219
|
+
await s3Client.send(new DeleteObjectsCommand({
|
|
220
|
+
Bucket: bucket,
|
|
221
|
+
Delete: {
|
|
222
|
+
Objects: keys.slice(index, index + deleteBatchSize).map((Key) => ({ Key })),
|
|
223
|
+
Quiet: true,
|
|
224
|
+
},
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
144
228
|
export async function deleteS3Files(bucket, keys) {
|
|
145
229
|
try {
|
|
146
|
-
await
|
|
230
|
+
await deleteObjects(bucket, keys);
|
|
147
231
|
}
|
|
148
232
|
catch (error) {
|
|
149
|
-
if (error
|
|
233
|
+
if (isS3ErrorCode(error, 'NoSuchBucket')) {
|
|
150
234
|
logger.warn(`S3 bucket "${bucket}" does not exist; skipping deletion`);
|
|
151
235
|
return;
|
|
152
236
|
}
|
|
@@ -157,34 +241,35 @@ export async function deleteS3Files(bucket, keys) {
|
|
|
157
241
|
export async function deleteS3File(bucket, key) {
|
|
158
242
|
await deleteS3Files(bucket, [key]);
|
|
159
243
|
}
|
|
244
|
+
export async function deleteAppAssetObjects(appId, assetIds) {
|
|
245
|
+
await deleteS3Files(getAppAssetsBucket(appId), assetIds.map((assetId) => getAppAssetLocation(appId, assetId).key));
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Remove every object this client can reach.
|
|
249
|
+
*
|
|
250
|
+
* In the single-bucket layout this empties the configured bucket. In the bucket-per-app layout
|
|
251
|
+
* this empties and removes every bucket.
|
|
252
|
+
*/
|
|
160
253
|
export async function clearAllS3Buckets() {
|
|
161
254
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
255
|
+
if (s3Bucket) {
|
|
256
|
+
await deleteObjects(s3Bucket, await listS3Keys(s3Bucket));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const { Buckets } = await s3Client.send(new ListBucketsCommand({}));
|
|
260
|
+
for (const { Name: bucket } of Buckets ?? []) {
|
|
164
261
|
try {
|
|
165
|
-
|
|
166
|
-
const objects = [];
|
|
167
|
-
for await (const o of objectsStream) {
|
|
168
|
-
objects.push(o.name);
|
|
169
|
-
}
|
|
170
|
-
await s3Client.removeObjects(bucket.name, objects);
|
|
262
|
+
await deleteObjects(bucket, await listS3Keys(bucket));
|
|
171
263
|
try {
|
|
172
|
-
await s3Client.
|
|
264
|
+
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
|
173
265
|
}
|
|
174
266
|
catch (error) {
|
|
175
|
-
if (isS3ErrorCode(error, 'NoSuchBucket')) {
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
267
|
if (!isS3ErrorCode(error, 'BucketNotEmpty')) {
|
|
179
268
|
throw error;
|
|
180
269
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
remainingObjects.push(o.name);
|
|
185
|
-
}
|
|
186
|
-
await s3Client.removeObjects(bucket.name, remainingObjects);
|
|
187
|
-
await s3Client.removeBucket(bucket.name);
|
|
270
|
+
// Objects uploaded while the bucket was being emptied.
|
|
271
|
+
await deleteObjects(bucket, await listS3Keys(bucket));
|
|
272
|
+
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
|
188
273
|
}
|
|
189
274
|
}
|
|
190
275
|
catch (error) {
|
package/testFixtures.js
CHANGED
|
@@ -27,7 +27,7 @@ export function resolveFixture(path) {
|
|
|
27
27
|
return fileURLToPath(new URL(`__fixtures__/${path}`, baseDir));
|
|
28
28
|
}
|
|
29
29
|
export function readFixture(path, encoding) {
|
|
30
|
-
return readFile(resolveFixture(path), encoding);
|
|
30
|
+
return readFile(resolveFixture(path), encoding ?? null);
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
33
|
* Create a read stream for a fixture.
|