@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
|
@@ -57,12 +57,12 @@ function isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix) {
|
|
|
57
57
|
}
|
|
58
58
|
function assertS3KeyScopedToTenant(storagePath, scope, pathPrefix) {
|
|
59
59
|
if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {
|
|
60
|
-
throw new Error("S3 key is not scoped to the active tenant");
|
|
60
|
+
throw new Error("[internal] S3 key is not scoped to the active tenant");
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
function assertS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix) {
|
|
64
64
|
if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {
|
|
65
|
-
throw new Error("S3 key is not scoped to the active tenant");
|
|
65
|
+
throw new Error("[internal] S3 key is not scoped to the active tenant");
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
function assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix) {
|
|
@@ -72,12 +72,12 @@ function assertS3ListPrefixScopedToTenant(prefix, scope, pathPrefix) {
|
|
|
72
72
|
if (parts.length === 0) return;
|
|
73
73
|
if (parts[0]?.startsWith("org_") || parts[1]?.startsWith("tenant_")) {
|
|
74
74
|
if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {
|
|
75
|
-
throw new Error("S3 prefix is not scoped to the active tenant");
|
|
75
|
+
throw new Error("[internal] S3 prefix is not scoped to the active tenant");
|
|
76
76
|
}
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
79
|
if (parts[1]?.startsWith("org_") && parts[1] !== segments.orgSegment || parts[2]?.startsWith("tenant_") && parts[2] !== segments.tenantSegment) {
|
|
80
|
-
throw new Error("S3 prefix is not scoped to the active tenant");
|
|
80
|
+
throw new Error("[internal] S3 prefix is not scoped to the active tenant");
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
function filterS3ObjectsToTenant(files, scope, pathPrefix) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/storage_s3/lib/key-scope.ts"],
|
|
4
|
-
"sourcesContent": ["export type S3TenantScope = {\n organizationId?: string | null\n tenantId?: string | null\n}\n\nexport type S3ScopedObject = {\n key: string\n}\n\ntype ScopeSegments = {\n orgSegment: string\n tenantSegment: string\n}\n\nconst SHARED_ORG_SEGMENT = 'org_shared'\nconst SHARED_TENANT_SEGMENT = 'tenant_shared'\n\nfunction resolveSegment(value: string | null | undefined, prefix: 'org' | 'tenant'): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed) return null\n return `${prefix}_${trimmed}`\n}\n\nfunction resolveScopeSegments(scope: S3TenantScope | null | undefined): ScopeSegments | null {\n const orgSegment = resolveSegment(scope?.organizationId, 'org')\n const tenantSegment = resolveSegment(scope?.tenantId, 'tenant')\n if (!orgSegment || !tenantSegment) return null\n return { orgSegment, tenantSegment }\n}\n\nfunction normalizePath(value: string): string {\n return value.replace(/^\\/+/, '')\n}\n\nfunction stripConfiguredPrefix(value: string, pathPrefix?: string): string {\n const normalized = normalizePath(value)\n const normalizedPrefix = normalizePath(pathPrefix ?? '')\n if (!normalizedPrefix) return normalized\n return normalized.startsWith(normalizedPrefix)\n ? normalized.slice(normalizedPrefix.length).replace(/^\\/+/, '')\n : normalized\n}\n\nfunction splitS3Path(value: string, pathPrefix?: string): string[] {\n return stripConfiguredPrefix(value, pathPrefix).split('/').filter(Boolean)\n}\n\nfunction findScopeSegmentIndex(parts: string[]): number {\n return parts.findIndex((part, index) => (\n part.startsWith('org_') && parts[index + 1]?.startsWith('tenant_')\n ))\n}\n\nfunction hasScopeSegments(parts: string[], orgSegment: string, tenantSegment: string): boolean {\n const scopeIndex = findScopeSegmentIndex(parts)\n return scopeIndex >= 0\n && parts[scopeIndex] === orgSegment\n && parts[scopeIndex + 1] === tenantSegment\n}\n\nfunction resolveScopeSegmentsFromArgs(\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n tenantId?: string,\n): ScopeSegments | null {\n if (typeof scopeOrOrgId === 'string') {\n return resolveScopeSegments({ organizationId: scopeOrOrgId, tenantId })\n }\n return resolveScopeSegments(scopeOrOrgId)\n}\n\nexport function isS3KeyScopedToTenant(key: string, orgId: string, tenantId: string): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n pathPrefixOrTenantId?: string,\n): boolean {\n const segments = resolveScopeSegmentsFromArgs(scopeOrOrgId, pathPrefixOrTenantId)\n if (!segments) return typeof scopeOrOrgId === 'string' ? false : true\n\n const pathPrefix = typeof scopeOrOrgId === 'string' ? undefined : pathPrefixOrTenantId\n const parts = splitS3Path(storagePath, pathPrefix)\n return hasScopeSegments(parts, segments.orgSegment, segments.tenantSegment)\n}\n\nexport function isS3KeyShared(key: string): boolean {\n return hasScopeSegments(splitS3Path(key), SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function isS3KeyAddressableByScope(key: string, orgId: string, tenantId: string): boolean {\n return isS3KeyScopedToTenant(key, orgId, tenantId) || isS3KeyShared(key)\n}\n\nexport function isS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean {\n if (!resolveScopeSegments(scope)) return true\n const parts = splitS3Path(storagePath, pathPrefix)\n return isS3KeyScopedToTenant(storagePath, scope, pathPrefix)\n || hasScopeSegments(parts, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function assertS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {\n throw new Error('S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {\n throw new Error('S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3ListPrefixScopedToTenant(\n prefix: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n const segments = resolveScopeSegments(scope)\n if (!segments) return\n\n const parts = splitS3Path(prefix, pathPrefix)\n if (parts.length === 0) return\n\n if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {\n if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {\n throw new Error('S3 prefix is not scoped to the active tenant')\n }\n return\n }\n\n if (\n (parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)\n || (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)\n ) {\n throw new Error('S3 prefix is not scoped to the active tenant')\n }\n}\n\nexport function filterS3ObjectsToTenant<T extends S3ScopedObject>(\n files: T[],\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): T[] {\n if (!resolveScopeSegments(scope)) return files\n return files.filter((file) => isS3KeyScopedToTenant(file.key, scope, pathPrefix))\n}\n"],
|
|
5
|
-
"mappings": "AAcA,MAAM,qBAAqB;AAC3B,MAAM,wBAAwB;AAE9B,SAAS,eAAe,OAAkC,QAAyC;AACjG,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,CAAC,QAAS,QAAO;AACrB,SAAO,GAAG,MAAM,IAAI,OAAO;AAC7B;AAEA,SAAS,qBAAqB,OAA+D;AAC3F,QAAM,aAAa,eAAe,OAAO,gBAAgB,KAAK;AAC9D,QAAM,gBAAgB,eAAe,OAAO,UAAU,QAAQ;AAC9D,MAAI,CAAC,cAAc,CAAC,cAAe,QAAO;AAC1C,SAAO,EAAE,YAAY,cAAc;AACrC;AAEA,SAAS,cAAc,OAAuB;AAC5C,SAAO,MAAM,QAAQ,QAAQ,EAAE;AACjC;AAEA,SAAS,sBAAsB,OAAe,YAA6B;AACzE,QAAM,aAAa,cAAc,KAAK;AACtC,QAAM,mBAAmB,cAAc,cAAc,EAAE;AACvD,MAAI,CAAC,iBAAkB,QAAO;AAC9B,SAAO,WAAW,WAAW,gBAAgB,IACzC,WAAW,MAAM,iBAAiB,MAAM,EAAE,QAAQ,QAAQ,EAAE,IAC5D;AACN;AAEA,SAAS,YAAY,OAAe,YAA+B;AACjE,SAAO,sBAAsB,OAAO,UAAU,EAAE,MAAM,GAAG,EAAE,OAAO,OAAO;AAC3E;AAEA,SAAS,sBAAsB,OAAyB;AACtD,SAAO,MAAM,UAAU,CAAC,MAAM,UAC5B,KAAK,WAAW,MAAM,KAAK,MAAM,QAAQ,CAAC,GAAG,WAAW,SAAS,CAClE;AACH;AAEA,SAAS,iBAAiB,OAAiB,YAAoB,eAAgC;AAC7F,QAAM,aAAa,sBAAsB,KAAK;AAC9C,SAAO,cAAc,KAChB,MAAM,UAAU,MAAM,cACtB,MAAM,aAAa,CAAC,MAAM;AACjC;AAEA,SAAS,6BACP,cACA,UACsB;AACtB,MAAI,OAAO,iBAAiB,UAAU;AACpC,WAAO,qBAAqB,EAAE,gBAAgB,cAAc,SAAS,CAAC;AAAA,EACxE;AACA,SAAO,qBAAqB,YAAY;AAC1C;AAQO,SAAS,sBACd,aACA,cACA,sBACS;AACT,QAAM,WAAW,6BAA6B,cAAc,oBAAoB;AAChF,MAAI,CAAC,SAAU,QAAO,OAAO,iBAAiB,WAAW,QAAQ;AAEjE,QAAM,aAAa,OAAO,iBAAiB,WAAW,SAAY;AAClE,QAAM,QAAQ,YAAY,aAAa,UAAU;AACjD,SAAO,iBAAiB,OAAO,SAAS,YAAY,SAAS,aAAa;AAC5E;AAEO,SAAS,cAAc,KAAsB;AAClD,SAAO,iBAAiB,YAAY,GAAG,GAAG,oBAAoB,qBAAqB;AACrF;AAEO,SAAS,0BAA0B,KAAa,OAAe,UAA2B;AAC/F,SAAO,sBAAsB,KAAK,OAAO,QAAQ,KAAK,cAAc,GAAG;AACzE;AAEO,SAAS,gCACd,aACA,OACA,YACS;AACT,MAAI,CAAC,qBAAqB,KAAK,EAAG,QAAO;AACzC,QAAM,QAAQ,YAAY,aAAa,UAAU;AACjD,SAAO,sBAAsB,aAAa,OAAO,UAAU,KACtD,iBAAiB,OAAO,oBAAoB,qBAAqB;AACxE;AAEO,SAAS,0BACd,aACA,OACA,YACM;AACN,MAAI,CAAC,sBAAsB,aAAa,OAAO,UAAU,GAAG;AAC1D,UAAM,IAAI,MAAM,
|
|
4
|
+
"sourcesContent": ["export type S3TenantScope = {\n organizationId?: string | null\n tenantId?: string | null\n}\n\nexport type S3ScopedObject = {\n key: string\n}\n\ntype ScopeSegments = {\n orgSegment: string\n tenantSegment: string\n}\n\nconst SHARED_ORG_SEGMENT = 'org_shared'\nconst SHARED_TENANT_SEGMENT = 'tenant_shared'\n\nfunction resolveSegment(value: string | null | undefined, prefix: 'org' | 'tenant'): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed) return null\n return `${prefix}_${trimmed}`\n}\n\nfunction resolveScopeSegments(scope: S3TenantScope | null | undefined): ScopeSegments | null {\n const orgSegment = resolveSegment(scope?.organizationId, 'org')\n const tenantSegment = resolveSegment(scope?.tenantId, 'tenant')\n if (!orgSegment || !tenantSegment) return null\n return { orgSegment, tenantSegment }\n}\n\nfunction normalizePath(value: string): string {\n return value.replace(/^\\/+/, '')\n}\n\nfunction stripConfiguredPrefix(value: string, pathPrefix?: string): string {\n const normalized = normalizePath(value)\n const normalizedPrefix = normalizePath(pathPrefix ?? '')\n if (!normalizedPrefix) return normalized\n return normalized.startsWith(normalizedPrefix)\n ? normalized.slice(normalizedPrefix.length).replace(/^\\/+/, '')\n : normalized\n}\n\nfunction splitS3Path(value: string, pathPrefix?: string): string[] {\n return stripConfiguredPrefix(value, pathPrefix).split('/').filter(Boolean)\n}\n\nfunction findScopeSegmentIndex(parts: string[]): number {\n return parts.findIndex((part, index) => (\n part.startsWith('org_') && parts[index + 1]?.startsWith('tenant_')\n ))\n}\n\nfunction hasScopeSegments(parts: string[], orgSegment: string, tenantSegment: string): boolean {\n const scopeIndex = findScopeSegmentIndex(parts)\n return scopeIndex >= 0\n && parts[scopeIndex] === orgSegment\n && parts[scopeIndex + 1] === tenantSegment\n}\n\nfunction resolveScopeSegmentsFromArgs(\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n tenantId?: string,\n): ScopeSegments | null {\n if (typeof scopeOrOrgId === 'string') {\n return resolveScopeSegments({ organizationId: scopeOrOrgId, tenantId })\n }\n return resolveScopeSegments(scopeOrOrgId)\n}\n\nexport function isS3KeyScopedToTenant(key: string, orgId: string, tenantId: string): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean\nexport function isS3KeyScopedToTenant(\n storagePath: string,\n scopeOrOrgId: S3TenantScope | string | null | undefined,\n pathPrefixOrTenantId?: string,\n): boolean {\n const segments = resolveScopeSegmentsFromArgs(scopeOrOrgId, pathPrefixOrTenantId)\n if (!segments) return typeof scopeOrOrgId === 'string' ? false : true\n\n const pathPrefix = typeof scopeOrOrgId === 'string' ? undefined : pathPrefixOrTenantId\n const parts = splitS3Path(storagePath, pathPrefix)\n return hasScopeSegments(parts, segments.orgSegment, segments.tenantSegment)\n}\n\nexport function isS3KeyShared(key: string): boolean {\n return hasScopeSegments(splitS3Path(key), SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function isS3KeyAddressableByScope(key: string, orgId: string, tenantId: string): boolean {\n return isS3KeyScopedToTenant(key, orgId, tenantId) || isS3KeyShared(key)\n}\n\nexport function isS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): boolean {\n if (!resolveScopeSegments(scope)) return true\n const parts = splitS3Path(storagePath, pathPrefix)\n return isS3KeyScopedToTenant(storagePath, scope, pathPrefix)\n || hasScopeSegments(parts, SHARED_ORG_SEGMENT, SHARED_TENANT_SEGMENT)\n}\n\nexport function assertS3KeyScopedToTenant(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyScopedToTenant(storagePath, scope, pathPrefix)) {\n throw new Error('[internal] S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3KeyAddressableByTenantScope(\n storagePath: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n if (!isS3KeyAddressableByTenantScope(storagePath, scope, pathPrefix)) {\n throw new Error('[internal] S3 key is not scoped to the active tenant')\n }\n}\n\nexport function assertS3ListPrefixScopedToTenant(\n prefix: string,\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): void {\n const segments = resolveScopeSegments(scope)\n if (!segments) return\n\n const parts = splitS3Path(prefix, pathPrefix)\n if (parts.length === 0) return\n\n if (parts[0]?.startsWith('org_') || parts[1]?.startsWith('tenant_')) {\n if (parts[0] !== segments.orgSegment || parts[1] !== segments.tenantSegment) {\n throw new Error('[internal] S3 prefix is not scoped to the active tenant')\n }\n return\n }\n\n if (\n (parts[1]?.startsWith('org_') && parts[1] !== segments.orgSegment)\n || (parts[2]?.startsWith('tenant_') && parts[2] !== segments.tenantSegment)\n ) {\n throw new Error('[internal] S3 prefix is not scoped to the active tenant')\n }\n}\n\nexport function filterS3ObjectsToTenant<T extends S3ScopedObject>(\n files: T[],\n scope: S3TenantScope | null | undefined,\n pathPrefix?: string,\n): T[] {\n if (!resolveScopeSegments(scope)) return files\n return files.filter((file) => isS3KeyScopedToTenant(file.key, scope, pathPrefix))\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isS3KeyScopedToTenant } from "./key-scope.js";
|
|
2
|
+
async function reconcileTenantS3Objects(input) {
|
|
3
|
+
const objects = [];
|
|
4
|
+
let continuationToken;
|
|
5
|
+
do {
|
|
6
|
+
const page = await input.driver.listObjects("", 1e3, continuationToken);
|
|
7
|
+
for (const file of page.files) {
|
|
8
|
+
if (isS3KeyScopedToTenant(file.key, input.organizationId, input.tenantId)) {
|
|
9
|
+
objects.push({ path: file.key, bytes: file.size });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
continuationToken = page.truncated ? page.nextContinuationToken : void 0;
|
|
13
|
+
} while (continuationToken);
|
|
14
|
+
await input.quotaService.reconcileStandaloneObjects({
|
|
15
|
+
tenantId: input.tenantId,
|
|
16
|
+
organizationId: input.organizationId,
|
|
17
|
+
storageDriver: "s3",
|
|
18
|
+
objects
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export {
|
|
22
|
+
reconcileTenantS3Objects
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=quota-accounting.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/storage_s3/lib/quota-accounting.ts"],
|
|
4
|
+
"sourcesContent": ["import type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { isS3KeyScopedToTenant } from './key-scope'\nimport type { S3StorageDriver } from './s3-driver'\n\nexport async function reconcileTenantS3Objects(input: {\n driver: S3StorageDriver\n quotaService: AttachmentQuotaService\n tenantId: string\n organizationId: string\n}): Promise<void> {\n const objects: Array<{ path: string; bytes: number }> = []\n let continuationToken: string | undefined\n do {\n const page = await input.driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, input.organizationId, input.tenantId)) {\n objects.push({ path: file.key, bytes: file.size })\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n await input.quotaService.reconcileStandaloneObjects({\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n storageDriver: 's3',\n objects,\n })\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,6BAA6B;AAGtC,eAAsB,yBAAyB,OAK7B;AAChB,QAAM,UAAkD,CAAC;AACzD,MAAI;AACJ,KAAG;AACD,UAAM,OAAO,MAAM,MAAM,OAAO,YAAY,IAAI,KAAM,iBAAiB;AACvE,eAAW,QAAQ,KAAK,OAAO;AAC7B,UAAI,sBAAsB,KAAK,KAAK,MAAM,gBAAgB,MAAM,QAAQ,GAAG;AACzE,gBAAQ,KAAK,EAAE,MAAM,KAAK,KAAK,OAAO,KAAK,KAAK,CAAC;AAAA,MACnD;AAAA,IACF;AACA,wBAAoB,KAAK,YAAY,KAAK,wBAAwB;AAAA,EACpE,SAAS;AAET,QAAM,MAAM,aAAa,2BAA2B;AAAA,IAClD,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,IACtB,eAAe;AAAA,IACf;AAAA,EACF,CAAC;AACH;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createModuleQueue } from "@open-mercato/queue";
|
|
2
|
+
const queue = createModuleQueue("storage-s3-quota-recovery", { concurrency: 2 });
|
|
3
|
+
async function scheduleStorageS3QuotaRecovery(payload, delayMs) {
|
|
4
|
+
await queue.enqueue(payload, { delayMs: Math.max(1e3, delayMs) });
|
|
5
|
+
}
|
|
6
|
+
export {
|
|
7
|
+
scheduleStorageS3QuotaRecovery
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=quota-recovery-queue.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/storage_s3/lib/quota-recovery-queue.ts"],
|
|
4
|
+
"sourcesContent": ["import { createModuleQueue } from '@open-mercato/queue'\n\nexport type StorageS3QuotaRecoveryJob = {\n reservationId: string\n tenantId: string\n organizationId: string\n absenceCheck?: number\n}\n\nconst queue = createModuleQueue<StorageS3QuotaRecoveryJob>('storage-s3-quota-recovery', { concurrency: 2 })\n\nexport async function scheduleStorageS3QuotaRecovery(\n payload: StorageS3QuotaRecoveryJob,\n delayMs: number,\n): Promise<void> {\n await queue.enqueue(payload, { delayMs: Math.max(1_000, delayMs) })\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,yBAAyB;AASlC,MAAM,QAAQ,kBAA6C,6BAA6B,EAAE,aAAa,EAAE,CAAC;AAE1G,eAAsB,+BACpB,SACA,SACe;AACf,QAAM,MAAM,QAAQ,SAAS,EAAE,SAAS,KAAK,IAAI,KAAO,OAAO,EAAE,CAAC;AACpE;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -10,6 +10,11 @@ import {
|
|
|
10
10
|
ListObjectsV2Command
|
|
11
11
|
} from "@aws-sdk/client-s3";
|
|
12
12
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
13
|
+
import {
|
|
14
|
+
assertSafeS3Endpoint,
|
|
15
|
+
assertStaticallySafeS3Endpoint,
|
|
16
|
+
createSafeS3RequestHandler
|
|
17
|
+
} from "./endpoint-safety.js";
|
|
13
18
|
import {
|
|
14
19
|
assertS3KeyAddressableByTenantScope,
|
|
15
20
|
assertS3KeyScopedToTenant,
|
|
@@ -41,6 +46,7 @@ class S3StorageDriver {
|
|
|
41
46
|
this.key = "s3";
|
|
42
47
|
const cfg = config;
|
|
43
48
|
this.bucket = cfg.bucket;
|
|
49
|
+
this.endpoint = cfg.endpoint;
|
|
44
50
|
this.pathPrefix = cfg.pathPrefix ?? "";
|
|
45
51
|
this.scope = cfg.organizationId && cfg.tenantId ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId } : null;
|
|
46
52
|
const authMode = cfg.authMode;
|
|
@@ -63,11 +69,14 @@ class S3StorageDriver {
|
|
|
63
69
|
};
|
|
64
70
|
}
|
|
65
71
|
}
|
|
72
|
+
assertStaticallySafeS3Endpoint(cfg.endpoint);
|
|
73
|
+
const requestHandler = createSafeS3RequestHandler(cfg.endpoint);
|
|
66
74
|
this.client = new S3Client({
|
|
67
75
|
region: cfg.region ?? "us-east-1",
|
|
68
76
|
endpoint: cfg.endpoint,
|
|
69
77
|
forcePathStyle: cfg.forcePathStyle ?? false,
|
|
70
|
-
credentials
|
|
78
|
+
credentials,
|
|
79
|
+
...requestHandler ? { requestHandler } : {}
|
|
71
80
|
});
|
|
72
81
|
}
|
|
73
82
|
assertKeyScoped(storagePath, scope) {
|
|
@@ -81,22 +90,29 @@ class S3StorageDriver {
|
|
|
81
90
|
const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix) ? storagePath.slice(this.pathPrefix.length).replace(/^\/+/, "") : storagePath.replace(/^\/+/, "");
|
|
82
91
|
const firstSegment = prefix.split("/").filter(Boolean)[0];
|
|
83
92
|
if (firstSegment !== partitionCode) {
|
|
84
|
-
throw new Error("S3 key is not scoped to the requested partition");
|
|
93
|
+
throw new Error("[internal] S3 key is not scoped to the requested partition");
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
|
-
|
|
96
|
+
prepareStoragePath(payload) {
|
|
88
97
|
const orgSegment = resolveOrgSegment(payload.orgId ?? null);
|
|
89
98
|
const tenantSegment = resolveTenantSegment(payload.tenantId ?? null);
|
|
90
99
|
const safeName = sanitizeFileName(payload.fileName || "file");
|
|
91
100
|
const uniqueSuffix = randomUUID().replace(/-/g, "").slice(0, 12);
|
|
92
101
|
const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`;
|
|
93
|
-
|
|
102
|
+
return `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`;
|
|
103
|
+
}
|
|
104
|
+
async store(payload) {
|
|
105
|
+
const key = payload.storagePath ?? this.prepareStoragePath(payload);
|
|
106
|
+
this.assertPartitionScoped(payload.partitionCode, key);
|
|
107
|
+
this.assertKeyScoped(key);
|
|
108
|
+
await this.assertEndpointSafe();
|
|
94
109
|
await this.client.send(
|
|
95
110
|
new PutObjectCommand({
|
|
96
111
|
Bucket: this.bucket,
|
|
97
112
|
Key: key,
|
|
98
113
|
Body: payload.buffer,
|
|
99
|
-
ContentLength: payload.buffer.length
|
|
114
|
+
ContentLength: payload.buffer.length,
|
|
115
|
+
IfNoneMatch: "*"
|
|
100
116
|
})
|
|
101
117
|
);
|
|
102
118
|
return { storagePath: key };
|
|
@@ -104,11 +120,12 @@ class S3StorageDriver {
|
|
|
104
120
|
async read(partitionCode, storagePath) {
|
|
105
121
|
this.assertPartitionScoped(partitionCode, storagePath);
|
|
106
122
|
this.assertKeyAddressable(storagePath);
|
|
123
|
+
await this.assertEndpointSafe();
|
|
107
124
|
const response = await this.client.send(
|
|
108
125
|
new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })
|
|
109
126
|
);
|
|
110
127
|
if (!response.Body) {
|
|
111
|
-
throw new Error(`S3 object body is empty for key: ${storagePath}`);
|
|
128
|
+
throw new Error(`[internal] S3 object body is empty for key: ${storagePath}`);
|
|
112
129
|
}
|
|
113
130
|
const buffer = await streamToBuffer(response.Body);
|
|
114
131
|
return { buffer, contentType: response.ContentType };
|
|
@@ -117,12 +134,16 @@ class S3StorageDriver {
|
|
|
117
134
|
this.assertPartitionScoped(partitionCode, storagePath);
|
|
118
135
|
this.assertKeyAddressable(storagePath);
|
|
119
136
|
try {
|
|
120
|
-
await this.
|
|
121
|
-
new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath })
|
|
122
|
-
);
|
|
137
|
+
await this.deleteStrict(partitionCode, storagePath);
|
|
123
138
|
} catch {
|
|
124
139
|
}
|
|
125
140
|
}
|
|
141
|
+
async deleteStrict(partitionCode, storagePath) {
|
|
142
|
+
this.assertPartitionScoped(partitionCode, storagePath);
|
|
143
|
+
this.assertKeyAddressable(storagePath);
|
|
144
|
+
await this.assertEndpointSafe();
|
|
145
|
+
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }));
|
|
146
|
+
}
|
|
126
147
|
async toLocalPath(partitionCode, storagePath) {
|
|
127
148
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "s3-tmp-"));
|
|
128
149
|
const cleanup = async () => {
|
|
@@ -144,13 +165,15 @@ class S3StorageDriver {
|
|
|
144
165
|
*/
|
|
145
166
|
async putObject(key, buffer, contentType) {
|
|
146
167
|
this.assertKeyScoped(key);
|
|
168
|
+
await this.assertEndpointSafe();
|
|
147
169
|
await this.client.send(
|
|
148
170
|
new PutObjectCommand({
|
|
149
171
|
Bucket: this.bucket,
|
|
150
172
|
Key: key,
|
|
151
173
|
Body: buffer,
|
|
152
174
|
ContentType: contentType,
|
|
153
|
-
ContentLength: buffer.length
|
|
175
|
+
ContentLength: buffer.length,
|
|
176
|
+
IfNoneMatch: "*"
|
|
154
177
|
})
|
|
155
178
|
);
|
|
156
179
|
}
|
|
@@ -160,6 +183,7 @@ class S3StorageDriver {
|
|
|
160
183
|
async listObjects(prefix, maxKeys = 100, continuationToken, scope) {
|
|
161
184
|
const activeScope = scope ?? this.scope;
|
|
162
185
|
assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix);
|
|
186
|
+
await this.assertEndpointSafe();
|
|
163
187
|
const response = await this.client.send(
|
|
164
188
|
new ListObjectsV2Command({
|
|
165
189
|
Bucket: this.bucket,
|
|
@@ -179,26 +203,31 @@ class S3StorageDriver {
|
|
|
179
203
|
nextContinuationToken: response.NextContinuationToken
|
|
180
204
|
};
|
|
181
205
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
async getSignedUrl(storagePath, operation, expiresIn = 3600, contentType, scope) {
|
|
206
|
+
async getSignedUrl(storagePath, operation, expiresIn = 3600, contentType, contentLengthOrScope, createOnly = false, explicitScope) {
|
|
207
|
+
const contentLength = typeof contentLengthOrScope === "number" ? contentLengthOrScope : void 0;
|
|
208
|
+
const scope = typeof contentLengthOrScope === "number" ? explicitScope : contentLengthOrScope;
|
|
186
209
|
if (operation === "upload") {
|
|
187
210
|
this.assertKeyScoped(storagePath, scope);
|
|
188
211
|
} else {
|
|
189
212
|
this.assertKeyAddressable(storagePath, scope);
|
|
190
213
|
}
|
|
214
|
+
await this.assertEndpointSafe();
|
|
191
215
|
if (operation === "upload") {
|
|
192
216
|
const command2 = new PutObjectCommand({
|
|
193
217
|
Bucket: this.bucket,
|
|
194
218
|
Key: storagePath,
|
|
195
|
-
ContentType: contentType
|
|
219
|
+
ContentType: contentType,
|
|
220
|
+
ContentLength: contentLength,
|
|
221
|
+
...createOnly ? { IfNoneMatch: "*" } : {}
|
|
196
222
|
});
|
|
197
223
|
return getSignedUrl(this.client, command2, { expiresIn });
|
|
198
224
|
}
|
|
199
225
|
const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath });
|
|
200
226
|
return getSignedUrl(this.client, command, { expiresIn });
|
|
201
227
|
}
|
|
228
|
+
async assertEndpointSafe() {
|
|
229
|
+
await assertSafeS3Endpoint(this.endpoint);
|
|
230
|
+
}
|
|
202
231
|
}
|
|
203
232
|
export {
|
|
204
233
|
S3StorageDriver
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/storage_s3/lib/s3-driver.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport os from 'os'\nimport path from 'path'\nimport { promises as fs } from 'fs'\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport type { StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n filterS3ObjectsToTenant,\n type S3TenantScope,\n} from './key-scope'\n\nexport type S3DriverConfig = {\n bucket: string\n region?: string\n endpoint?: string\n pathPrefix?: string\n forcePathStyle?: boolean\n /**\n * authMode:\n * - access_keys: use explicit credentials (direct values or env prefix)\n * - ambient: let AWS SDK resolve credentials from the default chain (STS/IRSA/instance profile/etc.)\n *\n * Backward compat: missing authMode behaves like access_keys when keys are provided; otherwise ambient.\n */\n authMode?: 'access_keys' | 'ambient'\n /** Resolve access key + secret from env vars: {PREFIX}_ACCESS_KEY_ID / {PREFIX}_SECRET_ACCESS_KEY */\n credentialsEnvPrefix?: string\n /** Direct credentials (used when passed from Integration Marketplace) */\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nasync function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = []\n stream.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))\n stream.on('end', () => resolve(Buffer.concat(chunks)))\n stream.on('error', reject)\n })\n}\n\nexport class S3StorageDriver implements StorageDriver {\n readonly key = 's3'\n private readonly client: S3Client\n private readonly bucket: string\n private readonly pathPrefix: string\n private readonly scope: S3TenantScope | null\n\n constructor(config: Record<string, unknown>) {\n const cfg = config as S3DriverConfig\n this.bucket = cfg.bucket\n this.pathPrefix = cfg.pathPrefix ?? ''\n this.scope = cfg.organizationId && cfg.tenantId\n ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }\n : null\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentials = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n }\n }\n\n this.client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials,\n })\n }\n\n private assertKeyScoped(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyScopedToTenant(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertKeyAddressable(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyAddressableByTenantScope(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertPartitionScoped(partitionCode: string, storagePath: string): void {\n if (!partitionCode) return\n const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix)\n ? storagePath.slice(this.pathPrefix.length).replace(/^\\/+/, '')\n : storagePath.replace(/^\\/+/, '')\n const firstSegment = prefix.split('/').filter(Boolean)[0]\n if (firstSegment !== partitionCode) {\n throw new Error('S3 key is not scoped to the requested partition')\n }\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n const key = `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: payload.buffer,\n ContentLength: payload.buffer.length,\n }),\n )\n\n return { storagePath: key }\n }\n\n async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n if (!response.Body) {\n throw new Error(`S3 object body is empty for key: ${storagePath}`)\n }\n const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)\n return { buffer, contentType: response.ContentType }\n }\n\n async delete(partitionCode: string, storagePath: string): Promise<void> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n try {\n await this.client.send(\n new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n } catch {\n // best-effort removal\n }\n }\n\n async toLocalPath(\n partitionCode: string,\n storagePath: string,\n ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {\n const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 's3-tmp-'))\n const cleanup = async () => {\n await fs.rm(tmpDir, { recursive: true, force: true })\n }\n\n try {\n const fileName = path.basename(storagePath) || 'download'\n const filePath = path.join(tmpDir, fileName)\n const { buffer } = await this.read(partitionCode, storagePath)\n await fs.writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 })\n return { filePath, cleanup }\n } catch (error) {\n await Promise.allSettled([cleanup()])\n throw error\n }\n }\n\n /**\n * Put an object directly at a specific S3 key (for standalone usage).\n */\n async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {\n this.assertKeyScoped(key)\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: buffer,\n ContentType: contentType,\n ContentLength: buffer.length,\n }),\n )\n }\n\n getBucket(): string {\n return this.bucket\n }\n\n async listObjects(\n prefix: string,\n maxKeys = 100,\n continuationToken?: string,\n scope?: S3TenantScope | null,\n ): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n const activeScope = scope ?? this.scope\n assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)\n const response = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n MaxKeys: maxKeys,\n ContinuationToken: continuationToken,\n }),\n )\n const files = (response.Contents ?? []).map((obj) => ({\n key: obj.Key ?? '',\n size: obj.Size ?? 0,\n lastModified: obj.LastModified ?? new Date(0),\n }))\n return {\n files: filterS3ObjectsToTenant(files, activeScope, this.pathPrefix),\n truncated: response.IsTruncated ?? false,\n nextContinuationToken: response.NextContinuationToken,\n }\n }\n\n /**\n * Generate a pre-signed URL for direct browser upload or download.\n */\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn = 3600,\n contentType?: string,\n scope?: S3TenantScope | null,\n ): Promise<string> {\n if (operation === 'upload') {\n this.assertKeyScoped(storagePath, scope)\n } else {\n this.assertKeyAddressable(storagePath, scope)\n }\n if (operation === 'upload') {\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: storagePath,\n ContentType: contentType,\n })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,OAAO,QAAQ;AACf,OAAO,UAAU;AACjB,SAAS,YAAY,UAAU;AAC/B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAE7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AA0BP,SAAS,iBAAiB,UAA0B;AAClD,MAAI,CAAC,SAAU,QAAO;AACtB,SAAO,SAAS,QAAQ,oBAAoB,GAAG;AACjD;AAEA,SAAS,kBAAkB,OAA0C;AACnE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,SAAS,EAAG,QAAO,OAAO,KAAK;AAC7E,SAAO;AACT;AAEA,SAAS,qBAAqB,UAA6C;AACzE,MAAI,OAAO,aAAa,YAAY,SAAS,KAAK,EAAE,SAAS,EAAG,QAAO,UAAU,QAAQ;AACzF,SAAO;AACT;AAEA,eAAe,eAAe,QAAgD;AAC5E,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,SAAmB,CAAC;AAC1B,WAAO,GAAG,QAAQ,CAAC,UAAkB,OAAO,KAAK,OAAO,SAAS,KAAK,IAAI,QAAQ,OAAO,KAAK,KAAK,CAAC,CAAC;AACrG,WAAO,GAAG,OAAO,MAAM,QAAQ,OAAO,OAAO,MAAM,CAAC,CAAC;AACrD,WAAO,GAAG,SAAS,MAAM;AAAA,EAC3B,CAAC;AACH;AAEO,MAAM,gBAAyC;AAAA,
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'crypto'\nimport os from 'os'\nimport path from 'path'\nimport { promises as fs } from 'fs'\nimport {\n S3Client,\n PutObjectCommand,\n GetObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3'\nimport { getSignedUrl } from '@aws-sdk/s3-request-presigner'\nimport type { PrepareFilePayload, StorageDriver, StoreFilePayload, StoredFile, ReadFileResult } from '@open-mercato/core/modules/attachments/lib/drivers'\nimport {\n assertSafeS3Endpoint,\n assertStaticallySafeS3Endpoint,\n createSafeS3RequestHandler,\n} from './endpoint-safety'\nimport {\n assertS3KeyAddressableByTenantScope,\n assertS3KeyScopedToTenant,\n assertS3ListPrefixScopedToTenant,\n filterS3ObjectsToTenant,\n type S3TenantScope,\n} from './key-scope'\n\nexport type S3DriverConfig = {\n bucket: string\n region?: string\n endpoint?: string\n pathPrefix?: string\n forcePathStyle?: boolean\n /**\n * authMode:\n * - access_keys: use explicit credentials (direct values or env prefix)\n * - ambient: let AWS SDK resolve credentials from the default chain (STS/IRSA/instance profile/etc.)\n *\n * Backward compat: missing authMode behaves like access_keys when keys are provided; otherwise ambient.\n */\n authMode?: 'access_keys' | 'ambient'\n /** Resolve access key + secret from env vars: {PREFIX}_ACCESS_KEY_ID / {PREFIX}_SECRET_ACCESS_KEY */\n credentialsEnvPrefix?: string\n /** Direct credentials (used when passed from Integration Marketplace) */\n accessKeyId?: string\n secretAccessKey?: string\n sessionToken?: string\n organizationId?: string | null\n tenantId?: string | null\n}\n\nfunction sanitizeFileName(fileName: string): string {\n if (!fileName) return 'file'\n return fileName.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\nfunction resolveOrgSegment(orgId: string | null | undefined): string {\n if (typeof orgId === 'string' && orgId.trim().length > 0) return `org_${orgId}`\n return 'org_shared'\n}\n\nfunction resolveTenantSegment(tenantId: string | null | undefined): string {\n if (typeof tenantId === 'string' && tenantId.trim().length > 0) return `tenant_${tenantId}`\n return 'tenant_shared'\n}\n\nasync function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = []\n stream.on('data', (chunk: Buffer) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))\n stream.on('end', () => resolve(Buffer.concat(chunks)))\n stream.on('error', reject)\n })\n}\n\nexport class S3StorageDriver implements StorageDriver {\n readonly key = 's3'\n private readonly client: S3Client\n private readonly bucket: string\n private readonly endpoint?: string\n private readonly pathPrefix: string\n private readonly scope: S3TenantScope | null\n\n constructor(config: Record<string, unknown>) {\n const cfg = config as S3DriverConfig\n this.bucket = cfg.bucket\n this.endpoint = cfg.endpoint\n this.pathPrefix = cfg.pathPrefix ?? ''\n this.scope = cfg.organizationId && cfg.tenantId\n ? { organizationId: cfg.organizationId, tenantId: cfg.tenantId }\n : null\n\n const authMode = cfg.authMode\n const shouldUseAccessKeys =\n authMode === 'access_keys'\n || (\n authMode !== 'ambient'\n && ((cfg.accessKeyId && cfg.secretAccessKey) || Boolean(cfg.credentialsEnvPrefix))\n )\n\n let credentials: { accessKeyId: string; secretAccessKey: string; sessionToken?: string } | undefined\n if (shouldUseAccessKeys) {\n if (cfg.credentialsEnvPrefix) {\n const prefix = cfg.credentialsEnvPrefix\n const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]\n const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]\n const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]\n if (accessKeyId && secretAccessKey) {\n credentials = { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) }\n }\n } else if (cfg.accessKeyId && cfg.secretAccessKey) {\n credentials = {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n ...(cfg.sessionToken ? { sessionToken: cfg.sessionToken } : {}),\n }\n }\n }\n\n assertStaticallySafeS3Endpoint(cfg.endpoint)\n const requestHandler = createSafeS3RequestHandler(cfg.endpoint)\n\n this.client = new S3Client({\n region: cfg.region ?? 'us-east-1',\n endpoint: cfg.endpoint,\n forcePathStyle: cfg.forcePathStyle ?? false,\n credentials,\n ...(requestHandler ? { requestHandler } : {}),\n })\n }\n\n private assertKeyScoped(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyScopedToTenant(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertKeyAddressable(storagePath: string, scope?: S3TenantScope | null): void {\n assertS3KeyAddressableByTenantScope(storagePath, scope ?? this.scope, this.pathPrefix)\n }\n\n private assertPartitionScoped(partitionCode: string, storagePath: string): void {\n if (!partitionCode) return\n const prefix = this.pathPrefix && storagePath.startsWith(this.pathPrefix)\n ? storagePath.slice(this.pathPrefix.length).replace(/^\\/+/, '')\n : storagePath.replace(/^\\/+/, '')\n const firstSegment = prefix.split('/').filter(Boolean)[0]\n if (firstSegment !== partitionCode) {\n throw new Error('[internal] S3 key is not scoped to the requested partition')\n }\n }\n\n prepareStoragePath(payload: PrepareFilePayload): string {\n const orgSegment = resolveOrgSegment(payload.orgId ?? null)\n const tenantSegment = resolveTenantSegment(payload.tenantId ?? null)\n const safeName = sanitizeFileName(payload.fileName || 'file')\n const uniqueSuffix = randomUUID().replace(/-/g, '').slice(0, 12)\n const storedName = `${Date.now()}_${uniqueSuffix}_${safeName}`\n return `${this.pathPrefix}${payload.partitionCode}/${orgSegment}/${tenantSegment}/${storedName}`\n }\n\n async store(payload: StoreFilePayload): Promise<StoredFile> {\n const key = payload.storagePath ?? this.prepareStoragePath(payload)\n this.assertPartitionScoped(payload.partitionCode, key)\n this.assertKeyScoped(key)\n await this.assertEndpointSafe()\n\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: payload.buffer,\n ContentLength: payload.buffer.length,\n IfNoneMatch: '*',\n }),\n )\n\n return { storagePath: key }\n }\n\n async read(partitionCode: string, storagePath: string): Promise<ReadFileResult> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n await this.assertEndpointSafe()\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: storagePath }),\n )\n if (!response.Body) {\n throw new Error(`[internal] S3 object body is empty for key: ${storagePath}`)\n }\n const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream)\n return { buffer, contentType: response.ContentType }\n }\n\n async delete(partitionCode: string, storagePath: string): Promise<void> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n try {\n await this.deleteStrict(partitionCode, storagePath)\n } catch {\n // Backward-compatible best-effort deletion.\n }\n }\n\n async deleteStrict(partitionCode: string, storagePath: string): Promise<void> {\n this.assertPartitionScoped(partitionCode, storagePath)\n this.assertKeyAddressable(storagePath)\n await this.assertEndpointSafe()\n await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storagePath }))\n }\n\n async toLocalPath(\n partitionCode: string,\n storagePath: string,\n ): Promise<{ filePath: string; cleanup: () => Promise<void> }> {\n const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 's3-tmp-'))\n const cleanup = async () => {\n await fs.rm(tmpDir, { recursive: true, force: true })\n }\n\n try {\n const fileName = path.basename(storagePath) || 'download'\n const filePath = path.join(tmpDir, fileName)\n const { buffer } = await this.read(partitionCode, storagePath)\n await fs.writeFile(filePath, buffer, { flag: 'wx', mode: 0o600 })\n return { filePath, cleanup }\n } catch (error) {\n await Promise.allSettled([cleanup()])\n throw error\n }\n }\n\n /**\n * Put an object directly at a specific S3 key (for standalone usage).\n */\n async putObject(key: string, buffer: Buffer, contentType?: string): Promise<void> {\n this.assertKeyScoped(key)\n await this.assertEndpointSafe()\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: buffer,\n ContentType: contentType,\n ContentLength: buffer.length,\n IfNoneMatch: '*',\n }),\n )\n }\n\n getBucket(): string {\n return this.bucket\n }\n\n async listObjects(\n prefix: string,\n maxKeys = 100,\n continuationToken?: string,\n scope?: S3TenantScope | null,\n ): Promise<{\n files: Array<{ key: string; size: number; lastModified: Date }>\n truncated: boolean\n nextContinuationToken?: string\n }> {\n const activeScope = scope ?? this.scope\n assertS3ListPrefixScopedToTenant(prefix, activeScope, this.pathPrefix)\n await this.assertEndpointSafe()\n const response = await this.client.send(\n new ListObjectsV2Command({\n Bucket: this.bucket,\n Prefix: prefix,\n MaxKeys: maxKeys,\n ContinuationToken: continuationToken,\n }),\n )\n const files = (response.Contents ?? []).map((obj) => ({\n key: obj.Key ?? '',\n size: obj.Size ?? 0,\n lastModified: obj.LastModified ?? new Date(0),\n }))\n return {\n files: filterS3ObjectsToTenant(files, activeScope, this.pathPrefix),\n truncated: response.IsTruncated ?? false,\n nextContinuationToken: response.NextContinuationToken,\n }\n }\n\n /**\n * Generate a pre-signed URL for direct browser upload or download.\n */\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn?: number,\n contentType?: string,\n contentLength?: number,\n createOnly?: boolean,\n scope?: S3TenantScope | null,\n ): Promise<string>\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn: number,\n contentType: string | undefined,\n scope?: S3TenantScope | null,\n ): Promise<string>\n async getSignedUrl(\n storagePath: string,\n operation: 'upload' | 'download',\n expiresIn = 3600,\n contentType?: string,\n contentLengthOrScope?: number | S3TenantScope | null,\n createOnly = false,\n explicitScope?: S3TenantScope | null,\n ): Promise<string> {\n const contentLength = typeof contentLengthOrScope === 'number' ? contentLengthOrScope : undefined\n const scope = typeof contentLengthOrScope === 'number' ? explicitScope : contentLengthOrScope\n if (operation === 'upload') {\n this.assertKeyScoped(storagePath, scope)\n } else {\n this.assertKeyAddressable(storagePath, scope)\n }\n await this.assertEndpointSafe()\n if (operation === 'upload') {\n const command = new PutObjectCommand({\n Bucket: this.bucket,\n Key: storagePath,\n ContentType: contentType,\n ContentLength: contentLength,\n ...(createOnly ? { IfNoneMatch: '*' } : {}),\n })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n const command = new GetObjectCommand({ Bucket: this.bucket, Key: storagePath })\n return getSignedUrl(this.client, command, { expiresIn })\n }\n\n private async assertEndpointSafe(): Promise<void> {\n await assertSafeS3Endpoint(this.endpoint)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["command"]
|
|
7
7
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomBytes } 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";
|
|
3
5
|
import {
|
|
4
6
|
assertS3KeyAddressableByTenantScope,
|
|
5
7
|
assertS3KeyScopedToTenant,
|
|
@@ -8,32 +10,89 @@ import {
|
|
|
8
10
|
function sanitizeSegment(value) {
|
|
9
11
|
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
10
12
|
}
|
|
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
|
-
}
|
|
18
13
|
function createStorageService(config) {
|
|
19
14
|
const driver = new S3StorageDriver(config);
|
|
15
|
+
const quotaService = config.quotaService;
|
|
16
|
+
const quotaRecoveryScheduler = config.quotaRecoveryScheduler;
|
|
20
17
|
const pathPrefix = config.pathPrefix;
|
|
21
18
|
return {
|
|
22
19
|
async upload({ namespace, fileName, buffer, contentType, scope }) {
|
|
23
|
-
const
|
|
24
|
-
partitionCode: namespace,
|
|
20
|
+
const storagePath = driver.prepareStoragePath({
|
|
21
|
+
partitionCode: sanitizeSegment(namespace),
|
|
25
22
|
orgId: scope.organizationId,
|
|
26
23
|
tenantId: scope.tenantId,
|
|
27
|
-
fileName
|
|
28
|
-
buffer
|
|
24
|
+
fileName
|
|
29
25
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
26
|
+
assertS3KeyScopedToTenant(storagePath, scope, pathPrefix);
|
|
27
|
+
let reservation = null;
|
|
28
|
+
let storageStarted = false;
|
|
29
|
+
let objectStored = false;
|
|
30
|
+
try {
|
|
31
|
+
if (quotaService && scope.tenantId && scope.organizationId) {
|
|
32
|
+
await reconcileTenantS3Objects({
|
|
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
|
+
}
|
|
37
96
|
},
|
|
38
97
|
async download({ key, scope }) {
|
|
39
98
|
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
|
|
@@ -41,16 +100,61 @@ function createStorageService(config) {
|
|
|
41
100
|
},
|
|
42
101
|
async delete({ key, scope }) {
|
|
43
102
|
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
|
|
44
|
-
return driver.
|
|
103
|
+
return driver.deleteStrict("", key).then(async () => {
|
|
104
|
+
if (quotaService && scope.tenantId) {
|
|
105
|
+
await quotaService.releaseCommittedByPath({
|
|
106
|
+
tenantId: scope.tenantId,
|
|
107
|
+
storageDriver: "s3",
|
|
108
|
+
storagePath: key
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
45
112
|
},
|
|
46
|
-
async getSignedUrl({ key, operation, expiresIn = 3600, contentType, scope }) {
|
|
113
|
+
async getSignedUrl({ key, operation, expiresIn = 3600, contentType, size, scope }) {
|
|
47
114
|
if (operation === "upload") {
|
|
48
115
|
assertS3KeyScopedToTenant(key, scope, pathPrefix);
|
|
49
116
|
} else {
|
|
50
117
|
assertS3KeyAddressableByTenantScope(key, scope, pathPrefix);
|
|
51
118
|
}
|
|
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
|
+
}
|
|
52
156
|
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope);
|
|
53
|
-
return { url, expiresAt
|
|
157
|
+
return { url, expiresAt };
|
|
54
158
|
},
|
|
55
159
|
async list({ prefix, maxKeys = 100, continuationToken, scope }) {
|
|
56
160
|
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 { createHash, randomBytes } from 'crypto'\nimport { S3StorageDriver } from './s3-driver'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { reconcileTenantS3Objects } from './quota-accounting'\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 size?: number\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 quotaService?: AttachmentQuotaService\n quotaRecoveryScheduler?: (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\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\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; reservationId?: string }>\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 quotaService = config.quotaService\n const quotaRecoveryScheduler = config.quotaRecoveryScheduler\n const pathPrefix = config.pathPrefix\n\n return {\n async upload({ namespace, fileName, buffer, contentType, scope }): Promise<StorageResult> {\n const storagePath = driver.prepareStoragePath({\n partitionCode: sanitizeSegment(namespace),\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n })\n assertS3KeyScopedToTenant(storagePath, scope, pathPrefix)\n let reservation = null as Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null\n let storageStarted = false\n let objectStored = false\n try {\n if (quotaService && scope.tenantId && scope.organizationId) {\n await reconcileTenantS3Objects({\n driver,\n quotaService,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n reservation = await quotaService.reserve({\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n bytes: buffer.length,\n source: 'storage_service',\n storageDriver: 's3',\n storagePath,\n partitionCode: namespace,\n })\n }\n if (reservation) {\n if (!quotaRecoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await quotaRecoveryScheduler({\n reservationId: reservation.id,\n tenantId: scope.tenantId!,\n organizationId: scope.organizationId!,\n },\n Math.max(1_000, reservation.expiresAt.getTime() - Date.now()),\n )\n await quotaService!.beginStorage(reservation.id, reservation.leaseToken)\n storageStarted = true\n }\n const stored = await driver.store({\n partitionCode: namespace,\n orgId: scope.organizationId,\n tenantId: scope.tenantId,\n fileName,\n buffer,\n storagePath,\n })\n objectStored = true\n if (reservation) {\n await quotaService!.markStored(reservation.id, reservation.leaseToken)\n await quotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n }\n return {\n key: stored.storagePath,\n namespace,\n fileName,\n size: buffer.length,\n contentType,\n }\n } catch (error) {\n if (reservation) {\n if (objectStored) {\n try {\n await driver.deleteStrict('', storagePath)\n await quotaService!.release(reservation.id, reservation.leaseToken)\n } catch {\n // Keep the reservation counted for recovery when absence is ambiguous.\n }\n } else if (!storageStarted) {\n await quotaService!.release(reservation.id, reservation.leaseToken).catch(() => {})\n }\n }\n throw error\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.deleteStrict('', key)\n .then(async () => {\n if (quotaService && scope.tenantId) {\n await quotaService.releaseCommittedByPath({\n tenantId: scope.tenantId,\n storageDriver: 's3',\n storagePath: key,\n })\n }\n })\n },\n\n async getSignedUrl({ key, operation, expiresIn = 3600, contentType, size, scope }) {\n if (operation === 'upload') {\n assertS3KeyScopedToTenant(key, scope, pathPrefix)\n } else {\n assertS3KeyAddressableByTenantScope(key, scope, pathPrefix)\n }\n const expiresAt = new Date(Date.now() + expiresIn * 1000)\n if (operation === 'upload' && quotaService && scope.tenantId && scope.organizationId) {\n const compatibilityToken = randomBytes(32).toString('base64url')\n let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n reservation = await quotaService.reserve({\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n bytes: size ?? resolveAttachmentMaxBytes(),\n source: 'storage_s3_signed',\n storageDriver: 's3',\n storagePath: key,\n uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),\n ttlMs: expiresIn * 1000,\n })\n if (!quotaRecoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await quotaRecoveryScheduler({\n reservationId: reservation.id,\n tenantId: scope.tenantId!,\n organizationId: scope.organizationId!,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n const url = `/api/storage-providers/s3/signed-upload/${compatibilityToken}`\n return { url, expiresAt, reservationId: reservation.id }\n } catch (error) {\n if (reservation) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n }\n throw error\n }\n }\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType, scope)\n return { url, expiresAt }\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": "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",
|
|
6
|
+
"names": ["url"]
|
|
7
7
|
}
|