@contember/engine-s3-plugin 2.2.0-alpha.3 → 2.2.0-alpha.6
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/src/Config.cjs.map +1 -1
- package/dist/development/src/Config.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 +27 -3
- package/dist/development/src/S3SchemaContributor.cjs.map +1 -1
- package/dist/development/src/S3SchemaContributor.js +28 -4
- 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 -14
- package/dist/development/src/S3Service.cjs.map +1 -1
- package/dist/development/src/S3Service.js +44 -14
- 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/src/Config.cjs.map +1 -1
- package/dist/production/src/Config.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 +27 -3
- package/dist/production/src/S3SchemaContributor.cjs.map +1 -1
- package/dist/production/src/S3SchemaContributor.js +28 -4
- 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 -14
- package/dist/production/src/S3Service.cjs.map +1 -1
- package/dist/production/src/S3Service.js +44 -14
- 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.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 +2 -0
- 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 +22 -1
- package/dist/types/S3Service.d.ts.map +1 -1
- package/dist/types/S3Signer.d.ts +1 -1
- package/dist/types/S3Signer.d.ts.map +1 -1
- 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/S3ObjectAuthorizator.ts +20 -0
- package/src/S3SchemaContributor.ts +29 -3
- package/src/S3SchemaTypes.ts +8 -0
- package/src/S3Service.ts +64 -14
- package/src/S3Signer.ts +8 -2
- package/tests/cases/authorizator.test.ts +11 -0
- package/tests/cases/signRequest.test.ts +81 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contember/engine-s3-plugin",
|
|
3
|
-
"version": "2.2.0-alpha.
|
|
3
|
+
"version": "2.2.0-alpha.6",
|
|
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.2.0-alpha.
|
|
27
|
-
"@contember/graphql-utils": "2.2.0-alpha.
|
|
28
|
-
"@contember/typesafe": "2.2.0-alpha.
|
|
26
|
+
"@contember/engine-plugins": "2.2.0-alpha.6",
|
|
27
|
+
"@contember/graphql-utils": "2.2.0-alpha.6",
|
|
28
|
+
"@contember/typesafe": "2.2.0-alpha.6",
|
|
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',
|
|
@@ -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
|
|
|
@@ -12,7 +12,7 @@ import { S3Service, S3ServiceFactory } from './S3Service.js'
|
|
|
12
12
|
import { Project3Config, resolveS3Config, S3Config } from './Config.js'
|
|
13
13
|
import { GraphQLSchemaContributor, GraphQLSchemaContributorContext, Providers } from '@contember/engine-plugins'
|
|
14
14
|
import * as types from './S3SchemaTypes.js'
|
|
15
|
-
import { S3Acl, S3GenerateSignedUploadInput, S3SignedRead, S3SignedUpload } from './S3SchemaTypes.js'
|
|
15
|
+
import { S3Acl, S3DeletedObject, S3GenerateSignedUploadInput, S3SignedRead, S3SignedUpload } from './S3SchemaTypes.js'
|
|
16
16
|
import { S3ObjectAuthorizator } from './S3ObjectAuthorizator.js'
|
|
17
17
|
|
|
18
18
|
interface Identity {
|
|
@@ -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
|
|
|
@@ -56,17 +57,23 @@ export class S3SchemaContributor implements GraphQLSchemaContributor {
|
|
|
56
57
|
const readRules = rules.filter(([, it]) => it.read).map(([it]) => ({
|
|
57
58
|
pattern: it,
|
|
58
59
|
}))
|
|
60
|
+
const deleteRules = rules.filter(([, it]) => it.delete).map(([it]) => ({
|
|
61
|
+
pattern: it,
|
|
62
|
+
}))
|
|
59
63
|
|
|
60
|
-
if (uploadRules.length === 0 && readRules.length === 0) {
|
|
64
|
+
if (uploadRules.length === 0 && readRules.length === 0 && deleteRules.length === 0) {
|
|
61
65
|
return undefined
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
const authorizator = new S3ObjectAuthorizator(uploadRules, readRules)
|
|
68
|
+
const authorizator = new S3ObjectAuthorizator(uploadRules, readRules, deleteRules)
|
|
65
69
|
const uploadMutation = this.createUploadMutation(authorizator)
|
|
66
70
|
const readMutation = this.createReadMutation(authorizator)
|
|
71
|
+
const deleteMutation = this.createDeleteMutation(authorizator)
|
|
67
72
|
const mutation = {
|
|
68
73
|
generateUploadUrl: uploadMutation as GraphQLFieldConfig<any, any, any>,
|
|
69
74
|
generateReadUrl: readMutation,
|
|
75
|
+
// not "deleteObject" — a content mutation for an entity named "Object" would shadow it
|
|
76
|
+
deleteS3Object: deleteMutation,
|
|
70
77
|
}
|
|
71
78
|
return {
|
|
72
79
|
mutation: new GraphQLObjectType({
|
|
@@ -106,6 +113,25 @@ export class S3SchemaContributor implements GraphQLSchemaContributor {
|
|
|
106
113
|
} as GraphQLFieldConfig<any, any, any>
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
private createDeleteMutation(authorizator: S3ObjectAuthorizator): GraphQLFieldConfig<any, any, any> {
|
|
117
|
+
return {
|
|
118
|
+
type: new GraphQLNonNull(S3DeletedObject),
|
|
119
|
+
args: {
|
|
120
|
+
objectKey: {
|
|
121
|
+
type: new GraphQLNonNull(GraphQLString),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
resolve: async (parent: any, args: { objectKey: string }, ctx: { project: Project3Config }) => {
|
|
125
|
+
if (!ctx.project.s3) {
|
|
126
|
+
throw new GraphQLError('S3 is not configured for this project')
|
|
127
|
+
}
|
|
128
|
+
const s3Config = resolveS3Config(ctx.project.s3)
|
|
129
|
+
const s3 = this.s3Factory.create(s3Config, this.providers, authorizator)
|
|
130
|
+
return await s3.deleteObject({ objectKey: args.objectKey })
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
109
135
|
private createUploadMutation(authorizator: S3ObjectAuthorizator): GraphQLFieldConfig<any, any, any> {
|
|
110
136
|
return {
|
|
111
137
|
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
|
@@ -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,38 +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
|
-
if (this.config.prefix && !objectKey.startsWith(this.config.prefix)) {
|
|
106
|
-
throw new ForbiddenError(
|
|
107
|
-
`Given object key "${objectKey}" does not start with a project prefix "${this.config.prefix}"`,
|
|
108
|
-
)
|
|
109
|
-
}
|
|
110
|
-
const localObjectKey = this.config.prefix ? objectKey.substring(this.config.prefix.length + 1) : objectKey
|
|
111
|
-
this.authorizator.verifyReadAccess({ key: localObjectKey })
|
|
117
|
+
const resolved = this.resolveObjectKey(objectKey)
|
|
118
|
+
this.authorizator.verifyReadAccess({ key: resolved.localObjectKey })
|
|
112
119
|
|
|
113
120
|
const url = this.signer.sign({
|
|
114
121
|
action: 'read',
|
|
115
122
|
expiration: expiration ?? 3600,
|
|
116
|
-
key: objectKey,
|
|
123
|
+
key: resolved.objectKey,
|
|
117
124
|
headers: {},
|
|
118
125
|
})
|
|
119
126
|
|
|
120
127
|
return {
|
|
121
128
|
bucket,
|
|
122
|
-
objectKey,
|
|
129
|
+
objectKey: resolved.objectKey,
|
|
123
130
|
url,
|
|
124
131
|
headers: [],
|
|
125
132
|
method: 'GET',
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
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
|
+
|
|
129
162
|
public formatPublicUrl(key: string): string {
|
|
130
163
|
return `${this.publicBaseUrl}/${key}`
|
|
131
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
|
+
}
|
|
132
182
|
}
|
|
133
183
|
|
|
134
184
|
export class S3ServiceFactory {
|
package/src/S3Signer.ts
CHANGED
|
@@ -4,12 +4,18 @@ 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),
|
|
@@ -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/index.js'
|
|
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 })
|
|
@@ -37,6 +48,13 @@ test('sign s3 request - prefix', () => {
|
|
|
37
48
|
)
|
|
38
49
|
})
|
|
39
50
|
|
|
51
|
+
test('sign s3 request - a sibling prefix is not a prefix', () => {
|
|
52
|
+
const service = createS3Service('test', 'lorem')
|
|
53
|
+
expect(() => service.getSignedReadUrl({ objectKey: 'loremipsum/foo.jpg', expiration: null })).toThrow(
|
|
54
|
+
'Given object key "loremipsum/foo.jpg" does not start with a project prefix "lorem"',
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
40
58
|
test('sign upload', () => {
|
|
41
59
|
const service = createS3Service('test')
|
|
42
60
|
const signed = service.getSignedUploadUrl({
|
|
@@ -73,6 +91,66 @@ test('sign upload #2', () => {
|
|
|
73
91
|
)
|
|
74
92
|
})
|
|
75
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
|
+
|
|
76
154
|
test('bucket with dot', () => {
|
|
77
155
|
const service = createS3Service('test.foo')
|
|
78
156
|
const signed = service.getSignedReadUrl({ objectKey: 'foo.jpg', expiration: null })
|