@open-mercato/storage-s3 0.6.7-develop.6860.1.f796b22e80 → 0.6.7
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 +5 -20
- 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 +5 -7
- 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 +4 -6
- 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 +8 -70
- 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 +15 -92
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js.map +2 -2
- package/dist/modules/storage_s3/di.js +2 -6
- 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/health.js +1 -18
- 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/s3-driver.js +15 -44
- package/dist/modules/storage_s3/lib/s3-driver.js.map +2 -2
- package/dist/modules/storage_s3/lib/storage-service.js +22 -126
- package/dist/modules/storage_s3/lib/storage-service.js.map +3 -3
- package/jest.config.cjs +0 -1
- package/package.json +6 -13
- package/src/modules/storage_s3/__tests__/s3Driver.test.ts +8 -23
- package/src/modules/storage_s3/__tests__/s3ListRoute.test.ts +1 -7
- package/src/modules/storage_s3/__tests__/s3Routes.test.ts +0 -26
- package/src/modules/storage_s3/__tests__/storageService.test.ts +5 -20
- package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +5 -22
- package/src/modules/storage_s3/api/get/storage-providers/s3/download.ts +5 -7
- package/src/modules/storage_s3/api/get/storage-providers/s3/list.ts +4 -6
- package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +6 -77
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +16 -101
- package/src/modules/storage_s3/di.ts +2 -14
- package/src/modules/storage_s3/integration.ts +1 -1
- package/src/modules/storage_s3/lib/health.ts +0 -18
- package/src/modules/storage_s3/lib/key-scope.ts +4 -4
- package/src/modules/storage_s3/lib/s3-driver.ts +10 -65
- package/src/modules/storage_s3/lib/storage-service.ts +22 -131
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js +0 -145
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +0 -7
- package/dist/modules/storage_s3/lib/endpoint-safety.js +0 -133
- package/dist/modules/storage_s3/lib/endpoint-safety.js.map +0 -7
- package/dist/modules/storage_s3/lib/quota-accounting.js +0 -24
- package/dist/modules/storage_s3/lib/quota-accounting.js.map +0 -7
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js +0 -9
- package/dist/modules/storage_s3/lib/quota-recovery-queue.js.map +0 -7
- package/dist/modules/storage_s3/workers/quota-recovery.js +0 -70
- package/dist/modules/storage_s3/workers/quota-recovery.js.map +0 -7
- package/src/modules/storage_s3/__tests__/endpoint-safety.test.ts +0 -71
- package/src/modules/storage_s3/__tests__/health.test.ts +0 -38
- package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +0 -13
- package/src/modules/storage_s3/__tests__/quota-lifecycle.test.ts +0 -216
- package/src/modules/storage_s3/__tests__/quota-recovery.test.ts +0 -83
- package/src/modules/storage_s3/__tests__/upload.test.ts +0 -93
- package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +0 -150
- package/src/modules/storage_s3/i18n/de.json +0 -17
- package/src/modules/storage_s3/i18n/en.json +0 -17
- package/src/modules/storage_s3/i18n/es.json +0 -17
- package/src/modules/storage_s3/i18n/ko.json +0 -17
- package/src/modules/storage_s3/i18n/pl.json +0 -17
- package/src/modules/storage_s3/lib/endpoint-safety.ts +0 -179
- package/src/modules/storage_s3/lib/quota-accounting.ts +0 -29
- package/src/modules/storage_s3/lib/quota-recovery-queue.ts +0 -17
- package/src/modules/storage_s3/workers/quota-recovery.ts +0 -82
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
2
|
import { S3StorageDriver } from "./s3-driver.js";
|
|
3
|
-
import { resolveAttachmentMaxBytes } from "@open-mercato/core/modules/attachments/lib/upload-limits";
|
|
4
|
-
import { reconcileTenantS3Objects } from "./quota-accounting.js";
|
|
5
3
|
import {
|
|
6
4
|
assertS3KeyAddressableByTenantScope,
|
|
7
5
|
assertS3KeyScopedToTenant,
|
|
@@ -10,89 +8,32 @@ import {
|
|
|
10
8
|
function sanitizeSegment(value) {
|
|
11
9
|
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
12
10
|
}
|
|
11
|
+
function buildKey(namespace, scope, fileName) {
|
|
12
|
+
const orgSeg = scope.organizationId ? `org_${scope.organizationId}` : "org_shared";
|
|
13
|
+
const tenantSeg = scope.tenantId ? `tenant_${scope.tenantId}` : "tenant_shared";
|
|
14
|
+
const uniqueSuffix = randomUUID().replace(/-/g, "").slice(0, 12);
|
|
15
|
+
const safeName = sanitizeSegment(fileName || "file");
|
|
16
|
+
return `${sanitizeSegment(namespace)}/${orgSeg}/${tenantSeg}/${Date.now()}_${uniqueSuffix}_${safeName}`;
|
|
17
|
+
}
|
|
13
18
|
function createStorageService(config) {
|
|
14
19
|
const driver = new S3StorageDriver(config);
|
|
15
|
-
const quotaService = config.quotaService;
|
|
16
|
-
const quotaRecoveryScheduler = config.quotaRecoveryScheduler;
|
|
17
20
|
const pathPrefix = config.pathPrefix;
|
|
18
21
|
return {
|
|
19
22
|
async upload({ namespace, fileName, buffer, contentType, scope }) {
|
|
20
|
-
const
|
|
21
|
-
partitionCode:
|
|
23
|
+
const stored = await driver.store({
|
|
24
|
+
partitionCode: namespace,
|
|
22
25
|
orgId: scope.organizationId,
|
|
23
26
|
tenantId: scope.tenantId,
|
|
24
|
-
fileName
|
|
27
|
+
fileName,
|
|
28
|
+
buffer
|
|
25
29
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
driver,
|
|
34
|
-
quotaService,
|
|
35
|
-
tenantId: scope.tenantId,
|
|
36
|
-
organizationId: scope.organizationId
|
|
37
|
-
});
|
|
38
|
-
reservation = await quotaService.reserve({
|
|
39
|
-
tenantId: scope.tenantId,
|
|
40
|
-
organizationId: scope.organizationId,
|
|
41
|
-
bytes: buffer.length,
|
|
42
|
-
source: "storage_service",
|
|
43
|
-
storageDriver: "s3",
|
|
44
|
-
storagePath,
|
|
45
|
-
partitionCode: namespace
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
if (reservation) {
|
|
49
|
-
if (!quotaRecoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
|
|
50
|
-
await quotaRecoveryScheduler(
|
|
51
|
-
{
|
|
52
|
-
reservationId: reservation.id,
|
|
53
|
-
tenantId: scope.tenantId,
|
|
54
|
-
organizationId: scope.organizationId
|
|
55
|
-
},
|
|
56
|
-
Math.max(1e3, reservation.expiresAt.getTime() - Date.now())
|
|
57
|
-
);
|
|
58
|
-
await quotaService.beginStorage(reservation.id, reservation.leaseToken);
|
|
59
|
-
storageStarted = true;
|
|
60
|
-
}
|
|
61
|
-
const stored = await driver.store({
|
|
62
|
-
partitionCode: namespace,
|
|
63
|
-
orgId: scope.organizationId,
|
|
64
|
-
tenantId: scope.tenantId,
|
|
65
|
-
fileName,
|
|
66
|
-
buffer,
|
|
67
|
-
storagePath
|
|
68
|
-
});
|
|
69
|
-
objectStored = true;
|
|
70
|
-
if (reservation) {
|
|
71
|
-
await quotaService.markStored(reservation.id, reservation.leaseToken);
|
|
72
|
-
await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length);
|
|
73
|
-
}
|
|
74
|
-
return {
|
|
75
|
-
key: stored.storagePath,
|
|
76
|
-
namespace,
|
|
77
|
-
fileName,
|
|
78
|
-
size: buffer.length,
|
|
79
|
-
contentType
|
|
80
|
-
};
|
|
81
|
-
} catch (error) {
|
|
82
|
-
if (reservation) {
|
|
83
|
-
if (objectStored) {
|
|
84
|
-
try {
|
|
85
|
-
await driver.deleteStrict("", storagePath);
|
|
86
|
-
await quotaService.release(reservation.id, reservation.leaseToken);
|
|
87
|
-
} catch {
|
|
88
|
-
}
|
|
89
|
-
} else if (!storageStarted) {
|
|
90
|
-
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
throw error;
|
|
95
|
-
}
|
|
30
|
+
return {
|
|
31
|
+
key: stored.storagePath,
|
|
32
|
+
namespace,
|
|
33
|
+
fileName,
|
|
34
|
+
size: buffer.length,
|
|
35
|
+
contentType
|
|
36
|
+
};
|
|
96
37
|
},
|
|
97
38
|
async download({ key, scope }) {
|
|
98
39
|
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
|
|
@@ -100,61 +41,16 @@ function createStorageService(config) {
|
|
|
100
41
|
},
|
|
101
42
|
async delete({ key, scope }) {
|
|
102
43
|
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
|
|
103
|
-
return driver.
|
|
104
|
-
if (quotaService && scope.tenantId) {
|
|
105
|
-
await quotaService.releaseCommittedByPath({
|
|
106
|
-
tenantId: scope.tenantId,
|
|
107
|
-
storageDriver: "s3",
|
|
108
|
-
storagePath: key
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
});
|
|
44
|
+
return driver.delete("", key);
|
|
112
45
|
},
|
|
113
|
-
async getSignedUrl({ key, operation, expiresIn = 3600, contentType,
|
|
46
|
+
async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }) {
|
|
114
47
|
if (operation === "upload") {
|
|
115
48
|
assertS3KeyScopedToTenant(key, scope, pathPrefix);
|
|
116
49
|
} else {
|
|
117
50
|
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
|
|
118
51
|
}
|
|
119
|
-
const expiresAt = new Date(Date.now() + expiresIn * 1e3);
|
|
120
|
-
if (operation === "upload" && quotaService && scope.tenantId && scope.organizationId) {
|
|
121
|
-
const compatibilityToken = randomBytes(32).toString("base64url");
|
|
122
|
-
let reservation = null;
|
|
123
|
-
try {
|
|
124
|
-
await reconcileTenantS3Objects({
|
|
125
|
-
driver,
|
|
126
|
-
quotaService,
|
|
127
|
-
tenantId: scope.tenantId,
|
|
128
|
-
organizationId: scope.organizationId
|
|
129
|
-
});
|
|
130
|
-
reservation = await quotaService.reserve({
|
|
131
|
-
tenantId: scope.tenantId,
|
|
132
|
-
organizationId: scope.organizationId,
|
|
133
|
-
bytes: size ?? resolveAttachmentMaxBytes(),
|
|
134
|
-
source: "storage_s3_signed",
|
|
135
|
-
storageDriver: "s3",
|
|
136
|
-
storagePath: key,
|
|
137
|
-
uploadTokenHash: createHash("sha256").update(compatibilityToken).digest("hex"),
|
|
138
|
-
ttlMs: expiresIn * 1e3
|
|
139
|
-
});
|
|
140
|
-
if (!quotaRecoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
|
|
141
|
-
await quotaRecoveryScheduler({
|
|
142
|
-
reservationId: reservation.id,
|
|
143
|
-
tenantId: scope.tenantId,
|
|
144
|
-
organizationId: scope.organizationId
|
|
145
|
-
}, Math.max(1e3, reservation.expiresAt.getTime() - Date.now()));
|
|
146
|
-
const url2 = `/api/storage-providers/s3/signed-upload/${compatibilityToken}`;
|
|
147
|
-
return { url: url2, expiresAt, reservationId: reservation.id };
|
|
148
|
-
} catch (error) {
|
|
149
|
-
if (reservation) {
|
|
150
|
-
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
throw error;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
52
|
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope);
|
|
157
|
-
return { url, expiresAt };
|
|
53
|
+
return { url, expiresAt: new Date(Date.now() + expiresIn * 1e3) };
|
|
158
54
|
},
|
|
159
55
|
async list({ prefix, maxKeys = 100, continuationToken, scope }) {
|
|
160
56
|
assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/storage_s3/lib/storage-service.ts"],
|
|
4
|
-
"sourcesContent": ["import {
|
|
5
|
-
"mappings": "AAAA,SAAS,
|
|
6
|
-
"names": [
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport { S3StorageDriver } from './s3-driver'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n} from './key-scope'\n\ntype TenantScope = {\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n}\n\ntype UploadInput = {\n namespace: string\n fileName: string\n buffer: Buffer\n contentType?: string\n scope: TenantScope\n}\n\ntype StorageResult = {\n key: string\n namespace: string\n fileName: string\n size: number\n contentType?: string\n}\n\ntype DownloadInput = { key: string; scope: TenantScope }\ntype DeleteInput = { key: string; scope: TenantScope }\ntype SignedUrlInput = {\n key: string\n operation: 'upload' | 'download'\n expiresIn?: number\n contentType?: string\n scope: TenantScope\n}\ntype ListInput = {\n prefix: string\n maxKeys?: number\n continuationToken?: string\n scope: TenantScope\n}\n\ntype S3Config = {\n bucket: string\n region?: string\n endpoint?: string\n forcePathStyle?: boolean\n authMode?: 'access_keys' | 'ambient'\n credentialsEnvPrefix?: string\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n pathPrefix?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeSegment(value: string): string {\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction buildKey(namespace: string, scope: TenantScope, fileName: string): string {\n const orgSeg = scope.organizationId ? `org_${scope.organizationId}` : 'org_shared'\n const tenantSeg = scope.tenantId ? `tenant_${scope.tenantId}` : 'tenant_shared'\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const safeName = sanitizeSegment(fileName || 'file')\n return `${sanitizeSegment(namespace)}/${orgSeg}/${tenantSeg}/${Date.now()}_${uniqueSuffix}_${safeName}`\n}\n\nexport interface StorageService {\n upload(input: UploadInput): Promise<StorageResult>\n download(input: DownloadInput): Promise<{ buffer: Buffer; contentType?: string }>\n delete(input: DeleteInput): Promise<void>\n getSignedUrl(input: SignedUrlInput): Promise<{ url: string; expiresAt: Date }>\n list(input: ListInput): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }>\n toLocalPath(input: { key: string; scope: TenantScope }): Promise<{ filePath: string; cleanup: () => Promise<void> }>\n}\n\nexport function createStorageService(config: S3Config): StorageService {\n const driver = new S3StorageDriver(config as Record<string, unknown>)\n const pathPrefix = config.pathPrefix\n\n return {\n async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {\n const stored = await driver.store({\n partitionCode: namespace,\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n buffer,\n })\n return {\n key: stored.storagePath,\n namespace,\n fileName,\n size: buffer.length,\n contentType,\n }\n },\n\n async download({ key, scope }): Promise<{ buffer: Buffer; contentType?: string }> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.read('', key)\n },\n\n async delete({ key, scope }): Promise<void> {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.delete('', key)\n },\n\n async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }): Promise<{ url: string; expiresAt: Date }> {\n if (operation === 'upload') {\n assertS3KeyScopedToTenant(key, scope, pathPrefix)\n } else {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n }\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)\n return { url, expiresAt: new Date(Date.now() + expiresIn * 1000) }\n },\n\n async list({ prefix, maxKeys = 100, continuationToken, scope }): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix)\n return driver.listObjects(prefix, maxKeys, continuationToken, scope)\n },\n\n async toLocalPath({ key, scope }) {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n return driver.toLocalPath('', key)\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAsDP,SAAS,gBAAgB,OAAuB;AAC9C,SAAO,MAAM,QAAQ,oBAAoB,GAAG;AAC9C;AAEA,SAAS,SAAS,WAAmB,OAAoB,UAA0B;AACjF,QAAM,SAAS,MAAM,iBAAiB,OAAO,MAAM,cAAc,KAAK;AACtE,QAAM,YAAY,MAAM,WAAW,UAAU,MAAM,QAAQ,KAAK;AAChE,QAAM,eAAe,WAAW,EAAE,QAAQ,MAAM,EAAE,EAAE,MAAM,GAAG,EAAE;AAC/D,QAAM,WAAW,gBAAgB,YAAY,MAAM;AACnD,SAAO,GAAG,gBAAgB,SAAS,CAAC,IAAI,MAAM,IAAI,SAAS,IAAI,KAAK,IAAI,CAAC,IAAI,YAAY,IAAI,QAAQ;AACvG;AAeO,SAAS,qBAAqB,QAAkC;AACrE,QAAM,SAAS,IAAI,gBAAgB,MAAiC;AACpE,QAAM,aAAa,OAAO;AAE1B,SAAO;AAAA,IACL,MAAM,OAAO,EAAE,WAAW,UAAU,QAAQ,aAAa,MAAM,GAA2B;AACxF,YAAM,SAAS,MAAM,OAAO,MAAM;AAAA,QAChC,eAAe;AAAA,QACf,OAAO,MAAM;AAAA,QACb,UAAU,MAAM;AAAA,QAChB;AAAA,QACA;AAAA,MACF,CAAC;AACD,aAAO;AAAA,QACL,KAAK,OAAO;AAAA,QACZ;AAAA,QACA;AAAA,QACA,MAAM,OAAO;AAAA,QACb;AAAA,MACF;AAAA,IACF;AAAA,IAEA,MAAM,SAAS,EAAE,KAAK,MAAM,GAAsD;AAChF,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,KAAK,IAAI,GAAG;AAAA,IAC5B;AAAA,IAEA,MAAM,OAAO,EAAE,KAAK,MAAM,GAAkB;AAC1C,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,OAAO,IAAI,GAAG;AAAA,IAC9B;AAAA,IAEA,MAAM,aAAa,EAAE,KAAK,WAAW,YAAY,MAAM,aAAa,MAAM,GAA8C;AACtH,UAAI,cAAc,UAAU;AAC1B,kCAA0B,KAAK,OAAO,UAAU;AAAA,MAClD,OAAO;AACL,4CAAoC,KAAK,OAAO,UAAU;AAAA,MAC5D;AACA,YAAM,MAAM,MAAM,OAAO,aAAa,KAAK,WAAW,WAAW,aAAa,KAAK;AACnF,aAAO,EAAE,KAAK,WAAW,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI,EAAE;AAAA,IACnE;AAAA,IAEA,MAAM,KAAK,EAAE,QAAQ,UAAU,KAAK,mBAAmB,MAAM,GAI1D;AACD,uCAAiC,QAAQ,OAAO,UAAU;AAC1D,aAAO,OAAO,YAAY,QAAQ,SAAS,mBAAmB,KAAK;AAAA,IACrE;AAAA,IAEA,MAAM,YAAY,EAAE,KAAK,MAAM,GAAG;AAChC,0CAAoC,KAAK,OAAO,UAAU;AAC1D,aAAO,OAAO,YAAY,IAAI,GAAG;AAAA,IACnC;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
7
|
}
|
package/jest.config.cjs
CHANGED
|
@@ -9,7 +9,6 @@ module.exports = {
|
|
|
9
9
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
|
10
10
|
moduleNameMapper: {
|
|
11
11
|
'^@open-mercato/core/(.*)$': '<rootDir>/../core/src/$1',
|
|
12
|
-
'^@open-mercato/queue$': '<rootDir>/../queue/src/index.ts',
|
|
13
12
|
'^@open-mercato/shared/(.*)$': '<rootDir>/../shared/src/$1',
|
|
14
13
|
},
|
|
15
14
|
transform: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/storage-s3",
|
|
3
|
-
"version": "0.6.7
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
"types": "./src/*.ts",
|
|
17
17
|
"default": "./dist/*.js"
|
|
18
18
|
},
|
|
19
|
-
"./*.json": "./src/*.json",
|
|
20
19
|
"./*": {
|
|
21
20
|
"types": [
|
|
22
21
|
"./src/*.ts",
|
|
@@ -24,7 +23,6 @@
|
|
|
24
23
|
],
|
|
25
24
|
"default": "./dist/*.js"
|
|
26
25
|
},
|
|
27
|
-
"./*/*.json": "./src/*/*.json",
|
|
28
26
|
"./*/*": {
|
|
29
27
|
"types": [
|
|
30
28
|
"./src/*/*.ts",
|
|
@@ -32,7 +30,6 @@
|
|
|
32
30
|
],
|
|
33
31
|
"default": "./dist/*/*.js"
|
|
34
32
|
},
|
|
35
|
-
"./*/*/*.json": "./src/*/*/*.json",
|
|
36
33
|
"./*/*/*": {
|
|
37
34
|
"types": [
|
|
38
35
|
"./src/*/*/*.ts",
|
|
@@ -40,7 +37,6 @@
|
|
|
40
37
|
],
|
|
41
38
|
"default": "./dist/*/*/*.js"
|
|
42
39
|
},
|
|
43
|
-
"./*/*/*/*.json": "./src/*/*/*/*.json",
|
|
44
40
|
"./*/*/*/*": {
|
|
45
41
|
"types": [
|
|
46
42
|
"./src/*/*/*/*.ts",
|
|
@@ -48,7 +44,6 @@
|
|
|
48
44
|
],
|
|
49
45
|
"default": "./dist/*/*/*/*.js"
|
|
50
46
|
},
|
|
51
|
-
"./*/*/*/*/*.json": "./src/*/*/*/*/*.json",
|
|
52
47
|
"./*/*/*/*/*": {
|
|
53
48
|
"types": [
|
|
54
49
|
"./src/*/*/*/*/*.ts",
|
|
@@ -74,20 +69,19 @@
|
|
|
74
69
|
"dependencies": {
|
|
75
70
|
"@aws-sdk/client-s3": "^3.1085.0",
|
|
76
71
|
"@aws-sdk/s3-request-presigner": "^3.1085.0",
|
|
77
|
-
"@open-mercato/core": "0.6.7
|
|
78
|
-
"@open-mercato/queue": "0.6.7-develop.6860.1.f796b22e80"
|
|
72
|
+
"@open-mercato/core": "0.6.7"
|
|
79
73
|
},
|
|
80
74
|
"peerDependencies": {
|
|
81
75
|
"@mikro-orm/postgresql": "^6.6.10",
|
|
82
|
-
"@open-mercato/shared": "0.6.7
|
|
76
|
+
"@open-mercato/shared": "0.6.7"
|
|
83
77
|
},
|
|
84
78
|
"devDependencies": {
|
|
85
|
-
"@open-mercato/shared": "0.6.7
|
|
79
|
+
"@open-mercato/shared": "0.6.7",
|
|
86
80
|
"@types/jest": "^30.0.0",
|
|
87
81
|
"esbuild": "^0.28.1",
|
|
88
82
|
"glob": "^13.0.6",
|
|
89
83
|
"jest": "^30.4.2",
|
|
90
|
-
"ts-jest": "^29.4.
|
|
84
|
+
"ts-jest": "^29.4.11",
|
|
91
85
|
"typescript": "7.0.2"
|
|
92
86
|
},
|
|
93
87
|
"publishConfig": {
|
|
@@ -97,6 +91,5 @@
|
|
|
97
91
|
"type": "git",
|
|
98
92
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
99
93
|
"directory": "packages/storage-s3"
|
|
100
|
-
}
|
|
101
|
-
"stableVersion": "0.6.6"
|
|
94
|
+
}
|
|
102
95
|
}
|
|
@@ -99,10 +99,6 @@ 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
|
-
}))
|
|
106
102
|
})
|
|
107
103
|
|
|
108
104
|
it('applies forcePathStyle and custom endpoint', () => {
|
|
@@ -111,17 +107,6 @@ describe('S3StorageDriver', () => {
|
|
|
111
107
|
expect(arg.endpoint).toBe('http://minio:9000')
|
|
112
108
|
expect(arg.forcePathStyle).toBe(true)
|
|
113
109
|
})
|
|
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
|
-
})
|
|
125
110
|
})
|
|
126
111
|
|
|
127
112
|
describe('store()', () => {
|
|
@@ -213,7 +198,7 @@ describe('S3StorageDriver', () => {
|
|
|
213
198
|
|
|
214
199
|
await expect(
|
|
215
200
|
driver.read('', 'docs/org_org-victim/tenant_tenant-owned/secret.txt'),
|
|
216
|
-
).rejects.toThrow('
|
|
201
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
217
202
|
|
|
218
203
|
expect(mockSend).not.toHaveBeenCalled()
|
|
219
204
|
})
|
|
@@ -244,7 +229,7 @@ describe('S3StorageDriver', () => {
|
|
|
244
229
|
|
|
245
230
|
await expect(
|
|
246
231
|
driver.read('docs', 'exports/org_o/tenant_t/file.txt'),
|
|
247
|
-
).rejects.toThrow('
|
|
232
|
+
).rejects.toThrow('S3 key is not scoped to the requested partition')
|
|
248
233
|
|
|
249
234
|
expect(mockSend).not.toHaveBeenCalled()
|
|
250
235
|
})
|
|
@@ -258,7 +243,7 @@ describe('S3StorageDriver', () => {
|
|
|
258
243
|
|
|
259
244
|
await expect(
|
|
260
245
|
driver.read('', 'docs/org_org-victim/tenant_tenant-owned/org_org-owned/tenant_tenant-owned/private.txt'),
|
|
261
|
-
).rejects.toThrow('
|
|
246
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
262
247
|
|
|
263
248
|
expect(mockSend).not.toHaveBeenCalled()
|
|
264
249
|
})
|
|
@@ -274,7 +259,7 @@ describe('S3StorageDriver', () => {
|
|
|
274
259
|
|
|
275
260
|
await expect(
|
|
276
261
|
driver.putObject('docs/org_org-victim/tenant_tenant-owned/file.txt', Buffer.from('secret')),
|
|
277
|
-
).rejects.toThrow('
|
|
262
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
278
263
|
|
|
279
264
|
expect(mockSend).not.toHaveBeenCalled()
|
|
280
265
|
})
|
|
@@ -306,7 +291,7 @@ describe('S3StorageDriver', () => {
|
|
|
306
291
|
|
|
307
292
|
await expect(
|
|
308
293
|
driver.delete('', 'docs/org_org-owned/tenant_tenant-victim/file.txt'),
|
|
309
|
-
).rejects.toThrow('
|
|
294
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
310
295
|
|
|
311
296
|
expect(mockSend).not.toHaveBeenCalled()
|
|
312
297
|
})
|
|
@@ -329,7 +314,7 @@ describe('S3StorageDriver', () => {
|
|
|
329
314
|
|
|
330
315
|
await expect(
|
|
331
316
|
driver.delete('docs', 'exports/org_o/tenant_t/file.txt'),
|
|
332
|
-
).rejects.toThrow('
|
|
317
|
+
).rejects.toThrow('S3 key is not scoped to the requested partition')
|
|
333
318
|
|
|
334
319
|
expect(mockSend).not.toHaveBeenCalled()
|
|
335
320
|
})
|
|
@@ -409,7 +394,7 @@ describe('S3StorageDriver', () => {
|
|
|
409
394
|
|
|
410
395
|
await expect(
|
|
411
396
|
driver.getSignedUrl('docs/org_org-victim/tenant_tenant-owned/file.pdf', 'download'),
|
|
412
|
-
).rejects.toThrow('
|
|
397
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
413
398
|
|
|
414
399
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
415
400
|
})
|
|
@@ -435,7 +420,7 @@ describe('S3StorageDriver', () => {
|
|
|
435
420
|
|
|
436
421
|
await expect(
|
|
437
422
|
driver.getSignedUrl('docs/org_shared/tenant_shared/shared.pdf', 'upload'),
|
|
438
|
-
).rejects.toThrow('
|
|
423
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
439
424
|
|
|
440
425
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
441
426
|
})
|
|
@@ -9,12 +9,6 @@ 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
|
-
|
|
18
12
|
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
19
13
|
createRequestContainer: async () => ({
|
|
20
14
|
resolve: (token: string) => {
|
|
@@ -75,7 +69,7 @@ describe('storage_s3 list route tenant scoping', () => {
|
|
|
75
69
|
|
|
76
70
|
expect(response.status).toBe(403)
|
|
77
71
|
await expect(response.json()).resolves.toEqual({
|
|
78
|
-
error: '
|
|
72
|
+
error: 'Access denied: prefix is not scoped to this tenant.',
|
|
79
73
|
})
|
|
80
74
|
expect(listObjectsMock).not.toHaveBeenCalled()
|
|
81
75
|
})
|
|
@@ -5,12 +5,6 @@ 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
|
-
|
|
14
8
|
const mockResolveCredentials = jest.fn()
|
|
15
9
|
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
16
10
|
createRequestContainer: jest.fn(async () => ({
|
|
@@ -99,26 +93,9 @@ describe('storage_s3 standalone route key scope', () => {
|
|
|
99
93
|
}))
|
|
100
94
|
|
|
101
95
|
expect(response.status).toBe(403)
|
|
102
|
-
await expect(response.json()).resolves.toEqual({
|
|
103
|
-
error: 'translated:storage_s3.errors.keyAccessDenied',
|
|
104
|
-
})
|
|
105
96
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
106
97
|
})
|
|
107
98
|
|
|
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
|
-
|
|
122
99
|
it('deletes shared namespace objects for an authorized storage manager', async () => {
|
|
123
100
|
mockDelete.mockResolvedValueOnce(undefined)
|
|
124
101
|
|
|
@@ -139,9 +116,6 @@ describe('storage_s3 standalone route key scope', () => {
|
|
|
139
116
|
)
|
|
140
117
|
|
|
141
118
|
expect(response.status).toBe(403)
|
|
142
|
-
await expect(response.json()).resolves.toEqual({
|
|
143
|
-
error: 'translated:storage_s3.errors.keyAccessDenied',
|
|
144
|
-
})
|
|
145
119
|
}
|
|
146
120
|
expect(mockRead).not.toHaveBeenCalled()
|
|
147
121
|
})
|
|
@@ -27,21 +27,6 @@ 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
|
-
|
|
45
30
|
it('rejects cross-tenant downloads before calling S3', async () => {
|
|
46
31
|
const service = createStorageService(BASE_CONFIG)
|
|
47
32
|
|
|
@@ -50,7 +35,7 @@ describe('createStorageService', () => {
|
|
|
50
35
|
key: 'docs/org_org-victim/tenant_tenant-owned/file.txt',
|
|
51
36
|
scope: TENANT_SCOPE,
|
|
52
37
|
}),
|
|
53
|
-
).rejects.toThrow('
|
|
38
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
54
39
|
|
|
55
40
|
expect(mockSend).not.toHaveBeenCalled()
|
|
56
41
|
})
|
|
@@ -85,7 +70,7 @@ describe('createStorageService', () => {
|
|
|
85
70
|
key: 'docs/org_org-victim/tenant_tenant-owned/file.txt',
|
|
86
71
|
scope: TENANT_SCOPE,
|
|
87
72
|
}),
|
|
88
|
-
).rejects.toThrow('
|
|
73
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
89
74
|
|
|
90
75
|
expect(mockSend).not.toHaveBeenCalled()
|
|
91
76
|
})
|
|
@@ -98,7 +83,7 @@ describe('createStorageService', () => {
|
|
|
98
83
|
key: 'docs/org_org-owned/tenant_tenant-victim/file.txt',
|
|
99
84
|
scope: TENANT_SCOPE,
|
|
100
85
|
}),
|
|
101
|
-
).rejects.toThrow('
|
|
86
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
102
87
|
|
|
103
88
|
expect(mockSend).not.toHaveBeenCalled()
|
|
104
89
|
})
|
|
@@ -112,7 +97,7 @@ describe('createStorageService', () => {
|
|
|
112
97
|
operation: 'download',
|
|
113
98
|
scope: TENANT_SCOPE,
|
|
114
99
|
}),
|
|
115
|
-
).rejects.toThrow('
|
|
100
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
116
101
|
|
|
117
102
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
118
103
|
})
|
|
@@ -126,7 +111,7 @@ describe('createStorageService', () => {
|
|
|
126
111
|
operation: 'upload',
|
|
127
112
|
scope: TENANT_SCOPE,
|
|
128
113
|
}),
|
|
129
|
-
).rejects.toThrow('
|
|
114
|
+
).rejects.toThrow('S3 key is not scoped to the active tenant')
|
|
130
115
|
|
|
131
116
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
132
117
|
})
|
|
@@ -3,10 +3,8 @@ 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'
|
|
7
6
|
import { isS3KeyAddressableByScope } from '../../../../lib/key-scope'
|
|
8
7
|
import { S3StorageDriver } from '../../../../lib/s3-driver'
|
|
9
|
-
import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'
|
|
10
8
|
|
|
11
9
|
export const metadata = {
|
|
12
10
|
path: '/storage-providers/s3/delete',
|
|
@@ -28,42 +26,27 @@ async function resolveDriver(tenantId: string, orgId: string): Promise<S3Storage
|
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
export async function DELETE(req: Request) {
|
|
31
|
-
const { t } = await resolveTranslations()
|
|
32
29
|
const auth = await getAuthFromRequest(req)
|
|
33
30
|
if (!auth?.tenantId || !auth.orgId) {
|
|
34
|
-
return NextResponse.json({ error:
|
|
31
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
35
32
|
}
|
|
36
33
|
|
|
37
34
|
const json = await req.json().catch(() => null)
|
|
38
35
|
const parsed = requestSchema.safeParse(json)
|
|
39
36
|
if (!parsed.success) {
|
|
40
|
-
return NextResponse.json({ error:
|
|
37
|
+
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
41
38
|
}
|
|
42
39
|
|
|
43
40
|
if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {
|
|
44
|
-
return NextResponse.json({ error:
|
|
41
|
+
return NextResponse.json({ error: 'Access denied: key is not scoped to this tenant.' }, { status: 403 })
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
const driver = await resolveDriver(auth.tenantId, auth.orgId)
|
|
48
45
|
if (!driver) {
|
|
49
|
-
return NextResponse.json({ error:
|
|
46
|
+
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
await
|
|
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
|
-
}
|
|
49
|
+
await driver.delete('', parsed.data.key)
|
|
67
50
|
return new NextResponse(null, { status: 204 })
|
|
68
51
|
}
|
|
69
52
|
|