@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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { lookup as dnsLookup } from 'node:dns/promises'
|
|
2
|
+
import { Agent as HttpAgent } from 'node:http'
|
|
3
|
+
import { Agent as HttpsAgent } from 'node:https'
|
|
4
|
+
import { isIP, type LookupFunction } from 'node:net'
|
|
5
|
+
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
6
|
+
import {
|
|
7
|
+
assertSafeOutboundUrl,
|
|
8
|
+
assertStaticallySafeOutboundUrl,
|
|
9
|
+
parseOutboundUrl,
|
|
10
|
+
resolveSafeOutboundUrl,
|
|
11
|
+
type HostLookup,
|
|
12
|
+
type ResolvedHostAddress,
|
|
13
|
+
type UrlSafetyReason,
|
|
14
|
+
} from '@open-mercato/shared/lib/url-safety'
|
|
15
|
+
|
|
16
|
+
const SUBJECT = 'S3 endpoint'
|
|
17
|
+
|
|
18
|
+
export class UnsafeS3EndpointError extends Error {
|
|
19
|
+
public readonly reason: string
|
|
20
|
+
|
|
21
|
+
constructor(reason: string, message?: string) {
|
|
22
|
+
super(message ?? `S3 endpoint rejected: ${reason}`)
|
|
23
|
+
this.name = 'UnsafeS3EndpointError'
|
|
24
|
+
this.reason = reason
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const s3EndpointErrorFactory = (reason: UrlSafetyReason, message: string) =>
|
|
29
|
+
new UnsafeS3EndpointError(reason, message)
|
|
30
|
+
|
|
31
|
+
export type AssertStaticS3EndpointDeps = {
|
|
32
|
+
allowInternal?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertStaticallySafeS3Endpoint(
|
|
36
|
+
rawEndpoint: string | null | undefined,
|
|
37
|
+
deps: AssertStaticS3EndpointDeps = {},
|
|
38
|
+
): void {
|
|
39
|
+
if (!rawEndpoint) return
|
|
40
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()
|
|
41
|
+
assertStaticallySafeOutboundUrl(rawEndpoint, {
|
|
42
|
+
errorFactory: s3EndpointErrorFactory,
|
|
43
|
+
subject: SUBJECT,
|
|
44
|
+
allowPrivate,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type AssertSafeS3EndpointDeps = {
|
|
49
|
+
lookupHost?: HostLookup
|
|
50
|
+
allowInternal?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function assertSafeS3Endpoint(
|
|
54
|
+
rawEndpoint: string | null | undefined,
|
|
55
|
+
deps: AssertSafeS3EndpointDeps = {},
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
if (!rawEndpoint) return
|
|
58
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()
|
|
59
|
+
await assertSafeOutboundUrl(rawEndpoint, {
|
|
60
|
+
errorFactory: s3EndpointErrorFactory,
|
|
61
|
+
subject: SUBJECT,
|
|
62
|
+
allowPrivate,
|
|
63
|
+
lookupHost: deps.lookupHost,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type SafeS3EndpointLookupDeps = {
|
|
68
|
+
lookupHost?: HostLookup
|
|
69
|
+
allowInternal?: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createSafeS3EndpointLookup(
|
|
73
|
+
rawEndpoint: string | null | undefined,
|
|
74
|
+
deps: SafeS3EndpointLookupDeps = {},
|
|
75
|
+
): LookupFunction | undefined {
|
|
76
|
+
if (!rawEndpoint) return undefined
|
|
77
|
+
const allowPrivate = deps.allowInternal ?? isAllowInternalS3EndpointsEnabled()
|
|
78
|
+
if (allowPrivate) return undefined
|
|
79
|
+
|
|
80
|
+
const { hostname } = parseOutboundUrl(rawEndpoint, {
|
|
81
|
+
errorFactory: s3EndpointErrorFactory,
|
|
82
|
+
subject: SUBJECT,
|
|
83
|
+
})
|
|
84
|
+
const endpointHostname = normalizeLookupHostname(hostname)
|
|
85
|
+
|
|
86
|
+
return (host, options, callback) => {
|
|
87
|
+
const lookupHostname = normalizeLookupHostname(host)
|
|
88
|
+
if (!isAllowedS3LookupHost(lookupHostname, endpointHostname)) {
|
|
89
|
+
callback(new UnsafeS3EndpointError(
|
|
90
|
+
'blocked_hostname',
|
|
91
|
+
`S3 endpoint lookup host "${host}" does not match configured host "${endpointHostname}"`,
|
|
92
|
+
), '', 0)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
resolveSafeOutboundUrl(buildLookupValidationUrl(rawEndpoint, lookupHostname), {
|
|
97
|
+
errorFactory: s3EndpointErrorFactory,
|
|
98
|
+
subject: SUBJECT,
|
|
99
|
+
lookupHost: deps.lookupHost ?? defaultLookupHost,
|
|
100
|
+
}).then(({ addresses }) => {
|
|
101
|
+
if (!addresses || addresses.length === 0) {
|
|
102
|
+
const family = isIP(lookupHostname)
|
|
103
|
+
if (options.all) callback(null, [{ address: lookupHostname, family }] as ResolvedHostAddress[])
|
|
104
|
+
else callback(null, lookupHostname, family)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
if (options.all) {
|
|
108
|
+
callback(null, addresses.map((record) => ({ address: record.address, family: record.family })))
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
callback(null, addresses[0].address, addresses[0].family)
|
|
112
|
+
}).catch((error: unknown) => {
|
|
113
|
+
callback(error instanceof Error ? error : new UnsafeS3EndpointError('dns_resolution_failed'), '', 0)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeLookupHostname(hostname: string): string {
|
|
119
|
+
let normalized = hostname.trim().toLowerCase()
|
|
120
|
+
if (normalized.startsWith('[') && normalized.endsWith(']')) {
|
|
121
|
+
normalized = normalized.slice(1, -1)
|
|
122
|
+
}
|
|
123
|
+
while (normalized.endsWith('.')) {
|
|
124
|
+
normalized = normalized.slice(0, -1)
|
|
125
|
+
}
|
|
126
|
+
return normalized
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isAllowedS3LookupHost(lookupHostname: string, endpointHostname: string): boolean {
|
|
130
|
+
if (lookupHostname === endpointHostname) return true
|
|
131
|
+
if (isIP(endpointHostname)) return false
|
|
132
|
+
return lookupHostname.endsWith(`.${endpointHostname}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildLookupValidationUrl(rawEndpoint: string, lookupHostname: string): string {
|
|
136
|
+
const { url } = parseOutboundUrl(rawEndpoint, {
|
|
137
|
+
errorFactory: s3EndpointErrorFactory,
|
|
138
|
+
subject: SUBJECT,
|
|
139
|
+
})
|
|
140
|
+
const host = isIP(lookupHostname) === 6 ? `[${lookupHostname}]` : lookupHostname
|
|
141
|
+
const port = url.port ? `:${url.port}` : ''
|
|
142
|
+
return `${url.protocol}//${host}${port}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type SafeS3RequestHandlerOptions = {
|
|
146
|
+
connectionTimeout: number
|
|
147
|
+
requestTimeout: number
|
|
148
|
+
httpAgent?: HttpAgent
|
|
149
|
+
httpsAgent?: HttpsAgent
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const S3_CONNECTION_TIMEOUT_MS = 10_000
|
|
153
|
+
const S3_REQUEST_TIMEOUT_MS = 120_000
|
|
154
|
+
|
|
155
|
+
export function createSafeS3RequestHandler(
|
|
156
|
+
rawEndpoint: string | null | undefined,
|
|
157
|
+
deps: SafeS3EndpointLookupDeps = {},
|
|
158
|
+
): SafeS3RequestHandlerOptions | undefined {
|
|
159
|
+
const lookup = createSafeS3EndpointLookup(rawEndpoint, deps)
|
|
160
|
+
const timeouts = {
|
|
161
|
+
connectionTimeout: S3_CONNECTION_TIMEOUT_MS,
|
|
162
|
+
requestTimeout: S3_REQUEST_TIMEOUT_MS,
|
|
163
|
+
}
|
|
164
|
+
if (!lookup) return timeouts
|
|
165
|
+
return {
|
|
166
|
+
...timeouts,
|
|
167
|
+
httpAgent: new HttpAgent({ lookup }),
|
|
168
|
+
httpsAgent: new HttpsAgent({ lookup }),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function isAllowInternalS3EndpointsEnabled(): boolean {
|
|
173
|
+
return parseBooleanWithDefault(process.env.OM_STORAGE_S3_ALLOW_INTERNAL_ENDPOINTS, false)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const defaultLookupHost: HostLookup = async (hostname) => {
|
|
177
|
+
const records = await dnsLookup(hostname, { all: true, verbatim: true })
|
|
178
|
+
return records
|
|
179
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3'
|
|
2
|
+
import { assertSafeS3Endpoint, createSafeS3RequestHandler } from './endpoint-safety'
|
|
2
3
|
import type { S3DriverConfig } from './s3-driver'
|
|
3
4
|
|
|
4
5
|
export const s3HealthCheck = {
|
|
@@ -67,11 +68,28 @@ export const s3HealthCheck = {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
try {
|
|
72
|
+
await assertSafeS3Endpoint(cfg.endpoint)
|
|
73
|
+
} catch (err: unknown) {
|
|
74
|
+
const message = err instanceof Error ? err.message : 'Unsafe S3 endpoint'
|
|
75
|
+
const reason = err && typeof err === 'object' && 'reason' in err ? (err as { reason?: unknown }).reason : undefined
|
|
76
|
+
return {
|
|
77
|
+
status: 'unhealthy',
|
|
78
|
+
message,
|
|
79
|
+
details: {
|
|
80
|
+
...(typeof reason === 'string' ? { reason } : {}),
|
|
81
|
+
},
|
|
82
|
+
checkedAt,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const requestHandler = createSafeS3RequestHandler(cfg.endpoint)
|
|
70
87
|
const client = new S3Client({
|
|
71
88
|
region: cfg.region ?? 'us-east-1',
|
|
72
89
|
endpoint: cfg.endpoint,
|
|
73
90
|
forcePathStyle: cfg.forcePathStyle ?? false,
|
|
74
91
|
credentials: credentialConfig,
|
|
92
|
+
...(requestHandler ? { requestHandler } : {}),
|
|
75
93
|
})
|
|
76
94
|
|
|
77
95
|
try {
|
|
@@ -113,7 +113,7 @@ export function assertS3KeyScopedToTenant(
|
|
|
113
113
|
pathPrefix?: string,
|
|
114
114
|
): void {
|
|
115
115
|
if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {
|
|
116
|
-
throw new Error('S3 key is not scoped to the active tenant')
|
|
116
|
+
throw new Error('[internal] S3 key is not scoped to the active tenant')
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -123,7 +123,7 @@ export function assertS3KeyAddressableByTenantScope(
|
|
|
123
123
|
pathPrefix?: string,
|
|
124
124
|
): void {
|
|
125
125
|
if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {
|
|
126
|
-
throw new Error('S3 key is not scoped to the active tenant')
|
|
126
|
+
throw new Error('[internal] S3 key is not scoped to the active tenant')
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
@@ -140,7 +140,7 @@ export function assertS3ListPrefixScopedToTenant(
|
|
|
140
140
|
|
|
141
141
|
if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {
|
|
142
142
|
if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {
|
|
143
|
-
throw new Error('S3 prefix is not scoped to the active tenant')
|
|
143
|
+
throw new Error('[internal] S3 prefix is not scoped to the active tenant')
|
|
144
144
|
}
|
|
145
145
|
return
|
|
146
146
|
}
|
|
@@ -149,7 +149,7 @@ export function assertS3ListPrefixScopedToTenant(
|
|
|
149
149
|
(parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)
|
|
150
150
|
|| (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)
|
|
151
151
|
) {
|
|
152
|
-
throw new Error('S3 prefix is not scoped to the active tenant')
|
|
152
|
+
throw new Error('[internal] S3 prefix is not scoped to the active tenant')
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
|
|
2
|
+
import { isS3KeyScopedToTenant } from './key-scope'
|
|
3
|
+
import type { S3StorageDriver } from './s3-driver'
|
|
4
|
+
|
|
5
|
+
export async function reconcileTenantS3Objects(input: {
|
|
6
|
+
driver: S3StorageDriver
|
|
7
|
+
quotaService: AttachmentQuotaService
|
|
8
|
+
tenantId: string
|
|
9
|
+
organizationId: string
|
|
10
|
+
}): Promise<void> {
|
|
11
|
+
const objects: Array<{ path: string; bytes: number }> = []
|
|
12
|
+
let continuationToken: string | undefined
|
|
13
|
+
do {
|
|
14
|
+
const page = await input.driver.listObjects('', 1000, continuationToken)
|
|
15
|
+
for (const file of page.files) {
|
|
16
|
+
if (isS3KeyScopedToTenant(file.key, input.organizationId, input.tenantId)) {
|
|
17
|
+
objects.push({ path: file.key, bytes: file.size })
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
continuationToken = page.truncated ? page.nextContinuationToken : undefined
|
|
21
|
+
} while (continuationToken)
|
|
22
|
+
|
|
23
|
+
await input.quotaService.reconcileStandaloneObjects({
|
|
24
|
+
tenantId: input.tenantId,
|
|
25
|
+
organizationId: input.organizationId,
|
|
26
|
+
storageDriver: 's3',
|
|
27
|
+
objects,
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createModuleQueue } from '@open-mercato/queue'
|
|
2
|
+
|
|
3
|
+
export type StorageS3QuotaRecoveryJob = {
|
|
4
|
+
reservationId: string
|
|
5
|
+
tenantId: string
|
|
6
|
+
organizationId: string
|
|
7
|
+
absenceCheck?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const queue = createModuleQueue<StorageS3QuotaRecoveryJob>('storage-s3-quota-recovery', { concurrency: 2 })
|
|
11
|
+
|
|
12
|
+
export async function scheduleStorageS3QuotaRecovery(
|
|
13
|
+
payload: StorageS3QuotaRecoveryJob,
|
|
14
|
+
delayMs: number,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
await queue.enqueue(payload, { delayMs: Math.max(1_000, delayMs) })
|
|
17
|
+
}
|
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
ListObjectsV2Command,
|
|
11
11
|
} from '@aws-sdk/client-s3'
|
|
12
12
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
|
13
|
-
import type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'
|
|
13
|
+
import type { PrepareFilePayload, StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'
|
|
14
|
+
import {
|
|
15
|
+
assertSafeS3Endpoint,
|
|
16
|
+
assertStaticallySafeS3Endpoint,
|
|
17
|
+
createSafeS3RequestHandler,
|
|
18
|
+
} from './endpoint-safety'
|
|
14
19
|
import {
|
|
15
20
|
assertS3KeyAddressableByTenantScope,
|
|
16
21
|
assertS3KeyScopedToTenant,
|
|
@@ -71,12 +76,14 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
71
76
|
readonly key = 's3'
|
|
72
77
|
private readonly client: S3Client
|
|
73
78
|
private readonly bucket: string
|
|
79
|
+
private readonly endpoint?: string
|
|
74
80
|
private readonly pathPrefix: string
|
|
75
81
|
private readonly scope: S3TenantScope | null
|
|
76
82
|
|
|
77
83
|
constructor(config: Record<string, unknown>) {
|
|
78
84
|
const cfg = config as S3DriverConfig
|
|
79
85
|
this.bucket = cfg.bucket
|
|
86
|
+
this.endpoint = cfg.endpoint
|
|
80
87
|
this.pathPrefix = cfg.pathPrefix ?? ''
|
|
81
88
|
this.scope = cfg.organizationId && cfg.tenantId
|
|
82
89
|
? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }
|
|
@@ -109,11 +116,15 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
109
116
|
}
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
assertStaticallySafeS3Endpoint(cfg.endpoint)
|
|
120
|
+
const requestHandler = createSafeS3RequestHandler(cfg.endpoint)
|
|
121
|
+
|
|
112
122
|
this.client = new S3Client({
|
|
113
123
|
region: cfg.region ?? 'us-east-1',
|
|
114
124
|
endpoint: cfg.endpoint,
|
|
115
125
|
forcePathStyle: cfg.forcePathStyle ?? false,
|
|
116
126
|
credentials,
|
|
127
|
+
...(requestHandler ? { requestHandler } : {}),
|
|
117
128
|
})
|
|
118
129
|
}
|
|
119
130
|
|
|
@@ -132,17 +143,24 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
132
143
|
: storagePath.replace(/^\/+/, '')
|
|
133
144
|
const firstSegment = prefix.split('/').filter(Boolean)[0]
|
|
134
145
|
if (firstSegment !== partitionCode) {
|
|
135
|
-
throw new Error('S3 key is not scoped to the requested partition')
|
|
146
|
+
throw new Error('[internal] S3 key is not scoped to the requested partition')
|
|
136
147
|
}
|
|
137
148
|
}
|
|
138
149
|
|
|
139
|
-
|
|
150
|
+
prepareStoragePath(payload: PrepareFilePayload): string {
|
|
140
151
|
const orgSegment = resolveOrgSegment(payload.orgId ?? null)
|
|
141
152
|
const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)
|
|
142
153
|
const safeName = sanitizeFileName(payload.fileName || 'file')
|
|
143
154
|
const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)
|
|
144
155
|
const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`
|
|
145
|
-
|
|
156
|
+
return `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async store(payload: StoreFilePayload): Promise<StoredFile> {
|
|
160
|
+
const key = payload.storagePath ?? this.prepareStoragePath(payload)
|
|
161
|
+
this.assertPartitionScoped(payload.partitionCode, key)
|
|
162
|
+
this.assertKeyScoped(key)
|
|
163
|
+
await this.assertEndpointSafe()
|
|
146
164
|
|
|
147
165
|
await this.client.send(
|
|
148
166
|
new PutObjectCommand({
|
|
@@ -150,6 +168,7 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
150
168
|
Key: key,
|
|
151
169
|
Body: payload.buffer,
|
|
152
170
|
ContentLength: payload.buffer.length,
|
|
171
|
+
IfNoneMatch: '*',
|
|
153
172
|
}),
|
|
154
173
|
)
|
|
155
174
|
|
|
@@ -159,11 +178,12 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
159
178
|
async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {
|
|
160
179
|
this.assertPartitionScoped(partitionCode, storagePath)
|
|
161
180
|
this.assertKeyAddressable(storagePath)
|
|
181
|
+
await this.assertEndpointSafe()
|
|
162
182
|
const response = await this.client.send(
|
|
163
183
|
new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),
|
|
164
184
|
)
|
|
165
185
|
if (!response.Body) {
|
|
166
|
-
throw new Error(`S3 object body is empty for key: ${storagePath}`)
|
|
186
|
+
throw new Error(`[internal] S3 object body is empty for key: ${storagePath}`)
|
|
167
187
|
}
|
|
168
188
|
const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)
|
|
169
189
|
return { buffer, contentType: response.ContentType }
|
|
@@ -173,14 +193,19 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
173
193
|
this.assertPartitionScoped(partitionCode, storagePath)
|
|
174
194
|
this.assertKeyAddressable(storagePath)
|
|
175
195
|
try {
|
|
176
|
-
await this.
|
|
177
|
-
new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),
|
|
178
|
-
)
|
|
196
|
+
await this.deleteStrict(partitionCode, storagePath)
|
|
179
197
|
} catch {
|
|
180
|
-
// best-effort
|
|
198
|
+
// Backward-compatible best-effort deletion.
|
|
181
199
|
}
|
|
182
200
|
}
|
|
183
201
|
|
|
202
|
+
async deleteStrict(partitionCode: string, storagePath: string): Promise<void> {
|
|
203
|
+
this.assertPartitionScoped(partitionCode, storagePath)
|
|
204
|
+
this.assertKeyAddressable(storagePath)
|
|
205
|
+
await this.assertEndpointSafe()
|
|
206
|
+
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }))
|
|
207
|
+
}
|
|
208
|
+
|
|
184
209
|
async toLocalPath(
|
|
185
210
|
partitionCode: string,
|
|
186
211
|
storagePath: string,
|
|
@@ -207,6 +232,7 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
207
232
|
*/
|
|
208
233
|
async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {
|
|
209
234
|
this.assertKeyScoped(key)
|
|
235
|
+
await this.assertEndpointSafe()
|
|
210
236
|
await this.client.send(
|
|
211
237
|
new PutObjectCommand({
|
|
212
238
|
Bucket: this.bucket,
|
|
@@ -214,6 +240,7 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
214
240
|
Body: buffer,
|
|
215
241
|
ContentType: contentType,
|
|
216
242
|
ContentLength: buffer.length,
|
|
243
|
+
IfNoneMatch: '*',
|
|
217
244
|
}),
|
|
218
245
|
)
|
|
219
246
|
}
|
|
@@ -234,6 +261,7 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
234
261
|
}> {
|
|
235
262
|
const activeScope = scope ?? this.scope
|
|
236
263
|
assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)
|
|
264
|
+
await this.assertEndpointSafe()
|
|
237
265
|
const response = await this.client.send(
|
|
238
266
|
new ListObjectsV2Command({
|
|
239
267
|
Bucket: this.bucket,
|
|
@@ -260,24 +288,51 @@ export class S3StorageDriver implements StorageDriver {
|
|
|
260
288
|
async getSignedUrl(
|
|
261
289
|
storagePath: string,
|
|
262
290
|
operation: 'upload' | 'download',
|
|
263
|
-
expiresIn
|
|
291
|
+
expiresIn?: number,
|
|
264
292
|
contentType?: string,
|
|
293
|
+
contentLength?: number,
|
|
294
|
+
createOnly?: boolean,
|
|
295
|
+
scope?: S3TenantScope | null,
|
|
296
|
+
): Promise<string>
|
|
297
|
+
async getSignedUrl(
|
|
298
|
+
storagePath: string,
|
|
299
|
+
operation: 'upload' | 'download',
|
|
300
|
+
expiresIn: number,
|
|
301
|
+
contentType: string | undefined,
|
|
265
302
|
scope?: S3TenantScope | null,
|
|
303
|
+
): Promise<string>
|
|
304
|
+
async getSignedUrl(
|
|
305
|
+
storagePath: string,
|
|
306
|
+
operation: 'upload' | 'download',
|
|
307
|
+
expiresIn = 3600,
|
|
308
|
+
contentType?: string,
|
|
309
|
+
contentLengthOrScope?: number | S3TenantScope | null,
|
|
310
|
+
createOnly = false,
|
|
311
|
+
explicitScope?: S3TenantScope | null,
|
|
266
312
|
): Promise<string> {
|
|
313
|
+
const contentLength = typeof contentLengthOrScope === 'number' ? contentLengthOrScope : undefined
|
|
314
|
+
const scope = typeof contentLengthOrScope === 'number' ? explicitScope : contentLengthOrScope
|
|
267
315
|
if (operation === 'upload') {
|
|
268
316
|
this.assertKeyScoped(storagePath, scope)
|
|
269
317
|
} else {
|
|
270
318
|
this.assertKeyAddressable(storagePath, scope)
|
|
271
319
|
}
|
|
320
|
+
await this.assertEndpointSafe()
|
|
272
321
|
if (operation === 'upload') {
|
|
273
322
|
const command = new PutObjectCommand({
|
|
274
323
|
Bucket: this.bucket,
|
|
275
324
|
Key: storagePath,
|
|
276
325
|
ContentType: contentType,
|
|
326
|
+
ContentLength: contentLength,
|
|
327
|
+
...(createOnly ? { IfNoneMatch: '*' } : {}),
|
|
277
328
|
})
|
|
278
329
|
return getSignedUrl(this.client, command, { expiresIn })
|
|
279
330
|
}
|
|
280
331
|
const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })
|
|
281
332
|
return getSignedUrl(this.client, command, { expiresIn })
|
|
282
333
|
}
|
|
334
|
+
|
|
335
|
+
private async assertEndpointSafe(): Promise<void> {
|
|
336
|
+
await assertSafeS3Endpoint(this.endpoint)
|
|
337
|
+
}
|
|
283
338
|
}
|