@open-mercato/storage-s3 0.6.8-develop.6874.1.982d6097d8 → 0.6.8-develop.6876.1.c514f2eb37
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/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js +5 -5
- package/dist/modules/storage_s3/api/post/storage-providers/s3/signed-url.js.map +2 -2
- package/dist/modules/storage_s3/api/post/storage-providers/s3/upload.js +2 -2
- 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 +14 -12
- package/dist/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].js.map +2 -2
- package/package.json +5 -5
- package/src/modules/storage_s3/__tests__/i18nPackageExports.test.ts +1 -1
- package/src/modules/storage_s3/__tests__/routeErrorLocalization.test.ts +111 -0
- package/src/modules/storage_s3/__tests__/s3Routes.test.ts +79 -0
- package/src/modules/storage_s3/__tests__/upload.test.ts +73 -0
- package/src/modules/storage_s3/api/post/storage-providers/s3/signed-url.ts +5 -5
- package/src/modules/storage_s3/api/post/storage-providers/s3/upload.ts +2 -2
- package/src/modules/storage_s3/api/put/storage-providers/s3/signed-upload/[token].ts +14 -12
- package/src/modules/storage_s3/i18n/de.json +6 -1
- package/src/modules/storage_s3/i18n/en.json +6 -1
- package/src/modules/storage_s3/i18n/es.json +6 -1
- package/src/modules/storage_s3/i18n/ko.json +6 -1
- package/src/modules/storage_s3/i18n/pl.json +6 -1
|
@@ -62,7 +62,7 @@ async function POST(req) {
|
|
|
62
62
|
} catch {
|
|
63
63
|
}
|
|
64
64
|
if (!attachmentQuotaService) {
|
|
65
|
-
return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
65
|
+
return NextResponse.json({ error: t("storage_s3.errors.quotaUnavailable", "Storage quota accounting is unavailable.") }, { status: 500 });
|
|
66
66
|
}
|
|
67
67
|
const compatibilityToken = randomBytes(32).toString("base64url");
|
|
68
68
|
const reservedBytes = size ?? resolveAttachmentMaxBytes();
|
|
@@ -84,7 +84,7 @@ async function POST(req) {
|
|
|
84
84
|
uploadTokenHash: createHash("sha256").update(compatibilityToken).digest("hex"),
|
|
85
85
|
ttlMs: expiresIn * 1e3
|
|
86
86
|
});
|
|
87
|
-
if (!recoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
|
|
87
|
+
if (!recoveryScheduler) throw new Error("[internal] Storage quota recovery is unavailable.");
|
|
88
88
|
await recoveryScheduler({
|
|
89
89
|
reservationId: reservation.id,
|
|
90
90
|
tenantId: auth.tenantId,
|
|
@@ -99,12 +99,12 @@ async function POST(req) {
|
|
|
99
99
|
}
|
|
100
100
|
const code = error?.code;
|
|
101
101
|
if (code === "quota_exceeded") {
|
|
102
|
-
return NextResponse.json({ error: "Attachment storage quota exceeded for this tenant." }, { status: 413 });
|
|
102
|
+
return NextResponse.json({ error: t("storage_s3.errors.quotaExceeded", "Attachment storage quota exceeded for this tenant.") }, { status: 413 });
|
|
103
103
|
}
|
|
104
104
|
if (code === "quota_target_exists") {
|
|
105
|
-
return NextResponse.json({ error: "The target storage key already exists." }, { status: 409 });
|
|
105
|
+
return NextResponse.json({ error: t("storage_s3.errors.quotaTargetExists", "The target storage key already exists.") }, { status: 409 });
|
|
106
106
|
}
|
|
107
|
-
return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
107
|
+
return NextResponse.json({ error: t("storage_s3.errors.quotaUnavailable", "Storage quota accounting is unavailable.") }, { status: 500 });
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
const url = await driver.getSignedUrl(key, operation, expiresIn, contentType);
|
|
@@ -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'\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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
|
|
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: t('storage_s3.errors.quotaUnavailable', '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('[internal] 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: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n if (code === 'quota_target_exists') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })\n }\n return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', '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
6
|
"names": ["url"]
|
|
7
7
|
}
|
|
@@ -129,7 +129,7 @@ async function POST(req) {
|
|
|
129
129
|
storageDriver: "s3",
|
|
130
130
|
storagePath: key
|
|
131
131
|
});
|
|
132
|
-
if (!recoveryScheduler) throw new Error("Storage quota recovery is unavailable.");
|
|
132
|
+
if (!recoveryScheduler) throw new Error("[internal] Storage quota recovery is unavailable.");
|
|
133
133
|
await recoveryScheduler({
|
|
134
134
|
reservationId: reservation.id,
|
|
135
135
|
tenantId: auth.tenantId,
|
|
@@ -171,7 +171,7 @@ async function POST(req) {
|
|
|
171
171
|
} catch {
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
-
return NextResponse.json({ error: "Failed to persist attachment." }, { status: 500 });
|
|
174
|
+
return NextResponse.json({ error: t("storage_s3.errors.persistFailed", "Failed to persist attachment.") }, { status: 500 });
|
|
175
175
|
}
|
|
176
176
|
return NextResponse.json({
|
|
177
177
|
key,
|
|
@@ -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 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: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n if (code === 'quota_target_exists') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })\n }\n return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', '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: t('storage_s3.errors.quotaExceeded', '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": "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;AAEhC,SAAS,gCAAgC;AACzC,SAAS,kBAAkB;AAEpB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,0BAA0B,EAAE;AAC3E;AAEA,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,KAAK,EAAE,OAAO;AAAA,EACd,QAAQ,EAAE,OAAO;AAAA,EACjB,MAAM,EAAE,OAAO,EAAE,IAAI;AAAA,EACrB,aAAa,EAAE,OAAO,EAAE,SAAS;AACnC,CAAC;AAED,SAAS,iBAAiB,MAAsB;AAC9C,SAAO,KAAK,QAAQ,oBAAoB,GAAG,KAAK;AAClD;AAEA,eAAe,cACb,UACA,OACiC;AACjC,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,QAAQ,MAAM,mBAAmB,QAAQ,cAAc,EAAE,UAAU,gBAAgB,MAAM,CAAC;AAChG,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,IAAI,gBAAgB,EAAE,GAAG,OAAO,gBAAgB,OAAO,SAAS,CAAC;AAC1E;AAEA,eAAe,4BACb,QACA,UACA,OACiB;AACjB,MAAI,aAAa;AACjB,MAAI;AAEJ,KAAG;AACD,UAAM,OAAO,MAAM,OAAO,YAAY,IAAI,KAAM,iBAAiB;AACjE,eAAW,QAAQ,KAAK,OAAO;AAC7B,UAAI,sBAAsB,KAAK,KAAK,OAAO,QAAQ,GAAG;AACpD,sBAAc,KAAK;AAAA,MACrB;AAAA,IACF;AACA,wBAAoB,KAAK,YAAY,KAAK,wBAAwB;AAAA,EACpE,SAAS;AAET,SAAO;AACT;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,YAAY,CAAC,KAAK,OAAO;AAClC,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,kCAAkC,cAAc,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1G;AAEA,MAAI,CAAC,oCAAoC,IAAI,QAAQ,IAAI,gBAAgB,CAAC,GAAG;AAC3E,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,mCAAmC,6CAA6C,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1I;AAEA,QAAM,cAAc,IAAI,QAAQ,IAAI,cAAc,KAAK;AACvD,MAAI,CAAC,YAAY,SAAS,qBAAqB,GAAG;AAChD,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,uCAAuC,8BAA8B,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC/H;AAEA,MAAI;AACJ,MAAI;AACF,WAAO,MAAM,wCAAwC,GAAG;AAAA,EAC1D,SAAS,OAAO;AACd,QAAI,4BAA4B,KAAK,GAAG;AACtC,aAAO,aAAa,KAAK,EAAE,OAAO,EAAE,mCAAmC,6CAA6C,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC1I;AACA,UAAM;AAAA,EACR;AACA,QAAM,OAAO,KAAK,IAAI,MAAM;AAC5B,MAAI,CAAC,MAAM;AACT,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,kCAAkC,wBAAwB,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACpH;AAEA,QAAM,cAAc,KAAK,IAAI,KAAK,IAAI,OAAO,KAAK,IAAI,KAAK,CAAC,IAAI;AAEhE,MAAI,gBAAgB,QAAQ,CAAC,sBAAsB,aAAa,KAAK,OAAO,KAAK,QAAQ,GAAG;AAC1F,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,EAAE,6CAA6C,2DAA2D,EAAE;AAAA,MACrH,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,MAAI,gCAAgC,KAAK,IAAI,GAAG;AAC9C,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,yCAAyC,uDAAuD,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1J;AAEA,QAAM,oBAAoB,0BAA0B;AACpD,MAAI,KAAK,OAAO,mBAAmB;AACjC,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,mCAAmC,6CAA6C,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1I;AAEA,QAAM,SAAS,MAAM,cAAc,KAAK,UAAU,KAAK,KAAK;AAC5D,MAAI,CAAC,QAAQ;AACX,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,8CAA8C,mCAAmC,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC3I;AAEA,QAAM,SAAS,OAAO,KAAK,MAAM,KAAK,YAAY,CAAC;AACnD,QAAM,WAAW,iBAAiB,KAAK,IAAI;AAC3C,QAAM,kBAAkB,yBAAyB,QAAQ,UAAU,KAAK,QAAQ,IAAI;AACpF,MAAI,0BAA0B,QAAQ,UAAU,eAAe,GAAG;AAChE,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,0CAA0C,yCAAyC,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC7I;AAEA,QAAM,MACJ,eACA,eAAe,KAAK,KAAK,WAAW,KAAK,QAAQ,IAAI,KAAK,IAAI,CAAC,IAAI,WAAW,EAAE,MAAM,GAAG,CAAC,CAAC,IAAI,QAAQ;AAEzG,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,MAAI,yBAAwD;AAC5D,MAAI,oBAGyB;AAC7B,MAAI;AACF,6BAAyB,QAAQ,wBAAwB;AACzD,wBAAoB,QAAQ,iCAAiC;AAAA,EAI/D,QAAQ;AAAA,EAER;AACA,MAAI,cAA0E;AAC9E,MAAI,wBAAwB;AAC1B,QAAI;AACF,YAAM,yBAAyB;AAAA,QAC7B;AAAA,QACA,cAAc;AAAA,QACd,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK;AAAA,MACvB,CAAC;AACD,oBAAc,MAAM,uBAAuB,QAAQ;AAAA,QACjD,UAAU,KAAK;AAAA,QACf,gBAAgB,KAAK;AAAA,QACrB,OAAO,OAAO;AAAA,QACd,QAAQ;AAAA,QACR,eAAe;AAAA,QACf,aAAa;AAAA,MACf,CAAC;AACD,UAAI,CAAC,kBAAmB,OAAM,IAAI,MAAM,
|
|
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('[internal] 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: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })\n }\n if (code === 'quota_target_exists') {\n return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })\n }\n return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', '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: t('storage_s3.errors.quotaExceeded', '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: t('storage_s3.errors.persistFailed', '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
|
}
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { NextResponse } from "next/server";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
6
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
6
7
|
import {
|
|
7
8
|
detectAttachmentMimeType,
|
|
8
9
|
hasDangerousExecutableExtension,
|
|
@@ -27,7 +28,7 @@ async function readBoundedBody(req, maxBytes) {
|
|
|
27
28
|
totalBytes += value.byteLength;
|
|
28
29
|
if (totalBytes > maxBytes) {
|
|
29
30
|
await reader.cancel();
|
|
30
|
-
throw new UploadBodyTooLargeError("Attachment exceeds the reserved upload size.");
|
|
31
|
+
throw new UploadBodyTooLargeError("[internal] Attachment exceeds the reserved upload size.");
|
|
31
32
|
}
|
|
32
33
|
chunks.push(Buffer.from(value));
|
|
33
34
|
}
|
|
@@ -37,27 +38,28 @@ async function readBoundedBody(req, maxBytes) {
|
|
|
37
38
|
return Buffer.concat(chunks, totalBytes);
|
|
38
39
|
}
|
|
39
40
|
async function PUT(req, ctx) {
|
|
41
|
+
const { t } = await resolveTranslations();
|
|
40
42
|
const params = ctx.params instanceof Promise ? await ctx.params : ctx.params;
|
|
41
43
|
const token = params?.token?.trim();
|
|
42
|
-
if (!token) return NextResponse.json({ error: "Upload token is required." }, { status: 400 });
|
|
44
|
+
if (!token) return NextResponse.json({ error: t("storage_s3.errors.uploadTokenRequired", "Upload token is required.") }, { status: 400 });
|
|
43
45
|
const { resolve } = await createRequestContainer();
|
|
44
46
|
let quotaService = null;
|
|
45
47
|
try {
|
|
46
48
|
quotaService = resolve("attachmentQuotaService");
|
|
47
49
|
} catch {
|
|
48
50
|
}
|
|
49
|
-
if (!quotaService) return NextResponse.json({ error: "Storage quota accounting is unavailable." }, { status: 500 });
|
|
51
|
+
if (!quotaService) return NextResponse.json({ error: t("storage_s3.errors.quotaUnavailable", "Storage quota accounting is unavailable.") }, { status: 500 });
|
|
50
52
|
const tokenHash = createHash("sha256").update(token).digest("hex");
|
|
51
53
|
const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash);
|
|
52
54
|
if (!reservation || reservation.source !== "storage_s3_signed") {
|
|
53
|
-
return NextResponse.json({ error: "Upload token is invalid or expired." }, { status: 410 });
|
|
55
|
+
return NextResponse.json({ error: t("storage_s3.errors.uploadTokenInvalid", "Upload token is invalid or expired.") }, { status: 410 });
|
|
54
56
|
}
|
|
55
57
|
const declaredLengthHeader = req.headers.get("content-length");
|
|
56
58
|
const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader);
|
|
57
59
|
if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {
|
|
58
60
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
59
61
|
});
|
|
60
|
-
return NextResponse.json({ error: "Attachment exceeds the reserved upload size." }, { status: 413 });
|
|
62
|
+
return NextResponse.json({ error: t("storage_s3.errors.reservedSizeExceeded", "Attachment exceeds the reserved upload size.") }, { status: 413 });
|
|
61
63
|
}
|
|
62
64
|
let buffer;
|
|
63
65
|
try {
|
|
@@ -66,26 +68,26 @@ async function PUT(req, ctx) {
|
|
|
66
68
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
67
69
|
});
|
|
68
70
|
if (error instanceof UploadBodyTooLargeError) {
|
|
69
|
-
return NextResponse.json({ error:
|
|
71
|
+
return NextResponse.json({ error: t("storage_s3.errors.reservedSizeExceeded", "Attachment exceeds the reserved upload size.") }, { status: 413 });
|
|
70
72
|
}
|
|
71
|
-
return NextResponse.json({ error: "Failed to read attachment." }, { status: 400 });
|
|
73
|
+
return NextResponse.json({ error: t("storage_s3.errors.readFailed", "Failed to read attachment.") }, { status: 400 });
|
|
72
74
|
}
|
|
73
75
|
if (buffer.length > reservation.reservedBytes) {
|
|
74
76
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
75
77
|
});
|
|
76
|
-
return NextResponse.json({ error: "Attachment exceeds the reserved upload size." }, { status: 413 });
|
|
78
|
+
return NextResponse.json({ error: t("storage_s3.errors.reservedSizeExceeded", "Attachment exceeds the reserved upload size.") }, { status: 413 });
|
|
77
79
|
}
|
|
78
80
|
const fileName = path.posix.basename(reservation.storagePath);
|
|
79
81
|
if (hasDangerousExecutableExtension(fileName)) {
|
|
80
82
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
81
83
|
});
|
|
82
|
-
return NextResponse.json({ error: "Executable file types are not allowed as attachments." }, { status: 400 });
|
|
84
|
+
return NextResponse.json({ error: t("storage_s3.errors.dangerousExecutable", "Executable file types are not allowed as attachments.") }, { status: 400 });
|
|
83
85
|
}
|
|
84
86
|
const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get("content-type"));
|
|
85
87
|
if (isActiveContentAttachment(buffer, fileName, contentType)) {
|
|
86
88
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
87
89
|
});
|
|
88
|
-
return NextResponse.json({ error: "Active content uploads are not allowed." }, { status: 400 });
|
|
90
|
+
return NextResponse.json({ error: t("storage_s3.errors.activeContentBlocked", "Active content uploads are not allowed.") }, { status: 400 });
|
|
89
91
|
}
|
|
90
92
|
const credentialsService = resolve("integrationCredentialsService");
|
|
91
93
|
const credentials = await credentialsService.resolve("storage_s3", {
|
|
@@ -95,7 +97,7 @@ async function PUT(req, ctx) {
|
|
|
95
97
|
if (!credentials) {
|
|
96
98
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {
|
|
97
99
|
});
|
|
98
|
-
return NextResponse.json({ error: "S3 integration is not configured." }, { status: 400 });
|
|
100
|
+
return NextResponse.json({ error: t("storage_s3.errors.integrationNotConfigured", "S3 integration is not configured.") }, { status: 400 });
|
|
99
101
|
}
|
|
100
102
|
const driver = new S3StorageDriver({
|
|
101
103
|
...credentials,
|
|
@@ -117,7 +119,7 @@ async function PUT(req, ctx) {
|
|
|
117
119
|
} catch {
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
|
-
return NextResponse.json({ error: "Failed to persist attachment." }, { status: 500 });
|
|
122
|
+
return NextResponse.json({ error: t("storage_s3.errors.persistFailed", "Failed to persist attachment.") }, { status: 500 });
|
|
121
123
|
}
|
|
122
124
|
}
|
|
123
125
|
var token_default = PUT;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
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:
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,OAAO,UAAU;AACjB,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,8BAA8B;AACvC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM;AAC5B;AAIA,MAAM,gCAAgC,MAAM;AAAC;AAE7C,eAAe,gBAAgB,KAAc,UAAmC;AAC9E,MAAI,CAAC,IAAI,KAAM,QAAO,OAAO,MAAM,CAAC;AACpC,QAAM,SAAS,IAAI,KAAK,UAAU;AAClC,QAAM,SAAmB,CAAC;AAC1B,MAAI,aAAa;AACjB,MAAI;AACF,WAAO,MAAM;AACX,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,UAAI,KAAM;AACV,oBAAc,MAAM;AACpB,UAAI,aAAa,UAAU;AACzB,cAAM,OAAO,OAAO;AACpB,cAAM,IAAI,wBAAwB,
|
|
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 { 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 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('[internal] 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 { t } = await resolveTranslations()\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: t('storage_s3.errors.uploadTokenRequired', '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: t('storage_s3.errors.quotaUnavailable', '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: t('storage_s3.errors.uploadTokenInvalid', '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: t('storage_s3.errors.reservedSizeExceeded', '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: t('storage_s3.errors.reservedSizeExceeded', 'Attachment exceeds the reserved upload size.') }, { status: 413 })\n }\n return NextResponse.json({ error: t('storage_s3.errors.readFailed', '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: t('storage_s3.errors.reservedSizeExceeded', '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: t('storage_s3.errors.dangerousExecutable', '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: t('storage_s3.errors.activeContentBlocked', '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: t('storage_s3.errors.integrationNotConfigured', '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: t('storage_s3.errors.persistFailed', '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": "AAAA,SAAS,kBAAkB;AAC3B,OAAO,UAAU;AACjB,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AACpC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,SAAS,uBAAuB;AAEzB,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM;AAC5B;AAIA,MAAM,gCAAgC,MAAM;AAAC;AAE7C,eAAe,gBAAgB,KAAc,UAAmC;AAC9E,MAAI,CAAC,IAAI,KAAM,QAAO,OAAO,MAAM,CAAC;AACpC,QAAM,SAAS,IAAI,KAAK,UAAU;AAClC,QAAM,SAAmB,CAAC;AAC1B,MAAI,aAAa;AACjB,MAAI;AACF,WAAO,MAAM;AACX,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,UAAI,KAAM;AACV,oBAAc,MAAM;AACpB,UAAI,aAAa,UAAU;AACzB,cAAM,OAAO,OAAO;AACpB,cAAM,IAAI,wBAAwB,yDAAyD;AAAA,MAC7F;AACA,aAAO,KAAK,OAAO,KAAK,KAAK,CAAC;AAAA,IAChC;AAAA,EACF,UAAE;AACA,WAAO,YAAY;AAAA,EACrB;AACA,SAAO,OAAO,OAAO,QAAQ,UAAU;AACzC;AAEA,eAAsB,IAAI,KAAc,KAAmB;AACzD,QAAM,EAAE,EAAE,IAAI,MAAM,oBAAoB;AACxC,QAAM,SAAS,IAAI,kBAAkB,UAAU,MAAM,IAAI,SAAS,IAAI;AACtE,QAAM,QAAQ,QAAQ,OAAO,KAAK;AAClC,MAAI,CAAC,MAAO,QAAO,aAAa,KAAK,EAAE,OAAO,EAAE,yCAAyC,2BAA2B,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAExI,QAAM,EAAE,QAAQ,IAAI,MAAM,uBAAuB;AACjD,MAAI,eAA8C;AAClD,MAAI;AACF,mBAAe,QAAQ,wBAAwB;AAAA,EACjD,QAAQ;AAAA,EAER;AACA,MAAI,CAAC,aAAc,QAAO,aAAa,KAAK,EAAE,OAAO,EAAE,sCAAsC,0CAA0C,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAE3J,QAAM,YAAY,WAAW,QAAQ,EAAE,OAAO,KAAK,EAAE,OAAO,KAAK;AACjE,QAAM,cAAc,MAAM,aAAa,8BAA8B,SAAS;AAC9E,MAAI,CAAC,eAAe,YAAY,WAAW,qBAAqB;AAC9D,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,wCAAwC,qCAAqC,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACvI;AAEA,QAAM,uBAAuB,IAAI,QAAQ,IAAI,gBAAgB;AAC7D,QAAM,iBAAiB,wBAAwB,OAAO,OAAO,OAAO,oBAAoB;AACxF,MAAI,mBAAmB,QAAQ,OAAO,SAAS,cAAc,KAAK,iBAAiB,YAAY,eAAe;AAC5G,UAAM,aAAa,QAAQ,YAAY,IAAI,YAAY,UAAU,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACjF,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,0CAA0C,8CAA8C,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClJ;AACA,MAAI;AACJ,MAAI;AACF,aAAS,MAAM,gBAAgB,KAAK,YAAY,aAAa;AAAA,EAC/D,SAAS,OAAO;AACd,UAAM,aAAa,QAAQ,YAAY,IAAI,YAAY,UAAU,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACjF,QAAI,iBAAiB,yBAAyB;AAC5C,aAAO,aAAa,KAAK,EAAE,OAAO,EAAE,0CAA0C,8CAA8C,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAClJ;AACA,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,gCAAgC,4BAA4B,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACtH;AACA,MAAI,OAAO,SAAS,YAAY,eAAe;AAC7C,UAAM,aAAa,QAAQ,YAAY,IAAI,YAAY,UAAU,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACjF,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,0CAA0C,8CAA8C,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAClJ;AACA,QAAM,WAAW,KAAK,MAAM,SAAS,YAAY,WAAW;AAC5D,MAAI,gCAAgC,QAAQ,GAAG;AAC7C,UAAM,aAAa,QAAQ,YAAY,IAAI,YAAY,UAAU,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACjF,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,yCAAyC,uDAAuD,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1J;AACA,QAAM,cAAc,yBAAyB,QAAQ,UAAU,IAAI,QAAQ,IAAI,cAAc,CAAC;AAC9F,MAAI,0BAA0B,QAAQ,UAAU,WAAW,GAAG;AAC5D,UAAM,aAAa,QAAQ,YAAY,IAAI,YAAY,UAAU,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACjF,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,0CAA0C,yCAAyC,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC7I;AAEA,QAAM,qBAAqB,QAAQ,+BAA+B;AAGlE,QAAM,cAAc,MAAM,mBAAmB,QAAQ,cAAc;AAAA,IACjE,UAAU,YAAY;AAAA,IACtB,gBAAgB,YAAY;AAAA,EAC9B,CAAC;AACD,MAAI,CAAC,aAAa;AAChB,UAAM,aAAa,QAAQ,YAAY,IAAI,YAAY,UAAU,EAAE,MAAM,MAAM;AAAA,IAAC,CAAC;AACjF,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,8CAA8C,mCAAmC,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC3I;AAEA,QAAM,SAAS,IAAI,gBAAgB;AAAA,IACjC,GAAG;AAAA,IACH,gBAAgB,YAAY;AAAA,IAC5B,UAAU,YAAY;AAAA,EACxB,CAAC;AACD,MAAI,eAAe;AACnB,MAAI;AACF,UAAM,OAAO,UAAU,YAAY,aAAa,QAAQ,WAAW;AACnE,mBAAe;AACf,UAAM,aAAa,WAAW,YAAY,IAAI,YAAY,UAAU;AACpE,UAAM,aAAa,mBAAmB,YAAY,IAAI,YAAY,YAAY,OAAO,MAAM;AAC3F,WAAO,IAAI,aAAa,MAAM,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC/C,SAAS,OAAO;AACd,QAAI,cAAc;AAChB,UAAI;AACF,cAAM,OAAO,aAAa,IAAI,YAAY,WAAW;AACrD,cAAM,aAAa,QAAQ,YAAY,IAAI,YAAY,UAAU;AAAA,MACnE,QAAQ;AAAA,MAER;AAAA,IACF;AACA,WAAO,aAAa,KAAK,EAAE,OAAO,EAAE,mCAAmC,+BAA+B,EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC5H;AACF;AAEA,IAAO,gBAAQ;AAER,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,oBAAoB,QAAQ,EAAE,IAAI,EAAE,CAAC;AAAA,MAC7E,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,kBAAkB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACtF,EAAE,QAAQ,KAAK,aAAa,4BAA4B,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QAChG,EAAE,QAAQ,KAAK,aAAa,gCAAgC,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,MACtG;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/storage-s3",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.6876.1.c514f2eb37",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -74,15 +74,15 @@
|
|
|
74
74
|
"dependencies": {
|
|
75
75
|
"@aws-sdk/client-s3": "^3.1085.0",
|
|
76
76
|
"@aws-sdk/s3-request-presigner": "^3.1085.0",
|
|
77
|
-
"@open-mercato/core": "0.6.8-develop.
|
|
78
|
-
"@open-mercato/queue": "0.6.8-develop.
|
|
77
|
+
"@open-mercato/core": "0.6.8-develop.6876.1.c514f2eb37",
|
|
78
|
+
"@open-mercato/queue": "0.6.8-develop.6876.1.c514f2eb37"
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
81
|
"@mikro-orm/postgresql": "^6.6.10",
|
|
82
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
82
|
+
"@open-mercato/shared": "0.6.8-develop.6876.1.c514f2eb37"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
85
|
+
"@open-mercato/shared": "0.6.8-develop.6876.1.c514f2eb37",
|
|
86
86
|
"@types/jest": "^30.0.0",
|
|
87
87
|
"esbuild": "^0.28.1",
|
|
88
88
|
"glob": "^13.0.6",
|
|
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'
|
|
|
4
4
|
const packageRequire = createRequire(__filename)
|
|
5
5
|
|
|
6
6
|
describe('storage_s3 locale package exports', () => {
|
|
7
|
-
it.each(['en', 'de', 'es', 'pl'] as const)('resolves the %s locale through the package export map', (locale) => {
|
|
7
|
+
it.each(['en', 'de', 'es', 'ko', 'pl'] as const)('resolves the %s locale through the package export map', (locale) => {
|
|
8
8
|
const localePath = packageRequire.resolve(`@open-mercato/storage-s3/modules/storage_s3/i18n/${locale}.json`)
|
|
9
9
|
const translations = JSON.parse(readFileSync(localePath, 'utf8')) as Record<string, unknown>
|
|
10
10
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
4
|
+
resolveTranslations: jest.fn(async () => ({
|
|
5
|
+
t: (key: string) => `translated:${key}`,
|
|
6
|
+
})),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
10
|
+
createRequestContainer: jest.fn(async () => ({ resolve: () => null })),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
const LOCALES = ['en', 'de', 'es', 'ko', 'pl'] as const
|
|
17
|
+
|
|
18
|
+
const apiDir = path.join(__dirname, '..', 'api')
|
|
19
|
+
const i18nDir = path.join(__dirname, '..', 'i18n')
|
|
20
|
+
|
|
21
|
+
function routeFiles(dir: string): string[] {
|
|
22
|
+
return readdirSync(dir).flatMap((entry) => {
|
|
23
|
+
const full = path.join(dir, entry)
|
|
24
|
+
if (statSync(full).isDirectory()) return routeFiles(full)
|
|
25
|
+
return full.endsWith('.ts') ? [full] : []
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function localeDictionary(locale: string): Record<string, string> {
|
|
30
|
+
return JSON.parse(readFileSync(path.join(i18nDir, `${locale}.json`), 'utf8')) as Record<string, string>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sources = routeFiles(apiDir).map((file) => ({
|
|
34
|
+
file: path.relative(apiDir, file),
|
|
35
|
+
source: readFileSync(file, 'utf8'),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
const dictionaries = Object.fromEntries(LOCALES.map((locale) => [locale, localeDictionary(locale)]))
|
|
39
|
+
|
|
40
|
+
describe('storage_s3 route error localization (#4830)', () => {
|
|
41
|
+
const usages = sources.flatMap(({ file, source }) =>
|
|
42
|
+
[...source.matchAll(/t\('(storage_s3\.errors\.[a-zA-Z]+)', '([^']*)'\)/g)].map((match) => ({
|
|
43
|
+
file,
|
|
44
|
+
key: match[1],
|
|
45
|
+
fallback: match[2],
|
|
46
|
+
})),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
it('uses translation keys in every route that reports an error', () => {
|
|
50
|
+
expect(usages.length).toBeGreaterThan(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('ships every key the routes use in all five supported locales', () => {
|
|
54
|
+
const missing = usages.flatMap(({ key }) =>
|
|
55
|
+
LOCALES.filter((locale) => dictionaries[locale][key] === undefined).map((locale) => `${locale}:${key}`),
|
|
56
|
+
)
|
|
57
|
+
expect(missing).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('keeps each inline English fallback identical to en.json', () => {
|
|
61
|
+
const drifted = usages
|
|
62
|
+
.filter(({ key, fallback }) => dictionaries.en[key] !== fallback)
|
|
63
|
+
.map(({ file, key }) => `${file}:${key}`)
|
|
64
|
+
expect(drifted).toEqual([])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('never returns a hardcoded user-facing error string from a route response', () => {
|
|
68
|
+
// #4076 added the atomic quota-admission paths on top of an already localized
|
|
69
|
+
// module and reintroduced hardcoded English, which is how this issue came back.
|
|
70
|
+
const hardcoded = sources.flatMap(({ file, source }) =>
|
|
71
|
+
[...source.matchAll(/error: '([^']+)'/g)].map((match) => `${file}: ${match[1]}`),
|
|
72
|
+
)
|
|
73
|
+
expect(hardcoded).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('marks diagnostic-only thrown errors with the [internal] opt-out marker', () => {
|
|
77
|
+
const unmarked = sources.flatMap(({ file, source }) =>
|
|
78
|
+
[...source.matchAll(/throw new \w*Error\('([^']+)'\)/g)]
|
|
79
|
+
.map((match) => match[1])
|
|
80
|
+
.filter((message) => !message.startsWith('[internal] '))
|
|
81
|
+
.map((message) => `${file}: ${message}`),
|
|
82
|
+
)
|
|
83
|
+
expect(unmarked).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('returns the translated string, not the English literal, when the upload token is missing', async () => {
|
|
87
|
+
const { PUT } = await import('../api/put/storage-providers/s3/signed-upload/[token]')
|
|
88
|
+
const response = await PUT(
|
|
89
|
+
new Request('http://localhost/api/storage-providers/s3/signed-upload/', { method: 'PUT' }),
|
|
90
|
+
{ params: { token: ' ' } },
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
expect(response.status).toBe(400)
|
|
94
|
+
await expect(response.json()).resolves.toEqual({
|
|
95
|
+
error: 'translated:storage_s3.errors.uploadTokenRequired',
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns the translated string when the quota service is not registered', async () => {
|
|
100
|
+
const { PUT } = await import('../api/put/storage-providers/s3/signed-upload/[token]')
|
|
101
|
+
const response = await PUT(
|
|
102
|
+
new Request('http://localhost/api/storage-providers/s3/signed-upload/tok', { method: 'PUT' }),
|
|
103
|
+
{ params: { token: 'tok' } },
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
expect(response.status).toBe(500)
|
|
107
|
+
await expect(response.json()).resolves.toEqual({
|
|
108
|
+
error: 'translated:storage_s3.errors.quotaUnavailable',
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -12,12 +12,25 @@ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
|
12
12
|
}))
|
|
13
13
|
|
|
14
14
|
const mockResolveCredentials = jest.fn()
|
|
15
|
+
const mockReserve = jest.fn()
|
|
16
|
+
const mockReconcileStandaloneObjects = jest.fn()
|
|
17
|
+
let quotaServiceRegistered = false
|
|
15
18
|
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
16
19
|
createRequestContainer: jest.fn(async () => ({
|
|
17
20
|
resolve: (name: string) => {
|
|
18
21
|
if (name === 'integrationCredentialsService') {
|
|
19
22
|
return { resolve: mockResolveCredentials }
|
|
20
23
|
}
|
|
24
|
+
if (quotaServiceRegistered && name === 'attachmentQuotaService') {
|
|
25
|
+
return {
|
|
26
|
+
reserve: (...args: unknown[]) => mockReserve(...args),
|
|
27
|
+
reconcileStandaloneObjects: (...args: unknown[]) => mockReconcileStandaloneObjects(...args),
|
|
28
|
+
release: jest.fn(),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (quotaServiceRegistered && name === 'storageS3QuotaRecoveryScheduler') {
|
|
32
|
+
return jest.fn()
|
|
33
|
+
}
|
|
21
34
|
throw new Error(`Unexpected dependency: ${name}`)
|
|
22
35
|
},
|
|
23
36
|
})),
|
|
@@ -26,11 +39,13 @@ jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
|
26
39
|
const mockRead = jest.fn()
|
|
27
40
|
const mockDelete = jest.fn()
|
|
28
41
|
const mockGetSignedUrl = jest.fn()
|
|
42
|
+
const mockListObjects = jest.fn()
|
|
29
43
|
jest.mock('../lib/s3-driver', () => ({
|
|
30
44
|
S3StorageDriver: jest.fn().mockImplementation(() => ({
|
|
31
45
|
read: (...args: unknown[]) => mockRead(...args),
|
|
32
46
|
delete: (...args: unknown[]) => mockDelete(...args),
|
|
33
47
|
getSignedUrl: (...args: unknown[]) => mockGetSignedUrl(...args),
|
|
48
|
+
listObjects: (...args: unknown[]) => mockListObjects(...args),
|
|
34
49
|
})),
|
|
35
50
|
}))
|
|
36
51
|
|
|
@@ -58,6 +73,11 @@ describe('storage_s3 standalone route key scope', () => {
|
|
|
58
73
|
mockRead.mockReset()
|
|
59
74
|
mockDelete.mockReset()
|
|
60
75
|
mockGetSignedUrl.mockReset()
|
|
76
|
+
mockReserve.mockReset()
|
|
77
|
+
mockReconcileStandaloneObjects.mockReset()
|
|
78
|
+
mockReconcileStandaloneObjects.mockResolvedValue(undefined)
|
|
79
|
+
mockListObjects.mockResolvedValue({ files: [], truncated: false })
|
|
80
|
+
quotaServiceRegistered = false
|
|
61
81
|
})
|
|
62
82
|
|
|
63
83
|
it('downloads shared namespace objects for an authorized storage manager', async () => {
|
|
@@ -105,6 +125,65 @@ describe('storage_s3 standalone route key scope', () => {
|
|
|
105
125
|
expect(mockGetSignedUrl).not.toHaveBeenCalled()
|
|
106
126
|
})
|
|
107
127
|
|
|
128
|
+
// Regression guards for the quota-admission responses #4076 shipped unlocalized
|
|
129
|
+
// (#4926, #4995, #5000 — the same shape three times). This suite mocks
|
|
130
|
+
// `resolveTranslations`, so a localized body reads `translated:<key>` while a
|
|
131
|
+
// hardcoded English string does not, which is what lets the guard live next to
|
|
132
|
+
// the route instead of only in the app-level consumer suite one layer up.
|
|
133
|
+
describe('signed upload URL quota-admission responses', () => {
|
|
134
|
+
const OWN_KEY = 'docs/org_org-1/tenant_tenant-1/mine.txt'
|
|
135
|
+
|
|
136
|
+
function uploadRequest(): Request {
|
|
137
|
+
return jsonRequest({ key: OWN_KEY, operation: 'upload', expiresIn: 600 })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it('localizes the missing quota service response', async () => {
|
|
141
|
+
const response = await POST(uploadRequest())
|
|
142
|
+
|
|
143
|
+
expect(response.status).toBe(500)
|
|
144
|
+
await expect(response.json()).resolves.toEqual({
|
|
145
|
+
error: 'translated:storage_s3.errors.quotaUnavailable',
|
|
146
|
+
})
|
|
147
|
+
expect(mockReserve).not.toHaveBeenCalled()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('localizes the exceeded tenant quota response', async () => {
|
|
151
|
+
quotaServiceRegistered = true
|
|
152
|
+
mockReserve.mockRejectedValueOnce(Object.assign(new Error('quota'), { code: 'quota_exceeded' }))
|
|
153
|
+
|
|
154
|
+
const response = await POST(uploadRequest())
|
|
155
|
+
|
|
156
|
+
expect(response.status).toBe(413)
|
|
157
|
+
await expect(response.json()).resolves.toEqual({
|
|
158
|
+
error: 'translated:storage_s3.errors.quotaExceeded',
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('localizes the existing target key response', async () => {
|
|
163
|
+
quotaServiceRegistered = true
|
|
164
|
+
mockReserve.mockRejectedValueOnce(Object.assign(new Error('exists'), { code: 'quota_target_exists' }))
|
|
165
|
+
|
|
166
|
+
const response = await POST(uploadRequest())
|
|
167
|
+
|
|
168
|
+
expect(response.status).toBe(409)
|
|
169
|
+
await expect(response.json()).resolves.toEqual({
|
|
170
|
+
error: 'translated:storage_s3.errors.quotaTargetExists',
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('localizes the reservation failure response', async () => {
|
|
175
|
+
quotaServiceRegistered = true
|
|
176
|
+
mockReserve.mockRejectedValueOnce(new Error('reservation backend down'))
|
|
177
|
+
|
|
178
|
+
const response = await POST(uploadRequest())
|
|
179
|
+
|
|
180
|
+
expect(response.status).toBe(500)
|
|
181
|
+
await expect(response.json()).resolves.toEqual({
|
|
182
|
+
error: 'translated:storage_s3.errors.quotaUnavailable',
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
108
187
|
it('localizes invalid delete payload errors', async () => {
|
|
109
188
|
const response = await DELETE(new Request('http://example.test/api/storage-providers/s3/delete', {
|
|
110
189
|
method: 'DELETE',
|
|
@@ -12,9 +12,21 @@ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
|
12
12
|
}))
|
|
13
13
|
|
|
14
14
|
const mockResolveCredentials = jest.fn()
|
|
15
|
+
const mockReserve = jest.fn()
|
|
16
|
+
const mockReconcileStandaloneObjects = jest.fn()
|
|
17
|
+
const mockRelease = jest.fn()
|
|
18
|
+
let quotaServiceRegistered = false
|
|
15
19
|
const mockCreateRequestContainer = jest.fn(async () => ({
|
|
16
20
|
resolve: (key: string) => {
|
|
17
21
|
if (key === 'integrationCredentialsService') return { resolve: mockResolveCredentials }
|
|
22
|
+
if (quotaServiceRegistered && key === 'attachmentQuotaService') {
|
|
23
|
+
return {
|
|
24
|
+
reserve: (...args: unknown[]) => mockReserve(...args),
|
|
25
|
+
reconcileStandaloneObjects: (...args: unknown[]) => mockReconcileStandaloneObjects(...args),
|
|
26
|
+
release: (...args: unknown[]) => mockRelease(...args),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (quotaServiceRegistered && key === 'storageS3QuotaRecoveryScheduler') return jest.fn()
|
|
18
30
|
throw new Error(`Unexpected dependency: ${key}`)
|
|
19
31
|
},
|
|
20
32
|
}))
|
|
@@ -43,6 +55,8 @@ describe('storage_s3 direct upload route', () => {
|
|
|
43
55
|
mockResolveCredentials.mockResolvedValue({ bucket: 'test-bucket', region: 'eu-central-1' })
|
|
44
56
|
mockListObjects.mockResolvedValue({ files: [], truncated: false })
|
|
45
57
|
mockPutObject.mockResolvedValue(undefined)
|
|
58
|
+
mockReconcileStandaloneObjects.mockResolvedValue(undefined)
|
|
59
|
+
quotaServiceRegistered = false
|
|
46
60
|
delete process.env.OM_ATTACHMENT_MAX_UPLOAD_MB
|
|
47
61
|
})
|
|
48
62
|
|
|
@@ -90,4 +104,63 @@ describe('storage_s3 direct upload route', () => {
|
|
|
90
104
|
await expect(response.json()).resolves.toMatchObject({ bucket: 'test-bucket', size: 4 })
|
|
91
105
|
expect(mockPutObject).toHaveBeenCalledTimes(1)
|
|
92
106
|
})
|
|
107
|
+
|
|
108
|
+
// Regression guards for the quota-admission responses #4076 shipped unlocalized
|
|
109
|
+
// (#4926, #4995, #5000). The suite mocks `resolveTranslations`, so a localized
|
|
110
|
+
// body reads `translated:<key>` while a hardcoded English string does not.
|
|
111
|
+
describe('quota-admission error responses', () => {
|
|
112
|
+
function uploadRequest(): Request {
|
|
113
|
+
const form = new FormData()
|
|
114
|
+
form.set('file', new File([new TextEncoder().encode('safe')], 'safe.txt', { type: 'text/plain' }))
|
|
115
|
+
return new Request('http://example.test/api/storage-providers/s3/upload', { method: 'POST', body: form })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
it('localizes the persist failure response', async () => {
|
|
119
|
+
mockPutObject.mockRejectedValueOnce(new Error('s3 unreachable'))
|
|
120
|
+
|
|
121
|
+
const response = await POST(uploadRequest())
|
|
122
|
+
|
|
123
|
+
expect(response.status).toBe(500)
|
|
124
|
+
await expect(response.json()).resolves.toEqual({
|
|
125
|
+
error: 'translated:storage_s3.errors.persistFailed',
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('localizes the exceeded tenant quota response', async () => {
|
|
130
|
+
quotaServiceRegistered = true
|
|
131
|
+
mockReserve.mockRejectedValueOnce(Object.assign(new Error('quota'), { code: 'quota_exceeded' }))
|
|
132
|
+
|
|
133
|
+
const response = await POST(uploadRequest())
|
|
134
|
+
|
|
135
|
+
expect(response.status).toBe(413)
|
|
136
|
+
await expect(response.json()).resolves.toEqual({
|
|
137
|
+
error: 'translated:storage_s3.errors.quotaExceeded',
|
|
138
|
+
})
|
|
139
|
+
expect(mockPutObject).not.toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('localizes the existing target key response', async () => {
|
|
143
|
+
quotaServiceRegistered = true
|
|
144
|
+
mockReserve.mockRejectedValueOnce(Object.assign(new Error('exists'), { code: 'quota_target_exists' }))
|
|
145
|
+
|
|
146
|
+
const response = await POST(uploadRequest())
|
|
147
|
+
|
|
148
|
+
expect(response.status).toBe(409)
|
|
149
|
+
await expect(response.json()).resolves.toEqual({
|
|
150
|
+
error: 'translated:storage_s3.errors.quotaTargetExists',
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('localizes the reservation failure response', async () => {
|
|
155
|
+
quotaServiceRegistered = true
|
|
156
|
+
mockReserve.mockRejectedValueOnce(new Error('reservation backend down'))
|
|
157
|
+
|
|
158
|
+
const response = await POST(uploadRequest())
|
|
159
|
+
|
|
160
|
+
expect(response.status).toBe(500)
|
|
161
|
+
await expect(response.json()).resolves.toEqual({
|
|
162
|
+
error: 'translated:storage_s3.errors.quotaUnavailable',
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
})
|
|
93
166
|
})
|
|
@@ -85,7 +85,7 @@ export async function POST(req: Request) {
|
|
|
85
85
|
// Fail closed below when the attachments quota service is not registered.
|
|
86
86
|
}
|
|
87
87
|
if (!attachmentQuotaService) {
|
|
88
|
-
return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
|
|
88
|
+
return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })
|
|
89
89
|
}
|
|
90
90
|
const compatibilityToken = randomBytes(32).toString('base64url')
|
|
91
91
|
const reservedBytes = size ?? resolveAttachmentMaxBytes()
|
|
@@ -107,7 +107,7 @@ export async function POST(req: Request) {
|
|
|
107
107
|
uploadTokenHash: createHash('sha256').update(compatibilityToken).digest('hex'),
|
|
108
108
|
ttlMs: expiresIn * 1000,
|
|
109
109
|
})
|
|
110
|
-
if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
|
|
110
|
+
if (!recoveryScheduler) throw new Error('[internal] Storage quota recovery is unavailable.')
|
|
111
111
|
await recoveryScheduler({
|
|
112
112
|
reservationId: reservation.id,
|
|
113
113
|
tenantId: auth.tenantId,
|
|
@@ -121,12 +121,12 @@ export async function POST(req: Request) {
|
|
|
121
121
|
}
|
|
122
122
|
const code = (error as { code?: unknown })?.code
|
|
123
123
|
if (code === 'quota_exceeded') {
|
|
124
|
-
return NextResponse.json({ error: 'Attachment storage quota exceeded for this tenant.' }, { status: 413 })
|
|
124
|
+
return NextResponse.json({ error: t('storage_s3.errors.quotaExceeded', 'Attachment storage quota exceeded for this tenant.') }, { status: 413 })
|
|
125
125
|
}
|
|
126
126
|
if (code === 'quota_target_exists') {
|
|
127
|
-
return NextResponse.json({ error: 'The target storage key already exists.' }, { status: 409 })
|
|
127
|
+
return NextResponse.json({ error: t('storage_s3.errors.quotaTargetExists', 'The target storage key already exists.') }, { status: 409 })
|
|
128
128
|
}
|
|
129
|
-
return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
|
|
129
|
+
return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
@@ -168,7 +168,7 @@ export async function POST(req: Request) {
|
|
|
168
168
|
storageDriver: 's3',
|
|
169
169
|
storagePath: key,
|
|
170
170
|
})
|
|
171
|
-
if (!recoveryScheduler) throw new Error('Storage quota recovery is unavailable.')
|
|
171
|
+
if (!recoveryScheduler) throw new Error('[internal] Storage quota recovery is unavailable.')
|
|
172
172
|
await recoveryScheduler({
|
|
173
173
|
reservationId: reservation.id,
|
|
174
174
|
tenantId: auth.tenantId,
|
|
@@ -211,7 +211,7 @@ export async function POST(req: Request) {
|
|
|
211
211
|
// Retain the reservation when object absence cannot be proven.
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
|
-
return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })
|
|
214
|
+
return NextResponse.json({ error: t('storage_s3.errors.persistFailed', 'Failed to persist attachment.') }, { status: 500 })
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
return NextResponse.json({
|
|
@@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'
|
|
|
4
4
|
import { z } from 'zod'
|
|
5
5
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
6
6
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
8
|
import {
|
|
8
9
|
detectAttachmentMimeType,
|
|
9
10
|
hasDangerousExecutableExtension,
|
|
@@ -33,7 +34,7 @@ async function readBoundedBody(req: Request, maxBytes: number): Promise<Buffer>
|
|
|
33
34
|
totalBytes += value.byteLength
|
|
34
35
|
if (totalBytes > maxBytes) {
|
|
35
36
|
await reader.cancel()
|
|
36
|
-
throw new UploadBodyTooLargeError('Attachment exceeds the reserved upload size.')
|
|
37
|
+
throw new UploadBodyTooLargeError('[internal] Attachment exceeds the reserved upload size.')
|
|
37
38
|
}
|
|
38
39
|
chunks.push(Buffer.from(value))
|
|
39
40
|
}
|
|
@@ -44,9 +45,10 @@ async function readBoundedBody(req: Request, maxBytes: number): Promise<Buffer>
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
export async function PUT(req: Request, ctx: RouteContext) {
|
|
48
|
+
const { t } = await resolveTranslations()
|
|
47
49
|
const params = ctx.params instanceof Promise ? await ctx.params : ctx.params
|
|
48
50
|
const token = params?.token?.trim()
|
|
49
|
-
if (!token) return NextResponse.json({ error: 'Upload token is required.' }, { status: 400 })
|
|
51
|
+
if (!token) return NextResponse.json({ error: t('storage_s3.errors.uploadTokenRequired', 'Upload token is required.') }, { status: 400 })
|
|
50
52
|
|
|
51
53
|
const { resolve } = await createRequestContainer()
|
|
52
54
|
let quotaService: AttachmentQuotaService | null = null
|
|
@@ -55,19 +57,19 @@ export async function PUT(req: Request, ctx: RouteContext) {
|
|
|
55
57
|
} catch {
|
|
56
58
|
// Fail closed below when the attachments quota service is not registered.
|
|
57
59
|
}
|
|
58
|
-
if (!quotaService) return NextResponse.json({ error: 'Storage quota accounting is unavailable.' }, { status: 500 })
|
|
60
|
+
if (!quotaService) return NextResponse.json({ error: t('storage_s3.errors.quotaUnavailable', 'Storage quota accounting is unavailable.') }, { status: 500 })
|
|
59
61
|
|
|
60
62
|
const tokenHash = createHash('sha256').update(token).digest('hex')
|
|
61
63
|
const reservation = await quotaService.claimPendingByUploadTokenHash(tokenHash)
|
|
62
64
|
if (!reservation || reservation.source !== 'storage_s3_signed') {
|
|
63
|
-
return NextResponse.json({ error: 'Upload token is invalid or expired.' }, { status: 410 })
|
|
65
|
+
return NextResponse.json({ error: t('storage_s3.errors.uploadTokenInvalid', 'Upload token is invalid or expired.') }, { status: 410 })
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
const declaredLengthHeader = req.headers.get('content-length')
|
|
67
69
|
const declaredLength = declaredLengthHeader == null ? null : Number(declaredLengthHeader)
|
|
68
70
|
if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > reservation.reservedBytes) {
|
|
69
71
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
70
|
-
return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })
|
|
72
|
+
return NextResponse.json({ error: t('storage_s3.errors.reservedSizeExceeded', 'Attachment exceeds the reserved upload size.') }, { status: 413 })
|
|
71
73
|
}
|
|
72
74
|
let buffer: Buffer
|
|
73
75
|
try {
|
|
@@ -75,23 +77,23 @@ export async function PUT(req: Request, ctx: RouteContext) {
|
|
|
75
77
|
} catch (error) {
|
|
76
78
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
77
79
|
if (error instanceof UploadBodyTooLargeError) {
|
|
78
|
-
return NextResponse.json({ error:
|
|
80
|
+
return NextResponse.json({ error: t('storage_s3.errors.reservedSizeExceeded', 'Attachment exceeds the reserved upload size.') }, { status: 413 })
|
|
79
81
|
}
|
|
80
|
-
return NextResponse.json({ error: 'Failed to read attachment.' }, { status: 400 })
|
|
82
|
+
return NextResponse.json({ error: t('storage_s3.errors.readFailed', 'Failed to read attachment.') }, { status: 400 })
|
|
81
83
|
}
|
|
82
84
|
if (buffer.length > reservation.reservedBytes) {
|
|
83
85
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
84
|
-
return NextResponse.json({ error: 'Attachment exceeds the reserved upload size.' }, { status: 413 })
|
|
86
|
+
return NextResponse.json({ error: t('storage_s3.errors.reservedSizeExceeded', 'Attachment exceeds the reserved upload size.') }, { status: 413 })
|
|
85
87
|
}
|
|
86
88
|
const fileName = path.posix.basename(reservation.storagePath)
|
|
87
89
|
if (hasDangerousExecutableExtension(fileName)) {
|
|
88
90
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
89
|
-
return NextResponse.json({ error: 'Executable file types are not allowed as attachments.' }, { status: 400 })
|
|
91
|
+
return NextResponse.json({ error: t('storage_s3.errors.dangerousExecutable', 'Executable file types are not allowed as attachments.') }, { status: 400 })
|
|
90
92
|
}
|
|
91
93
|
const contentType = detectAttachmentMimeType(buffer, fileName, req.headers.get('content-type'))
|
|
92
94
|
if (isActiveContentAttachment(buffer, fileName, contentType)) {
|
|
93
95
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
94
|
-
return NextResponse.json({ error: 'Active content uploads are not allowed.' }, { status: 400 })
|
|
96
|
+
return NextResponse.json({ error: t('storage_s3.errors.activeContentBlocked', 'Active content uploads are not allowed.') }, { status: 400 })
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
const credentialsService = resolve('integrationCredentialsService') as {
|
|
@@ -103,7 +105,7 @@ export async function PUT(req: Request, ctx: RouteContext) {
|
|
|
103
105
|
})
|
|
104
106
|
if (!credentials) {
|
|
105
107
|
await quotaService.release(reservation.id, reservation.leaseToken).catch(() => {})
|
|
106
|
-
return NextResponse.json({ error: 'S3 integration is not configured.' }, { status: 400 })
|
|
108
|
+
return NextResponse.json({ error: t('storage_s3.errors.integrationNotConfigured', 'S3 integration is not configured.') }, { status: 400 })
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
const driver = new S3StorageDriver({
|
|
@@ -127,7 +129,7 @@ export async function PUT(req: Request, ctx: RouteContext) {
|
|
|
127
129
|
// Retain the reservation when object absence cannot be proven.
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
|
-
return NextResponse.json({ error: 'Failed to persist attachment.' }, { status: 500 })
|
|
132
|
+
return NextResponse.json({ error: t('storage_s3.errors.persistFailed', 'Failed to persist attachment.') }, { status: 500 })
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
|
|
@@ -11,9 +11,14 @@
|
|
|
11
11
|
"storage_s3.errors.keyRequired": "Der Abfrageparameter „key“ ist erforderlich",
|
|
12
12
|
"storage_s3.errors.maxUploadSize": "Der Anhang überschreitet die maximal zulässige Upload-Größe.",
|
|
13
13
|
"storage_s3.errors.multipartRequired": "multipart/form-data wurde erwartet",
|
|
14
|
+
"storage_s3.errors.persistFailed": "Der Anhang konnte nicht gespeichert werden.",
|
|
14
15
|
"storage_s3.errors.prefixAccessDenied": "Zugriff verweigert: Das Präfix ist nicht auf diesen Tenant beschränkt.",
|
|
15
16
|
"storage_s3.errors.quotaExceeded": "Das Speicherkontingent für Anhänge dieses Mandanten ist erschöpft.",
|
|
16
17
|
"storage_s3.errors.quotaTargetExists": "Der Ziel-Speicherschlüssel existiert bereits.",
|
|
17
18
|
"storage_s3.errors.quotaUnavailable": "Die Kontingentabrechnung des Speichers ist nicht verfügbar.",
|
|
18
|
-
"storage_s3.errors.
|
|
19
|
+
"storage_s3.errors.readFailed": "Der Anhang konnte nicht gelesen werden.",
|
|
20
|
+
"storage_s3.errors.reservedSizeExceeded": "Der Anhang überschreitet die reservierte Upload-Größe.",
|
|
21
|
+
"storage_s3.errors.unauthorized": "Nicht autorisiert",
|
|
22
|
+
"storage_s3.errors.uploadTokenInvalid": "Das Upload-Token ist ungültig oder abgelaufen.",
|
|
23
|
+
"storage_s3.errors.uploadTokenRequired": "Ein Upload-Token ist erforderlich."
|
|
19
24
|
}
|
|
@@ -11,9 +11,14 @@
|
|
|
11
11
|
"storage_s3.errors.keyRequired": "key query param is required",
|
|
12
12
|
"storage_s3.errors.maxUploadSize": "Attachment exceeds the maximum upload size.",
|
|
13
13
|
"storage_s3.errors.multipartRequired": "Expected multipart/form-data",
|
|
14
|
+
"storage_s3.errors.persistFailed": "Failed to persist attachment.",
|
|
14
15
|
"storage_s3.errors.prefixAccessDenied": "Access denied: prefix is not scoped to this tenant.",
|
|
15
16
|
"storage_s3.errors.quotaExceeded": "Attachment storage quota exceeded for this tenant.",
|
|
16
17
|
"storage_s3.errors.quotaTargetExists": "The target storage key already exists.",
|
|
17
18
|
"storage_s3.errors.quotaUnavailable": "Storage quota accounting is unavailable.",
|
|
18
|
-
"storage_s3.errors.
|
|
19
|
+
"storage_s3.errors.readFailed": "Failed to read attachment.",
|
|
20
|
+
"storage_s3.errors.reservedSizeExceeded": "Attachment exceeds the reserved upload size.",
|
|
21
|
+
"storage_s3.errors.unauthorized": "Unauthorized",
|
|
22
|
+
"storage_s3.errors.uploadTokenInvalid": "Upload token is invalid or expired.",
|
|
23
|
+
"storage_s3.errors.uploadTokenRequired": "Upload token is required."
|
|
19
24
|
}
|
|
@@ -11,9 +11,14 @@
|
|
|
11
11
|
"storage_s3.errors.keyRequired": "Se requiere el parámetro de consulta key",
|
|
12
12
|
"storage_s3.errors.maxUploadSize": "El archivo adjunto supera el tamaño máximo de carga permitido.",
|
|
13
13
|
"storage_s3.errors.multipartRequired": "Se esperaba multipart/form-data",
|
|
14
|
+
"storage_s3.errors.persistFailed": "No se pudo guardar el archivo adjunto.",
|
|
14
15
|
"storage_s3.errors.prefixAccessDenied": "Acceso denegado: el prefijo no está limitado a este tenant.",
|
|
15
16
|
"storage_s3.errors.quotaExceeded": "Se superó la cuota de almacenamiento de adjuntos para este inquilino.",
|
|
16
17
|
"storage_s3.errors.quotaTargetExists": "La clave de almacenamiento de destino ya existe.",
|
|
17
18
|
"storage_s3.errors.quotaUnavailable": "La contabilidad de la cuota de almacenamiento no está disponible.",
|
|
18
|
-
"storage_s3.errors.
|
|
19
|
+
"storage_s3.errors.readFailed": "No se pudo leer el archivo adjunto.",
|
|
20
|
+
"storage_s3.errors.reservedSizeExceeded": "El archivo adjunto supera el tamaño de carga reservado.",
|
|
21
|
+
"storage_s3.errors.unauthorized": "No autorizado",
|
|
22
|
+
"storage_s3.errors.uploadTokenInvalid": "El token de carga no es válido o ha caducado.",
|
|
23
|
+
"storage_s3.errors.uploadTokenRequired": "Se requiere un token de carga."
|
|
19
24
|
}
|
|
@@ -11,9 +11,14 @@
|
|
|
11
11
|
"storage_s3.errors.keyRequired": "주요 쿼리 매개변수가 필요합니다.",
|
|
12
12
|
"storage_s3.errors.maxUploadSize": "첨부파일이 최대 업로드 크기를 초과합니다.",
|
|
13
13
|
"storage_s3.errors.multipartRequired": "예상되는 멀티파트/양식 데이터",
|
|
14
|
+
"storage_s3.errors.persistFailed": "첨부파일을 저장하지 못했습니다.",
|
|
14
15
|
"storage_s3.errors.prefixAccessDenied": "액세스 거부됨: 접두사의 범위가 이 테넌트로 지정되지 않았습니다.",
|
|
15
16
|
"storage_s3.errors.quotaExceeded": "이 테넌트의 첨부 파일 저장 용량 한도를 초과했습니다.",
|
|
16
17
|
"storage_s3.errors.quotaTargetExists": "대상 저장 키가 이미 존재합니다.",
|
|
17
18
|
"storage_s3.errors.quotaUnavailable": "저장 용량 정산을 사용할 수 없습니다.",
|
|
18
|
-
"storage_s3.errors.
|
|
19
|
+
"storage_s3.errors.readFailed": "첨부파일을 읽지 못했습니다.",
|
|
20
|
+
"storage_s3.errors.reservedSizeExceeded": "첨부파일이 예약된 업로드 크기를 초과합니다.",
|
|
21
|
+
"storage_s3.errors.unauthorized": "승인되지 않은",
|
|
22
|
+
"storage_s3.errors.uploadTokenInvalid": "업로드 토큰이 유효하지 않거나 만료되었습니다.",
|
|
23
|
+
"storage_s3.errors.uploadTokenRequired": "업로드 토큰이 필요합니다."
|
|
19
24
|
}
|
|
@@ -11,9 +11,14 @@
|
|
|
11
11
|
"storage_s3.errors.keyRequired": "Wymagany jest parametr zapytania key",
|
|
12
12
|
"storage_s3.errors.maxUploadSize": "Załącznik przekracza maksymalny dopuszczalny rozmiar przesyłania.",
|
|
13
13
|
"storage_s3.errors.multipartRequired": "Oczekiwano multipart/form-data",
|
|
14
|
+
"storage_s3.errors.persistFailed": "Nie udało się zapisać załącznika.",
|
|
14
15
|
"storage_s3.errors.prefixAccessDenied": "Odmowa dostępu: prefiks nie jest ograniczony do tego tenantu.",
|
|
15
16
|
"storage_s3.errors.quotaExceeded": "Przekroczono limit przestrzeni na załączniki dla tego najemcy.",
|
|
16
17
|
"storage_s3.errors.quotaTargetExists": "Docelowy klucz w magazynie już istnieje.",
|
|
17
18
|
"storage_s3.errors.quotaUnavailable": "Rozliczanie limitu magazynu jest niedostępne.",
|
|
18
|
-
"storage_s3.errors.
|
|
19
|
+
"storage_s3.errors.readFailed": "Nie udało się odczytać załącznika.",
|
|
20
|
+
"storage_s3.errors.reservedSizeExceeded": "Załącznik przekracza zarezerwowany rozmiar przesyłania.",
|
|
21
|
+
"storage_s3.errors.unauthorized": "Brak autoryzacji",
|
|
22
|
+
"storage_s3.errors.uploadTokenInvalid": "Token przesyłania jest nieprawidłowy lub wygasł.",
|
|
23
|
+
"storage_s3.errors.uploadTokenRequired": "Token przesyłania jest wymagany."
|
|
19
24
|
}
|