@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 CHANGED
@@ -1,9 +1,9 @@
1
- # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.38.1/config/assets/logo.svg) Appsemble Node Utilities
1
+ # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.39.0/config/assets/logo.svg) Appsemble Node Utilities
2
2
 
3
3
  > NodeJS utilities used by Appsemble internally.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@appsemble/node-utils)](https://www.npmjs.com/package/@appsemble/node-utils)
6
- [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.38.1/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.38.1)
6
+ [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.39.0/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.39.0)
7
7
  [![Prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](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.38.1/LICENSE.md) ©
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()` functions and app asset URLs in CSS to absolute app asset URLs.
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
- const assetUrlMatcher = /url\(\s*asset\(\s*(?:("|')(.*?)\1|([^\s"'()][^)]*))\s*\)\s*\)/g;
2
- const standaloneAssetMatcher = /asset\(\s*(?:("|')(.*?)\1|([^\s"'()][^)]*))\s*\)/g;
3
- const urlMatcher = /url\(\s*(?:("|')(.*?)\1|([^\s"'()][^)]*))\s*\)/g;
4
- const appAssetPathMatcher = /^\/api\/apps\/\d+\/assets\/(.+)$/;
5
- function hasRejectedTokens(value) {
6
- let currentValue = value;
7
- for (let i = 0; i < 3; i += 1) {
8
- if (currentValue.includes('..') || /%2f|%5c/i.test(currentValue)) {
9
- return true;
10
- }
11
- if (!currentValue.includes('%')) {
12
- return false;
13
- }
14
- try {
15
- const decodedValue = decodeURIComponent(currentValue);
16
- if (decodedValue === currentValue) {
17
- return false;
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
- function rewriteAppAssetURL(url, appId, host) {
31
- const trimmedUrl = url.trim();
32
- if (trimmedUrl.startsWith('/')) {
33
- const appAssetPathMatch = appAssetPathMatcher.exec(trimmedUrl);
34
- if (!appAssetPathMatch) {
35
- return trimmedUrl;
36
- }
37
- const appAssetPath = appAssetPathMatch[1];
38
- if (hasRejectedTokens(appAssetPath)) {
39
- return trimmedUrl;
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
- const parsedUrl = new URL(trimmedUrl, host);
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 trimmedUrl;
33
+ return null;
60
34
  }
61
35
  }
62
- function resolveAssetURL(appId, assetId, host) {
63
- if (assetId.startsWith('data:')) {
64
- return assetId;
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 (assetId.startsWith('/')) {
67
- const appAssetPathMatch = appAssetPathMatcher.exec(assetId.trim());
68
- if (appAssetPathMatch && hasRejectedTokens(appAssetPathMatch[1])) {
69
- return null;
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 (/^https?:\/\//.test(assetId)) {
74
- const trimmedAssetId = assetId.trim();
75
- try {
76
- const parsedUrl = new URL(trimmedAssetId, host);
77
- const appAssetPathMatch = appAssetPathMatcher.exec(parsedUrl.pathname);
78
- if (appAssetPathMatch && hasRejectedTokens(appAssetPathMatch[1])) {
79
- return null;
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
- catch {
128
+ const url = new URL(value);
129
+ if (url.origin !== new URL(host).origin) {
83
130
  return null;
84
131
  }
85
- return rewriteAppAssetURL(assetId, appId, host);
132
+ path = url.pathname;
133
+ suffix = `${url.search}${url.hash}`;
86
134
  }
87
- if (hasRejectedTokens(assetId)) {
88
- return null;
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 String(new URL(`/api/apps/${appId}/assets/${assetId}`, host));
184
+ return result;
91
185
  }
92
186
  /**
93
- * Resolve `asset()` functions and app asset URLs in CSS to absolute app asset URLs.
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
- if (!appId) {
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 BucketItemStat } from 'minio';
3
- export declare function setAssetHeaders(ctx: Context, mime: string, filename: string | null, stats?: BucketItemStat): void;
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
- await uploadS3File(`app-${appId}`, id, stream, stats.size);
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.38.1",
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.38.1",
44
- "@appsemble/types": "0.38.1",
45
- "@appsemble/utils": "0.38.1",
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
- import { type BucketItemStat } from 'minio';
3
- export interface S3FileReference {
4
- etag: string;
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: BucketItemStat['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<BucketItemStat>;
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 { Client, S3Error } from 'minio';
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
- export function initS3Client({ accessKey, endPoint, port = 9000, secretKey, useSSL = true, }) {
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 Client({
9
- endPoint,
10
- port,
11
- useSSL,
12
- accessKey,
13
- secretKey,
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
- const bucketExists = await s3Client.bucketExists(name);
24
- if (!bucketExists) {
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
- logger.error(error);
42
- throw error;
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 ensureBucket(bucket);
51
- await s3Client.putObject(bucket, key, content, size, metadata);
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, 'NoSuchBucket') && !(content instanceof Readable)) {
85
+ if (isS3ErrorCode(error, 'BucketAlreadyOwnedByYou')) {
55
86
  logger.warn(error);
56
- await ensureBucket(bucket);
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
- export async function uploadS3FileFromPath(bucket, key, path) {
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 s3Client.fPutObject(bucket, key, path);
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 s3Client.fPutObject(bucket, key, path);
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
- return await s3Client.getObject(bucket, key);
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
- return await s3Client.statObject(bucket, key);
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
- export async function listS3Files(bucket) {
112
- const keys = await new Promise((resolve, reject) => {
113
- const objects = [];
114
- const stream = s3Client.listObjectsV2(bucket, '', true);
115
- stream.on('data', (item) => {
116
- if (item.name) {
117
- objects.push(item.name);
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
- stream.on('error', reject);
121
- stream.on('end', () => resolve(objects));
122
- });
123
- return Promise.all(keys.map(async (key) => {
124
- const stats = await getS3FileStats(bucket, key);
125
- return {
126
- etag: stats.etag,
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.setBucketPolicy(bucket, policy);
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 s3Client.removeObjects(bucket, keys);
230
+ await deleteObjects(bucket, keys);
147
231
  }
148
232
  catch (error) {
149
- if (error instanceof S3Error && error.code === 'NoSuchBucket') {
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
- const buckets = await s3Client.listBuckets();
163
- for (const bucket of buckets) {
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
- const objectsStream = s3Client.listObjectsV2(bucket.name, '', true);
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.removeBucket(bucket.name);
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
- const remainingObjectsStream = s3Client.listObjectsV2(bucket.name, '', true);
182
- const remainingObjects = [];
183
- for await (const o of remainingObjectsStream) {
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.