@appsemble/node-utils 0.36.7-test.0 → 0.36.7

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.36.7-test.0/config/assets/logo.svg) Appsemble Node Utilities
1
+ # ![](https://gitlab.com/appsemble/appsemble/-/raw/0.36.7/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.36.7-test.0/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.36.7-test.0)
6
+ [![GitLab CI](https://gitlab.com/appsemble/appsemble/badges/0.36.7/pipeline.svg)](https://gitlab.com/appsemble/appsemble/-/releases/0.36.7)
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.36.7-test.0/LICENSE.md) ©
29
+ [LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.36.7/LICENSE.md) ©
30
30
  [Appsemble](https://appsemble.com)
package/assets.d.ts CHANGED
@@ -11,6 +11,6 @@ export interface AssetToUpload {
11
11
  mime: string;
12
12
  path: string;
13
13
  }
14
- export declare function uploadAsset(appId: number, asset: AssetToUpload): Promise<string[]>;
14
+ export declare function uploadAsset(appId: number, asset: AssetToUpload): Promise<void>;
15
15
  export declare function uploadAssets(appId: number, assets: AssetToUpload[]): Promise<void>;
16
16
  export {};
package/assets.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { createReadStream } from 'node:fs';
2
2
  import { stat } from 'node:fs/promises';
3
- import sharp from 'sharp';
4
3
  import { logger } from './logger.js';
5
4
  import { uploadS3File } from './s3.js';
6
5
  import { removeUploads } from './uploads.js';
@@ -17,49 +16,30 @@ export function setAssetHeaders(ctx, mime, filename, stats) {
17
16
  ctx.set('Access-Control-Expose-Headers', 'Content-Disposition');
18
17
  ctx.set('Cache-Control', 'max-age=31536000,immutable');
19
18
  }
20
- const ignoredMimes = new Set(['image/avif']);
21
19
  export function getCompressedFileMeta({ filename, mime }) {
22
- if (mime?.startsWith('image') && !ignoredMimes.has(mime)) {
23
- return {
24
- filename: filename
25
- ? filename.includes('.')
26
- ? `${filename.slice(0, filename.lastIndexOf('.'))}.avif`
27
- : `${filename}.avif`
28
- : undefined,
29
- mime: 'image/avif',
30
- };
31
- }
32
20
  return { filename, mime };
33
21
  }
34
22
  export async function uploadAsset(appId, asset) {
35
- const { id, mime, path } = asset;
36
- const filesToUnlink = [path];
37
- let uploadFrom = path;
38
- if (mime?.startsWith('image') && !ignoredMimes.has(mime)) {
39
- uploadFrom = `${path}_compressed`;
40
- await sharp(path).rotate().toFormat('avif').toFile(uploadFrom);
41
- filesToUnlink.push(uploadFrom);
42
- }
23
+ const { id, path } = asset;
43
24
  try {
44
- const stats = await stat(uploadFrom);
45
- const stream = createReadStream(uploadFrom);
25
+ const stats = await stat(path);
26
+ const stream = createReadStream(path);
46
27
  await uploadS3File(`app-${appId}`, id, stream, stats.size);
47
28
  }
48
29
  catch (error) {
49
30
  logger.error(error);
31
+ throw error;
50
32
  }
51
- return filesToUnlink;
52
33
  }
53
34
  export async function uploadAssets(appId, assets) {
54
- const filesToUnlink = [];
55
- for (const asset of assets) {
56
- const toUnlink = await uploadAsset(appId, asset);
57
- for (const path of toUnlink) {
58
- if (!filesToUnlink.includes(path)) {
59
- filesToUnlink.push(path);
60
- }
35
+ const filesToUnlink = [...new Set(assets.map(({ path }) => path))];
36
+ try {
37
+ for (const asset of assets) {
38
+ await uploadAsset(appId, asset);
61
39
  }
62
40
  }
63
- await removeUploads(filesToUnlink);
41
+ finally {
42
+ await removeUploads(filesToUnlink);
43
+ }
64
44
  }
65
45
  //# sourceMappingURL=assets.js.map
package/index.d.ts CHANGED
@@ -22,6 +22,7 @@ export * from './getAppsembleMessages.js';
22
22
  export * from './odata.js';
23
23
  export * from './resource.js';
24
24
  export * from './app.js';
25
+ export * from './uploadValidation.js';
25
26
  export * from './mergeMessages.js';
26
27
  export * from './jsonschema.js';
27
28
  export * from './organizationBlocklist.js';
package/index.js CHANGED
@@ -22,6 +22,7 @@ export * from './getAppsembleMessages.js';
22
22
  export * from './odata.js';
23
23
  export * from './resource.js';
24
24
  export * from './app.js';
25
+ export * from './uploadValidation.js';
25
26
  export * from './mergeMessages.js';
26
27
  export * from './jsonschema.js';
27
28
  export * from './organizationBlocklist.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appsemble/node-utils",
3
- "version": "0.36.7-test.0",
3
+ "version": "0.36.7",
4
4
  "description": "NodeJS utilities used by Appsemble internally.",
5
5
  "keywords": [
6
6
  "app",
@@ -40,11 +40,12 @@
40
40
  "test": "vitest"
41
41
  },
42
42
  "dependencies": {
43
- "@appsemble/lang-sdk": "0.36.7-test.0",
44
- "@appsemble/types": "0.36.7-test.0",
45
- "@appsemble/utils": "0.36.7-test.0",
43
+ "@appsemble/lang-sdk": "0.36.7",
44
+ "@appsemble/types": "0.36.7",
45
+ "@appsemble/utils": "0.36.7",
46
46
  "@formatjs/fast-memoize": "^2.0.0",
47
47
  "@fortawesome/fontawesome-free": "^6.0.0",
48
+ "@inquirer/prompts": "^8.0.0",
48
49
  "@kubernetes/client-node": "1.4.0",
49
50
  "@odata/parser": "^0.2.0",
50
51
  "@types/koa": "^2.0.0",
@@ -62,9 +63,9 @@
62
63
  "date-fns": "^2.0.0",
63
64
  "express-to-koa": "^2.0.0",
64
65
  "fast-glob": "^3.0.0",
66
+ "file-type": "^21.3.3",
65
67
  "form-data": "^4.0.4",
66
68
  "fs-extra": "^11.0.0",
67
- "@inquirer/prompts": "^8.0.0",
68
69
  "intl-messageformat": "^11.0.0",
69
70
  "jsonschema": "~1.4.1",
70
71
  "keytar": "^7.0.0",
package/resource.d.ts CHANGED
@@ -67,4 +67,4 @@ export declare function extractResourceBody(ctx: Context | ParameterizedContext<
67
67
  export declare function processResourceBody(ctx: Context | ParameterizedContext<DefaultState, DefaultContext, any>, definition: ResourceDefinition, knownAssetIds?: string[], knownExpires?: Date, knownAssetNameIds?: {
68
68
  id: string;
69
69
  name?: string;
70
- }[], isPatch?: boolean, resourceBody?: SerializedServerResourceBody): [Record<string, unknown> | Record<string, unknown>[], PreparedAsset[], string[]];
70
+ }[], isPatch?: boolean, resourceBody?: SerializedServerResourceBody): Promise<[Record<string, unknown> | Record<string, unknown>[], PreparedAsset[], string[]]>;
package/resource.js CHANGED
@@ -7,6 +7,7 @@ import parseDuration from 'parse-duration';
7
7
  import { preProcessCSV } from './csv.js';
8
8
  import { handleValidatorResult, TempFile } from './index.js';
9
9
  import { throwKoaError } from './koa.js';
10
+ import { AssetUploadValidationError, validateUploadedFile } from './uploadValidation.js';
10
11
  export function stripResource({ $author, $created, $editor, $ephemeral, $group, $seed, $updated, ...data }) {
11
12
  return data;
12
13
  }
@@ -161,11 +162,24 @@ export function extractResourceBody(ctx, suppliedBody) {
161
162
  * 2. A list of newly uploaded assets which should be linked to the resources.
162
163
  * 3. Asset IDs from the `knownAssetIds` array which are no longer used.
163
164
  */
164
- export function processResourceBody(ctx, definition, knownAssetIds = [], knownExpires, knownAssetNameIds = [], isPatch = false, resourceBody) {
165
+ export async function processResourceBody(ctx, definition, knownAssetIds = [], knownExpires, knownAssetNameIds = [], isPatch = false, resourceBody) {
165
166
  const [resource, assets, preValidateProperty] = extractResourceBody(ctx, resourceBody);
166
167
  const validator = new Validator();
167
168
  const reusedAssets = new Set();
168
169
  const usedAssetIndices = new Set();
170
+ const validatedAssetMimes = new Map();
171
+ const customErrors = [];
172
+ for (const [index, asset] of assets.entries()) {
173
+ try {
174
+ validatedAssetMimes.set(index, await validateUploadedFile(asset));
175
+ }
176
+ catch (error) {
177
+ if (!(error instanceof AssetUploadValidationError)) {
178
+ throw error;
179
+ }
180
+ customErrors.push(new ValidationError(error.message, index, undefined, ['assets', index], 'binary', 'content'));
181
+ }
182
+ }
169
183
  const thumbnailAssetSuffix = '-thumbnail.png';
170
184
  const [thumbnailAssets, regularAssets] = partition(
171
185
  // Preserve original index to handle asset references in data
@@ -175,7 +189,7 @@ export function processResourceBody(ctx, definition, knownAssetIds = [], knownEx
175
189
  asset: {
176
190
  id: randomUUID(),
177
191
  filename,
178
- mime,
192
+ mime: validatedAssetMimes.get(index) ?? mime,
179
193
  path,
180
194
  },
181
195
  }));
@@ -247,7 +261,6 @@ export function processResourceBody(ctx, definition, knownAssetIds = [], knownEx
247
261
  ])),
248
262
  },
249
263
  };
250
- const customErrors = [];
251
264
  const expiresDuration = definition.expires ? parseDuration(definition.expires) : undefined;
252
265
  const result = validator.validate(resource, Array.isArray(resource) ? { type: 'array', items: patchedSchema } : patchedSchema, {
253
266
  base: '#',
@@ -1,6 +1,16 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { copyFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import { isDeepStrictEqual } from 'node:util';
3
- import { getResourceDefinition, processResourceBody, uploadAssets, } from '../../../../../index.js';
6
+ import { getResourceDefinition, processResourceBody, } from '../../../../../index.js';
7
+ function clonePreparedAssets(preparedAssets) {
8
+ return Promise.all(preparedAssets.map(async (asset) => {
9
+ const path = join(tmpdir(), `${Date.now()}-${randomUUID()}`);
10
+ await copyFile(asset.path, path);
11
+ return { ...asset, path };
12
+ }));
13
+ }
4
14
  /**
5
15
  * Create a controller for resource creation.
6
16
  *
@@ -29,15 +39,16 @@ export function createCreateAppResourceController(options) {
29
39
  });
30
40
  const resourceDefinition = getResourceDefinition(app.definition, resourceType, ctx);
31
41
  const appAssets = await getAppAssets({ app, context: ctx });
32
- const [processedBody, preparedAssets] = processResourceBody(ctx, resourceDefinition, appAssets.map(({ id }) => id), undefined, appAssets.map((appAsset) => ({ id: appAsset.id, name: appAsset.name })));
42
+ const [processedBody, preparedAssets] = await processResourceBody(ctx, resourceDefinition, appAssets.map(({ id }) => id), undefined, appAssets.map((appAsset) => ({ id: appAsset.id, name: appAsset.name })));
33
43
  if (Array.isArray(processedBody) && !processedBody.length) {
34
44
  ctx.body = [];
35
45
  return;
36
46
  }
37
47
  const resources = Array.isArray(processedBody) ? processedBody : [processedBody];
38
- const assetsToUpload = [];
39
48
  if (!(ctx.client && 'app' in ctx.client) && query?.seed === 'true') {
40
- const preparedSeedAssets = structuredClone(preparedAssets);
49
+ const preparedSeedAssets = app.demoMode
50
+ ? await clonePreparedAssets(structuredClone(preparedAssets))
51
+ : structuredClone(preparedAssets);
41
52
  const preparedSeedResources = resources.map((resource) => {
42
53
  const cleanResource = { ...resource };
43
54
  if (app.demoMode) {
@@ -75,12 +86,8 @@ export function createCreateAppResourceController(options) {
75
86
  });
76
87
  if (!app.demoMode) {
77
88
  ctx.body = Array.isArray(processedBody) ? createdSeedResources : createdSeedResources[0];
78
- // @ts-expect-error 2345 argument of type is not assignable to parameter of type
79
- // (strictNullChecks)
80
- await uploadAssets(app.id, preparedSeedAssets);
81
89
  return;
82
90
  }
83
- assetsToUpload.push(...preparedSeedAssets);
84
91
  }
85
92
  const createdResources = await createAppResourcesWithAssets({
86
93
  app,
@@ -96,10 +103,6 @@ export function createCreateAppResourceController(options) {
96
103
  resourceType,
97
104
  options,
98
105
  });
99
- assetsToUpload.push(...preparedAssets);
100
- // @ts-expect-error 2345 argument of type is not assignable to parameter of type
101
- // (strictNullChecks)
102
- await uploadAssets(app.id, assetsToUpload);
103
106
  ctx.body = Array.isArray(processedBody) ? createdResources : createdResources[0];
104
107
  };
105
108
  }
@@ -36,7 +36,7 @@ export function createUpdateAppResourceController(options) {
36
36
  });
37
37
  const appAssets = await getAppAssets({ context: ctx, app });
38
38
  const resourceDefinition = getResourceDefinition(app.definition, resourceType, ctx);
39
- const [processedBody, preparedAssets, deletedAssetIds] = processResourceBody(ctx, resourceDefinition, appAssets.filter((asset) => asset.resourceId === resourceId).map((asset) => asset.id), oldResource.expires, appAssets.map((asset) => ({ id: asset.id, name: asset.name })));
39
+ const [processedBody, preparedAssets, deletedAssetIds] = await processResourceBody(ctx, resourceDefinition, appAssets.filter((asset) => asset.resourceId === resourceId).map((asset) => asset.id), oldResource.expires, appAssets.map((asset) => ({ id: asset.id, name: asset.name })));
40
40
  const resources = Array.isArray(processedBody) ? processedBody : [processedBody];
41
41
  ctx.body = await updateAppResource({
42
42
  app,
@@ -1,16 +1,27 @@
1
- import { assertKoaCondition } from '../../../../../koa.js';
1
+ import { assertKoaCondition, throwKoaError } from '../../../../../koa.js';
2
+ import { AssetUploadValidationError, validateUploadedFile, } from '../../../../../uploadValidation.js';
2
3
  export function createCreateAppAssetController({ createAppAsset, getApp }) {
3
4
  return async (ctx) => {
4
5
  const { pathParams: { appId }, request: { body: { file: { filename, mime, path }, name, }, }, } = ctx;
5
6
  const app = await getApp({ context: ctx, query: { attributes: ['id'], where: { id: appId } } });
6
7
  assertKoaCondition(app != null, ctx, 404, 'App not found');
8
+ let validatedMime;
9
+ try {
10
+ validatedMime = await validateUploadedFile({ filename, mime, path });
11
+ }
12
+ catch (error) {
13
+ if (error instanceof AssetUploadValidationError) {
14
+ throwKoaError(ctx, 400, error.message);
15
+ }
16
+ throw error;
17
+ }
7
18
  const asset = await createAppAsset({
8
19
  app,
9
20
  context: ctx,
10
- payload: { filename, mime, name, path },
21
+ payload: { filename, mime: validatedMime, name, path },
11
22
  });
12
23
  ctx.status = 201;
13
- ctx.body = { id: asset.id, mime, filename, name };
24
+ ctx.body = { id: asset.id, mime: asset.mime, filename, name };
14
25
  };
15
26
  }
16
27
  //# sourceMappingURL=createCreateAppAssetController.js.map
@@ -0,0 +1,12 @@
1
+ import { MimeTypeCategory } from '@appsemble/utils';
2
+ interface UploadedFileLike {
3
+ filename?: string | null;
4
+ mime: string;
5
+ path: string;
6
+ }
7
+ export declare class AssetUploadValidationError extends Error {
8
+ readonly Category: MimeTypeCategory.Image | MimeTypeCategory.Video;
9
+ constructor(message: string, category: MimeTypeCategory.Image | MimeTypeCategory.Video);
10
+ }
11
+ export declare function validateUploadedFile({ mime, path }: UploadedFileLike): Promise<string>;
12
+ export {};
@@ -0,0 +1,121 @@
1
+ // CSpell:words theora
2
+ import { open, stat } from 'node:fs/promises';
3
+ import { MimeTypeCategory, getMimeTypeCategory } from '@appsemble/utils';
4
+ import { fileTypeFromBuffer } from 'file-type';
5
+ import { lookup } from 'mime-types';
6
+ import sharp from 'sharp';
7
+ const headerLength = 4096;
8
+ export class AssetUploadValidationError extends Error {
9
+ constructor(message, category) {
10
+ super(message);
11
+ this.name = 'AssetUploadValidationError';
12
+ this.Category = category;
13
+ }
14
+ }
15
+ function detectSvgMime(buffer) {
16
+ const content = buffer
17
+ .toString('utf8')
18
+ .replace(/^\uFEFF/u, '')
19
+ .trimStart();
20
+ if (!content.startsWith('<')) {
21
+ return null;
22
+ }
23
+ return /<svg[\s>]/i.test(content) ? 'image/svg+xml' : null;
24
+ }
25
+ function detectOggVideoMime(buffer, declaredMime) {
26
+ if (buffer.length < 4 || buffer.toString('ascii', 0, 4) !== 'OggS') {
27
+ return null;
28
+ }
29
+ const content = buffer.toString('ascii').toLowerCase();
30
+ if (content.includes('theora') || declaredMime === 'video/ogg') {
31
+ return 'video/ogg';
32
+ }
33
+ return null;
34
+ }
35
+ function normalizeImageMime(format) {
36
+ const mime = lookup(format);
37
+ if (mime) {
38
+ return mime;
39
+ }
40
+ if (format === 'heif') {
41
+ return 'image/heif';
42
+ }
43
+ return null;
44
+ }
45
+ function normalizeDetectedMime(mime) {
46
+ if (mime === 'video/vnd.avi') {
47
+ return 'video/x-msvideo';
48
+ }
49
+ return mime;
50
+ }
51
+ async function readHeader(path) {
52
+ const file = await open(path, 'r');
53
+ try {
54
+ const { size } = await file.stat();
55
+ const buffer = Buffer.alloc(Math.min(size, headerLength));
56
+ if (buffer.length) {
57
+ await file.read(buffer, 0, buffer.length, 0);
58
+ }
59
+ return buffer;
60
+ }
61
+ finally {
62
+ await file.close();
63
+ }
64
+ }
65
+ async function detectMime(buffer, declaredMime) {
66
+ const svgMime = detectSvgMime(buffer);
67
+ if (svgMime) {
68
+ return svgMime;
69
+ }
70
+ const detected = await fileTypeFromBuffer(buffer);
71
+ if (detected) {
72
+ return normalizeDetectedMime(detected.mime);
73
+ }
74
+ return detectOggVideoMime(buffer, declaredMime);
75
+ }
76
+ export async function validateUploadedFile({ mime, path }) {
77
+ const declaredCategory = getMimeTypeCategory(mime);
78
+ const { size } = await stat(path);
79
+ const buffer = await readHeader(path);
80
+ const detectedMime = await detectMime(buffer, mime);
81
+ const detectedCategory = detectedMime ? getMimeTypeCategory(detectedMime) : null;
82
+ const category = detectedCategory ?? declaredCategory;
83
+ if (category !== MimeTypeCategory.Image && category !== MimeTypeCategory.Video) {
84
+ return mime;
85
+ }
86
+ if (size === 0) {
87
+ throw new AssetUploadValidationError(`${category === MimeTypeCategory.Image ? 'Image' : 'Video'} uploads cannot be empty`, category);
88
+ }
89
+ if (declaredCategory &&
90
+ detectedCategory &&
91
+ declaredCategory !== detectedCategory &&
92
+ [MimeTypeCategory.Image, MimeTypeCategory.Video].includes(declaredCategory)) {
93
+ throw new AssetUploadValidationError(declaredCategory === MimeTypeCategory.Image
94
+ ? 'Image uploads must contain a valid image'
95
+ : 'Video uploads must contain a supported video container', declaredCategory);
96
+ }
97
+ if (declaredCategory === MimeTypeCategory.Image || detectedCategory === MimeTypeCategory.Image) {
98
+ try {
99
+ const metadata = await sharp(path).metadata();
100
+ if (!metadata.format) {
101
+ throw new Error('Missing image format');
102
+ }
103
+ const normalizedMime = normalizeImageMime(metadata.format);
104
+ if (!normalizedMime) {
105
+ throw new Error(`Unsupported image format: ${metadata.format}`);
106
+ }
107
+ return normalizedMime;
108
+ }
109
+ catch {
110
+ throw new AssetUploadValidationError('Image uploads must contain a valid image', MimeTypeCategory.Image);
111
+ }
112
+ }
113
+ if (declaredCategory === MimeTypeCategory.Video || detectedCategory === MimeTypeCategory.Video) {
114
+ if (detectedCategory !== MimeTypeCategory.Video || !detectedMime) {
115
+ throw new AssetUploadValidationError('Video uploads must contain a supported video container', MimeTypeCategory.Video);
116
+ }
117
+ return detectedMime;
118
+ }
119
+ return mime;
120
+ }
121
+ //# sourceMappingURL=uploadValidation.js.map