@contember/engine-s3-plugin 1.2.1 → 1.3.0-alpha.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.
Files changed (58) hide show
  1. package/dist/src/Config.d.ts +2 -3
  2. package/dist/src/Config.d.ts.map +1 -1
  3. package/dist/src/Config.js.map +1 -1
  4. package/dist/src/S3ConfigProcessor.d.ts +2 -2
  5. package/dist/src/S3ConfigProcessor.d.ts.map +1 -1
  6. package/dist/src/S3ConfigProcessor.js.map +1 -1
  7. package/dist/src/S3ObjectAuthorizator.d.ts +22 -0
  8. package/dist/src/S3ObjectAuthorizator.d.ts.map +1 -0
  9. package/dist/src/S3ObjectAuthorizator.js +48 -0
  10. package/dist/src/S3ObjectAuthorizator.js.map +1 -0
  11. package/dist/src/S3SchemaContributor.d.ts +11 -5
  12. package/dist/src/S3SchemaContributor.d.ts.map +1 -1
  13. package/dist/src/S3SchemaContributor.js +61 -85
  14. package/dist/src/S3SchemaContributor.js.map +1 -1
  15. package/dist/src/S3SchemaTypes.d.ts +21 -0
  16. package/dist/src/S3SchemaTypes.d.ts.map +1 -0
  17. package/dist/src/S3SchemaTypes.js +65 -0
  18. package/dist/src/S3SchemaTypes.js.map +1 -0
  19. package/dist/src/S3Service.d.ts +10 -10
  20. package/dist/src/S3Service.d.ts.map +1 -1
  21. package/dist/src/S3Service.js +40 -37
  22. package/dist/src/S3Service.js.map +1 -1
  23. package/dist/src/S3Signer.d.ts +1 -1
  24. package/dist/src/S3Signer.d.ts.map +1 -1
  25. package/dist/src/S3Signer.js +1 -1
  26. package/dist/src/S3Signer.js.map +1 -1
  27. package/dist/src/index.d.ts +6 -4
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/index.js +7 -4
  30. package/dist/src/index.js.map +1 -1
  31. package/dist/src/tsconfig.tsbuildinfo +1 -1
  32. package/dist/tests/cases/authorizator.test.d.ts +2 -0
  33. package/dist/tests/cases/authorizator.test.d.ts.map +1 -0
  34. package/dist/tests/cases/authorizator.test.js +21 -0
  35. package/dist/tests/cases/authorizator.test.js.map +1 -0
  36. package/dist/tests/cases/signRequest.test.js +14 -5
  37. package/dist/tests/cases/signRequest.test.js.map +1 -1
  38. package/dist/tests/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +4 -4
  40. package/src/Config.ts +1 -2
  41. package/src/S3ConfigProcessor.ts +2 -2
  42. package/src/S3ObjectAuthorizator.ts +63 -0
  43. package/src/S3SchemaContributor.ts +51 -109
  44. package/src/S3SchemaTypes.ts +89 -0
  45. package/src/S3Service.ts +51 -43
  46. package/src/S3Signer.ts +2 -2
  47. package/src/index.ts +9 -7
  48. package/src/tsconfig.json +3 -9
  49. package/tests/cases/authorizator.test.ts +22 -0
  50. package/tests/cases/signRequest.test.ts +18 -18
  51. package/tests/tsconfig.json +1 -3
  52. package/tsconfig.json +2 -6
  53. package/dist/src/ObjectKeyVerifier.d.ts +0 -3
  54. package/dist/src/ObjectKeyVerifier.d.ts.map +0 -1
  55. package/dist/src/ObjectKeyVerifier.js +0 -18
  56. package/dist/src/ObjectKeyVerifier.js.map +0 -1
  57. package/src/ObjectKeyVerifier.ts +0 -14
  58. package/types/sign3/index.d.ts +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contember/engine-s3-plugin",
3
- "version": "1.2.1",
3
+ "version": "1.3.0-alpha.2",
4
4
  "license": "Apache-2.0",
5
5
  "main": "dist/src/index.js",
6
6
  "typings": "dist/src/index.d.ts",
@@ -8,9 +8,9 @@
8
8
  "test": "vitest"
9
9
  },
10
10
  "dependencies": {
11
- "@contember/engine-plugins": "^1.2.1",
12
- "@contember/graphql-utils": "^1.2.1",
13
- "@contember/typesafe": "^1.2.1",
11
+ "@contember/engine-plugins": "^1.3.0-alpha.2",
12
+ "@contember/graphql-utils": "^1.3.0-alpha.2",
13
+ "@contember/typesafe": "^1.3.0-alpha.2",
14
14
  "mime-types": "^2.1.24",
15
15
  "picomatch": "^2.2.1",
16
16
  "qs": "^6.10.1",
package/src/Config.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { ProjectConfig } from '@contember/engine-plugins'
2
1
  import * as Typesafe from '@contember/typesafe'
3
2
 
4
3
  export enum S3Providers {
@@ -30,7 +29,7 @@ export const s3ConfigSchema = Typesafe.intersection(
30
29
 
31
30
  export type S3Config = ReturnType<typeof s3ConfigSchema>
32
31
 
33
- export type ProjectWithS3Config = ProjectConfig<{ s3?: S3Config }>
32
+ export type Project3Config = { s3?: S3Config }
34
33
 
35
34
  // todo
36
35
  const serversWithoutAclSupport = [S3Providers.minio]
@@ -1,8 +1,8 @@
1
1
  import { ConfigProcessor, ConfigTemplate, ConfigTemplateContext } from '@contember/engine-plugins'
2
- import { ProjectWithS3Config, s3ConfigSchema } from './Config'
2
+ import { Project3Config, s3ConfigSchema } from './Config'
3
3
  import * as Typesafe from '@contember/typesafe'
4
4
 
5
- export class S3ConfigProcessor implements ConfigProcessor<ProjectWithS3Config> {
5
+ export class S3ConfigProcessor implements ConfigProcessor<Project3Config> {
6
6
  getDefaultEnv(): Record<string, string> {
7
7
  return {
8
8
  DEFAULT_S3_ENDPOINT: '',
@@ -0,0 +1,63 @@
1
+ import pm from 'picomatch'
2
+ import { ForbiddenError } from '@contember/graphql-utils'
3
+
4
+ export type S3ObjectUploadRule = {
5
+ pattern: string
6
+ maxSize?: number
7
+ matcher?: (key: string) => boolean
8
+ }
9
+
10
+ export type S3ObjectReadRule = {
11
+ pattern: string
12
+ matcher?: (key: string) => boolean
13
+ }
14
+
15
+ export class S3ObjectAuthorizator {
16
+ private readonly uploadRules: S3ObjectUploadRule[]
17
+ private readonly readRules: S3ObjectReadRule[]
18
+
19
+ constructor(
20
+ uploadRules: Omit<S3ObjectUploadRule, 'matcher'>[],
21
+ readRules: Omit<S3ObjectReadRule, 'matcher'>[],
22
+ ) {
23
+ this.uploadRules = uploadRules
24
+ this.readRules = readRules
25
+ }
26
+
27
+ public verifyReadAccess({ key }: { key: string }): void {
28
+ for (const rule of this.readRules) {
29
+ rule.matcher ??= pm(rule.pattern)
30
+
31
+ if (rule.matcher(key)) {
32
+ return
33
+ }
34
+ }
35
+
36
+ throw new ForbiddenError(`Read access forbidden for object key ${key}`)
37
+ }
38
+
39
+ public verifyUploadAccess({ key, size }: { key: string; size: number | null }): void {
40
+ const matchedPatterns: S3ObjectUploadRule[] = []
41
+
42
+ for (const rule of this.uploadRules) {
43
+ rule.matcher ??= pm(rule.pattern)
44
+
45
+ if (rule.matcher(key)) {
46
+ if (!rule.maxSize || (size !== null && size <= rule.maxSize)) {
47
+ return
48
+ }
49
+ matchedPatterns.push(rule)
50
+ }
51
+ }
52
+
53
+ if (matchedPatterns.length > 0) {
54
+ if (size === null) {
55
+ throw new ForbiddenError('File size must be provided')
56
+ } else {
57
+ throw new ForbiddenError('Uploaded file is too large')
58
+ }
59
+ }
60
+
61
+ throw new ForbiddenError(`Upload access forbidden for object key ${key}`)
62
+ }
63
+ }
@@ -1,61 +1,51 @@
1
1
  import {
2
- GraphQLEnumType,
3
2
  GraphQLFieldConfig,
4
3
  GraphQLInt,
5
- GraphQLList,
6
4
  GraphQLNonNull,
7
5
  GraphQLObjectType,
8
6
  GraphQLSchema,
9
7
  GraphQLString,
10
8
  } from 'graphql'
11
- import { S3Acl, S3Service, S3ServiceFactory } from './S3Service'
9
+ import { S3Service, S3ServiceFactory } from './S3Service'
12
10
  import { resolveS3Config, S3Config } from './Config'
13
- import { createObjectKeyVerifier, ObjectKeyVerifier } from './ObjectKeyVerifier'
14
- import { GraphQLSchemaContributor, SchemaContext } from '@contember/engine-plugins'
15
- import { Providers } from '@contember/engine-plugins'
11
+ import { GraphQLSchemaContributor, Providers, GraphQLSchemaContributorContext } from '@contember/engine-plugins'
12
+ import * as types from './S3SchemaTypes'
13
+ import { S3Acl, S3GenerateSignedUploadInput, S3SignedRead, S3SignedUpload } from './S3SchemaTypes'
14
+ import { S3ObjectAuthorizator } from './S3ObjectAuthorizator'
16
15
 
17
16
  interface Identity {
18
17
  projectRoles: string[]
19
18
  }
20
19
 
21
- type S3SchemaAcl = Record<
20
+ export type S3SchemaUploadAcl =
21
+ | boolean
22
+ | {
23
+ maxSize?: number
24
+ }
25
+
26
+ export type S3SchemaAcl = Record<
22
27
  string, // public/*.jpg
23
28
  {
24
29
  read?: boolean
25
- upload?: boolean
30
+ upload?: S3SchemaUploadAcl
26
31
  }
27
32
  >
28
33
 
29
- export class S3SchemaContributor implements GraphQLSchemaContributor {
30
- private s3HeadersType: GraphQLFieldConfig<any, any> = {
31
- type: new GraphQLNonNull(
32
- new GraphQLList(
33
- new GraphQLNonNull(
34
- new GraphQLObjectType({
35
- name: 'S3Header',
36
- fields: {
37
- key: { type: new GraphQLNonNull(GraphQLString) },
38
- value: { type: new GraphQLNonNull(GraphQLString) },
39
- },
40
- }),
41
- ),
42
- ),
43
- ),
44
- }
45
34
 
35
+ export class S3SchemaContributor implements GraphQLSchemaContributor {
46
36
  constructor(
47
37
  private readonly s3Config: S3Config | undefined,
48
38
  private readonly s3Factory: S3ServiceFactory,
49
39
  private readonly providers: Providers,
50
40
  ) {}
51
41
 
52
- getCacheKey(context: SchemaContext): string {
42
+ getCacheKey(context: GraphQLSchemaContributorContext): string {
53
43
  const roles = context.identity.projectRoles
54
44
  roles.sort()
55
45
  return roles.join('||')
56
46
  }
57
47
 
58
- createSchema(context: SchemaContext): GraphQLSchema | undefined {
48
+ createSchema(context: GraphQLSchemaContributorContext): GraphQLSchema | undefined {
59
49
  if (!this.s3Config) {
60
50
  return undefined
61
51
  }
@@ -63,17 +53,24 @@ export class S3SchemaContributor implements GraphQLSchemaContributor {
63
53
  Object.entries((context.schema.acl.roles[it]?.s3 as S3SchemaAcl) || {}),
64
54
  )
65
55
 
66
- const allowedUploads = rules.filter(([, it]) => it.upload).map(([it]) => it)
67
- const allowedReads = rules.filter(([, it]) => it.read).map(([it]) => it)
68
56
 
69
- if (allowedUploads.length === 0 && allowedReads.length == 0) {
57
+ const uploadRules = rules.filter(([, it]) => it.upload).map(([it, val]) => ({
58
+ pattern: it,
59
+ ...(typeof val.upload !== 'boolean' ? val.upload : {}),
60
+ }))
61
+ const readRules = rules.filter(([, it]) => it.read).map(([it]) => ({
62
+ pattern: it,
63
+ }))
64
+
65
+ if (uploadRules.length === 0 && readRules.length == 0) {
70
66
  return undefined
71
67
  }
72
68
 
73
69
  const s3Config = resolveS3Config(this.s3Config)
74
- const s3 = this.s3Factory.create(s3Config, this.providers)
75
- const uploadMutation = this.createUploadMutation(s3Config, s3, allowedUploads)
76
- const readMutation = this.createReadMutation(s3, allowedReads)
70
+ const authorizator = new S3ObjectAuthorizator(uploadRules, readRules)
71
+ const s3 = this.s3Factory.create(s3Config, this.providers, authorizator)
72
+ const uploadMutation = this.createUploadMutation(s3)
73
+ const readMutation = this.createReadMutation(s3)
77
74
  const mutation = {
78
75
  generateUploadUrl: uploadMutation as GraphQLFieldConfig<any, any, any>,
79
76
  generateReadUrl: readMutation,
@@ -94,24 +91,9 @@ export class S3SchemaContributor implements GraphQLSchemaContributor {
94
91
  })
95
92
  }
96
93
 
97
- private createReadMutation(s3: S3Service, allowedKeyPatterns: string[]): GraphQLFieldConfig<any, any, any> {
98
- let verifier: ObjectKeyVerifier
94
+ private createReadMutation(s3: S3Service): GraphQLFieldConfig<any, any, any> {
99
95
  return {
100
- type: new GraphQLNonNull(
101
- new GraphQLObjectType({
102
- name: 'S3SignedRead',
103
- fields: {
104
- url: { type: new GraphQLNonNull(GraphQLString) },
105
- headers: this.s3HeadersType,
106
- method: { type: new GraphQLNonNull(GraphQLString) },
107
- objectKey: {
108
- type: new GraphQLNonNull(GraphQLString),
109
- description: `Allowed patterns:\n${allowedKeyPatterns.join('\n')}`,
110
- },
111
- bucket: { type: new GraphQLNonNull(GraphQLString) },
112
- },
113
- }),
114
- ),
96
+ type: new GraphQLNonNull(S3SignedRead),
115
97
  args: {
116
98
  objectKey: {
117
99
  type: new GraphQLNonNull(GraphQLString),
@@ -121,76 +103,36 @@ export class S3SchemaContributor implements GraphQLSchemaContributor {
121
103
  },
122
104
  },
123
105
  resolve: async (parent: any, args: { objectKey: string; expiration?: number }) => {
124
- if (!verifier) {
125
- verifier = createObjectKeyVerifier(allowedKeyPatterns)
126
- }
127
- return s3.getSignedReadUrl(args.objectKey, verifier, args.expiration)
106
+ return s3.getSignedReadUrl({ objectKey: args.objectKey, expiration: args.expiration ?? null })
128
107
  },
129
108
  } as GraphQLFieldConfig<any, any, any>
130
109
  }
131
110
 
132
- private createUploadMutation(
133
- s3Config: S3Config,
134
- s3: S3Service,
135
- allowedKeyPatterns: string[],
136
- ): GraphQLFieldConfig<any, any, any> {
137
- let verifier: ObjectKeyVerifier
111
+ private createUploadMutation(s3: S3Service): GraphQLFieldConfig<any, any, any> {
138
112
  return {
139
- type: new GraphQLNonNull(
140
- new GraphQLObjectType({
141
- name: 'S3SignedUpload',
142
- fields: {
143
- url: { type: new GraphQLNonNull(GraphQLString) },
144
- headers: this.s3HeadersType,
145
- method: { type: new GraphQLNonNull(GraphQLString) },
146
- objectKey: {
147
- type: new GraphQLNonNull(GraphQLString),
148
- description: `Allowed patterns:\n${allowedKeyPatterns.join('\n')}`,
149
- },
150
- bucket: { type: new GraphQLNonNull(GraphQLString) },
151
- publicUrl: { type: new GraphQLNonNull(GraphQLString) },
152
- },
153
- }),
154
- ),
113
+ type: new GraphQLNonNull(S3SignedUpload),
155
114
  args: {
156
- contentType: {
157
- type: new GraphQLNonNull(GraphQLString),
158
- },
159
- expiration: {
160
- type: GraphQLInt,
161
- },
162
- prefix: {
163
- type: GraphQLString,
164
- },
165
- ...(s3Config.noAcl
166
- ? {}
167
- : {
168
- acl: {
169
- type: new GraphQLEnumType({
170
- name: 'S3Acl',
171
- values: {
172
- PUBLIC_READ: {},
173
- PRIVATE: {},
174
- NONE: {},
175
- },
176
- }),
177
- },
178
- }),
115
+ input: { type: types.S3GenerateSignedUploadInput },
116
+ contentType: { type: GraphQLString, deprecationReason: 'Use input.contentType' },
117
+ expiration: { type: GraphQLInt, deprecationReason: 'Use input.expiration' },
118
+ prefix: { type: GraphQLString, deprecationReason: 'Use input.prefix' },
119
+ acl: { type: types.S3Acl, deprecationReason: 'Use input.acl' },
179
120
  },
180
121
  resolve: async (
181
122
  parent: any,
182
- args: { contentType: string; acl?: string; expiration?: number; prefix?: string },
123
+ args: { input?: Partial<S3GenerateSignedUploadInput>; contentType?: string; acl?: S3Acl; expiration?: number; prefix?: string },
183
124
  ) => {
184
- if (!verifier) {
185
- verifier = createObjectKeyVerifier(allowedKeyPatterns)
186
- }
187
- const aclMapping: Record<string, S3Acl> = {
188
- PUBLIC_READ: S3Acl.PublicRead,
189
- PRIVATE: S3Acl.Private,
190
- NONE: S3Acl.None,
191
- }
192
- const acl: S3Acl | undefined = args.acl ? aclMapping[args.acl] || undefined : undefined
193
- return s3.getSignedUploadUrl(args.contentType, verifier, acl, args.expiration, args.prefix)
125
+ return s3.getSignedUploadUrl({
126
+ acl: args.input?.acl ?? args.acl ?? null,
127
+ contentDisposition: args.input?.contentDisposition ?? null,
128
+ contentType: args.input?.contentType ?? args.contentType ?? null,
129
+ expiration: args.input?.expiration ?? args.expiration ?? null,
130
+ extension: args.input?.extension ?? null,
131
+ fileName: args.input?.fileName ?? null,
132
+ prefix: args.input?.prefix ?? args.prefix ?? null,
133
+ suffix: args.input?.suffix ?? null,
134
+ size: args.input?.size ?? null,
135
+ })
194
136
  },
195
137
  }
196
138
  }
@@ -0,0 +1,89 @@
1
+ import {
2
+ GraphQLEnumType,
3
+ GraphQLInputObjectType,
4
+ GraphQLInt,
5
+ GraphQLList,
6
+ GraphQLNonNull,
7
+ GraphQLObjectType,
8
+ GraphQLString,
9
+ } from 'graphql'
10
+
11
+ export const S3Header = new GraphQLObjectType({
12
+ name: 'S3Header',
13
+ fields: {
14
+ key: { type: new GraphQLNonNull(GraphQLString) },
15
+ value: { type: new GraphQLNonNull(GraphQLString) },
16
+ },
17
+ })
18
+
19
+ export const S3ContentDisposition = new GraphQLEnumType({
20
+ name: 'S33ContentDisposition', values: {
21
+ ATTACHMENT: {},
22
+ INLINE: {},
23
+ },
24
+ })
25
+ export type S3ContentDisposition = 'ATTACHMENT' | 'INLINE'
26
+
27
+ export const S3SignedRead = new GraphQLObjectType({
28
+ name: 'S3SignedRead',
29
+ fields: {
30
+ url: { type: new GraphQLNonNull(GraphQLString) },
31
+ headers: {
32
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(S3Header))),
33
+ },
34
+ method: { type: new GraphQLNonNull(GraphQLString) },
35
+ objectKey: { type: new GraphQLNonNull(GraphQLString) },
36
+ bucket: { type: new GraphQLNonNull(GraphQLString) },
37
+ },
38
+ })
39
+
40
+ export const S3SignedUpload = new GraphQLObjectType({
41
+ name: 'S3SignedUpload',
42
+ fields: {
43
+ url: { type: new GraphQLNonNull(GraphQLString) },
44
+ headers: {
45
+ type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(S3Header))),
46
+ },
47
+ method: { type: new GraphQLNonNull(GraphQLString) },
48
+ objectKey: { type: new GraphQLNonNull(GraphQLString) },
49
+ bucket: { type: new GraphQLNonNull(GraphQLString) },
50
+ publicUrl: { type: new GraphQLNonNull(GraphQLString) },
51
+ },
52
+ })
53
+
54
+ export const S3Acl = new GraphQLEnumType({
55
+ name: 'S3Acl',
56
+ values: {
57
+ PUBLIC_READ: {},
58
+ PRIVATE: {},
59
+ NONE: {},
60
+ },
61
+ })
62
+
63
+ export type S3Acl = 'PUBLIC_READ' | 'PRIVATE' | 'NONE'
64
+
65
+ export const S3GenerateSignedUploadInput = new GraphQLInputObjectType({
66
+ name: 'S3GenerateSignedUploadInput',
67
+ fields: {
68
+ contentType: { type: GraphQLString },
69
+ extension: { type: GraphQLString, description: 'If not provided, extension is detected from a content-type.' },
70
+ expiration: { type: GraphQLInt, description: 'Signed URL expiration.' },
71
+ size: { type: GraphQLInt, description: 'Uploaded file size. Required when you enable ACL size limits.' },
72
+ prefix: { type: GraphQLString, description: 'Can be used as a "directory".' },
73
+ suffix: { type: GraphQLString, description: 'Suffix after generated id and before the extension.' },
74
+ fileName: { type: GraphQLString, description: 'This only affects Content-disposition header. Does not affect actual object key.' },
75
+ contentDisposition: { type: S3ContentDisposition },
76
+ acl: { type: S3Acl, description: 'If not supported by S3 provider, an error is thrown.' },
77
+ },
78
+ })
79
+ export interface S3GenerateSignedUploadInput {
80
+ contentType: null | string
81
+ extension: null | string
82
+ expiration: null | number
83
+ size: null | number
84
+ prefix: null | string
85
+ suffix: null | string
86
+ fileName: null | string
87
+ contentDisposition: null | S3ContentDisposition
88
+ acl: null | S3Acl
89
+ }
package/src/S3Service.ts CHANGED
@@ -1,15 +1,10 @@
1
- import { extension } from 'mime-types'
1
+ import { extension as resolveExtension } from 'mime-types'
2
2
  import { resolveS3PublicBaseUrl, S3Config } from './Config'
3
- import { ObjectKeyVerifier } from './ObjectKeyVerifier'
4
- import { ForbiddenError } from '@contember/graphql-utils'
3
+ import { ForbiddenError, UserInputError } from '@contember/graphql-utils'
5
4
  import { Providers } from '@contember/engine-plugins'
6
5
  import { S3Signer } from './S3Signer'
7
-
8
- export enum S3Acl {
9
- None = 'none',
10
- PublicRead = 'public-read',
11
- Private = 'private',
12
- }
6
+ import { S3GenerateSignedUploadInput } from './S3SchemaTypes'
7
+ import { S3ObjectAuthorizator } from './S3ObjectAuthorizator'
13
8
 
14
9
  type SignedReadUrl = {
15
10
  url: string
@@ -34,67 +29,79 @@ export class S3Service {
34
29
 
35
30
  private readonly signer: S3Signer
36
31
 
37
- constructor(public readonly config: S3Config, private readonly providers: Providers) {
32
+ constructor(
33
+ public readonly config: S3Config,
34
+ private readonly providers: Pick<Providers, 'uuid' | 'now'>,
35
+ private readonly authorizator: S3ObjectAuthorizator,
36
+ ) {
38
37
  this.publicBaseUrl = resolveS3PublicBaseUrl(config)
39
38
  this.signer = new S3Signer(config, providers)
40
39
  }
41
40
 
42
41
  public getSignedUploadUrl(
43
- contentType: string,
44
- verifyKey: ObjectKeyVerifier,
45
- acl?: S3Acl,
46
- expiration?: number,
47
- prefix?: string,
42
+ { acl, contentDisposition, contentType, expiration, prefix, suffix, fileName, extension, size }: S3GenerateSignedUploadInput,
48
43
  ): SignedUploadUrl {
49
- const ext = extension(contentType) || 'bin'
44
+ const ext = extension ?? ((contentType ? resolveExtension(contentType) : null) || 'bin')
50
45
  const id = this.providers.uuid()
51
- const localObjectKey = (prefix ? prefix + '/' : '') + `${id}.${ext}`
52
- verifyKey(localObjectKey)
46
+ const localObjectKey = (prefix ? prefix + '/' : '') + `${id}${suffix ?? ''}.${ext}`
47
+ this.authorizator.verifyUploadAccess({ key: localObjectKey, size })
48
+
53
49
  const objectKey = (this.config.prefix ? this.config.prefix + '/' : '') + localObjectKey
54
50
 
55
51
  const bucket = this.config.bucket
56
52
  if (acl && this.config.noAcl) {
57
- throw new Error('ACL is not supported')
53
+ throw new UserInputError('ACL is not supported')
54
+ }
55
+
56
+ const headers: Record<string, string> = {
57
+ 'Cache-Control': 'immutable',
58
+ }
59
+ if (contentType) {
60
+ headers['Content-Type'] = contentType
61
+ }
62
+
63
+ if (!this.config.noAcl && acl !== 'NONE') {
64
+ const mapping = { PUBLIC_READ: 'public-read', PRIVATE: 'private' }
65
+ headers['x-amz-acl'] = mapping[acl ?? 'PUBLIC_READ']
66
+ }
67
+ if (fileName || contentDisposition) {
68
+ let contentDispositionHeader = contentDisposition?.toLowerCase() ?? 'inline'
69
+ if (fileName) {
70
+ contentDispositionHeader += `; filename*=UTF-8''${encodeURIComponent(fileName)}`
71
+ }
72
+ headers['Content-Disposition'] = contentDispositionHeader
73
+ }
74
+ const headersToSend = Object.entries(headers).map(([key, value]) => ({ key, value }))
75
+
76
+ // intentionally not sending following headers
77
+ if (size) {
78
+ headers['Content-Length'] = String(size)
58
79
  }
59
80
 
60
- const aclValue = this.config.noAcl || acl === S3Acl.None ? undefined : acl || S3Acl.PublicRead
61
81
  const url = this.signer.sign({
62
82
  action: 'upload',
63
- expiration: expiration || 3600,
83
+ expiration: expiration ?? 3600,
64
84
  key: objectKey,
65
- headers: {
66
- 'Content-Type': contentType,
67
- 'Cache-Control': 'immutable',
68
- ...(aclValue ? { 'x-amz-acl': aclValue } : {}),
69
- },
85
+ headers: { ...headers },
70
86
  })
87
+
71
88
  const publicUrl = this.formatPublicUrl(objectKey)
72
89
  return {
73
90
  bucket,
74
91
  objectKey,
75
92
  url,
76
93
  publicUrl,
77
- headers: [
78
- {
79
- key: 'Content-Type',
80
- value: contentType,
81
- },
82
- {
83
- key: 'Cache-Control',
84
- value: 'immutable',
85
- },
86
- ...(aclValue ? [{ key: 'X-Amz-Acl', value: aclValue }] : []),
87
- ],
94
+ headers: headersToSend,
88
95
  method: 'PUT',
89
96
  }
90
97
  }
91
98
 
92
- public getSignedReadUrl(objectKey: string, verifyKey: ObjectKeyVerifier, expiration?: number): SignedReadUrl {
99
+ public getSignedReadUrl({ objectKey, expiration }: { objectKey: string; expiration: number | null}): SignedReadUrl {
93
100
  const bucket = this.config.bucket
94
101
 
95
102
  const publicPrefix = this.formatPublicUrl('')
96
103
  if (objectKey.startsWith(publicPrefix)) {
97
- objectKey = objectKey.substr(publicPrefix.length)
104
+ objectKey = objectKey.substring(publicPrefix.length)
98
105
  }
99
106
  if (this.config.prefix && !objectKey.startsWith(this.config.prefix)) {
100
107
  throw new ForbiddenError(
@@ -102,14 +109,15 @@ export class S3Service {
102
109
  )
103
110
  }
104
111
  const localObjectKey = this.config.prefix ? objectKey.substring(this.config.prefix.length + 1) : objectKey
105
- verifyKey(localObjectKey)
112
+ this.authorizator.verifyReadAccess({ key: localObjectKey })
106
113
 
107
114
  const url = this.signer.sign({
108
115
  action: 'read',
109
- expiration: expiration || 3600,
116
+ expiration: expiration ?? 3600,
110
117
  key: objectKey,
111
118
  headers: {},
112
119
  })
120
+
113
121
  return {
114
122
  bucket,
115
123
  objectKey,
@@ -125,7 +133,7 @@ export class S3Service {
125
133
  }
126
134
 
127
135
  export class S3ServiceFactory {
128
- public create(config: S3Config, providers: Providers) {
129
- return new S3Service(config, providers)
136
+ public create(config: S3Config, providers: Providers, authorizator: S3ObjectAuthorizator) {
137
+ return new S3Service(config, providers, authorizator)
130
138
  }
131
139
  }
package/src/S3Signer.ts CHANGED
@@ -14,7 +14,7 @@ const hmac = (secret: BinaryLike, data: BinaryLike) => createHmac('sha256', secr
14
14
 
15
15
  const hash = (data: BinaryLike) => createHash('sha256').update(data).digest('hex')
16
16
 
17
- const unsignableHeaders = ['authorization', 'content-type', 'content-length', 'user-agent', 'expect', 'x-amzn-trace-id']
17
+ const unsignableHeaders = ['authorization', 'user-agent', 'content-type', 'expect', 'x-amzn-trace-id']
18
18
 
19
19
  const sortMap = <T extends Record<string, any>>(input: T): T => {
20
20
  return Object.fromEntries(Object.entries(input).sort((a, b) => (a[0] < b[0] ? -1 : 1))) as T
@@ -22,7 +22,7 @@ const sortMap = <T extends Record<string, any>>(input: T): T => {
22
22
  export class S3Signer {
23
23
  private region: string
24
24
 
25
- constructor(private readonly config: S3Config, private readonly providers: Providers) {
25
+ constructor(private readonly config: S3Config, private readonly providers: Pick<Providers, 'now'>) {
26
26
  this.region = config.region || 'eu-west-1'
27
27
  }
28
28
 
package/src/index.ts CHANGED
@@ -1,23 +1,25 @@
1
- import { Plugin, ProjectContainer } from '@contember/engine-plugins'
1
+ import { Plugin, SchemaContributorArgs } from '@contember/engine-plugins'
2
2
  import { S3ConfigProcessor } from './S3ConfigProcessor'
3
3
  import { S3SchemaContributor } from './S3SchemaContributor'
4
4
  import { S3ServiceFactory } from './S3Service'
5
- import { ProjectWithS3Config } from './Config'
5
+ import { Project3Config } from './Config'
6
6
 
7
+ export * from './S3ObjectAuthorizator'
7
8
  export * from './S3SchemaContributor'
8
9
  export * from './S3Service'
9
10
 
10
- export default class S3 implements Plugin<ProjectWithS3Config> {
11
+ export default class S3 implements Plugin<Project3Config> {
12
+ name = 'contember/s3'
13
+
11
14
  getConfigProcessor() {
12
15
  return new S3ConfigProcessor()
13
16
  }
14
17
 
15
- getSchemaContributor(container: ProjectContainer<ProjectWithS3Config>) {
16
- const projectConfig = container.project
17
- if (!projectConfig.s3) {
18
+ getSchemaContributor({ project, providers }: SchemaContributorArgs<Project3Config>) {
19
+ if (!project.s3) {
18
20
  return undefined
19
21
  }
20
22
  const s3ServiceFactory = new S3ServiceFactory()
21
- return new S3SchemaContributor(projectConfig.s3, s3ServiceFactory, container.providers)
23
+ return new S3SchemaContributor(project.s3, s3ServiceFactory, providers)
22
24
  }
23
25
  }
package/src/tsconfig.json CHANGED
@@ -5,14 +5,8 @@
5
5
  "outDir": "../dist/src"
6
6
  },
7
7
  "references": [
8
- {
9
- "path": "../../typesafe/src"
10
- },
11
- {
12
- "path": "../../engine-plugins/src"
13
- },
14
- {
15
- "path": "../../graphql-utils/src"
16
- }
8
+ { "path": "../../typesafe/src" },
9
+ { "path": "../../engine-plugins/src" },
10
+ { "path": "../../graphql-utils/src" }
17
11
  ]
18
12
  }