@appsemble/node-utils 0.37.4 → 0.37.5

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.37.4/config/assets/logo.svg) Appsemble Node Utilities
1
+ # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.37.5/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.37.4/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.37.4)
6
+ [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.37.5/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.37.5)
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.37.4/LICENSE.md) ©
29
+ [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.37.5/LICENSE.md) ©
30
30
  [Appsemble](https://appsemble.com)
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Resolve `asset()` functions and app asset URLs in CSS to absolute app asset URLs.
3
+ *
4
+ * @param css The CSS to process.
5
+ * @param appId The id of the app the assets belong to.
6
+ * @param host The host on which the app asset endpoints are available.
7
+ * @returns The CSS with all app asset references resolved.
8
+ */
9
+ export declare function replaceAssetFunctions(css: string, appId: number | undefined, host: string): string;
package/assetCssURL.js ADDED
@@ -0,0 +1,134 @@
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;
29
+ }
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
+ }
43
+ 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);
57
+ }
58
+ catch {
59
+ return trimmedUrl;
60
+ }
61
+ }
62
+ function resolveAssetURL(appId, assetId, host) {
63
+ if (assetId.startsWith('data:')) {
64
+ return assetId;
65
+ }
66
+ if (assetId.startsWith('/')) {
67
+ const appAssetPathMatch = appAssetPathMatcher.exec(assetId.trim());
68
+ if (appAssetPathMatch && hasRejectedTokens(appAssetPathMatch[1])) {
69
+ return null;
70
+ }
71
+ return rewriteAppAssetURL(assetId, appId, host);
72
+ }
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
+ }
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ return rewriteAppAssetURL(assetId, appId, host);
86
+ }
87
+ if (hasRejectedTokens(assetId)) {
88
+ return null;
89
+ }
90
+ return String(new URL(`/api/apps/${appId}/assets/${assetId}`, host));
91
+ }
92
+ /**
93
+ * Resolve `asset()` functions and app asset URLs in CSS to absolute app asset URLs.
94
+ *
95
+ * @param css The CSS to process.
96
+ * @param appId The id of the app the assets belong to.
97
+ * @param host The host on which the app asset endpoints are available.
98
+ * @returns The CSS with all app asset references resolved.
99
+ */
100
+ 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
+ });
133
+ }
134
+ //# sourceMappingURL=assetCssURL.js.map
@@ -0,0 +1,3 @@
1
+ import { type BlockManifest } from '@appsemble/types';
2
+ export declare function isValidBlockAssetFilename(filename: string): boolean;
3
+ export declare function getBlockAssetDownloadUrl(blockUrl: string, fileUrls: BlockManifest['fileUrls'], filename: string): string;
@@ -0,0 +1,22 @@
1
+ const blockAssetUnsafePathPattern = /(^|\/)\.{1,2}(\/|$)|^\/|\\/;
2
+ export function isValidBlockAssetFilename(filename) {
3
+ if (!filename || blockAssetUnsafePathPattern.test(filename)) {
4
+ return false;
5
+ }
6
+ for (let i = 0; i < filename.length; i += 1) {
7
+ if (filename.charCodeAt(i) < 0x20) {
8
+ return false;
9
+ }
10
+ }
11
+ return true;
12
+ }
13
+ export function getBlockAssetDownloadUrl(blockUrl, fileUrls, filename) {
14
+ const fileUrl = fileUrls && Object.hasOwn(fileUrls, filename) ? fileUrls[filename] : undefined;
15
+ if (fileUrl) {
16
+ return fileUrl;
17
+ }
18
+ const fallbackUrl = new URL(`${blockUrl.replace(/\/+$/, '')}/asset`);
19
+ fallbackUrl.searchParams.set('filename', filename);
20
+ return String(fallbackUrl);
21
+ }
22
+ //# sourceMappingURL=getBlockAssetDownloadUrl.js.map
package/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export * from './icon.js';
19
19
  export * from './readAsset.js';
20
20
  export * from './render.js';
21
21
  export * from './getAppsembleMessages.js';
22
+ export * from './getBlockAssetDownloadUrl.js';
22
23
  export * from './odata.js';
23
24
  export * from './resource.js';
24
25
  export * from './resourceEtag.js';
@@ -43,6 +44,7 @@ export * from './container/index.js';
43
44
  export * from './s3.js';
44
45
  export * from './uploads.js';
45
46
  export * from './assets.js';
47
+ export * from './assetCssURL.js';
46
48
  export * from './getValidTrainings.js';
47
49
  export * from './PhoneNumberValidationError.js';
48
50
  export * from './createUser.js';
package/index.js CHANGED
@@ -19,6 +19,7 @@ export * from './icon.js';
19
19
  export * from './readAsset.js';
20
20
  export * from './render.js';
21
21
  export * from './getAppsembleMessages.js';
22
+ export * from './getBlockAssetDownloadUrl.js';
22
23
  export * from './odata.js';
23
24
  export * from './resource.js';
24
25
  export * from './resourceEtag.js';
@@ -43,6 +44,7 @@ export * from './container/index.js';
43
44
  export * from './s3.js';
44
45
  export * from './uploads.js';
45
46
  export * from './assets.js';
47
+ export * from './assetCssURL.js';
46
48
  export * from './getValidTrainings.js';
47
49
  export * from './PhoneNumberValidationError.js';
48
50
  export * from './createUser.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/node-utils",
3
- "version": "0.37.4",
3
+ "version": "0.37.5",
4
4
  "description": "NodeJS utilities used by Appsemble internally.",
5
5
  "keywords": [
6
6
  "app",
@@ -40,9 +40,9 @@
40
40
  "test": "vitest"
41
41
  },
42
42
  "dependencies": {
43
- "@appsemble/lang-sdk": "0.37.4",
44
- "@appsemble/types": "0.37.4",
45
- "@appsemble/utils": "0.37.4",
43
+ "@appsemble/lang-sdk": "0.37.5",
44
+ "@appsemble/types": "0.37.5",
45
+ "@appsemble/utils": "0.37.5",
46
46
  "@formatjs/fast-memoize": "^2.0.0",
47
47
  "@fortawesome/fontawesome-free": "^6.0.0",
48
48
  "@inquirer/prompts": "^8.0.0",
@@ -85,7 +85,7 @@
85
85
  "logform": "^2.0.0",
86
86
  "memfs": "4.57.8",
87
87
  "mime-types": "^2.0.0",
88
- "minio": "^8.0.3",
88
+ "minio": "^8.0.7",
89
89
  "mustache": "^4.0.0",
90
90
  "openapi-types": "^12.1.3",
91
91
  "parse-duration": "^1.0.0",
package/s3.d.ts CHANGED
@@ -1,5 +1,12 @@
1
- import { type Readable } from 'node:stream';
1
+ import { Readable } from 'node:stream';
2
2
  import { type BucketItemStat } from 'minio';
3
+ export interface S3FileReference {
4
+ etag: string;
5
+ key: string;
6
+ lastModified: Date;
7
+ metadata: BucketItemStat['metaData'];
8
+ size: number;
9
+ }
3
10
  export interface InitS3ClientParams {
4
11
  endPoint: string;
5
12
  port?: number;
@@ -8,11 +15,13 @@ export interface InitS3ClientParams {
8
15
  secretKey: string;
9
16
  }
10
17
  export declare function initS3Client({ accessKey, endPoint, port, secretKey, useSSL, }: InitS3ClientParams): void;
11
- export declare function uploadS3File(bucket: string, key: string, content: Buffer | Readable | string, size?: number): Promise<void>;
18
+ export declare function uploadS3File(bucket: string, key: string, content: Buffer | Readable | string, size?: number, metadata?: Record<string, string>): Promise<void>;
12
19
  export declare function uploadS3FileFromPath(bucket: string, key: string, path: string): Promise<void>;
13
20
  export declare function getS3File(bucket: string, key: string): Promise<Readable>;
14
21
  export declare function getS3FileBuffer(bucket: string, key: string): Promise<Buffer>;
15
22
  export declare function getS3FileStats(bucket: string, key: string): Promise<BucketItemStat>;
23
+ export declare function listS3Files(bucket: string): Promise<S3FileReference[]>;
24
+ export declare function setS3BucketPolicy(bucket: string, policy: string): Promise<void>;
16
25
  export declare function deleteS3Files(bucket: string, keys: string[]): Promise<void>;
17
26
  export declare function deleteS3File(bucket: string, key: string): Promise<void>;
18
27
  export declare function clearAllS3Buckets(): Promise<void>;
package/s3.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Readable } from 'node:stream';
1
2
  import { buffer as streamToBuffer } from 'node:stream/consumers';
2
3
  import { Client, S3Error } from 'minio';
3
4
  import { logger } from './logger.js';
@@ -41,12 +42,21 @@ async function ensureBucket(name) {
41
42
  throw error;
42
43
  }
43
44
  }
44
- export async function uploadS3File(bucket, key, content, size) {
45
+ function isS3ErrorCode(error, code) {
46
+ return error instanceof S3Error && error.code === code;
47
+ }
48
+ export async function uploadS3File(bucket, key, content, size, metadata) {
45
49
  try {
46
50
  await ensureBucket(bucket);
47
- await s3Client.putObject(bucket, key, content, size);
51
+ await s3Client.putObject(bucket, key, content, size, metadata);
48
52
  }
49
53
  catch (error) {
54
+ if (isS3ErrorCode(error, 'NoSuchBucket') && !(content instanceof Readable)) {
55
+ logger.warn(error);
56
+ await ensureBucket(bucket);
57
+ await s3Client.putObject(bucket, key, content, size, metadata);
58
+ return;
59
+ }
50
60
  logger.error(error);
51
61
  throw error;
52
62
  }
@@ -57,6 +67,12 @@ export async function uploadS3FileFromPath(bucket, key, path) {
57
67
  await s3Client.fPutObject(bucket, key, path);
58
68
  }
59
69
  catch (error) {
70
+ if (isS3ErrorCode(error, 'NoSuchBucket')) {
71
+ logger.warn(error);
72
+ await ensureBucket(bucket);
73
+ await s3Client.fPutObject(bucket, key, path);
74
+ return;
75
+ }
60
76
  logger.error(error);
61
77
  throw error;
62
78
  }
@@ -92,6 +108,39 @@ export async function getS3FileStats(bucket, key) {
92
108
  throw error;
93
109
  }
94
110
  }
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);
118
+ }
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
+ }));
133
+ }
134
+ export async function setS3BucketPolicy(bucket, policy) {
135
+ try {
136
+ await ensureBucket(bucket);
137
+ await s3Client.setBucketPolicy(bucket, policy);
138
+ }
139
+ catch (error) {
140
+ logger.error(error);
141
+ throw error;
142
+ }
143
+ }
95
144
  export async function deleteS3Files(bucket, keys) {
96
145
  try {
97
146
  await s3Client.removeObjects(bucket, keys);
@@ -112,13 +161,37 @@ export async function clearAllS3Buckets() {
112
161
  try {
113
162
  const buckets = await s3Client.listBuckets();
114
163
  for (const bucket of buckets) {
115
- const objectsStream = s3Client.listObjectsV2(bucket.name, '', true);
116
- const objects = [];
117
- for await (const o of objectsStream) {
118
- objects.push(o.name);
164
+ 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);
171
+ try {
172
+ await s3Client.removeBucket(bucket.name);
173
+ }
174
+ catch (error) {
175
+ if (isS3ErrorCode(error, 'NoSuchBucket')) {
176
+ continue;
177
+ }
178
+ if (!isS3ErrorCode(error, 'BucketNotEmpty')) {
179
+ throw error;
180
+ }
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);
188
+ }
189
+ }
190
+ catch (error) {
191
+ if (!isS3ErrorCode(error, 'NoSuchBucket')) {
192
+ throw error;
193
+ }
119
194
  }
120
- await s3Client.removeObjects(bucket.name, objects);
121
- await s3Client.removeBucket(bucket.name);
122
195
  }
123
196
  }
124
197
  catch (error) {
@@ -6,8 +6,17 @@ export function createBlockAssetHandler({ getBlockAsset }) {
6
6
  params: { filename, name, version }, } = ctx;
7
7
  const blockAsset = await getBlockAsset({ filename, name, version, context: ctx });
8
8
  assertKoaCondition(blockAsset != null, ctx, 404, 'Block asset not found');
9
- ctx.set('Cache-Control', 'max-age=31536000,immutable');
10
- ctx.body = blockAsset.content;
9
+ ctx.set('Cache-Control', 'public,max-age=31536000,immutable');
10
+ if (blockAsset.size != null) {
11
+ ctx.set('Content-Length', String(blockAsset.size));
12
+ }
13
+ if (blockAsset.etag) {
14
+ ctx.set('ETag', blockAsset.etag);
15
+ }
16
+ if (blockAsset.lastModified) {
17
+ ctx.set('Last-Modified', blockAsset.lastModified.toUTCString());
18
+ }
19
+ ctx.body = blockAsset.stream ?? blockAsset.content;
11
20
  ctx.type = blockAsset.mime;
12
21
  };
13
22
  }
package/server/types.d.ts CHANGED
@@ -391,7 +391,11 @@ export interface AppAsset extends Asset {
391
391
  export interface ProjectAsset {
392
392
  filename: string;
393
393
  mime: string;
394
- content: Buffer;
394
+ content?: Buffer;
395
+ stream?: Readable;
396
+ size?: number;
397
+ etag?: string;
398
+ lastModified?: Date;
395
399
  }
396
400
  export interface Block extends BlockDefinition {
397
401
  OrganizationId: string;