@open-mercato/storage-s3 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js +20 -5
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js +7 -5
- package/dist/modules/storage_s3/api/get/storage-providers/s3/download.js.map +2 -2
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js +6 -4
- package/dist/modules/storage_s3/api/get/storage-providers/s3/list.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +70 -8
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +3 -3
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +92 -15
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +145 -0
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +7 -0
- package/dist/modules/storage_s3/di.js +6 -2
- package/dist/modules/storage_s3/di.js.map +2 -2
- package/dist/modules/storage_s3/integration.js +1 -1
- package/dist/modules/storage_s3/integration.js.map +1 -1
- package/dist/modules/storage_s3/lib/endpoint-safety.js +133 -0
- package/dist/modules/storage_s3/lib/endpoint-safety.js.map +7 -0
- package/dist/modules/storage_s3/lib/health.js +18 -1
- package/dist/modules/storage_s3/lib/health.js.map +2 -2
- package/dist/modules/storage_s3/lib/key-scope.js +4 -4
- package/dist/modules/storage_s3/lib/key-scope.js.map +2 -2
- package/dist/modules/storage_s3/lib/quota-accounting.js +24 -0
- package/dist/modules/storage_s3/lib/quota-accounting.js.map +7 -0
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js +9 -0
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +7 -0
- package/dist/modules/storage_s3/lib/s3-driver.js +44 -15
- package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
- package/dist/modules/storage_s3/lib/storage-service.js +126 -22
- package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
- package/dist/modules/storage_s3/workers/quota-recovery.js +70 -0
- package/dist/modules/storage_s3/workers/quota-recovery.js.map +7 -0
- package/jest.config.cjs +1 -0
- package/package.json +13 -6
- package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +71 -0
- package/src/modules/storage_s3/__tests__/health.test.ts +38 -0
- package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +13 -0
- package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +216 -0
- package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +83 -0
- package/src/modules/storage_s3/__tests__/s3Driver.test.ts +23 -8
- package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +7 -1
- package/src/modules/storage_s3/__tests__/s3Routes.test.ts +26 -0
- package/src/modules/storage_s3/__tests__/storageService.test.ts +20 -5
- package/src/modules/storage_s3/__tests__/upload.test.ts +93 -0
- package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +22 -5
- package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +7 -5
- package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +6 -4
- package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +77 -6
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +101 -16
- package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +150 -0
- package/src/modules/storage_s3/di.ts +14 -2
- package/src/modules/storage_s3/i18n/de.json +19 -0
- package/src/modules/storage_s3/i18n/en.json +19 -0
- package/src/modules/storage_s3/i18n/es.json +19 -0
- package/src/modules/storage_s3/i18n/ko.json +19 -0
- package/src/modules/storage_s3/i18n/pl.json +19 -0
- package/src/modules/storage_s3/integration.ts +1 -1
- package/src/modules/storage_s3/lib/endpoint-safety.ts +179 -0
- package/src/modules/storage_s3/lib/health.ts +18 -0
- package/src/modules/storage_s3/lib/key-scope.ts +4 -4
- package/src/modules/storage_s3/lib/quota-accounting.ts +29 -0
- package/src/modules/storage_s3/lib/quota-recovery-queue.ts +17 -0
- package/src/modules/storage_s3/lib/s3-driver.ts +65 -10
- package/src/modules/storage_s3/lib/storage-service.ts +131 -22
- package/src/modules/storage_s3/workers/quota-recovery.ts +82 -0
|
@@ -99,6 +99,10 @@ describe('S3StorageDriver', () => {
|
|
|
99
99
|
new S3StorageDriver(BASE_CONFIG)
|
|
100
100
|
const [[arg]] = (S3Client as jest.Mock).mock.calls
|
|
101
101
|
expect(arg.credentials).toBeUndefined()
|
|
102
|
+
expect(arg.requestHandler).toEqual(expect.objectContaining({
|
|
103
|
+
connectionTimeout: 10_000,
|
|
104
|
+
requestTimeout: 120_000,
|
|
105
|
+
}))
|
|
102
106
|
})
|
|
103
107
|
|
|
104
108
|
it('applies forcePathStyle and custom endpoint', () => {
|
|
@@ -107,6 +111,17 @@ describe('S3StorageDriver', () => {
|
|
|
107
111
|
expect(arg.endpoint).toBe('http://minio:9000')
|
|
108
112
|
expect(arg.forcePathStyle).toBe(true)
|
|
109
113
|
})
|
|
114
|
+
|
|
115
|
+
it('rejects link-local custom endpoints before constructing an S3 client', () => {
|
|
116
|
+
expect(() => {
|
|
117
|
+
new S3StorageDriver({
|
|
118
|
+
...BASE_CONFIG,
|
|
119
|
+
endpoint: 'http://169.254.169.254/latest/meta-data/',
|
|
120
|
+
forcePathStyle: true,
|
|
121
|
+
})
|
|
122
|
+
}).toThrow(/private or reserved IP range/i)
|
|
123
|
+
expect(S3Client).not.toHaveBeenCalled()
|
|
124
|
+
})
|
|
110
125
|
})
|
|
111
126
|
|
|
112
127
|
describe('store()', () => {
|
|
@@ -198,7 +213,7 @@ describe('S3StorageDriver', () => {
|
|
|
198
213
|
|
|
199
214
|
await expect(
|
|
200
215
|
driver.read('', 'docs/org_org-victim/tenant_tenant-owned/secret.txt'),
|
|
201
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
216
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
202
217
|
|
|
203
218
|
expect(mockSend).not.toHaveBeenCalled()
|
|
204
219
|
})
|
|
@@ -229,7 +244,7 @@ describe('S3StorageDriver', () => {
|
|
|
229
244
|
|
|
230
245
|
await expect(
|
|
231
246
|
driver.read('docs', 'exports/org_o/tenant_t/file.txt'),
|
|
232
|
-
).rejects.toThrow('S3 key is not scoped to the requested partition')
|
|
247
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the requested partition')
|
|
233
248
|
|
|
234
249
|
expect(mockSend).not.toHaveBeenCalled()
|
|
235
250
|
})
|
|
@@ -243,7 +258,7 @@ describe('S3StorageDriver', () => {
|
|
|
243
258
|
|
|
244
259
|
await expect(
|
|
245
260
|
driver.read('', 'docs/org_org-victim/tenant_tenant-owned/org_org-owned/tenant_tenant-owned/private.txt'),
|
|
246
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
261
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
247
262
|
|
|
248
263
|
expect(mockSend).not.toHaveBeenCalled()
|
|
249
264
|
})
|
|
@@ -259,7 +274,7 @@ describe('S3StorageDriver', () => {
|
|
|
259
274
|
|
|
260
275
|
await expect(
|
|
261
276
|
driver.putObject('docs/org_org-victim/tenant_tenant-owned/file.txt', Buffer.from('secret')),
|
|
262
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
277
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
263
278
|
|
|
264
279
|
expect(mockSend).not.toHaveBeenCalled()
|
|
265
280
|
})
|
|
@@ -291,7 +306,7 @@ describe('S3StorageDriver', () => {
|
|
|
291
306
|
|
|
292
307
|
await expect(
|
|
293
308
|
driver.delete('', 'docs/org_org-owned/tenant_tenant-victim/file.txt'),
|
|
294
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
309
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
295
310
|
|
|
296
311
|
expect(mockSend).not.toHaveBeenCalled()
|
|
297
312
|
})
|
|
@@ -314,7 +329,7 @@ describe('S3StorageDriver', () => {
|
|
|
314
329
|
|
|
315
330
|
await expect(
|
|
316
331
|
driver.delete('docs', 'exports/org_o/tenant_t/file.txt'),
|
|
317
|
-
).rejects.toThrow('S3 key is not scoped to the requested partition')
|
|
332
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the requested partition')
|
|
318
333
|
|
|
319
334
|
expect(mockSend).not.toHaveBeenCalled()
|
|
320
335
|
})
|
|
@@ -394,7 +409,7 @@ describe('S3StorageDriver', () => {
|
|
|
394
409
|
|
|
395
410
|
await expect(
|
|
396
411
|
driver.getSignedUrl('docs/org_org-victim/tenant_tenant-owned/file.pdf', 'download'),
|
|
397
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
412
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
398
413
|
|
|
399
414
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
400
415
|
})
|
|
@@ -420,7 +435,7 @@ describe('S3StorageDriver', () => {
|
|
|
420
435
|
|
|
421
436
|
await expect(
|
|
422
437
|
driver.getSignedUrl('docs/org_shared/tenant_shared/shared.pdf', 'upload'),
|
|
423
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
438
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
424
439
|
|
|
425
440
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
426
441
|
})
|
|
@@ -9,6 +9,12 @@ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
|
9
9
|
getAuthFromRequest: async () => ({ tenantId, orgId, sub: null }),
|
|
10
10
|
}))
|
|
11
11
|
|
|
12
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
13
|
+
resolveTranslations: async () => ({
|
|
14
|
+
t: (key: string) => `translated:${key}`,
|
|
15
|
+
}),
|
|
16
|
+
}))
|
|
17
|
+
|
|
12
18
|
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
13
19
|
createRequestContainer: async () => ({
|
|
14
20
|
resolve: (token: string) => {
|
|
@@ -69,7 +75,7 @@ describe('storage_s3 list route tenant scoping', () => {
|
|
|
69
75
|
|
|
70
76
|
expect(response.status).toBe(403)
|
|
71
77
|
await expect(response.json()).resolves.toEqual({
|
|
72
|
-
error: '
|
|
78
|
+
error: 'translated:storage_s3.errors.prefixAccessDenied',
|
|
73
79
|
})
|
|
74
80
|
expect(listObjectsMock).not.toHaveBeenCalled()
|
|
75
81
|
})
|
|
@@ -5,6 +5,12 @@ jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
|
5
5
|
getAuthFromRequest: (...args: unknown[]) => mockGetAuthFromRequest(...args),
|
|
6
6
|
}))
|
|
7
7
|
|
|
8
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
9
|
+
resolveTranslations: async () => ({
|
|
10
|
+
t: (key: string) => `translated:${key}`,
|
|
11
|
+
}),
|
|
12
|
+
}))
|
|
13
|
+
|
|
8
14
|
const mockResolveCredentials = jest.fn()
|
|
9
15
|
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
10
16
|
createRequestContainer: jest.fn(async () => ({
|
|
@@ -93,9 +99,26 @@ describe('storage_s3 standalone route key scope', () => {
|
|
|
93
99
|
}))
|
|
94
100
|
|
|
95
101
|
expect(response.status).toBe(403)
|
|
102
|
+
await expect(response.json()).resolves.toEqual({
|
|
103
|
+
error: 'translated:storage_s3.errors.keyAccessDenied',
|
|
104
|
+
})
|
|
96
105
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
97
106
|
})
|
|
98
107
|
|
|
108
|
+
it('localizes invalid delete payload errors', async () => {
|
|
109
|
+
const response = await DELETE(new Request('http://example.test/api/storage-providers/s3/delete', {
|
|
110
|
+
method: 'DELETE',
|
|
111
|
+
headers: { 'content-type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({}),
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
expect(response.status).toBe(400)
|
|
116
|
+
await expect(response.json()).resolves.toEqual({
|
|
117
|
+
error: 'translated:storage_s3.errors.invalidPayload',
|
|
118
|
+
})
|
|
119
|
+
expect(mockDelete).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
99
122
|
it('deletes shared namespace objects for an authorized storage manager', async () => {
|
|
100
123
|
mockDelete.mockResolvedValueOnce(undefined)
|
|
101
124
|
|
|
@@ -116,6 +139,9 @@ describe('storage_s3 standalone route key scope', () => {
|
|
|
116
139
|
)
|
|
117
140
|
|
|
118
141
|
expect(response.status).toBe(403)
|
|
142
|
+
await expect(response.json()).resolves.toEqual({
|
|
143
|
+
error: 'translated:storage_s3.errors.keyAccessDenied',
|
|
144
|
+
})
|
|
119
145
|
}
|
|
120
146
|
expect(mockRead).not.toHaveBeenCalled()
|
|
121
147
|
})
|
|
@@ -27,6 +27,21 @@ beforeEach(() => {
|
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
describe('createStorageService', () => {
|
|
30
|
+
it('preserves the configured path prefix for programmatic uploads', async () => {
|
|
31
|
+
mockSend.mockResolvedValueOnce({})
|
|
32
|
+
const service = createStorageService({ ...BASE_CONFIG, pathPrefix: 'backups/' })
|
|
33
|
+
|
|
34
|
+
const result = await service.upload({
|
|
35
|
+
namespace: 'docs',
|
|
36
|
+
fileName: 'report.pdf',
|
|
37
|
+
buffer: Buffer.from('pdf'),
|
|
38
|
+
scope: TENANT_SCOPE,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(result.key).toMatch(/^backups\/docs\/org_org-owned\/tenant_tenant-owned\//)
|
|
42
|
+
expect(mockSend.mock.calls[0]?.[0]?.Key).toBe(result.key)
|
|
43
|
+
})
|
|
44
|
+
|
|
30
45
|
it('rejects cross-tenant downloads before calling S3', async () => {
|
|
31
46
|
const service = createStorageService(BASE_CONFIG)
|
|
32
47
|
|
|
@@ -35,7 +50,7 @@ describe('createStorageService', () => {
|
|
|
35
50
|
key: 'docs/org_org-victim/tenant_tenant-owned/file.txt',
|
|
36
51
|
scope: TENANT_SCOPE,
|
|
37
52
|
}),
|
|
38
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
53
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
39
54
|
|
|
40
55
|
expect(mockSend).not.toHaveBeenCalled()
|
|
41
56
|
})
|
|
@@ -70,7 +85,7 @@ describe('createStorageService', () => {
|
|
|
70
85
|
key: 'docs/org_org-victim/tenant_tenant-owned/file.txt',
|
|
71
86
|
scope: TENANT_SCOPE,
|
|
72
87
|
}),
|
|
73
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
88
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
74
89
|
|
|
75
90
|
expect(mockSend).not.toHaveBeenCalled()
|
|
76
91
|
})
|
|
@@ -83,7 +98,7 @@ describe('createStorageService', () => {
|
|
|
83
98
|
key: 'docs/org_org-owned/tenant_tenant-victim/file.txt',
|
|
84
99
|
scope: TENANT_SCOPE,
|
|
85
100
|
}),
|
|
86
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
101
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
87
102
|
|
|
88
103
|
expect(mockSend).not.toHaveBeenCalled()
|
|
89
104
|
})
|
|
@@ -97,7 +112,7 @@ describe('createStorageService', () => {
|
|
|
97
112
|
operation: 'download',
|
|
98
113
|
scope: TENANT_SCOPE,
|
|
99
114
|
}),
|
|
100
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
115
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
101
116
|
|
|
102
117
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
103
118
|
})
|
|
@@ -111,7 +126,7 @@ describe('createStorageService', () => {
|
|
|
111
126
|
operation: 'upload',
|
|
112
127
|
scope: TENANT_SCOPE,
|
|
113
128
|
}),
|
|
114
|
-
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
129
|
+
).rejects.toThrow('[internal] S3 key is not scoped to the active tenant')
|
|
115
130
|
|
|
116
131
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
117
132
|
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
const mockGetAuthFromRequest = jest.fn()
|
|
4
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
5
|
+
getAuthFromRequest: (...args: unknown[]) => mockGetAuthFromRequest(...args),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
9
|
+
resolveTranslations: async () => ({
|
|
10
|
+
t: (key: string) => `translated:${key}`,
|
|
11
|
+
}),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const mockResolveCredentials = jest.fn()
|
|
15
|
+
const mockCreateRequestContainer = jest.fn(async () => ({
|
|
16
|
+
resolve: (key: string) => {
|
|
17
|
+
if (key === 'integrationCredentialsService') return { resolve: mockResolveCredentials }
|
|
18
|
+
throw new Error(`Unexpected dependency: ${key}`)
|
|
19
|
+
},
|
|
20
|
+
}))
|
|
21
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
22
|
+
createRequestContainer: () => mockCreateRequestContainer(),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
const mockListObjects = jest.fn()
|
|
26
|
+
const mockPutObject = jest.fn()
|
|
27
|
+
jest.mock('../lib/s3-driver', () => ({
|
|
28
|
+
S3StorageDriver: jest.fn().mockImplementation(() => ({
|
|
29
|
+
getBucket: () => 'test-bucket',
|
|
30
|
+
listObjects: (...args: unknown[]) => mockListObjects(...args),
|
|
31
|
+
putObject: (...args: unknown[]) => mockPutObject(...args),
|
|
32
|
+
})),
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
import { POST } from '../api/post/storage-providers/s3/upload'
|
|
36
|
+
|
|
37
|
+
describe('storage_s3 direct upload route', () => {
|
|
38
|
+
const originalMaxUploadMb = process.env.OM_ATTACHMENT_MAX_UPLOAD_MB
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
jest.clearAllMocks()
|
|
42
|
+
mockGetAuthFromRequest.mockResolvedValue({ tenantId: 'tenant-1', orgId: 'org-1' })
|
|
43
|
+
mockResolveCredentials.mockResolvedValue({ bucket: 'test-bucket', region: 'eu-central-1' })
|
|
44
|
+
mockListObjects.mockResolvedValue({ files: [], truncated: false })
|
|
45
|
+
mockPutObject.mockResolvedValue(undefined)
|
|
46
|
+
delete process.env.OM_ATTACHMENT_MAX_UPLOAD_MB
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterAll(() => {
|
|
50
|
+
if (originalMaxUploadMb === undefined) delete process.env.OM_ATTACHMENT_MAX_UPLOAD_MB
|
|
51
|
+
else process.env.OM_ATTACHMENT_MAX_UPLOAD_MB = originalMaxUploadMb
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('rejects oversized streamed metadata without trusting content length', async () => {
|
|
55
|
+
process.env.OM_ATTACHMENT_MAX_UPLOAD_MB = '0.000001'
|
|
56
|
+
const boundary = 'oversized-key'
|
|
57
|
+
const body = new TextEncoder().encode([
|
|
58
|
+
`--${boundary}\r\nContent-Disposition: form-data; name="key"\r\n\r\n`,
|
|
59
|
+
'x'.repeat(1024 * 1024),
|
|
60
|
+
`\r\n--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="small.txt"\r\n`,
|
|
61
|
+
'Content-Type: text/plain\r\n\r\ns\r\n',
|
|
62
|
+
`--${boundary}--\r\n`,
|
|
63
|
+
].join(''))
|
|
64
|
+
const request = new Request('http://example.test/api/storage-providers/s3/upload', {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
67
|
+
body,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(request.headers.get('content-length')).toBeNull()
|
|
71
|
+
const response = await POST(request)
|
|
72
|
+
|
|
73
|
+
expect(response.status).toBe(413)
|
|
74
|
+
await expect(response.json()).resolves.toEqual({
|
|
75
|
+
error: 'translated:storage_s3.errors.maxUploadSize',
|
|
76
|
+
})
|
|
77
|
+
expect(mockCreateRequestContainer).not.toHaveBeenCalled()
|
|
78
|
+
expect(mockPutObject).not.toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('preserves valid multipart parsing for a normal upload', async () => {
|
|
82
|
+
const form = new FormData()
|
|
83
|
+
form.set('file', new File([new TextEncoder().encode('safe')], 'safe.txt', { type: 'text/plain' }))
|
|
84
|
+
const response = await POST(new Request('http://example.test/api/storage-providers/s3/upload', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
body: form,
|
|
87
|
+
}))
|
|
88
|
+
|
|
89
|
+
expect(response.status).toBe(200)
|
|
90
|
+
await expect(response.json()).resolves.toMatchObject({ bucket: 'test-bucket', size: 4 })
|
|
91
|
+
expect(mockPutObject).toHaveBeenCalledTimes(1)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -3,8 +3,10 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
7
|
import { isS3KeyAddressableByScope } from '../../../../lib/key-scope'
|
|
7
8
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
9
|
+
import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
|
|
8
10
|
|
|
9
11
|
export const metadata = {
|
|
10
12
|
path: '/storage-providers/s3/delete',
|
|
@@ -26,27 +28,42 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export async function DELETE(req: Request) {
|
|
31
|
+
const { t } = await resolveTranslations()
|
|
29
32
|
const auth = await getAuthFromRequest(req)
|
|
30
33
|
if (!auth?.tenantId || !auth.orgId) {
|
|
31
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
34
|
+
return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
const json = await req.json().catch(() => null)
|
|
35
38
|
const parsed = requestSchema.safeParse(json)
|
|
36
39
|
if (!parsed.success) {
|
|
37
|
-
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
40
|
+
return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {
|
|
41
|
-
return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
|
|
44
|
+
return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
45
48
|
if (!driver) {
|
|
46
|
-
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
49
|
+
return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
await driver.delete('', parsed.data.key)
|
|
52
|
+
await (driver.deleteStrict?.('', parsed.data.key) ?? driver.delete('', parsed.data.key))
|
|
53
|
+
const { resolve } = await createRequestContainer()
|
|
54
|
+
let attachmentQuotaService: AttachmentQuotaService | null = null
|
|
55
|
+
try {
|
|
56
|
+
attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService | null
|
|
57
|
+
} catch {
|
|
58
|
+
// Backward-compatible when the attachments quota service is not registered.
|
|
59
|
+
}
|
|
60
|
+
if (attachmentQuotaService) {
|
|
61
|
+
await attachmentQuotaService.releaseCommittedByPath({
|
|
62
|
+
tenantId: auth.tenantId,
|
|
63
|
+
storageDriver: 's3',
|
|
64
|
+
storagePath: parsed.data.key,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
50
67
|
return new NextResponse(null, { status: 204 })
|
|
51
68
|
}
|
|
52
69
|
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
7
|
import {
|
|
7
8
|
buildAttachmentContentDisposition,
|
|
8
9
|
canRenderInlineAttachment,
|
|
@@ -27,23 +28,24 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export async function GET(req: Request) {
|
|
31
|
+
const { t } = await resolveTranslations()
|
|
30
32
|
const auth = await getAuthFromRequest(req)
|
|
31
33
|
if (!auth?.tenantId || !auth.orgId) {
|
|
32
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
34
|
+
return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
const key = new URL(req.url).searchParams.get('key')
|
|
36
38
|
if (!key) {
|
|
37
|
-
return NextResponse.json({ error: 'key query param is required' }, { status: 400 })
|
|
39
|
+
return NextResponse.json({ error: t('storage_s3.errors.keyRequired', 'key query param is required') }, { status: 400 })
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
if (!isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)) {
|
|
41
|
-
return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
|
|
43
|
+
return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
45
47
|
if (!driver) {
|
|
46
|
-
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
48
|
+
return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
let buffer: Buffer
|
|
@@ -53,7 +55,7 @@ export async function GET(req: Request) {
|
|
|
53
55
|
buffer = result.buffer
|
|
54
56
|
contentType = result.contentType
|
|
55
57
|
} catch {
|
|
56
|
-
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
|
58
|
+
return NextResponse.json({ error: t('storage_s3.errors.fileNotFound', 'File not found') }, { status: 404 })
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
const fileName = sanitizeUploadedFileName(key.split('/').pop() || 'download')
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
7
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
7
8
|
|
|
8
9
|
export const metadata = {
|
|
@@ -71,29 +72,30 @@ function resolveTenantScopedPrefix(prefix: string, tenantId: string, orgId: stri
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
export async function GET(req: Request) {
|
|
75
|
+
const { t } = await resolveTranslations()
|
|
74
76
|
const auth = await getAuthFromRequest(req)
|
|
75
77
|
if (!auth?.tenantId || !auth.orgId) {
|
|
76
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
78
|
+
return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
const params = Object.fromEntries(new URL(req.url).searchParams.entries())
|
|
80
82
|
const parsed = querySchema.safeParse(params)
|
|
81
83
|
if (!parsed.success) {
|
|
82
|
-
return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })
|
|
84
|
+
return NextResponse.json({ error: t('storage_s3.errors.invalidQuery', 'Invalid query parameters') }, { status: 400 })
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
// Always scope list operations to the tenant namespace to prevent cross-tenant enumeration.
|
|
86
88
|
const effectivePrefix = resolveTenantScopedPrefix(parsed.data.prefix, auth.tenantId, auth.orgId)
|
|
87
89
|
if (effectivePrefix === null) {
|
|
88
90
|
return NextResponse.json(
|
|
89
|
-
{ error: 'Access denied: prefix is not scoped to this tenant.' },
|
|
91
|
+
{ error: t('storage_s3.errors.prefixAccessDenied', 'Access denied: prefix is not scoped to this tenant.') },
|
|
90
92
|
{ status: 403 },
|
|
91
93
|
)
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
95
97
|
if (!driver) {
|
|
96
|
-
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
98
|
+
return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
const result = await driver.listObjects(
|
|
@@ -3,8 +3,13 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
7
|
import { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'
|
|
7
8
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
9
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
10
|
+
import { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'
|
|
11
|
+
import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
|
|
12
|
+
import { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'
|
|
8
13
|
|
|
9
14
|
export const metadata = {
|
|
10
15
|
path: '/storage-providers/s3/signed-url',
|
|
@@ -16,11 +21,13 @@ const requestSchema = z.object({
|
|
|
16
21
|
operation: z.enum(['upload', 'download']),
|
|
17
22
|
expiresIn: z.number().int().min(60).max(604800).optional().default(3600),
|
|
18
23
|
contentType: z.string().optional(),
|
|
24
|
+
size: z.number().int().nonnegative().optional(),
|
|
19
25
|
})
|
|
20
26
|
|
|
21
27
|
const responseSchema = z.object({
|
|
22
28
|
url: z.string(),
|
|
23
29
|
expiresAt: z.string(),
|
|
30
|
+
reservationId: z.string().optional(),
|
|
24
31
|
})
|
|
25
32
|
|
|
26
33
|
async function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {
|
|
@@ -34,33 +41,97 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export async function POST(req: Request) {
|
|
44
|
+
const { t } = await resolveTranslations()
|
|
37
45
|
const auth = await getAuthFromRequest(req)
|
|
38
46
|
if (!auth?.tenantId || !auth.orgId) {
|
|
39
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
47
|
+
return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
const json = await req.json().catch(() => null)
|
|
43
51
|
const parsed = requestSchema.safeParse(json)
|
|
44
52
|
if (!parsed.success) {
|
|
45
|
-
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
53
|
+
return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
const { key, operation, expiresIn, contentType } = parsed.data
|
|
56
|
+
const { key, operation, expiresIn, contentType, size } = parsed.data
|
|
49
57
|
const canAccessKey = operation === 'download'
|
|
50
58
|
? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)
|
|
51
59
|
: isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)
|
|
52
60
|
if (!canAccessKey) {
|
|
53
|
-
return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
|
|
61
|
+
return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
57
65
|
if (!driver) {
|
|
58
|
-
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
66
|
+
return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)
|
|
62
69
|
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()
|
|
63
70
|
|
|
71
|
+
if (operation === 'upload') {
|
|
72
|
+
const { resolve } = await createRequestContainer()
|
|
73
|
+
let attachmentQuotaService: AttachmentQuotaService | null = null
|
|
74
|
+
let recoveryScheduler: ((
|
|
75
|
+
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
76
|
+
delayMs: number,
|
|
77
|
+
) => Promise<void>) | null = null
|
|
78
|
+
try {
|
|
79
|
+
attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService
|
|
80
|
+
recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (
|
|
81
|
+
payload: { reservationId: string; tenantId: string; organizationId: string },
|
|
82
|
+
delayMs: number,
|
|
83
|
+
) => Promise<void>
|
|
84
|
+
} catch {
|
|
85
|
+
// Fail closed below when the attachments quota service is not registered.
|
|
86
|
+
}
|
|
87
|
+
if (!attachmentQuotaService) {
|
|
88
|
+
return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
|
|
89
|
+
}
|
|
90
|
+
const compatibilityToken = randomBytes(32).toString('base64url')
|
|
91
|
+
const reservedBytes = size ?? resolveAttachmentMaxBytes()
|
|
92
|
+
let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null
|
|
93
|
+
try {
|
|
94
|
+
await reconcileTenantS3Objects({
|
|
95
|
+
driver,
|
|
96
|
+
quotaService: attachmentQuotaService,
|
|
97
|
+
tenantId: auth.tenantId,
|
|
98
|
+
organizationId: auth.orgId,
|
|
99
|
+
})
|
|
100
|
+
reservation = await attachmentQuotaService.reserve({
|
|
101
|
+
tenantId: auth.tenantId,
|
|
102
|
+
organizationId: auth.orgId,
|
|
103
|
+
bytes: reservedBytes,
|
|
104
|
+
source: 'storage_s3_signed',
|
|
105
|
+
storageDriver: 's3',
|
|
106
|
+
storagePath: key,
|
|
107
|
+
uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),
|
|
108
|
+
ttlMs: expiresIn * 1000,
|
|
109
|
+
})
|
|
110
|
+
if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
|
|
111
|
+
await recoveryScheduler({
|
|
112
|
+
reservationId: reservation.id,
|
|
113
|
+
tenantId: auth.tenantId,
|
|
114
|
+
organizationId: auth.orgId,
|
|
115
|
+
}, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))
|
|
116
|
+
const url = new URL(`/api/storage-providers/s3/signed-upload/${compatibilityToken}`, req.url).toString()
|
|
117
|
+
return NextResponse.json({ url, expiresAt, reservationId: reservation.id })
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (reservation) {
|
|
120
|
+
await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
121
|
+
}
|
|
122
|
+
const code = (error as { code?: unknown })?.code
|
|
123
|
+
if (code === 'quota_exceeded') {
|
|
124
|
+
return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })
|
|
125
|
+
}
|
|
126
|
+
if (code === 'quota_target_exists') {
|
|
127
|
+
return NextResponse.json({ error: 'The target storage key already exists.' }, { status: 409 })
|
|
128
|
+
}
|
|
129
|
+
return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)
|
|
134
|
+
|
|
64
135
|
return NextResponse.json({ url, expiresAt })
|
|
65
136
|
}
|
|
66
137
|
|