@contember/engine-s3-plugin 1.2.0 → 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.
- package/dist/src/Config.d.ts +2 -3
- package/dist/src/Config.d.ts.map +1 -1
- package/dist/src/Config.js.map +1 -1
- package/dist/src/S3ConfigProcessor.d.ts +2 -2
- package/dist/src/S3ConfigProcessor.d.ts.map +1 -1
- package/dist/src/S3ConfigProcessor.js.map +1 -1
- package/dist/src/S3ObjectAuthorizator.d.ts +22 -0
- package/dist/src/S3ObjectAuthorizator.d.ts.map +1 -0
- package/dist/src/S3ObjectAuthorizator.js +48 -0
- package/dist/src/S3ObjectAuthorizator.js.map +1 -0
- package/dist/src/S3SchemaContributor.d.ts +11 -5
- package/dist/src/S3SchemaContributor.d.ts.map +1 -1
- package/dist/src/S3SchemaContributor.js +61 -85
- package/dist/src/S3SchemaContributor.js.map +1 -1
- package/dist/src/S3SchemaTypes.d.ts +21 -0
- package/dist/src/S3SchemaTypes.d.ts.map +1 -0
- package/dist/src/S3SchemaTypes.js +65 -0
- package/dist/src/S3SchemaTypes.js.map +1 -0
- package/dist/src/S3Service.d.ts +10 -10
- package/dist/src/S3Service.d.ts.map +1 -1
- package/dist/src/S3Service.js +40 -37
- package/dist/src/S3Service.js.map +1 -1
- package/dist/src/S3Signer.d.ts +1 -1
- package/dist/src/S3Signer.d.ts.map +1 -1
- package/dist/src/S3Signer.js +1 -1
- package/dist/src/S3Signer.js.map +1 -1
- package/dist/src/index.d.ts +6 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/tsconfig.tsbuildinfo +1 -1
- package/dist/tests/cases/authorizator.test.d.ts +2 -0
- package/dist/tests/cases/authorizator.test.d.ts.map +1 -0
- package/dist/tests/cases/authorizator.test.js +21 -0
- package/dist/tests/cases/authorizator.test.js.map +1 -0
- package/dist/tests/cases/signRequest.test.js +14 -5
- package/dist/tests/cases/signRequest.test.js.map +1 -1
- package/dist/tests/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/Config.ts +1 -2
- package/src/S3ConfigProcessor.ts +2 -2
- package/src/S3ObjectAuthorizator.ts +63 -0
- package/src/S3SchemaContributor.ts +51 -109
- package/src/S3SchemaTypes.ts +89 -0
- package/src/S3Service.ts +51 -43
- package/src/S3Signer.ts +2 -2
- package/src/index.ts +9 -7
- package/src/tsconfig.json +3 -9
- package/tests/cases/authorizator.test.ts +22 -0
- package/tests/cases/signRequest.test.ts +18 -18
- package/tests/tsconfig.json +1 -3
- package/tsconfig.json +2 -6
- package/dist/src/ObjectKeyVerifier.d.ts +0 -3
- package/dist/src/ObjectKeyVerifier.d.ts.map +0 -1
- package/dist/src/ObjectKeyVerifier.js +0 -18
- package/dist/src/ObjectKeyVerifier.js.map +0 -1
- package/src/ObjectKeyVerifier.ts +0 -14
- 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.
|
|
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.
|
|
12
|
-
"@contember/graphql-utils": "^1.
|
|
13
|
-
"@contember/typesafe": "^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
|
|
32
|
+
export type Project3Config = { s3?: S3Config }
|
|
34
33
|
|
|
35
34
|
// todo
|
|
36
35
|
const serversWithoutAclSupport = [S3Providers.minio]
|
package/src/S3ConfigProcessor.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { ConfigProcessor, ConfigTemplate, ConfigTemplateContext } from '@contember/engine-plugins'
|
|
2
|
-
import {
|
|
2
|
+
import { Project3Config, s3ConfigSchema } from './Config'
|
|
3
3
|
import * as Typesafe from '@contember/typesafe'
|
|
4
4
|
|
|
5
|
-
export class S3ConfigProcessor implements ConfigProcessor<
|
|
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 {
|
|
9
|
+
import { S3Service, S3ServiceFactory } from './S3Service'
|
|
12
10
|
import { resolveS3Config, S3Config } from './Config'
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
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
|
|
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?:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
75
|
-
const
|
|
76
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
},
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
123
|
+
args: { input?: Partial<S3GenerateSignedUploadInput>; contentType?: string; acl?: S3Acl; expiration?: number; prefix?: string },
|
|
183
124
|
) => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
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
|
-
|
|
112
|
+
this.authorizator.verifyReadAccess({ key: localObjectKey })
|
|
106
113
|
|
|
107
114
|
const url = this.signer.sign({
|
|
108
115
|
action: 'read',
|
|
109
|
-
expiration: expiration
|
|
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', '
|
|
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,
|
|
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 {
|
|
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<
|
|
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(
|
|
16
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|