@contember/engine-s3-plugin 2.1.0-rc.3 → 2.2.0-alpha.10
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/development/index.cjs.map +1 -1
- package/dist/development/index.js.map +1 -1
- package/dist/development/src/Config.cjs.map +1 -1
- package/dist/development/src/Config.js.map +1 -1
- package/dist/development/src/S3ConfigProcessor.cjs.map +1 -1
- package/dist/development/src/S3ConfigProcessor.js.map +1 -1
- package/dist/development/src/S3ObjectAuthorizator.cjs +12 -1
- package/dist/development/src/S3ObjectAuthorizator.cjs.map +1 -1
- package/dist/development/src/S3ObjectAuthorizator.js +12 -1
- package/dist/development/src/S3ObjectAuthorizator.js.map +1 -1
- package/dist/development/src/S3SchemaContributor.cjs +48 -16
- package/dist/development/src/S3SchemaContributor.cjs.map +1 -1
- package/dist/development/src/S3SchemaContributor.js +49 -17
- package/dist/development/src/S3SchemaContributor.js.map +1 -1
- package/dist/development/src/S3SchemaTypes.cjs +8 -0
- package/dist/development/src/S3SchemaTypes.cjs.map +1 -1
- package/dist/development/src/S3SchemaTypes.js +8 -0
- package/dist/development/src/S3SchemaTypes.js.map +1 -1
- package/dist/development/src/S3Service.cjs +44 -15
- package/dist/development/src/S3Service.cjs.map +1 -1
- package/dist/development/src/S3Service.js +44 -15
- package/dist/development/src/S3Service.js.map +1 -1
- package/dist/development/src/S3Signer.cjs +6 -1
- package/dist/development/src/S3Signer.cjs.map +1 -1
- package/dist/development/src/S3Signer.js +6 -1
- package/dist/development/src/S3Signer.js.map +1 -1
- package/dist/production/index.cjs.map +1 -1
- package/dist/production/index.js.map +1 -1
- package/dist/production/src/Config.cjs.map +1 -1
- package/dist/production/src/Config.js.map +1 -1
- package/dist/production/src/S3ConfigProcessor.cjs.map +1 -1
- package/dist/production/src/S3ConfigProcessor.js.map +1 -1
- package/dist/production/src/S3ObjectAuthorizator.cjs +12 -1
- package/dist/production/src/S3ObjectAuthorizator.cjs.map +1 -1
- package/dist/production/src/S3ObjectAuthorizator.js +12 -1
- package/dist/production/src/S3ObjectAuthorizator.js.map +1 -1
- package/dist/production/src/S3SchemaContributor.cjs +48 -16
- package/dist/production/src/S3SchemaContributor.cjs.map +1 -1
- package/dist/production/src/S3SchemaContributor.js +49 -17
- package/dist/production/src/S3SchemaContributor.js.map +1 -1
- package/dist/production/src/S3SchemaTypes.cjs +8 -0
- package/dist/production/src/S3SchemaTypes.cjs.map +1 -1
- package/dist/production/src/S3SchemaTypes.js +8 -0
- package/dist/production/src/S3SchemaTypes.js.map +1 -1
- package/dist/production/src/S3Service.cjs +44 -15
- package/dist/production/src/S3Service.cjs.map +1 -1
- package/dist/production/src/S3Service.js +44 -15
- package/dist/production/src/S3Service.js.map +1 -1
- package/dist/production/src/S3Signer.cjs +6 -1
- package/dist/production/src/S3Signer.cjs.map +1 -1
- package/dist/production/src/S3Signer.js +6 -1
- package/dist/production/src/S3Signer.js.map +1 -1
- package/dist/tests/tsconfig.tsbuildinfo +1 -1
- package/dist/types/Config.d.ts.map +1 -1
- package/dist/types/S3ConfigProcessor.d.ts +1 -1
- package/dist/types/S3ConfigProcessor.d.ts.map +1 -1
- package/dist/types/S3ObjectAuthorizator.d.ts +9 -1
- package/dist/types/S3ObjectAuthorizator.d.ts.map +1 -1
- package/dist/types/S3SchemaContributor.d.ts +5 -2
- package/dist/types/S3SchemaContributor.d.ts.map +1 -1
- package/dist/types/S3SchemaTypes.d.ts +1 -0
- package/dist/types/S3SchemaTypes.d.ts.map +1 -1
- package/dist/types/S3Service.d.ts +25 -4
- package/dist/types/S3Service.d.ts.map +1 -1
- package/dist/types/S3Signer.d.ts +2 -2
- package/dist/types/S3Signer.d.ts.map +1 -1
- package/dist/types/index.d.ts +6 -6
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/Config.ts +1 -0
- package/src/S3ConfigProcessor.ts +1 -1
- package/src/S3ObjectAuthorizator.ts +20 -0
- package/src/S3SchemaContributor.ts +58 -22
- package/src/S3SchemaTypes.ts +8 -0
- package/src/S3Service.ts +68 -20
- package/src/S3Signer.ts +9 -3
- package/src/index.ts +7 -7
- package/tests/cases/authorizator.test.ts +12 -1
- package/tests/cases/signRequest.test.ts +74 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contember/engine-s3-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0-alpha.10",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "./dist/production/index.js",
|
|
6
6
|
"typings": "./dist/types/index.d.ts",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@contember/engine-plugins": "2.
|
|
27
|
-
"@contember/graphql-utils": "2.
|
|
28
|
-
"@contember/typesafe": "2.
|
|
26
|
+
"@contember/engine-plugins": "2.2.0-alpha.10",
|
|
27
|
+
"@contember/graphql-utils": "2.2.0-alpha.10",
|
|
28
|
+
"@contember/typesafe": "2.2.0-alpha.10",
|
|
29
29
|
"mime-types": "^3.0.2",
|
|
30
30
|
"picomatch": "^4.0.3",
|
|
31
31
|
"qs": "^6.15.0",
|
package/src/Config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as Typesafe from '@contember/typesafe'
|
|
|
2
2
|
|
|
3
3
|
export enum S3Providers {
|
|
4
4
|
aws = 'aws',
|
|
5
|
+
// Path-style addressing with no object ACL — the right value for any MinIO-compatible server, SeaweedFS included.
|
|
5
6
|
minio = 'minio',
|
|
6
7
|
ceph = 'ceph',
|
|
7
8
|
localstack = 'localstack',
|
package/src/S3ConfigProcessor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ConfigProcessor, ConfigTemplate, ConfigTemplateContext } from '@contember/engine-plugins'
|
|
2
|
-
import { Project3Config, s3ConfigSchema } from './Config'
|
|
2
|
+
import { Project3Config, s3ConfigSchema } from './Config.js'
|
|
3
3
|
import * as Typesafe from '@contember/typesafe'
|
|
4
4
|
|
|
5
5
|
export class S3ConfigProcessor implements ConfigProcessor<Project3Config> {
|
|
@@ -13,16 +13,24 @@ export type S3ObjectReadRule = {
|
|
|
13
13
|
matcher?: (key: string) => boolean
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export type S3ObjectDeleteRule = {
|
|
17
|
+
pattern: string
|
|
18
|
+
matcher?: (key: string) => boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export class S3ObjectAuthorizator {
|
|
17
22
|
private readonly uploadRules: S3ObjectUploadRule[]
|
|
18
23
|
private readonly readRules: S3ObjectReadRule[]
|
|
24
|
+
private readonly deleteRules: S3ObjectDeleteRule[]
|
|
19
25
|
|
|
20
26
|
constructor(
|
|
21
27
|
uploadRules: Omit<S3ObjectUploadRule, 'matcher'>[],
|
|
22
28
|
readRules: Omit<S3ObjectReadRule, 'matcher'>[],
|
|
29
|
+
deleteRules: Omit<S3ObjectDeleteRule, 'matcher'>[] = [],
|
|
23
30
|
) {
|
|
24
31
|
this.uploadRules = uploadRules
|
|
25
32
|
this.readRules = readRules
|
|
33
|
+
this.deleteRules = deleteRules
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
public verifyReadAccess({ key }: { key: string }): void {
|
|
@@ -37,6 +45,18 @@ export class S3ObjectAuthorizator {
|
|
|
37
45
|
throw new ForbiddenError(`Read access forbidden for object key ${key}`)
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
public verifyDeleteAccess({ key }: { key: string }): void {
|
|
49
|
+
for (const rule of this.deleteRules) {
|
|
50
|
+
rule.matcher ??= pm(rule.pattern)
|
|
51
|
+
|
|
52
|
+
if (rule.matcher(key)) {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw new ForbiddenError(`Delete access forbidden for object key ${key}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
public verifyUploadAccess({ key, size }: { key: string; size: number | null }): void {
|
|
41
61
|
const matchedPatterns: S3ObjectUploadRule[] = []
|
|
42
62
|
|
|
@@ -8,12 +8,12 @@ import {
|
|
|
8
8
|
GraphQLSchemaConfig,
|
|
9
9
|
GraphQLString,
|
|
10
10
|
} from 'graphql'
|
|
11
|
-
import { S3Service, S3ServiceFactory } from './S3Service'
|
|
12
|
-
import { Project3Config, resolveS3Config, S3Config } from './Config'
|
|
11
|
+
import { S3Service, S3ServiceFactory } from './S3Service.js'
|
|
12
|
+
import { Project3Config, resolveS3Config, S3Config } from './Config.js'
|
|
13
13
|
import { GraphQLSchemaContributor, GraphQLSchemaContributorContext, Providers } from '@contember/engine-plugins'
|
|
14
|
-
import * as types from './S3SchemaTypes'
|
|
15
|
-
import { S3Acl, S3GenerateSignedUploadInput, S3SignedRead, S3SignedUpload } from './S3SchemaTypes'
|
|
16
|
-
import { S3ObjectAuthorizator } from './S3ObjectAuthorizator'
|
|
14
|
+
import * as types from './S3SchemaTypes.js'
|
|
15
|
+
import { S3Acl, S3DeletedObject, S3GenerateSignedUploadInput, S3SignedRead, S3SignedUpload } from './S3SchemaTypes.js'
|
|
16
|
+
import { S3ObjectAuthorizator } from './S3ObjectAuthorizator.js'
|
|
17
17
|
|
|
18
18
|
interface Identity {
|
|
19
19
|
projectRoles: string[]
|
|
@@ -30,6 +30,7 @@ export type S3SchemaAcl = Record<
|
|
|
30
30
|
{
|
|
31
31
|
read?: boolean
|
|
32
32
|
upload?: S3SchemaUploadAcl
|
|
33
|
+
delete?: boolean
|
|
33
34
|
}
|
|
34
35
|
>
|
|
35
36
|
|
|
@@ -39,34 +40,26 @@ export class S3SchemaContributor implements GraphQLSchemaContributor {
|
|
|
39
40
|
private readonly providers: Providers,
|
|
40
41
|
) {}
|
|
41
42
|
|
|
42
|
-
getCacheKey
|
|
43
|
-
|
|
43
|
+
getCacheKey(context: GraphQLSchemaContributorContext): string {
|
|
44
|
+
const rules = this.resolveRules(context)
|
|
45
|
+
return rules ? JSON.stringify(rules) : 'no'
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
createSchema(context: GraphQLSchemaContributorContext): GraphQLSchemaConfig | undefined {
|
|
47
|
-
|
|
49
|
+
const rules = this.resolveRules(context)
|
|
50
|
+
if (!rules) {
|
|
48
51
|
return undefined
|
|
49
52
|
}
|
|
50
|
-
const rules = context.identity.projectRoles.flatMap(it => Object.entries((context.schema.acl.roles[it]?.s3 as S3SchemaAcl) || {}))
|
|
51
53
|
|
|
52
|
-
const
|
|
53
|
-
pattern: it,
|
|
54
|
-
...(typeof val.upload !== 'boolean' ? val.upload : {}),
|
|
55
|
-
}))
|
|
56
|
-
const readRules = rules.filter(([, it]) => it.read).map(([it]) => ({
|
|
57
|
-
pattern: it,
|
|
58
|
-
}))
|
|
59
|
-
|
|
60
|
-
if (uploadRules.length === 0 && readRules.length === 0) {
|
|
61
|
-
return undefined
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const authorizator = new S3ObjectAuthorizator(uploadRules, readRules)
|
|
54
|
+
const authorizator = new S3ObjectAuthorizator(rules.uploadRules, rules.readRules, rules.deleteRules)
|
|
65
55
|
const uploadMutation = this.createUploadMutation(authorizator)
|
|
66
56
|
const readMutation = this.createReadMutation(authorizator)
|
|
57
|
+
const deleteMutation = this.createDeleteMutation(authorizator)
|
|
67
58
|
const mutation = {
|
|
68
59
|
generateUploadUrl: uploadMutation as GraphQLFieldConfig<any, any, any>,
|
|
69
60
|
generateReadUrl: readMutation,
|
|
61
|
+
// not "deleteObject" — a content mutation for an entity named "Object" would shadow it
|
|
62
|
+
deleteS3Object: deleteMutation,
|
|
70
63
|
}
|
|
71
64
|
return {
|
|
72
65
|
mutation: new GraphQLObjectType({
|
|
@@ -84,6 +77,30 @@ export class S3SchemaContributor implements GraphQLSchemaContributor {
|
|
|
84
77
|
}
|
|
85
78
|
}
|
|
86
79
|
|
|
80
|
+
private resolveRules({ project, schema, identity }: GraphQLSchemaContributorContext) {
|
|
81
|
+
if (!project.s3) {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
// sorted, so getCacheKey does not depend on the order of memberships
|
|
85
|
+
const rules = [...identity.projectRoles].sort().flatMap(it => Object.entries((schema.acl.roles[it]?.s3 as S3SchemaAcl) || {}))
|
|
86
|
+
|
|
87
|
+
const uploadRules = rules.filter(([, it]) => it.upload).map(([it, val]) => ({
|
|
88
|
+
pattern: it,
|
|
89
|
+
...(typeof val.upload !== 'boolean' ? val.upload : {}),
|
|
90
|
+
}))
|
|
91
|
+
const readRules = rules.filter(([, it]) => it.read).map(([it]) => ({
|
|
92
|
+
pattern: it,
|
|
93
|
+
}))
|
|
94
|
+
const deleteRules = rules.filter(([, it]) => it.delete).map(([it]) => ({
|
|
95
|
+
pattern: it,
|
|
96
|
+
}))
|
|
97
|
+
|
|
98
|
+
if (uploadRules.length === 0 && readRules.length === 0 && deleteRules.length === 0) {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
return { uploadRules, readRules, deleteRules }
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
private createReadMutation(authorizator: S3ObjectAuthorizator): GraphQLFieldConfig<any, { project: Project3Config }, any> {
|
|
88
105
|
return {
|
|
89
106
|
type: new GraphQLNonNull(S3SignedRead),
|
|
@@ -106,6 +123,25 @@ export class S3SchemaContributor implements GraphQLSchemaContributor {
|
|
|
106
123
|
} as GraphQLFieldConfig<any, any, any>
|
|
107
124
|
}
|
|
108
125
|
|
|
126
|
+
private createDeleteMutation(authorizator: S3ObjectAuthorizator): GraphQLFieldConfig<any, any, any> {
|
|
127
|
+
return {
|
|
128
|
+
type: new GraphQLNonNull(S3DeletedObject),
|
|
129
|
+
args: {
|
|
130
|
+
objectKey: {
|
|
131
|
+
type: new GraphQLNonNull(GraphQLString),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
resolve: async (parent: any, args: { objectKey: string }, ctx: { project: Project3Config }) => {
|
|
135
|
+
if (!ctx.project.s3) {
|
|
136
|
+
throw new GraphQLError('S3 is not configured for this project')
|
|
137
|
+
}
|
|
138
|
+
const s3Config = resolveS3Config(ctx.project.s3)
|
|
139
|
+
const s3 = this.s3Factory.create(s3Config, this.providers, authorizator)
|
|
140
|
+
return await s3.deleteObject({ objectKey: args.objectKey })
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
109
145
|
private createUploadMutation(authorizator: S3ObjectAuthorizator): GraphQLFieldConfig<any, any, any> {
|
|
110
146
|
return {
|
|
111
147
|
type: new GraphQLNonNull(S3SignedUpload),
|
package/src/S3SchemaTypes.ts
CHANGED
|
@@ -44,6 +44,14 @@ export const S3SignedUpload = new GraphQLObjectType({
|
|
|
44
44
|
},
|
|
45
45
|
})
|
|
46
46
|
|
|
47
|
+
export const S3DeletedObject = new GraphQLObjectType({
|
|
48
|
+
name: 'S3DeletedObject',
|
|
49
|
+
fields: {
|
|
50
|
+
objectKey: { type: new GraphQLNonNull(GraphQLString) },
|
|
51
|
+
bucket: { type: new GraphQLNonNull(GraphQLString) },
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
|
|
47
55
|
export const S3Acl = new GraphQLEnumType({
|
|
48
56
|
name: 'S3Acl',
|
|
49
57
|
values: {
|
package/src/S3Service.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { extension as resolveExtension } from 'mime-types'
|
|
2
|
-
import { resolveS3PublicBaseUrl, S3Config } from './Config'
|
|
2
|
+
import { resolveS3PublicBaseUrl, S3Config } from './Config.js'
|
|
3
3
|
import { ForbiddenError, UserInputError } from '@contember/graphql-utils'
|
|
4
4
|
import { Providers } from '@contember/engine-plugins'
|
|
5
|
-
import { S3Signer } from './S3Signer'
|
|
6
|
-
import { S3GenerateSignedUploadInput } from './S3SchemaTypes'
|
|
7
|
-
import { S3ObjectAuthorizator } from './S3ObjectAuthorizator'
|
|
5
|
+
import { S3Signer } from './S3Signer.js'
|
|
6
|
+
import { S3GenerateSignedUploadInput } from './S3SchemaTypes.js'
|
|
7
|
+
import { S3ObjectAuthorizator } from './S3ObjectAuthorizator.js'
|
|
8
8
|
|
|
9
9
|
type SignedReadUrl = {
|
|
10
10
|
url: string
|
|
@@ -23,6 +23,22 @@ type SignedUploadUrl = {
|
|
|
23
23
|
method: string
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
type DeletedObject = {
|
|
27
|
+
bucket: string
|
|
28
|
+
objectKey: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type S3FetchResponse = {
|
|
32
|
+
readonly ok: boolean
|
|
33
|
+
readonly status: number
|
|
34
|
+
text(): Promise<string>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type S3Fetch = (url: string, init: { method: string; signal: AbortSignal }) => Promise<S3FetchResponse>
|
|
38
|
+
|
|
39
|
+
const deleteUrlExpiration = 60
|
|
40
|
+
const deleteTimeoutMs = 10_000
|
|
41
|
+
|
|
26
42
|
export class S3Service {
|
|
27
43
|
private readonly publicBaseUrl: string
|
|
28
44
|
|
|
@@ -32,6 +48,7 @@ export class S3Service {
|
|
|
32
48
|
public readonly config: S3Config,
|
|
33
49
|
private readonly providers: Pick<Providers, 'uuid' | 'now'>,
|
|
34
50
|
private readonly authorizator: S3ObjectAuthorizator,
|
|
51
|
+
private readonly fetch: S3Fetch = (url, init) => globalThis.fetch(url, init),
|
|
35
52
|
) {
|
|
36
53
|
this.publicBaseUrl = resolveS3PublicBaseUrl(config)
|
|
37
54
|
this.signer = new S3Signer(config, providers)
|
|
@@ -97,40 +114,71 @@ export class S3Service {
|
|
|
97
114
|
|
|
98
115
|
public getSignedReadUrl({ objectKey, expiration }: { objectKey: string; expiration: number | null }): SignedReadUrl {
|
|
99
116
|
const bucket = this.config.bucket
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (objectKey.startsWith(publicPrefix)) {
|
|
103
|
-
objectKey = objectKey.substring(publicPrefix.length)
|
|
104
|
-
}
|
|
105
|
-
// the separator is a part of the prefix: without it, a project prefixed "blog" reaches into "blog-staging" in a shared bucket
|
|
106
|
-
const prefix = this.config.prefix ? this.config.prefix + '/' : ''
|
|
107
|
-
if (prefix && !objectKey.startsWith(prefix)) {
|
|
108
|
-
throw new ForbiddenError(
|
|
109
|
-
`Given object key "${objectKey}" does not start with a project prefix "${this.config.prefix}"`,
|
|
110
|
-
)
|
|
111
|
-
}
|
|
112
|
-
const localObjectKey = objectKey.substring(prefix.length)
|
|
113
|
-
this.authorizator.verifyReadAccess({ key: localObjectKey })
|
|
117
|
+
const resolved = this.resolveObjectKey(objectKey)
|
|
118
|
+
this.authorizator.verifyReadAccess({ key: resolved.localObjectKey })
|
|
114
119
|
|
|
115
120
|
const url = this.signer.sign({
|
|
116
121
|
action: 'read',
|
|
117
122
|
expiration: expiration ?? 3600,
|
|
118
|
-
key: objectKey,
|
|
123
|
+
key: resolved.objectKey,
|
|
119
124
|
headers: {},
|
|
120
125
|
})
|
|
121
126
|
|
|
122
127
|
return {
|
|
123
128
|
bucket,
|
|
124
|
-
objectKey,
|
|
129
|
+
objectKey: resolved.objectKey,
|
|
125
130
|
url,
|
|
126
131
|
headers: [],
|
|
127
132
|
method: 'GET',
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Unlike an upload or a read, a delete carries no payload, so the signed URL never leaves the server.
|
|
138
|
+
*/
|
|
139
|
+
public async deleteObject({ objectKey }: { objectKey: string }): Promise<DeletedObject> {
|
|
140
|
+
const bucket = this.config.bucket
|
|
141
|
+
const resolved = this.resolveObjectKey(objectKey)
|
|
142
|
+
this.authorizator.verifyDeleteAccess({ key: resolved.localObjectKey })
|
|
143
|
+
|
|
144
|
+
const url = this.signer.sign({
|
|
145
|
+
action: 'delete',
|
|
146
|
+
expiration: deleteUrlExpiration,
|
|
147
|
+
key: resolved.objectKey,
|
|
148
|
+
headers: {},
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const response = await this.fetch(url, { method: 'DELETE', signal: AbortSignal.timeout(deleteTimeoutMs) })
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
throw new Error(`Failed to delete an object "${resolved.objectKey}": S3 responded with ${response.status}: ${await response.text()}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
bucket,
|
|
158
|
+
objectKey: resolved.objectKey,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
131
162
|
public formatPublicUrl(key: string): string {
|
|
132
163
|
return `${this.publicBaseUrl}/${key}`
|
|
133
164
|
}
|
|
165
|
+
|
|
166
|
+
private resolveObjectKey(objectKey: string): { objectKey: string; localObjectKey: string } {
|
|
167
|
+
const publicPrefix = this.formatPublicUrl('')
|
|
168
|
+
if (objectKey.startsWith(publicPrefix)) {
|
|
169
|
+
objectKey = objectKey.substring(publicPrefix.length)
|
|
170
|
+
}
|
|
171
|
+
// the separator is a part of the prefix: without it, a project prefixed "blog" reaches into "blog-staging" in a shared bucket
|
|
172
|
+
const prefix = this.config.prefix ? this.config.prefix + '/' : ''
|
|
173
|
+
if (prefix && !objectKey.startsWith(prefix)) {
|
|
174
|
+
throw new ForbiddenError(
|
|
175
|
+
`Given object key "${objectKey}" does not start with a project prefix "${this.config.prefix}"`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
const localObjectKey = objectKey.substring(prefix.length)
|
|
179
|
+
|
|
180
|
+
return { objectKey, localObjectKey }
|
|
181
|
+
}
|
|
134
182
|
}
|
|
135
183
|
|
|
136
184
|
export class S3ServiceFactory {
|
package/src/S3Signer.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
import { resolveS3Endpoint, S3Config } from './Config'
|
|
1
|
+
import { resolveS3Endpoint, S3Config } from './Config.js'
|
|
2
2
|
import { Providers } from '@contember/engine-plugins'
|
|
3
3
|
import qs from 'qs'
|
|
4
4
|
import { BinaryLike, createHash, createHmac } from 'node:crypto'
|
|
5
5
|
|
|
6
6
|
interface S3Request {
|
|
7
|
-
action: 'read' | 'upload'
|
|
7
|
+
action: 'read' | 'upload' | 'delete'
|
|
8
8
|
key: string
|
|
9
9
|
expiration: number
|
|
10
10
|
headers: Record<string, string>
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
const methods: Record<S3Request['action'], string> = {
|
|
14
|
+
read: 'GET',
|
|
15
|
+
upload: 'PUT',
|
|
16
|
+
delete: 'DELETE',
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
const hmac = (secret: BinaryLike, data: BinaryLike) => createHmac('sha256', secret).update(data).digest()
|
|
14
20
|
|
|
15
21
|
const hash = (data: BinaryLike) => createHash('sha256').update(data).digest('hex')
|
|
@@ -36,7 +42,7 @@ export class S3Signer {
|
|
|
36
42
|
const queryString = this.getQueryParams(request, nowFormatted)
|
|
37
43
|
const path = uris.basePath + '/' + request.key
|
|
38
44
|
const lines = [
|
|
39
|
-
request.action
|
|
45
|
+
methods[request.action],
|
|
40
46
|
path,
|
|
41
47
|
queryString,
|
|
42
48
|
this.getSignedHeaders(request),
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Plugin, SchemaContributorArgs } from '@contember/engine-plugins'
|
|
2
|
-
import { S3ConfigProcessor } from './S3ConfigProcessor'
|
|
3
|
-
import { S3SchemaContributor } from './S3SchemaContributor'
|
|
4
|
-
import { S3ServiceFactory } from './S3Service'
|
|
5
|
-
import { Project3Config } from './Config'
|
|
2
|
+
import { S3ConfigProcessor } from './S3ConfigProcessor.js'
|
|
3
|
+
import { S3SchemaContributor } from './S3SchemaContributor.js'
|
|
4
|
+
import { S3ServiceFactory } from './S3Service.js'
|
|
5
|
+
import { Project3Config } from './Config.js'
|
|
6
6
|
|
|
7
|
-
export * from './S3ObjectAuthorizator'
|
|
8
|
-
export * from './S3SchemaContributor'
|
|
9
|
-
export * from './S3Service'
|
|
7
|
+
export * from './S3ObjectAuthorizator.js'
|
|
8
|
+
export * from './S3SchemaContributor.js'
|
|
9
|
+
export * from './S3Service.js'
|
|
10
10
|
|
|
11
11
|
export default class S3 implements Plugin<Project3Config> {
|
|
12
12
|
name = 'contember/s3'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from 'bun:test'
|
|
2
|
-
import { S3ObjectAuthorizator } from '../../src'
|
|
2
|
+
import { S3ObjectAuthorizator } from '../../src/index.js'
|
|
3
3
|
|
|
4
4
|
test('required read pattern', () => {
|
|
5
5
|
const authorizator = new S3ObjectAuthorizator([], [{ pattern: 'foo/**' }])
|
|
@@ -13,6 +13,17 @@ test('required upload pattern', () => {
|
|
|
13
13
|
expect(() => authorizator.verifyUploadAccess({ key: 'lorem.jpg', size: null })).toThrow('Upload access forbidden for object key lorem.jpg')
|
|
14
14
|
})
|
|
15
15
|
|
|
16
|
+
test('required delete pattern', () => {
|
|
17
|
+
const authorizator = new S3ObjectAuthorizator([], [], [{ pattern: 'foo/**' }])
|
|
18
|
+
expect(() => authorizator.verifyDeleteAccess({ key: 'foo/lorem.jpg' })).not.toThrow()
|
|
19
|
+
expect(() => authorizator.verifyDeleteAccess({ key: 'lorem.jpg' })).toThrow('Delete access forbidden for object key lorem.jpg')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('delete is not implied by upload nor read', () => {
|
|
23
|
+
const authorizator = new S3ObjectAuthorizator([{ pattern: '**' }], [{ pattern: '**' }])
|
|
24
|
+
expect(() => authorizator.verifyDeleteAccess({ key: 'foo/lorem.jpg' })).toThrow('Delete access forbidden for object key foo/lorem.jpg')
|
|
25
|
+
})
|
|
26
|
+
|
|
16
27
|
test('max upload size', () => {
|
|
17
28
|
const authorizator = new S3ObjectAuthorizator([{ pattern: '**', maxSize: 1024 }], [])
|
|
18
29
|
expect(() => authorizator.verifyUploadAccess({ key: 'lorem.jpg', size: 1000 })).not.toThrow()
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { expect, test } from 'bun:test'
|
|
2
|
-
import { S3ObjectAuthorizator, S3Service } from '../../src'
|
|
2
|
+
import { S3Fetch, S3ObjectAuthorizator, S3Service } from '../../src/index.js'
|
|
3
3
|
|
|
4
4
|
const mocked = new Date('2021-07-02T17:22Z')
|
|
5
5
|
const constantUuid = '9fce3907-ff2b-45bb-b4ce-eff5527dd315'
|
|
6
|
-
const createS3Service = (bucket: string, prefix = '') =>
|
|
6
|
+
const createS3Service = (bucket: string, prefix = '', fetch?: S3Fetch) =>
|
|
7
7
|
new S3Service(
|
|
8
8
|
{
|
|
9
9
|
bucket,
|
|
@@ -18,9 +18,20 @@ const createS3Service = (bucket: string, prefix = '') =>
|
|
|
18
18
|
uuid: () => constantUuid,
|
|
19
19
|
now: () => mocked,
|
|
20
20
|
},
|
|
21
|
-
new S3ObjectAuthorizator([{ pattern: '**' }], [{ pattern: '**' }]),
|
|
21
|
+
new S3ObjectAuthorizator([{ pattern: '**' }], [{ pattern: '**' }], [{ pattern: '**' }]),
|
|
22
|
+
fetch,
|
|
22
23
|
)
|
|
23
24
|
|
|
25
|
+
const createFetchMock = (response: { ok: boolean; status: number; body?: string } = { ok: true, status: 204 }) => {
|
|
26
|
+
const calls: { url: string; method: string }[] = []
|
|
27
|
+
const fetch: S3Fetch = async (url, init) => {
|
|
28
|
+
calls.push({ url, method: init.method })
|
|
29
|
+
return { ...response, text: async () => response.body ?? '' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { calls, fetch }
|
|
33
|
+
}
|
|
34
|
+
|
|
24
35
|
test('sign s3 request', () => {
|
|
25
36
|
const service = createS3Service('test')
|
|
26
37
|
const signed = service.getSignedReadUrl({ objectKey: 'foo.jpg', expiration: null })
|
|
@@ -80,6 +91,66 @@ test('sign upload #2', () => {
|
|
|
80
91
|
)
|
|
81
92
|
})
|
|
82
93
|
|
|
94
|
+
test('delete object', async () => {
|
|
95
|
+
const { calls, fetch } = createFetchMock()
|
|
96
|
+
const service = createS3Service('test', '', fetch)
|
|
97
|
+
const deleted = await service.deleteObject({ objectKey: 'foo.jpg' })
|
|
98
|
+
expect(deleted).toEqual({ bucket: 'test', objectKey: 'foo.jpg' })
|
|
99
|
+
expect(calls).toEqual([{
|
|
100
|
+
method: 'DELETE',
|
|
101
|
+
url:
|
|
102
|
+
'https://test.s3.eu-central-1.amazonaws.com/foo.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=test%2F20210702%2Feu-central-1%2Fs3%2Faws4_request&X-Amz-Date=20210702T172200Z&X-Amz-Expires=60&X-Amz-Signature=441f2003478d8dc8761b6c4c48c6c0805a36d1f088138a5b89d6bea365922663&X-Amz-SignedHeaders=host',
|
|
103
|
+
}])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('delete object - public url is accepted', async () => {
|
|
107
|
+
const { calls, fetch } = createFetchMock()
|
|
108
|
+
const service = createS3Service('test', 'lorem', fetch)
|
|
109
|
+
const deleted = await service.deleteObject({
|
|
110
|
+
objectKey: 'https://test.s3.eu-central-1.amazonaws.com/lorem/foo.jpg',
|
|
111
|
+
})
|
|
112
|
+
expect(deleted.objectKey).toEqual('lorem/foo.jpg')
|
|
113
|
+
expect(calls.length).toEqual(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('delete object - project prefix is enforced', async () => {
|
|
117
|
+
const { calls, fetch } = createFetchMock()
|
|
118
|
+
const service = createS3Service('test', 'lorem', fetch)
|
|
119
|
+
await expect(service.deleteObject({ objectKey: 'ipsum/foo.jpg' })).rejects.toThrow(
|
|
120
|
+
'Given object key "ipsum/foo.jpg" does not start with a project prefix "lorem"',
|
|
121
|
+
)
|
|
122
|
+
expect(calls.length).toEqual(0)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('delete object - a sibling prefix is not a prefix', async () => {
|
|
126
|
+
const { calls, fetch } = createFetchMock()
|
|
127
|
+
const service = createS3Service('test', 'lorem', fetch)
|
|
128
|
+
await expect(service.deleteObject({ objectKey: 'loremipsum/foo.jpg' })).rejects.toThrow(
|
|
129
|
+
'Given object key "loremipsum/foo.jpg" does not start with a project prefix "lorem"',
|
|
130
|
+
)
|
|
131
|
+
expect(calls.length).toEqual(0)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('delete object - acl is verified', async () => {
|
|
135
|
+
const { calls, fetch } = createFetchMock()
|
|
136
|
+
const service = new S3Service(
|
|
137
|
+
{ bucket: 'test', region: 'eu-central-1', credentials: { key: 'test', secret: 'abcd' }, prefix: '' },
|
|
138
|
+
{ uuid: () => constantUuid, now: () => mocked },
|
|
139
|
+
new S3ObjectAuthorizator([{ pattern: '**' }], [{ pattern: '**' }], [{ pattern: 'foo/**' }]),
|
|
140
|
+
fetch,
|
|
141
|
+
)
|
|
142
|
+
await expect(service.deleteObject({ objectKey: 'bar/foo.jpg' })).rejects.toThrow('Delete access forbidden for object key bar/foo.jpg')
|
|
143
|
+
expect(calls.length).toEqual(0)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('delete object - s3 failure', async () => {
|
|
147
|
+
const { fetch } = createFetchMock({ ok: false, status: 403, body: '<Error>AccessDenied</Error>' })
|
|
148
|
+
const service = createS3Service('test', '', fetch)
|
|
149
|
+
await expect(service.deleteObject({ objectKey: 'foo.jpg' })).rejects.toThrow(
|
|
150
|
+
'Failed to delete an object "foo.jpg": S3 responded with 403: <Error>AccessDenied</Error>',
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
|
|
83
154
|
test('bucket with dot', () => {
|
|
84
155
|
const service = createS3Service('test.foo')
|
|
85
156
|
const signed = service.getSignedReadUrl({ objectKey: 'foo.jpg', expiration: null })
|