@openstax/ts-utils 1.31.0 → 1.31.2

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.
@@ -38,7 +38,7 @@ export type ApiClientResponse<Ro> = Ro extends any ? {
38
38
  } : never;
39
39
  export type ExpandRoute<T> = T extends ((...args: infer A) => infer R) & {
40
40
  renderUrl: (...args: infer Ar) => Promise<string>;
41
- } ? (...args: A) => R & {
41
+ } ? ((...args: A) => R) & {
42
42
  renderUrl: (...args: Ar) => Promise<string>;
43
43
  } : never;
44
44
  export type MapRoutesToClient<Ru> = [Ru] extends [AnyRoute<Ru>] ? {
@@ -13,6 +13,18 @@ export declare const isFolderValue: (thing: any) => thing is FolderValue;
13
13
  export interface FileServerAdapter {
14
14
  putFileContent: (source: FileValue, content: string) => Promise<FileValue>;
15
15
  getSignedViewerUrl: (source: FileValue) => Promise<string>;
16
+ getPublicViewerUrl: (source: FileValue) => Promise<string>;
16
17
  getFileContent: (source: FileValue) => Promise<Buffer>;
18
+ getSignedFileUploadConfig: () => Promise<{
19
+ url: string;
20
+ payload: {
21
+ [key: string]: string;
22
+ };
23
+ }>;
24
+ copyFileTo: (source: FileValue, destinationPath: string) => Promise<FileValue>;
25
+ copyFileToDirectory: (source: FileValue, destinationDirectory: string) => Promise<FileValue>;
26
+ isTemporaryUpload: (source: FileValue) => boolean;
27
+ getFileChecksum: (source: FileValue) => Promise<string>;
28
+ filesEqual: (sourceA: FileValue, sourceB: FileValue) => Promise<boolean>;
17
29
  }
18
30
  export declare const isFileOrFolder: (thing: any) => thing is FileValue | FolderValue;
@@ -1,16 +1,18 @@
1
1
  /* cspell:ignore originalname */
2
+ import crypto from 'crypto';
2
3
  import fs from 'fs';
3
4
  import https from 'https';
4
5
  import path from 'path';
5
6
  import cors from 'cors';
6
7
  import express from 'express';
7
8
  import multer from 'multer';
9
+ import { v4 as uuid } from 'uuid';
8
10
  import { assertString } from '../../assertions';
9
11
  import { resolveConfigValue } from '../../config';
10
12
  import { ifDefined } from '../../guards';
11
- import { once } from '../../misc/helpers';
13
+ import { memoize } from '../../misc/helpers';
12
14
  /* istanbul ignore next */
13
- const startServer = once((port, uploadDir) => {
15
+ const startServer = memoize((port, uploadDir) => {
14
16
  // TODO - re-evaluate the `preservePath` behavior to match whatever s3 does
15
17
  const upload = multer({ dest: uploadDir, preservePath: true });
16
18
  const fileServerApp = express();
@@ -55,6 +57,9 @@ export const localFileServer = (initializer) => (configProvider) => {
55
57
  const getSignedViewerUrl = async (source) => {
56
58
  return `https://${await host}:${await port}/${source.path}`;
57
59
  };
60
+ const getPublicViewerUrl = async (source) => {
61
+ return `https://${await host}:${await port}/${source.path}`;
62
+ };
58
63
  const getFileContent = async (source) => {
59
64
  const filePath = path.join(await fileDir, source.path);
60
65
  return fs.promises.readFile(filePath);
@@ -66,9 +71,55 @@ export const localFileServer = (initializer) => (configProvider) => {
66
71
  await fs.promises.writeFile(filePath, content);
67
72
  return source;
68
73
  };
74
+ const getSignedFileUploadConfig = async () => {
75
+ const prefix = 'uploads/' + uuid();
76
+ return {
77
+ url: `https://${await host}:${await port}/`,
78
+ payload: {
79
+ key: prefix + '/${filename}',
80
+ }
81
+ };
82
+ };
83
+ const copyFileTo = async (source, destinationPath) => {
84
+ const sourcePath = path.join(await fileDir, source.path);
85
+ const destPath = path.join(await fileDir, destinationPath);
86
+ const destDirectory = path.dirname(destPath);
87
+ await fs.promises.mkdir(destDirectory, { recursive: true });
88
+ await fs.promises.copyFile(sourcePath, destPath);
89
+ return {
90
+ ...source,
91
+ path: destinationPath
92
+ };
93
+ };
94
+ const copyFileToDirectory = async (source, destination) => {
95
+ const destinationPath = path.join(destination, source.label);
96
+ return copyFileTo(source, destinationPath);
97
+ };
98
+ const isTemporaryUpload = (source) => {
99
+ return source.path.indexOf('uploads/') === 0;
100
+ };
101
+ const getFileChecksum = async (source) => {
102
+ const filePath = path.join(await fileDir, source.path);
103
+ const fileContent = await fs.promises.readFile(filePath);
104
+ return crypto.createHash('md5').update(fileContent).digest('hex');
105
+ };
106
+ const filesEqual = async (sourceA, sourceB) => {
107
+ const [aSum, bSum] = await Promise.all([
108
+ getFileChecksum(sourceA),
109
+ getFileChecksum(sourceB)
110
+ ]);
111
+ return aSum === bSum;
112
+ };
69
113
  return {
70
114
  getSignedViewerUrl,
115
+ getPublicViewerUrl,
71
116
  getFileContent,
72
117
  putFileContent,
118
+ getSignedFileUploadConfig,
119
+ copyFileTo,
120
+ copyFileToDirectory,
121
+ isTemporaryUpload,
122
+ getFileChecksum,
123
+ filesEqual,
73
124
  };
74
125
  };
@@ -4,6 +4,7 @@ import { FileServerAdapter } from '.';
4
4
  export type Config = {
5
5
  bucketName: string;
6
6
  bucketRegion: string;
7
+ publicViewerDomain?: string;
7
8
  };
8
9
  interface Initializer<C> {
9
10
  configSpace?: C;
@@ -1,6 +1,9 @@
1
1
  /* cspell:ignore presigner */
2
- import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
2
+ import { CopyObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
3
+ import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
3
4
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
5
+ import path from 'path';
6
+ import { v4 as uuid } from 'uuid';
4
7
  import { once } from '../..';
5
8
  import { assertDefined } from '../../assertions';
6
9
  import { resolveConfigValue } from '../../config';
@@ -9,6 +12,9 @@ export const s3FileServer = (initializer) => (configProvider) => {
9
12
  const config = configProvider[ifDefined(initializer.configSpace, 'deployed')];
10
13
  const bucketName = once(() => resolveConfigValue(config.bucketName));
11
14
  const bucketRegion = once(() => resolveConfigValue(config.bucketRegion));
15
+ const publicViewerDomain = once(() => 'publicViewerDomain' in config && config.publicViewerDomain
16
+ ? resolveConfigValue(config.publicViewerDomain)
17
+ : undefined);
12
18
  const s3Service = once(async () => {
13
19
  var _a, _b;
14
20
  const args = { apiVersion: '2012-08-10', region: await bucketRegion() };
@@ -24,6 +30,10 @@ export const s3FileServer = (initializer) => (configProvider) => {
24
30
  expiresIn: 3600, // 1 hour
25
31
  });
26
32
  };
33
+ const getPublicViewerUrl = async (source) => {
34
+ const host = assertDefined(await publicViewerDomain(), new Error(`Tried to get public viewer URL for ${source.path} but no publicViewerDomain configured`));
35
+ return `https://${host}/${source.path}`;
36
+ };
27
37
  const getFileContent = async (source) => {
28
38
  const bucket = await bucketName();
29
39
  const command = new GetObjectCommand({ Bucket: bucket, Key: source.path });
@@ -41,9 +51,74 @@ export const s3FileServer = (initializer) => (configProvider) => {
41
51
  await (await s3Service()).send(command);
42
52
  return source;
43
53
  };
54
+ /*
55
+ * https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/modules/_aws_sdk_s3_presigned_post.html
56
+ * https://docs.aws.amazon.com/AmazonS3/latest/userguide/HTTPPOSTExamples.html
57
+ * https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html
58
+ */
59
+ const getSignedFileUploadConfig = async () => {
60
+ const prefix = 'uploads/' + uuid();
61
+ const bucket = (await bucketName());
62
+ const Conditions = [
63
+ { acl: 'private' },
64
+ { bucket },
65
+ ['starts-with', '$key', prefix]
66
+ ];
67
+ const defaultFields = {
68
+ acl: 'private',
69
+ };
70
+ const { url, fields } = await createPresignedPost(await s3Service(), {
71
+ Bucket: bucket,
72
+ Key: prefix + '/${filename}',
73
+ Conditions,
74
+ Fields: defaultFields,
75
+ Expires: 3600, // 1 hour
76
+ });
77
+ return {
78
+ url, payload: fields
79
+ };
80
+ };
81
+ const copyFileTo = async (source, destinationPath) => {
82
+ const bucket = (await bucketName());
83
+ const destinationPathWithoutLeadingSlash = destinationPath.replace(/^\//, '');
84
+ const command = new CopyObjectCommand({
85
+ Bucket: bucket,
86
+ Key: destinationPathWithoutLeadingSlash,
87
+ CopySource: path.join(bucket, source.path),
88
+ });
89
+ await (await s3Service()).send(command);
90
+ return {
91
+ ...source,
92
+ path: destinationPathWithoutLeadingSlash
93
+ };
94
+ };
95
+ const copyFileToDirectory = async (source, destination) => {
96
+ const destinationPath = path.join(destination, source.label);
97
+ return copyFileTo(source, destinationPath);
98
+ };
99
+ const isTemporaryUpload = (source) => {
100
+ return source.path.indexOf('uploads/') === 0;
101
+ };
102
+ const getFileChecksum = async (source) => {
103
+ const bucket = (await bucketName());
104
+ const command = new HeadObjectCommand({ Bucket: bucket, Key: source.path });
105
+ const response = await (await s3Service()).send(command);
106
+ return assertDefined(response.ETag);
107
+ };
108
+ const filesEqual = async (sourceA, sourceB) => {
109
+ const [aSum, bSum] = await Promise.all([getFileChecksum(sourceA), getFileChecksum(sourceB)]);
110
+ return aSum === bSum;
111
+ };
44
112
  return {
45
113
  getFileContent,
46
114
  putFileContent,
47
115
  getSignedViewerUrl,
116
+ getPublicViewerUrl,
117
+ getSignedFileUploadConfig,
118
+ copyFileTo,
119
+ copyFileToDirectory,
120
+ isTemporaryUpload,
121
+ getFileChecksum,
122
+ filesEqual,
48
123
  };
49
124
  };
@@ -26,10 +26,6 @@ export const openSearchService = (initializer = {}) => (configProvider) => {
26
26
  maxRetries: 4, // default is 3
27
27
  requestTimeout: 5000, // default is 30000
28
28
  pingTimeout: 2000, // default is 30000
29
- sniffOnConnectionFault: true,
30
- sniffOnStart: true,
31
- resurrectStrategy: 'ping',
32
- agent: { keepAlive: false },
33
29
  node: await resolveConfigValue(config.node),
34
30
  }));
35
31
  return (indexConfig) => {
@@ -65,6 +61,9 @@ export const openSearchService = (initializer = {}) => (configProvider) => {
65
61
  body: params.body,
66
62
  id: params.id,
67
63
  refresh: true
64
+ }, {
65
+ requestTimeout: 10000,
66
+ maxRetries: 1,
68
67
  });
69
68
  };
70
69
  const bulkIndex = async (items) => {
@@ -76,6 +75,9 @@ export const openSearchService = (initializer = {}) => (configProvider) => {
76
75
  item.body
77
76
  ]),
78
77
  refresh: true
78
+ }, {
79
+ requestTimeout: 10000,
80
+ maxRetries: 1,
79
81
  });
80
82
  };
81
83
  const search = async (options) => {