@open-mercato/storage-s3 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6859.1.e4727b568a
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 +14 -1
- package/dist/modules/storage_s3/api/delete/storage-providers/s3/delete.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +64 -4
- 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 +70 -5
- 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 +5 -1
- package/dist/modules/storage_s3/di.js.map +2 -2
- package/dist/modules/storage_s3/lib/endpoint-safety.js +8 -1
- package/dist/modules/storage_s3/lib/endpoint-safety.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 +25 -14
- 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 +5 -4
- 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 +4 -0
- package/src/modules/storage_s3/__tests__/storageService.test.ts +15 -0
- package/src/modules/storage_s3/__tests__/upload.test.ts +4 -1
- package/src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts +16 -1
- package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +71 -2
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +79 -6
- package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +150 -0
- package/src/modules/storage_s3/di.ts +13 -1
- package/src/modules/storage_s3/lib/endpoint-safety.ts +13 -3
- 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 +44 -10
- package/src/modules/storage_s3/lib/storage-service.ts +131 -22
- package/src/modules/storage_s3/workers/quota-recovery.ts +82 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
Found
|
|
1
|
+
Found 24 entry points
|
|
2
2
|
storage-s3 built successfully
|
|
@@ -37,7 +37,20 @@ async function DELETE(req) {
|
|
|
37
37
|
if (!driver) {
|
|
38
38
|
return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
|
|
39
39
|
}
|
|
40
|
-
await driver.delete("", parsed.data.key);
|
|
40
|
+
await (driver.deleteStrict?.("", parsed.data.key) ?? driver.delete("", parsed.data.key));
|
|
41
|
+
const { resolve } = await createRequestContainer();
|
|
42
|
+
let attachmentQuotaService = null;
|
|
43
|
+
try {
|
|
44
|
+
attachmentQuotaService = resolve("attachmentQuotaService");
|
|
45
|
+
} catch {
|
|
46
|
+
}
|
|
47
|
+
if (attachmentQuotaService) {
|
|
48
|
+
await attachmentQuotaService.releaseCommittedByPath({
|
|
49
|
+
tenantId: auth.tenantId,
|
|
50
|
+
storageDriver: "s3",
|
|
51
|
+
storagePath: parsed.data.key
|
|
52
|
+
});
|
|
53
|
+
}
|
|
41
54
|
return new NextResponse(null, { status: 204 });
|
|
42
55
|
}
|
|
43
56
|
var delete_default = DELETE;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/delete/storage-providers/s3/delete.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/delete',\n DELETE: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nexport async function DELETE(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {\n return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n await driver.delete('', parsed.data.key)\n return new NextResponse(null, { status: 204 })\n}\n\nexport default DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Delete file from S3',\n methods: {\n DELETE: {\n summary: 'Delete a file from S3 by key',\n description: 'Permanently removes the object at the given key from the configured S3 bucket.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 204, description: 'File deleted', schema: z.null() }],\n errors: [\n { status: 400, description: 'Invalid payload or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key not scoped to this tenant', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AACpC,SAAS,iCAAiC;AAC1C,SAAS,uBAAuB;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { isS3KeyAddressableByScope } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\n\nexport const metadata = {\n path: '/storage-providers/s3/delete',\n DELETE: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nexport async function DELETE(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })\n }\n\n if (!isS3KeyAddressableByScope(parsed.data.key, auth.orgId, auth.tenantId)) {\n return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n await (driver.deleteStrict?.('', parsed.data.key) ?? driver.delete('', parsed.data.key))\n const { resolve } = await createRequestContainer()\n let attachmentQuotaService: AttachmentQuotaService | null = null\n try {\n attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService | null\n } catch {\n // Backward-compatible when the attachments quota service is not registered.\n }\n if (attachmentQuotaService) {\n await attachmentQuotaService.releaseCommittedByPath({\n tenantId: auth.tenantId,\n storageDriver: 's3',\n storagePath: parsed.data.key,\n })\n }\n return new NextResponse(null, { status: 204 })\n}\n\nexport default DELETE\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Delete file from S3',\n methods: {\n DELETE: {\n summary: 'Delete a file from S3 by key',\n description: 'Permanently removes the object at the given key from the configured S3 bucket.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 204, description: 'File deleted', schema: z.null() }],\n errors: [\n { status: 400, description: 'Invalid payload or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key not scoped to this tenant', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -5,6 +5,9 @@ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
|
5
5
|
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
6
6
|
import { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from "../../../../lib/key-scope.js";
|
|
7
7
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { resolveAttachmentMaxBytes } from "@open-mercato/core/modules/attachments/lib/upload-limits";
|
|
10
|
+
import { reconcileTenantS3Objects } from "../../../../lib/quota-accounting.js";
|
|
8
11
|
const metadata = {
|
|
9
12
|
path: "/storage-providers/s3/signed-url",
|
|
10
13
|
POST: { requireAuth: true, requireFeatures: ["storage_providers.manage"] }
|
|
@@ -13,11 +16,13 @@ const requestSchema = z.object({
|
|
|
13
16
|
key: z.string().min(1),
|
|
14
17
|
operation: z.enum(["upload", "download"]),
|
|
15
18
|
expiresIn: z.number().int().min(60).max(604800).optional().default(3600),
|
|
16
|
-
contentType: z.string().optional()
|
|
19
|
+
contentType: z.string().optional(),
|
|
20
|
+
size: z.number().int().nonnegative().optional()
|
|
17
21
|
});
|
|
18
22
|
const responseSchema = z.object({
|
|
19
23
|
url: z.string(),
|
|
20
|
-
expiresAt: z.string()
|
|
24
|
+
expiresAt: z.string(),
|
|
25
|
+
reservationId: z.string().optional()
|
|
21
26
|
});
|
|
22
27
|
async function resolveDriver(tenantId, orgId) {
|
|
23
28
|
const { resolve } = await createRequestContainer();
|
|
@@ -37,7 +42,7 @@ async function POST(req) {
|
|
|
37
42
|
if (!parsed.success) {
|
|
38
43
|
return NextResponse.json({ error: t("storage_s3.errors.invalidPayload", "Invalid payload") }, { status: 400 });
|
|
39
44
|
}
|
|
40
|
-
const { key, operation, expiresIn, contentType } = parsed.data;
|
|
45
|
+
const { key, operation, expiresIn, contentType, size } = parsed.data;
|
|
41
46
|
const canAccessKey = operation === "download" ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId) : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId);
|
|
42
47
|
if (!canAccessKey) {
|
|
43
48
|
return NextResponse.json({ error: t("storage_s3.errors.keyAccessDenied", "Access denied: key is not scoped to this tenant.") }, { status: 403 });
|
|
@@ -46,8 +51,63 @@ async function POST(req) {
|
|
|
46
51
|
if (!driver) {
|
|
47
52
|
return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
|
|
48
53
|
}
|
|
49
|
-
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType);
|
|
50
54
|
const expiresAt = new Date(Date.now() + expiresIn * 1e3).toISOString();
|
|
55
|
+
if (operation === "upload") {
|
|
56
|
+
const { resolve } = await createRequestContainer();
|
|
57
|
+
let attachmentQuotaService = null;
|
|
58
|
+
let recoveryScheduler = null;
|
|
59
|
+
try {
|
|
60
|
+
attachmentQuotaService = resolve("attachmentQuotaService");
|
|
61
|
+
recoveryScheduler = resolve("storageS3QuotaRecoveryScheduler");
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
if (!attachmentQuotaService) {
|
|
65
|
+
return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
66
|
+
}
|
|
67
|
+
const compatibilityToken = randomBytes(32).toString("base64url");
|
|
68
|
+
const reservedBytes = size ?? resolveAttachmentMaxBytes();
|
|
69
|
+
let reservation = null;
|
|
70
|
+
try {
|
|
71
|
+
await reconcileTenantS3Objects({
|
|
72
|
+
driver,
|
|
73
|
+
quotaService: attachmentQuotaService,
|
|
74
|
+
tenantId: auth.tenantId,
|
|
75
|
+
organizationId: auth.orgId
|
|
76
|
+
});
|
|
77
|
+
reservation = await attachmentQuotaService.reserve({
|
|
78
|
+
tenantId: auth.tenantId,
|
|
79
|
+
organizationId: auth.orgId,
|
|
80
|
+
bytes: reservedBytes,
|
|
81
|
+
source: "storage_s3_signed",
|
|
82
|
+
storageDriver: "s3",
|
|
83
|
+
storagePath: key,
|
|
84
|
+
uploadTokenHash: createHash("sha256").update(compatibilityToken).digest("hex"),
|
|
85
|
+
ttlMs: expiresIn * 1e3
|
|
86
|
+
});
|
|
87
|
+
if (!recoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
|
|
88
|
+
await recoveryScheduler({
|
|
89
|
+
reservationId: reservation.id,
|
|
90
|
+
tenantId: auth.tenantId,
|
|
91
|
+
organizationId: auth.orgId
|
|
92
|
+
}, Math.max(1e3, reservation.expiresAt.getTime() - Date.now()));
|
|
93
|
+
const url2 = new URL(`/api/storage-providers/s3/signed-upload/${compatibilityToken}`, req.url).toString();
|
|
94
|
+
return NextResponse.json({ url: url2, expiresAt, reservationId: reservation.id });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (reservation) {
|
|
97
|
+
await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const code = error?.code;
|
|
101
|
+
if (code === "quota_exceeded") {
|
|
102
|
+
return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
|
|
103
|
+
}
|
|
104
|
+
if (code === "quota_target_exists") {
|
|
105
|
+
return NextResponse.json({ error: "The target storage key already exists." }, { status: 409 });
|
|
106
|
+
}
|
|
107
|
+
return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType);
|
|
51
111
|
return NextResponse.json({ url, expiresAt });
|
|
52
112
|
}
|
|
53
113
|
var signed_url_default = POST;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-url',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n operation: z.enum(['upload', 'download']),\n expiresIn: z.number().int().min(60).max(604800).optional().default(3600),\n contentType: z.string().optional(),\n})\n\nconst responseSchema = z.object({\n url: z.string(),\n expiresAt: z.string(),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })\n }\n\n const { key, operation, expiresIn, contentType } = parsed.data\n const canAccessKey = operation === 'download'\n ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)\n : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)\n if (!canAccessKey) {\n return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AACpC,SAAS,2BAA2B,6BAA6B;AACjE,SAAS,uBAAuB;
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { isS3KeyAddressableByScope, isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport { createHash, randomBytes } from 'node:crypto'\nimport { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-url',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst requestSchema = z.object({\n key: z.string().min(1),\n operation: z.enum(['upload', 'download']),\n expiresIn: z.number().int().min(60).max(604800).optional().default(3600),\n contentType: z.string().optional(),\n size: z.number().int().nonnegative().optional(),\n})\n\nconst responseSchema = z.object({\n url: z.string(),\n expiresAt: z.string(),\n reservationId: z.string().optional(),\n})\n\nasync function resolveDriver(tenantId: string, orgId: string): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const json = await req.json().catch(() => null)\n const parsed = requestSchema.safeParse(json)\n if (!parsed.success) {\n return NextResponse.json({ error: t('storage_s3.errors.invalidPayload', 'Invalid payload') }, { status: 400 })\n }\n\n const { key, operation, expiresIn, contentType, size } = parsed.data\n const canAccessKey = operation === 'download'\n ? isS3KeyAddressableByScope(key, auth.orgId, auth.tenantId)\n : isS3KeyScopedToTenant(key, auth.orgId, auth.tenantId)\n if (!canAccessKey) {\n return NextResponse.json({ error: t('storage_s3.errors.keyAccessDenied', 'Access denied: key is not scoped to this tenant.') }, { status: 403 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString()\n\n if (operation === 'upload') {\n const { resolve } = await createRequestContainer()\n let attachmentQuotaService: AttachmentQuotaService | null = null\n let recoveryScheduler: ((\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>) | null = null\n try {\n attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n } catch {\n // Fail closed below when the attachments quota service is not registered.\n }\n if (!attachmentQuotaService) {\n return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })\n }\n const compatibilityToken = randomBytes(32).toString('base64url')\n const reservedBytes = size ?? resolveAttachmentMaxBytes()\n let reservation: Awaited<ReturnType<AttachmentQuotaService['reserve']>> | null = null\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService: attachmentQuotaService,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n reservation = await attachmentQuotaService.reserve({\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n bytes: reservedBytes,\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 (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await recoveryScheduler({\n reservationId: reservation.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n const url = new URL(`/api/storage-providers/s3/signed-upload/${compatibilityToken}`, req.url).toString()\n return NextResponse.json({ url, expiresAt, reservationId: reservation.id })\n } catch (error) {\n if (reservation) {\n await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n }\n const code = (error as { code?: unknown })?.code\n if (code === 'quota_exceeded') {\n return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })\n }\n if (code === 'quota_target_exists') {\n return NextResponse.json({ error: 'The target storage key already exists.' }, { status: 409 })\n }\n return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })\n }\n }\n\n const url = await driver.getSignedUrl(key, operation, expiresIn, contentType)\n\n return NextResponse.json({ url, expiresAt })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Generate S3 pre-signed URL',\n methods: {\n POST: {\n summary: 'Generate a pre-signed URL for direct browser upload or download',\n description: 'Returns a time-limited URL that allows a browser to directly upload or download a file from S3.',\n requestBody: { contentType: 'application/json', schema: requestSchema },\n responses: [{ status: 200, description: 'Pre-signed URL and expiry', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Invalid payload or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key not scoped to this tenant', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["url"]
|
|
7
7
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "@open-mercato/core/modules/attachments/lib/upload-limits";
|
|
18
18
|
import { isS3KeyScopedToTenant } from "../../../../lib/key-scope.js";
|
|
19
19
|
import { S3StorageDriver } from "../../../../lib/s3-driver.js";
|
|
20
|
+
import { reconcileTenantS3Objects } from "../../../../lib/quota-accounting.js";
|
|
20
21
|
import { randomUUID } from "crypto";
|
|
21
22
|
const metadata = {
|
|
22
23
|
path: "/storage-providers/s3/upload",
|
|
@@ -102,12 +103,76 @@ async function POST(req) {
|
|
|
102
103
|
if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {
|
|
103
104
|
return NextResponse.json({ error: t("storage_s3.errors.activeContentBlocked", "Active content uploads are not allowed.") }, { status: 400 });
|
|
104
105
|
}
|
|
105
|
-
const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId);
|
|
106
|
-
if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
|
|
107
|
-
return NextResponse.json({ error: t("storage_s3.errors.quotaExceeded", "Attachment storage quota exceeded for this tenant.") }, { status: 413 });
|
|
108
|
-
}
|
|
109
106
|
const key = keyOverride ?? `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
|
|
110
|
-
await
|
|
107
|
+
const { resolve } = await createRequestContainer();
|
|
108
|
+
let attachmentQuotaService = null;
|
|
109
|
+
let recoveryScheduler = null;
|
|
110
|
+
try {
|
|
111
|
+
attachmentQuotaService = resolve("attachmentQuotaService");
|
|
112
|
+
recoveryScheduler = resolve("storageS3QuotaRecoveryScheduler");
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
115
|
+
let reservation = null;
|
|
116
|
+
if (attachmentQuotaService) {
|
|
117
|
+
try {
|
|
118
|
+
await reconcileTenantS3Objects({
|
|
119
|
+
driver,
|
|
120
|
+
quotaService: attachmentQuotaService,
|
|
121
|
+
tenantId: auth.tenantId,
|
|
122
|
+
organizationId: auth.orgId
|
|
123
|
+
});
|
|
124
|
+
reservation = await attachmentQuotaService.reserve({
|
|
125
|
+
tenantId: auth.tenantId,
|
|
126
|
+
organizationId: auth.orgId,
|
|
127
|
+
bytes: buffer.length,
|
|
128
|
+
source: "storage_s3_upload",
|
|
129
|
+
storageDriver: "s3",
|
|
130
|
+
storagePath: key
|
|
131
|
+
});
|
|
132
|
+
if (!recoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
|
|
133
|
+
await recoveryScheduler({
|
|
134
|
+
reservationId: reservation.id,
|
|
135
|
+
tenantId: auth.tenantId,
|
|
136
|
+
organizationId: auth.orgId
|
|
137
|
+
}, Math.max(1e3, reservation.expiresAt.getTime() - Date.now()));
|
|
138
|
+
await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (reservation) {
|
|
141
|
+
await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
142
|
+
});
|
|
143
|
+
reservation = null;
|
|
144
|
+
}
|
|
145
|
+
const code = error?.code;
|
|
146
|
+
if (code === "quota_exceeded") {
|
|
147
|
+
return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
|
|
148
|
+
}
|
|
149
|
+
if (code === "quota_target_exists") {
|
|
150
|
+
return NextResponse.json({ error: "The target storage key already exists." }, { status: 409 });
|
|
151
|
+
}
|
|
152
|
+
return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId);
|
|
156
|
+
if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {
|
|
157
|
+
return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await driver.putObject(key, buffer, trustedMimeType);
|
|
162
|
+
if (reservation) {
|
|
163
|
+
await attachmentQuotaService.markStored(reservation.id, reservation.leaseToken);
|
|
164
|
+
await attachmentQuotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length);
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (reservation) {
|
|
168
|
+
try {
|
|
169
|
+
await driver.deleteStrict("", key);
|
|
170
|
+
await attachmentQuotaService.release(reservation.id, reservation.leaseToken);
|
|
171
|
+
} catch {
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return NextResponse.json({ error: "Failed to persist attachment." }, { status: 500 });
|
|
175
|
+
}
|
|
111
176
|
return NextResponse.json({
|
|
112
177
|
key,
|
|
113
178
|
bucket: driver.getBucket(),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/storage_s3/api/post/storage-providers/s3/upload.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport {\n isMultipartUploadLimitError,\n isMultipartRequestWithinUploadLimit,\n parseMultipartFormDataWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport { randomUUID } from 'crypto'\n\nexport const metadata = {\n path: '/storage-providers/s3/upload',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst responseSchema = z.object({\n key: z.string(),\n bucket: z.string(),\n size: z.number().int(),\n contentType: z.string().optional(),\n})\n\nfunction sanitizeFileName(name: string): string {\n return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'\n}\n\nasync function resolveDriver(\n tenantId: string,\n orgId: string,\n): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nasync function readTenantStorageUsageBytes(\n driver: S3StorageDriver,\n tenantId: string,\n orgId: string,\n): Promise<number> {\n let totalBytes = 0\n let continuationToken: string | undefined\n\n do {\n const page = await driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {\n totalBytes += file.size\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n return totalBytes\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: t('storage_s3.errors.multipartRequired', 'Expected multipart/form-data') }, { status: 400 })\n }\n\n let form: FormData\n try {\n form = await parseMultipartFormDataWithinUploadLimit(req)\n } catch (error) {\n if (isMultipartUploadLimitError(error)) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n throw error\n }\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: t('storage_s3.errors.fileRequired', 'file field is required') }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n\n if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {\n return NextResponse.json(\n { error: t('storage_s3.errors.keyOverrideAccessDenied', 'Access denied: key override is not scoped to this tenant.') },\n { status: 403 },\n )\n }\n\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })\n }\n\n const effectiveMaxBytes = resolveAttachmentMaxBytes()\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const buffer = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeFileName(file.name)\n const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)\n if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {\n return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })\n }\n\n const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {\n return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n\n const key =\n keyOverride ??\n `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`\n\n await driver.putObject(key, buffer, trustedMimeType)\n\n return NextResponse.json({\n key,\n bucket: driver.getBucket(),\n size: buffer.length,\n contentType: trustedMimeType,\n })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload file to S3',\n methods: {\n POST: {\n summary: 'Upload a file directly to S3',\n description: 'Uploads a file to the configured S3 bucket. Requires storage_providers.manage feature.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: z.object({\n file: z.any().describe('File to upload'),\n key: z.string().optional().describe('Optional S3 key override (must be scoped to org/tenant)'),\n contentType: z.string().optional().describe('Optional client-provided content-type hint; the server derives the trusted MIME type.'),\n }),\n },\n responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Missing file, blocked file type, or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key override not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload too large or tenant storage quota exceeded', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AACpC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,6BAA6B;AACtC,SAAS,uBAAuB;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport {\n isMultipartUploadLimitError,\n isMultipartRequestWithinUploadLimit,\n parseMultipartFormDataWithinUploadLimit,\n resolveAttachmentMaxBytes,\n willExceedAttachmentTenantQuota,\n} from '@open-mercato/core/modules/attachments/lib/upload-limits'\nimport { isS3KeyScopedToTenant } from '../../../../lib/key-scope'\nimport { S3StorageDriver } from '../../../../lib/s3-driver'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { reconcileTenantS3Objects } from '../../../../lib/quota-accounting'\nimport { randomUUID } from 'crypto'\n\nexport const metadata = {\n path: '/storage-providers/s3/upload',\n POST: { requireAuth: true, requireFeatures: ['storage_providers.manage'] },\n}\n\nconst responseSchema = z.object({\n key: z.string(),\n bucket: z.string(),\n size: z.number().int(),\n contentType: z.string().optional(),\n})\n\nfunction sanitizeFileName(name: string): string {\n return name.replace(/[^a-zA-Z0-9._-]/g, '_') || 'upload'\n}\n\nasync function resolveDriver(\n tenantId: string,\n orgId: string,\n): Promise<S3StorageDriver | null> {\n const { resolve } = await createRequestContainer()\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const creds = await credentialsService.resolve('storage_s3', { tenantId, organizationId: orgId })\n if (!creds) return null\n return new S3StorageDriver({ ...creds, organizationId: orgId, tenantId })\n}\n\nasync function readTenantStorageUsageBytes(\n driver: S3StorageDriver,\n tenantId: string,\n orgId: string,\n): Promise<number> {\n let totalBytes = 0\n let continuationToken: string | undefined\n\n do {\n const page = await driver.listObjects('', 1000, continuationToken)\n for (const file of page.files) {\n if (isS3KeyScopedToTenant(file.key, orgId, tenantId)) {\n totalBytes += file.size\n }\n }\n continuationToken = page.truncated ? page.nextContinuationToken : undefined\n } while (continuationToken)\n\n return totalBytes\n}\n\nexport async function POST(req: Request) {\n const { t } = await resolveTranslations()\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: t('storage_s3.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const contentType = req.headers.get('content-type') ?? ''\n if (!contentType.includes('multipart/form-data')) {\n return NextResponse.json({ error: t('storage_s3.errors.multipartRequired', 'Expected multipart/form-data') }, { status: 400 })\n }\n\n let form: FormData\n try {\n form = await parseMultipartFormDataWithinUploadLimit(req)\n } catch (error) {\n if (isMultipartUploadLimitError(error)) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n throw error\n }\n const file = form.get('file') as File | null\n if (!file) {\n return NextResponse.json({ error: t('storage_s3.errors.fileRequired', 'file field is required') }, { status: 400 })\n }\n\n const keyOverride = form.get('key') ? String(form.get('key')) : null\n\n if (keyOverride !== null && !isS3KeyScopedToTenant(keyOverride, auth.orgId, auth.tenantId)) {\n return NextResponse.json(\n { error: t('storage_s3.errors.keyOverrideAccessDenied', 'Access denied: key override is not scoped to this tenant.') },\n { status: 403 },\n )\n }\n\n if (hasDangerousExecutableExtension(file.name)) {\n return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })\n }\n\n const effectiveMaxBytes = resolveAttachmentMaxBytes()\n if (file.size > effectiveMaxBytes) {\n return NextResponse.json({ error: t('storage_s3.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.') }, { status: 413 })\n }\n\n const driver = await resolveDriver(auth.tenantId, auth.orgId)\n if (!driver) {\n return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })\n }\n\n const buffer = Buffer.from(await file.arrayBuffer())\n const safeName = sanitizeFileName(file.name)\n const trustedMimeType = detectAttachmentMimeType(buffer, safeName, file.type || null)\n if (isActiveContentAttachment(buffer, safeName, trustedMimeType)) {\n return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })\n }\n\n const key =\n keyOverride ??\n `uploads/org_${auth.orgId}/tenant_${auth.tenantId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`\n\n const { resolve } = await createRequestContainer()\n let attachmentQuotaService: AttachmentQuotaService | null = null\n let recoveryScheduler: ((\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>) | null = null\n try {\n attachmentQuotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n recoveryScheduler = resolve('storageS3QuotaRecoveryScheduler') as (\n payload: { reservationId: string; tenantId: string; organizationId: string },\n delayMs: number,\n ) => Promise<void>\n } catch {\n // Backward-compatible fallback below when the attachments quota service is not registered.\n }\n let reservation: { id: string; leaseToken: string; expiresAt: Date } | null = null\n if (attachmentQuotaService) {\n try {\n await reconcileTenantS3Objects({\n driver,\n quotaService: attachmentQuotaService,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n reservation = await attachmentQuotaService.reserve({\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n bytes: buffer.length,\n source: 'storage_s3_upload',\n storageDriver: 's3',\n storagePath: key,\n })\n if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')\n await recoveryScheduler({\n reservationId: reservation.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n }, Math.max(1_000, reservation.expiresAt.getTime() - Date.now()))\n await attachmentQuotaService.beginStorage(reservation.id, reservation.leaseToken)\n } catch (error) {\n if (reservation) {\n await attachmentQuotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n reservation = null\n }\n const code = (error as { code?: unknown })?.code\n if (code === 'quota_exceeded') {\n return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })\n }\n if (code === 'quota_target_exists') {\n return NextResponse.json({ error: 'The target storage key already exists.' }, { status: 409 })\n }\n return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })\n }\n } else {\n const tenantUsageBytes = await readTenantStorageUsageBytes(driver, auth.tenantId, auth.orgId)\n if (willExceedAttachmentTenantQuota(tenantUsageBytes, buffer.length)) {\n return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })\n }\n }\n\n try {\n await driver.putObject(key, buffer, trustedMimeType)\n if (reservation) {\n await attachmentQuotaService!.markStored(reservation.id, reservation.leaseToken)\n await attachmentQuotaService!.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n }\n } catch (error) {\n if (reservation) {\n try {\n await driver.deleteStrict('', key)\n await attachmentQuotaService!.release(reservation.id, reservation.leaseToken)\n } catch {\n // Retain the reservation when object absence cannot be proven.\n }\n }\n return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })\n }\n\n return NextResponse.json({\n key,\n bucket: driver.getBucket(),\n size: buffer.length,\n contentType: trustedMimeType,\n })\n}\n\nexport default POST\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload file to S3',\n methods: {\n POST: {\n summary: 'Upload a file directly to S3',\n description: 'Uploads a file to the configured S3 bucket. Requires storage_providers.manage feature.',\n requestBody: {\n contentType: 'multipart/form-data',\n schema: z.object({\n file: z.any().describe('File to upload'),\n key: z.string().optional().describe('Optional S3 key override (must be scoped to org/tenant)'),\n contentType: z.string().optional().describe('Optional client-provided content-type hint; the server derives the trusted MIME type.'),\n }),\n },\n responses: [{ status: 200, description: 'Upload result', schema: responseSchema }],\n errors: [\n { status: 400, description: 'Missing file, blocked file type, or S3 not configured', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Key override not scoped to this tenant', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload too large or tenant storage quota exceeded', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
6
|
+
import {
|
|
7
|
+
detectAttachmentMimeType,
|
|
8
|
+
hasDangerousExecutableExtension,
|
|
9
|
+
isActiveContentAttachment
|
|
10
|
+
} from "@open-mercato/core/modules/attachments/lib/security";
|
|
11
|
+
import { S3StorageDriver } from "../../../../../lib/s3-driver.js";
|
|
12
|
+
const metadata = {
|
|
13
|
+
path: "/storage-providers/s3/signed-upload/:token",
|
|
14
|
+
PUT: { requireAuth: false }
|
|
15
|
+
};
|
|
16
|
+
class UploadBodyTooLargeError extends Error {
|
|
17
|
+
}
|
|
18
|
+
async function readBoundedBody(req, maxBytes) {
|
|
19
|
+
if (!req.body) return Buffer.alloc(0);
|
|
20
|
+
const reader = req.body.getReader();
|
|
21
|
+
const chunks = [];
|
|
22
|
+
let totalBytes = 0;
|
|
23
|
+
try {
|
|
24
|
+
while (true) {
|
|
25
|
+
const { done, value } = await reader.read();
|
|
26
|
+
if (done) break;
|
|
27
|
+
totalBytes += value.byteLength;
|
|
28
|
+
if (totalBytes > maxBytes) {
|
|
29
|
+
await reader.cancel();
|
|
30
|
+
throw new UploadBodyTooLargeError("Attachment exceeds the reserved upload size.");
|
|
31
|
+
}
|
|
32
|
+
chunks.push(Buffer.from(value));
|
|
33
|
+
}
|
|
34
|
+
} finally {
|
|
35
|
+
reader.releaseLock();
|
|
36
|
+
}
|
|
37
|
+
return Buffer.concat(chunks, totalBytes);
|
|
38
|
+
}
|
|
39
|
+
async function PUT(req, ctx) {
|
|
40
|
+
const params = ctx.params instanceof Promise ? await ctx.params : ctx.params;
|
|
41
|
+
const token = params?.token?.trim();
|
|
42
|
+
if (!token) return NextResponse.json({ error: "Upload token is required." }, { status: 400 });
|
|
43
|
+
const { resolve } = await createRequestContainer();
|
|
44
|
+
let quotaService = null;
|
|
45
|
+
try {
|
|
46
|
+
quotaService = resolve("attachmentQuotaService");
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
if (!quotaService) return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
50
|
+
const tokenHash = createHash("sha256").update(token).digest("hex");
|
|
51
|
+
const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash);
|
|
52
|
+
if (!reservation || reservation.source !== "storage_s3_signed") {
|
|
53
|
+
return NextResponse.json({ error: "Upload token is invalid or expired." }, { status: 410 });
|
|
54
|
+
}
|
|
55
|
+
const declaredLengthHeader = req.headers.get("content-length");
|
|
56
|
+
const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader);
|
|
57
|
+
if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {
|
|
58
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
59
|
+
});
|
|
60
|
+
return NextResponse.json({ error: "Attachment exceeds the reserved upload size." }, { status: 413 });
|
|
61
|
+
}
|
|
62
|
+
let buffer;
|
|
63
|
+
try {
|
|
64
|
+
buffer = await readBoundedBody(req, reservation.reservedBytes);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
67
|
+
});
|
|
68
|
+
if (error instanceof UploadBodyTooLargeError) {
|
|
69
|
+
return NextResponse.json({ error: error.message }, { status: 413 });
|
|
70
|
+
}
|
|
71
|
+
return NextResponse.json({ error: "Failed to read attachment." }, { status: 400 });
|
|
72
|
+
}
|
|
73
|
+
if (buffer.length > reservation.reservedBytes) {
|
|
74
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
75
|
+
});
|
|
76
|
+
return NextResponse.json({ error: "Attachment exceeds the reserved upload size." }, { status: 413 });
|
|
77
|
+
}
|
|
78
|
+
const fileName = path.posix.basename(reservation.storagePath);
|
|
79
|
+
if (hasDangerousExecutableExtension(fileName)) {
|
|
80
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
81
|
+
});
|
|
82
|
+
return NextResponse.json({ error: "Executable file types are not allowed as attachments." }, { status: 400 });
|
|
83
|
+
}
|
|
84
|
+
const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get("content-type"));
|
|
85
|
+
if (isActiveContentAttachment(buffer, fileName, contentType)) {
|
|
86
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
87
|
+
});
|
|
88
|
+
return NextResponse.json({ error: "Active content uploads are not allowed." }, { status: 400 });
|
|
89
|
+
}
|
|
90
|
+
const credentialsService = resolve("integrationCredentialsService");
|
|
91
|
+
const credentials = await credentialsService.resolve("storage_s3", {
|
|
92
|
+
tenantId: reservation.tenantId,
|
|
93
|
+
organizationId: reservation.organizationId
|
|
94
|
+
});
|
|
95
|
+
if (!credentials) {
|
|
96
|
+
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
97
|
+
});
|
|
98
|
+
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
99
|
+
}
|
|
100
|
+
const driver = new S3StorageDriver({
|
|
101
|
+
...credentials,
|
|
102
|
+
organizationId: reservation.organizationId,
|
|
103
|
+
tenantId: reservation.tenantId
|
|
104
|
+
});
|
|
105
|
+
let objectStored = false;
|
|
106
|
+
try {
|
|
107
|
+
await driver.putObject(reservation.storagePath, buffer, contentType);
|
|
108
|
+
objectStored = true;
|
|
109
|
+
await quotaService.markStored(reservation.id, reservation.leaseToken);
|
|
110
|
+
await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length);
|
|
111
|
+
return new NextResponse(null, { status: 200 });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (objectStored) {
|
|
114
|
+
try {
|
|
115
|
+
await driver.deleteStrict("", reservation.storagePath);
|
|
116
|
+
await quotaService.release(reservation.id, reservation.leaseToken);
|
|
117
|
+
} catch {
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return NextResponse.json({ error: "Failed to persist attachment." }, { status: 500 });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
var token_default = PUT;
|
|
124
|
+
const openApi = {
|
|
125
|
+
tag: "Storage",
|
|
126
|
+
summary: "Upload through a bounded one-time compatibility URL",
|
|
127
|
+
methods: {
|
|
128
|
+
PUT: {
|
|
129
|
+
summary: "Upload an object using a bounded one-time token",
|
|
130
|
+
responses: [{ status: 200, description: "Upload completed", schema: z.any() }],
|
|
131
|
+
errors: [
|
|
132
|
+
{ status: 400, description: "Invalid upload", schema: z.object({ error: z.string() }) },
|
|
133
|
+
{ status: 410, description: "Token invalid or expired", schema: z.object({ error: z.string() }) },
|
|
134
|
+
{ status: 413, description: "Upload exceeds reserved size", schema: z.object({ error: z.string() }) }
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
export {
|
|
140
|
+
PUT,
|
|
141
|
+
token_default as default,
|
|
142
|
+
metadata,
|
|
143
|
+
openApi
|
|
144
|
+
};
|
|
145
|
+
//# sourceMappingURL=%5Btoken%5D.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../../../../src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/%5Btoken%5D.ts"],
|
|
4
|
+
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport path from 'node:path'\nimport { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n detectAttachmentMimeType,\n hasDangerousExecutableExtension,\n isActiveContentAttachment,\n} from '@open-mercato/core/modules/attachments/lib/security'\nimport type { AttachmentQuotaService } from '@open-mercato/core/modules/attachments/lib/quota-service'\nimport { S3StorageDriver } from '../../../../../lib/s3-driver'\n\nexport const metadata = {\n path: '/storage-providers/s3/signed-upload/:token',\n PUT: { requireAuth: false },\n}\n\ntype RouteContext = { params?: Promise<{ token?: string }> | { token?: string } }\n\nclass UploadBodyTooLargeError extends Error {}\n\nasync function readBoundedBody(req: Request, maxBytes: number): Promise<Buffer> {\n if (!req.body) return Buffer.alloc(0)\n const reader = req.body.getReader()\n const chunks: Buffer[] = []\n let totalBytes = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBytes) {\n await reader.cancel()\n throw new UploadBodyTooLargeError('Attachment exceeds the reserved upload size.')\n }\n chunks.push(Buffer.from(value))\n }\n } finally {\n reader.releaseLock()\n }\n return Buffer.concat(chunks, totalBytes)\n}\n\nexport async function PUT(req: Request, ctx: RouteContext) {\n const params = ctx.params instanceof Promise ? await ctx.params : ctx.params\n const token = params?.token?.trim()\n if (!token) return NextResponse.json({ error: 'Upload token is required.' }, { status: 400 })\n\n const { resolve } = await createRequestContainer()\n let quotaService: AttachmentQuotaService | null = null\n try {\n quotaService = resolve('attachmentQuotaService') as AttachmentQuotaService\n } catch {\n // Fail closed below when the attachments quota service is not registered.\n }\n if (!quotaService) return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })\n\n const tokenHash = createHash('sha256').update(token).digest('hex')\n const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash)\n if (!reservation || reservation.source !== 'storage_s3_signed') {\n return NextResponse.json({ error: 'Upload token is invalid or expired.' }, { status: 410 })\n }\n\n const declaredLengthHeader = req.headers.get('content-length')\n const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader)\n if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })\n }\n let buffer: Buffer\n try {\n buffer = await readBoundedBody(req, reservation.reservedBytes)\n } catch (error) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n if (error instanceof UploadBodyTooLargeError) {\n return NextResponse.json({ error: error.message }, { status: 413 })\n }\n return NextResponse.json({ error: 'Failed to read attachment.' }, { status: 400 })\n }\n if (buffer.length > reservation.reservedBytes) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })\n }\n const fileName = path.posix.basename(reservation.storagePath)\n if (hasDangerousExecutableExtension(fileName)) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })\n }\n const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get('content-type'))\n if (isActiveContentAttachment(buffer, fileName, contentType)) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })\n }\n\n const credentialsService = resolve('integrationCredentialsService') as {\n resolve(integrationId: string, scope: { tenantId: string; organizationId: string }): Promise<Record<string, unknown> | null>\n }\n const credentials = await credentialsService.resolve('storage_s3', {\n tenantId: reservation.tenantId,\n organizationId: reservation.organizationId,\n })\n if (!credentials) {\n await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})\n return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })\n }\n\n const driver = new S3StorageDriver({\n ...credentials,\n organizationId: reservation.organizationId,\n tenantId: reservation.tenantId,\n })\n let objectStored = false\n try {\n await driver.putObject(reservation.storagePath, buffer, contentType)\n objectStored = true\n await quotaService.markStored(reservation.id, reservation.leaseToken)\n await quotaService.completeStandalone(reservation.id, reservation.leaseToken, buffer.length)\n return new NextResponse(null, { status: 200 })\n } catch (error) {\n if (objectStored) {\n try {\n await driver.deleteStrict('', reservation.storagePath)\n await quotaService.release(reservation.id, reservation.leaseToken)\n } catch {\n // Retain the reservation when object absence cannot be proven.\n }\n }\n return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })\n }\n}\n\nexport default PUT\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Storage',\n summary: 'Upload through a bounded one-time compatibility URL',\n methods: {\n PUT: {\n summary: 'Upload an object using a bounded one-time token',\n responses: [{ status: 200, description: 'Upload completed', schema: z.any() }],\n errors: [\n { status: 400, description: 'Invalid upload', schema: z.object({ error: z.string() }) },\n { status: 410, description: 'Token invalid or expired', schema: z.object({ error: z.string() }) },\n { status: 413, description: 'Upload exceeds reserved size', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -7,6 +7,7 @@ import { S3StorageDriver } from "./lib/s3-driver.js";
|
|
|
7
7
|
import { createStorageService } from "./lib/storage-service.js";
|
|
8
8
|
import { s3HealthCheck } from "./lib/health.js";
|
|
9
9
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
10
|
+
import { scheduleStorageS3QuotaRecovery } from "./lib/quota-recovery-queue.js";
|
|
10
11
|
const logger = createLogger("storage_s3");
|
|
11
12
|
registerExternalStorageDriver("s3", (config) => {
|
|
12
13
|
logger.debug("Creating S3StorageDriver", {
|
|
@@ -54,8 +55,9 @@ function register(container) {
|
|
|
54
55
|
});
|
|
55
56
|
container.register({
|
|
56
57
|
s3HealthCheck: asValue(s3HealthCheck),
|
|
58
|
+
storageS3QuotaRecoveryScheduler: asValue(scheduleStorageS3QuotaRecovery),
|
|
57
59
|
storageService: asFunction(
|
|
58
|
-
({ integrationCredentialsService }) => {
|
|
60
|
+
({ integrationCredentialsService, attachmentQuotaService, storageS3QuotaRecoveryScheduler }) => {
|
|
59
61
|
return {
|
|
60
62
|
async _resolveService(scope) {
|
|
61
63
|
const creds = await integrationCredentialsService.resolve("storage_s3", scope);
|
|
@@ -69,6 +71,8 @@ function register(container) {
|
|
|
69
71
|
accessKeyId: creds.accessKeyId ? String(creds.accessKeyId) : void 0,
|
|
70
72
|
secretAccessKey: creds.secretAccessKey ? String(creds.secretAccessKey) : void 0,
|
|
71
73
|
sessionToken: creds.sessionToken ? String(creds.sessionToken) : void 0,
|
|
74
|
+
quotaService: attachmentQuotaService,
|
|
75
|
+
quotaRecoveryScheduler: storageS3QuotaRecoveryScheduler,
|
|
72
76
|
organizationId: scope.organizationId,
|
|
73
77
|
tenantId: scope.tenantId
|
|
74
78
|
// Credentials are resolved from the Integration Marketplace (encrypted at rest)
|