@open-mercato/core 0.10.1-develop.7797.1.f0365b4031 → 0.10.1-develop.7799.1.d9cd0c8e41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/forms/api/attachment-helpers.js +24 -0
- package/dist/modules/forms/api/attachment-helpers.js.map +2 -2
- package/dist/modules/forms/api/public/submissions/[id]/attachments/[attachmentId]/route.js +26 -1
- package/dist/modules/forms/api/public/submissions/[id]/attachments/[attachmentId]/route.js.map +2 -2
- package/dist/modules/forms/api/public/submissions/[id]/attachments/route.js +17 -1
- package/dist/modules/forms/api/public/submissions/[id]/attachments/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/forms/api/attachment-helpers.ts +34 -0
- package/src/modules/forms/api/public/submissions/[id]/attachments/[attachmentId]/route.ts +26 -1
- package/src/modules/forms/api/public/submissions/[id]/attachments/route.ts +16 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
2
3
|
import { FormSubmission, FormVersion } from "../data/entities.js";
|
|
3
4
|
import { isAttachmentServiceError } from "../services/attachment-service.js";
|
|
4
5
|
import { resolveMaxUploadBytes } from "../services/upload-validation.js";
|
|
@@ -14,6 +15,28 @@ function mapAttachmentError(error) {
|
|
|
14
15
|
const message = error instanceof Error ? error.message : "Unknown error";
|
|
15
16
|
return NextResponse.json({ error: "INTERNAL_ERROR", message }, { status: 500 });
|
|
16
17
|
}
|
|
18
|
+
async function resolveAttachmentRolePolicy(em, args, compiler, rolePolicy) {
|
|
19
|
+
if (!args.role) return null;
|
|
20
|
+
const scope = { organizationId: args.organizationId, tenantId: args.tenantId };
|
|
21
|
+
const submission = await findOneWithDecryption(em, FormSubmission, {
|
|
22
|
+
id: args.submissionId,
|
|
23
|
+
...scope,
|
|
24
|
+
deletedAt: null
|
|
25
|
+
}, void 0, scope);
|
|
26
|
+
if (!submission) return null;
|
|
27
|
+
const version = await findOneWithDecryption(em, FormVersion, {
|
|
28
|
+
id: submission.formVersionId,
|
|
29
|
+
...scope
|
|
30
|
+
}, void 0, scope);
|
|
31
|
+
if (!version) return null;
|
|
32
|
+
const compiled = compiler.compile({
|
|
33
|
+
id: version.id,
|
|
34
|
+
updatedAt: version.updatedAt,
|
|
35
|
+
schema: version.schema,
|
|
36
|
+
uiSchema: version.uiSchema
|
|
37
|
+
});
|
|
38
|
+
return rolePolicy.resolve(compiled, args.role);
|
|
39
|
+
}
|
|
17
40
|
async function resolveFieldUploadConfig(em, args) {
|
|
18
41
|
const submission = await em.findOne(FormSubmission, {
|
|
19
42
|
id: args.submissionId,
|
|
@@ -106,6 +129,7 @@ export {
|
|
|
106
129
|
SANDBOXED_DOWNLOAD_HEADERS,
|
|
107
130
|
mapAttachmentError,
|
|
108
131
|
parseUploadBody,
|
|
132
|
+
resolveAttachmentRolePolicy,
|
|
109
133
|
resolveFieldUploadConfig
|
|
110
134
|
};
|
|
111
135
|
//# sourceMappingURL=attachment-helpers.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/forms/api/attachment-helpers.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Shared helpers for the W4 attachment upload / download routes.\n *\n * Resolves the per-field upload config (`x-om-accept`, `x-om-max-size-bytes`,\n * `x-om-multiple`) from the submission's pinned form version \u2014 the server is\n * authoritative; the client never supplies these constraints.\n */\n\nimport { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { FormSubmission, FormVersion } from '../data/entities'\nimport { AttachmentServiceError, isAttachmentServiceError } from '../services/attachment-service'\nimport { resolveMaxUploadBytes } from '../services/upload-validation'\n\nconst MULTIPART_OVERHEAD_BYTES = 64 * 1024\n\nexport const SANDBOXED_DOWNLOAD_HEADERS = {\n 'content-security-policy': \"default-src 'none'; sandbox\",\n 'x-content-type-options': 'nosniff',\n} as const\n\nexport type FieldUploadConfig = {\n fieldKey: string\n accept: string[] | null\n maxSizeBytes: number | null\n multiple: boolean\n}\n\nexport function mapAttachmentError(error: unknown): NextResponse {\n if (isAttachmentServiceError(error)) {\n return NextResponse.json({ error: error.code, message: error.message }, { status: error.httpStatus })\n }\n const message = error instanceof Error ? error.message : 'Unknown error'\n return NextResponse.json({ error: 'INTERNAL_ERROR', message }, { status: 500 })\n}\n\n/**\n * Looks up the `file`-typed field node on the submission's pinned form version\n * and reads its upload config. Returns `null` when the submission, version, or\n * field is missing, or when the field is not a `file` type. Scope is enforced\n * by org+tenant on both reads.\n */\nexport async function resolveFieldUploadConfig(\n em: EntityManager,\n args: { organizationId: string; tenantId: string; submissionId: string; fieldKey: string },\n): Promise<FieldUploadConfig | null> {\n const submission = await em.findOne(FormSubmission, {\n id: args.submissionId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n deletedAt: null,\n })\n if (!submission) return null\n\n const version = await em.findOne(FormVersion, {\n id: submission.formVersionId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n })\n if (!version) return null\n\n const properties = (version.schema as Record<string, unknown>).properties\n if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return null\n const node = (properties as Record<string, unknown>)[args.fieldKey]\n if (!node || typeof node !== 'object' || Array.isArray(node)) return null\n\n const record = node as Record<string, unknown>\n if (record['x-om-type'] !== 'file') return null\n\n const acceptRaw = record['x-om-accept']\n const accept = Array.isArray(acceptRaw)\n ? acceptRaw.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)\n : null\n const maxRaw = record['x-om-max-size-bytes']\n const maxSizeBytes = typeof maxRaw === 'number' && Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : null\n\n return {\n fieldKey: args.fieldKey,\n accept: accept && accept.length > 0 ? accept : null,\n maxSizeBytes,\n multiple: record['x-om-multiple'] === true,\n }\n}\n\nexport type ParsedUpload = {\n fieldKey: string\n filename: string\n contentType: string\n bytes: Buffer\n}\n\n/**\n * Parses a multipart/form-data upload body. Expects a `file` part and a\n * `field_key` text part. Returns a 422 response describing the failure when\n * the body is malformed.\n */\nexport async function parseUploadBody(\n req: Request,\n maxFileBytes: number = resolveMaxUploadBytes(process.env),\n): Promise<ParsedUpload | NextResponse> {\n const maxBodyBytes = maxFileBytes + MULTIPART_OVERHEAD_BYTES\n const declaredLength = Number(req.headers.get('content-length'))\n if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n\n const boundedBody = await readBoundedBody(req, maxBodyBytes)\n if (boundedBody instanceof NextResponse) return boundedBody\n\n let form: FormData\n try {\n const boundedRequest = new Request(req.url, {\n method: req.method,\n headers: req.headers,\n body: boundedBody as unknown as BodyInit,\n })\n form = await boundedRequest.formData()\n } catch {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Expected multipart/form-data.' }, { status: 422 })\n }\n const fieldKeyRaw = form.get('field_key')\n if (typeof fieldKeyRaw !== 'string' || fieldKeyRaw.length === 0) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Missing field_key.' }, { status: 422 })\n }\n const filePart = form.get('file')\n if (!(filePart instanceof File)) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Missing file part.' }, { status: 422 })\n }\n if (filePart.size > maxFileBytes) {\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n const arrayBuffer = await filePart.arrayBuffer()\n return {\n fieldKey: fieldKeyRaw,\n filename: filePart.name || 'upload',\n contentType: filePart.type || 'application/octet-stream',\n bytes: Buffer.from(arrayBuffer),\n }\n}\n\nasync function readBoundedBody(req: Request, maxBodyBytes: number): Promise<Buffer | NextResponse> {\n if (!req.body) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Expected multipart/form-data.' }, { status: 422 })\n }\n\n const reader = req.body.getReader()\n const chunks: Buffer[] = []\n let totalBytes = 0\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBodyBytes) {\n await reader.cancel()\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n chunks.push(Buffer.from(value))\n }\n return Buffer.concat(chunks, totalBytes)\n}\n"],
|
|
5
|
-
"mappings": "AAQA,SAAS,oBAAoB;AAE7B,SAAS,gBAAgB,mBAAmB;
|
|
4
|
+
"sourcesContent": ["/**\n * Shared helpers for the W4 attachment upload / download routes.\n *\n * Resolves the per-field upload config (`x-om-accept`, `x-om-max-size-bytes`,\n * `x-om-multiple`) from the submission's pinned form version \u2014 the server is\n * authoritative; the client never supplies these constraints.\n */\n\nimport { NextResponse } from 'next/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { FormSubmission, FormVersion } from '../data/entities'\nimport type { FormVersionCompiler } from '../services/form-version-compiler'\nimport type { RolePolicyResolution, RolePolicyService } from '../services/role-policy-service'\nimport { AttachmentServiceError, isAttachmentServiceError } from '../services/attachment-service'\nimport { resolveMaxUploadBytes } from '../services/upload-validation'\n\nconst MULTIPART_OVERHEAD_BYTES = 64 * 1024\n\nexport const SANDBOXED_DOWNLOAD_HEADERS = {\n 'content-security-policy': \"default-src 'none'; sandbox\",\n 'x-content-type-options': 'nosniff',\n} as const\n\nexport type FieldUploadConfig = {\n fieldKey: string\n accept: string[] | null\n maxSizeBytes: number | null\n multiple: boolean\n}\n\nexport function mapAttachmentError(error: unknown): NextResponse {\n if (isAttachmentServiceError(error)) {\n return NextResponse.json({ error: error.code, message: error.message }, { status: error.httpStatus })\n }\n const message = error instanceof Error ? error.message : 'Unknown error'\n return NextResponse.json({ error: 'INTERNAL_ERROR', message }, { status: 500 })\n}\n\nexport async function resolveAttachmentRolePolicy(\n em: EntityManager,\n args: { organizationId: string; tenantId: string; submissionId: string; role: string | null },\n compiler: FormVersionCompiler,\n rolePolicy: RolePolicyService,\n): Promise<RolePolicyResolution | null> {\n if (!args.role) return null\n\n const scope = { organizationId: args.organizationId, tenantId: args.tenantId }\n const submission = await findOneWithDecryption(em, FormSubmission, {\n id: args.submissionId,\n ...scope,\n deletedAt: null,\n }, undefined, scope)\n if (!submission) return null\n\n const version = await findOneWithDecryption(em, FormVersion, {\n id: submission.formVersionId,\n ...scope,\n }, undefined, scope)\n if (!version) return null\n\n const compiled = compiler.compile({\n id: version.id,\n updatedAt: version.updatedAt,\n schema: version.schema,\n uiSchema: version.uiSchema,\n })\n return rolePolicy.resolve(compiled, args.role)\n}\n\n/**\n * Looks up the `file`-typed field node on the submission's pinned form version\n * and reads its upload config. Returns `null` when the submission, version, or\n * field is missing, or when the field is not a `file` type. Scope is enforced\n * by org+tenant on both reads.\n */\nexport async function resolveFieldUploadConfig(\n em: EntityManager,\n args: { organizationId: string; tenantId: string; submissionId: string; fieldKey: string },\n): Promise<FieldUploadConfig | null> {\n const submission = await em.findOne(FormSubmission, {\n id: args.submissionId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n deletedAt: null,\n })\n if (!submission) return null\n\n const version = await em.findOne(FormVersion, {\n id: submission.formVersionId,\n organizationId: args.organizationId,\n tenantId: args.tenantId,\n })\n if (!version) return null\n\n const properties = (version.schema as Record<string, unknown>).properties\n if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return null\n const node = (properties as Record<string, unknown>)[args.fieldKey]\n if (!node || typeof node !== 'object' || Array.isArray(node)) return null\n\n const record = node as Record<string, unknown>\n if (record['x-om-type'] !== 'file') return null\n\n const acceptRaw = record['x-om-accept']\n const accept = Array.isArray(acceptRaw)\n ? acceptRaw.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)\n : null\n const maxRaw = record['x-om-max-size-bytes']\n const maxSizeBytes = typeof maxRaw === 'number' && Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : null\n\n return {\n fieldKey: args.fieldKey,\n accept: accept && accept.length > 0 ? accept : null,\n maxSizeBytes,\n multiple: record['x-om-multiple'] === true,\n }\n}\n\nexport type ParsedUpload = {\n fieldKey: string\n filename: string\n contentType: string\n bytes: Buffer\n}\n\n/**\n * Parses a multipart/form-data upload body. Expects a `file` part and a\n * `field_key` text part. Returns a 422 response describing the failure when\n * the body is malformed.\n */\nexport async function parseUploadBody(\n req: Request,\n maxFileBytes: number = resolveMaxUploadBytes(process.env),\n): Promise<ParsedUpload | NextResponse> {\n const maxBodyBytes = maxFileBytes + MULTIPART_OVERHEAD_BYTES\n const declaredLength = Number(req.headers.get('content-length'))\n if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n\n const boundedBody = await readBoundedBody(req, maxBodyBytes)\n if (boundedBody instanceof NextResponse) return boundedBody\n\n let form: FormData\n try {\n const boundedRequest = new Request(req.url, {\n method: req.method,\n headers: req.headers,\n body: boundedBody as unknown as BodyInit,\n })\n form = await boundedRequest.formData()\n } catch {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Expected multipart/form-data.' }, { status: 422 })\n }\n const fieldKeyRaw = form.get('field_key')\n if (typeof fieldKeyRaw !== 'string' || fieldKeyRaw.length === 0) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Missing field_key.' }, { status: 422 })\n }\n const filePart = form.get('file')\n if (!(filePart instanceof File)) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Missing file part.' }, { status: 422 })\n }\n if (filePart.size > maxFileBytes) {\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n const arrayBuffer = await filePart.arrayBuffer()\n return {\n fieldKey: fieldKeyRaw,\n filename: filePart.name || 'upload',\n contentType: filePart.type || 'application/octet-stream',\n bytes: Buffer.from(arrayBuffer),\n }\n}\n\nasync function readBoundedBody(req: Request, maxBodyBytes: number): Promise<Buffer | NextResponse> {\n if (!req.body) {\n return NextResponse.json({ error: 'VALIDATION_FAILED', message: 'Expected multipart/form-data.' }, { status: 422 })\n }\n\n const reader = req.body.getReader()\n const chunks: Buffer[] = []\n let totalBytes = 0\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n totalBytes += value.byteLength\n if (totalBytes > maxBodyBytes) {\n await reader.cancel()\n return NextResponse.json({ error: 'TOO_LARGE', message: 'Upload exceeds the allowed size.' }, { status: 413 })\n }\n chunks.push(Buffer.from(value))\n }\n return Buffer.concat(chunks, totalBytes)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
3
|
+
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
4
|
+
import { FormAttachment } from "../../../../../../data/entities.js";
|
|
3
5
|
import { resolveRuntimePrincipal } from "../../../../../../lib/runtime-principal.js";
|
|
4
6
|
import {
|
|
5
7
|
mapAttachmentError,
|
|
8
|
+
resolveAttachmentRolePolicy,
|
|
6
9
|
SANDBOXED_DOWNLOAD_HEADERS
|
|
7
10
|
} from "../../../../../attachment-helpers.js";
|
|
8
11
|
import { buildPublicRateLimitKey, enforcePublicRateLimit } from "../../../../rate-limit.js";
|
|
@@ -24,6 +27,28 @@ async function GET(req, context) {
|
|
|
24
27
|
const principal = await resolveRuntimePrincipal({ req, submissionId, em });
|
|
25
28
|
if (!principal) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
|
26
29
|
try {
|
|
30
|
+
const scope = { organizationId: principal.organizationId, tenantId: principal.tenantId };
|
|
31
|
+
const policy = await resolveAttachmentRolePolicy(
|
|
32
|
+
em,
|
|
33
|
+
{
|
|
34
|
+
...scope,
|
|
35
|
+
submissionId,
|
|
36
|
+
role: principal.role
|
|
37
|
+
},
|
|
38
|
+
container.resolve("formVersionCompiler"),
|
|
39
|
+
container.resolve("formsRolePolicyService")
|
|
40
|
+
);
|
|
41
|
+
if (!policy) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
|
|
42
|
+
const attachment = await findOneWithDecryption(em, FormAttachment, {
|
|
43
|
+
id: attachmentId,
|
|
44
|
+
submissionId,
|
|
45
|
+
organizationId: principal.organizationId,
|
|
46
|
+
kind: "user_upload",
|
|
47
|
+
removedAt: null
|
|
48
|
+
}, { fields: ["fieldKey"] }, scope);
|
|
49
|
+
if (!attachment || !policy.canRead(attachment.fieldKey)) {
|
|
50
|
+
return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
|
|
51
|
+
}
|
|
27
52
|
const result = await service.readUpload({
|
|
28
53
|
organizationId: principal.organizationId,
|
|
29
54
|
tenantId: principal.tenantId,
|
|
@@ -45,7 +70,7 @@ async function GET(req, context) {
|
|
|
45
70
|
}
|
|
46
71
|
const getMethodDoc = {
|
|
47
72
|
summary: "Download a submission attachment (public/anonymous)",
|
|
48
|
-
description: "Streams
|
|
73
|
+
description: "Streams decrypted user-upload bytes only when the participant role can read the attachment field in the submission\u2019s pinned version. Authorized via access token or portal session.",
|
|
49
74
|
tags: ["Forms Public Runtime"],
|
|
50
75
|
responses: [{ status: 200, description: "Attachment stream", mediaType: "application/octet-stream" }],
|
|
51
76
|
errors: [
|
package/dist/modules/forms/api/public/submissions/[id]/attachments/[attachmentId]/route.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../../src/modules/forms/api/public/submissions/%5Bid%5D/attachments/%5BattachmentId%5D/route.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Public/anonymous attachment download \u2014\n * GET /api/forms/public/submissions/:id/attachments/:attachmentId\n *\n * Streams a decrypted user-upload attachment back to the owning participant so\n * they can preview / confirm what they uploaded. Authorized via\n * `resolveRuntimePrincipal` (bearer access token OR portal session); org/tenant\n * are re-derived from the persisted submission (R-2d-4). Cross-tenant ids\n * return 404 (no enumeration).\n */\n\nimport { NextResponse, type NextRequest } from 'next/server'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { AttachmentService } from '../../../../../../services/attachment-service'\nimport { resolveRuntimePrincipal } from '../../../../../../lib/runtime-principal'\nimport {\n mapAttachmentError,\n SANDBOXED_DOWNLOAD_HEADERS,\n} from '../../../../../attachment-helpers'\nimport { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../../rate-limit'\n\nexport const metadata = {\n GET: { requireAuth: false },\n}\n\nexport async function GET(\n req: NextRequest,\n context: { params: { id: string; attachmentId: string } | Promise<{ id: string; attachmentId: string }> },\n) {\n const params = await Promise.resolve(context.params)\n const submissionId = String(params.id)\n const attachmentId = String(params.attachmentId)\n\n const limited = await enforcePublicRateLimit(\n req,\n buildPublicRateLimitKey('submission-attachment', submissionId, attachmentId),\n )\n if (limited) return limited\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = container.resolve('formsAttachmentService') as AttachmentService\n\n const principal = await resolveRuntimePrincipal({ req, submissionId, em })\n if (!principal) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 })\n\n try {\n const result = await service.readUpload({\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n attachmentId,\n })\n const safeName = result.filename.replace(/[\"\\\\\\r\\n]/g, '_') || 'attachment'\n return new NextResponse(result.bytes as unknown as BodyInit, {\n status: 200,\n headers: {\n 'content-type': result.contentType || 'application/octet-stream',\n 'content-disposition': `attachment; filename=\"${safeName}\"`,\n ...SANDBOXED_DOWNLOAD_HEADERS,\n },\n })\n } catch (error) {\n return mapAttachmentError(error)\n }\n}\n\nconst getMethodDoc: OpenApiMethodDoc = {\n summary: 'Download a submission attachment (public/anonymous)',\n description: 'Streams
|
|
5
|
-
"mappings": "AAWA,SAAS,oBAAsC;AAG/C,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["/**\n * Public/anonymous attachment download \u2014\n * GET /api/forms/public/submissions/:id/attachments/:attachmentId\n *\n * Streams a decrypted user-upload attachment back to the owning participant so\n * they can preview / confirm what they uploaded. Authorized via\n * `resolveRuntimePrincipal` (bearer access token OR portal session); org/tenant\n * are re-derived from the persisted submission (R-2d-4). Cross-tenant ids\n * return 404 (no enumeration).\n */\n\nimport { NextResponse, type NextRequest } from 'next/server'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { FormAttachment } from '../../../../../../data/entities'\nimport type { AttachmentService } from '../../../../../../services/attachment-service'\nimport type { FormVersionCompiler } from '../../../../../../services/form-version-compiler'\nimport type { RolePolicyService } from '../../../../../../services/role-policy-service'\nimport { resolveRuntimePrincipal } from '../../../../../../lib/runtime-principal'\nimport {\n mapAttachmentError,\n resolveAttachmentRolePolicy,\n SANDBOXED_DOWNLOAD_HEADERS,\n} from '../../../../../attachment-helpers'\nimport { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../../rate-limit'\n\nexport const metadata = {\n GET: { requireAuth: false },\n}\n\nexport async function GET(\n req: NextRequest,\n context: { params: { id: string; attachmentId: string } | Promise<{ id: string; attachmentId: string }> },\n) {\n const params = await Promise.resolve(context.params)\n const submissionId = String(params.id)\n const attachmentId = String(params.attachmentId)\n\n const limited = await enforcePublicRateLimit(\n req,\n buildPublicRateLimitKey('submission-attachment', submissionId, attachmentId),\n )\n if (limited) return limited\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = container.resolve('formsAttachmentService') as AttachmentService\n\n const principal = await resolveRuntimePrincipal({ req, submissionId, em })\n if (!principal) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 })\n\n try {\n const scope = { organizationId: principal.organizationId, tenantId: principal.tenantId }\n const policy = await resolveAttachmentRolePolicy(em, {\n ...scope,\n submissionId,\n role: principal.role,\n }, container.resolve('formVersionCompiler') as FormVersionCompiler,\n container.resolve('formsRolePolicyService') as RolePolicyService)\n if (!policy) return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })\n\n const attachment = await findOneWithDecryption(em, FormAttachment, {\n id: attachmentId,\n submissionId,\n organizationId: principal.organizationId,\n kind: 'user_upload',\n removedAt: null,\n }, { fields: ['fieldKey'] }, scope)\n if (!attachment || !policy.canRead(attachment.fieldKey)) {\n return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })\n }\n\n const result = await service.readUpload({\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n attachmentId,\n })\n const safeName = result.filename.replace(/[\"\\\\\\r\\n]/g, '_') || 'attachment'\n return new NextResponse(result.bytes as unknown as BodyInit, {\n status: 200,\n headers: {\n 'content-type': result.contentType || 'application/octet-stream',\n 'content-disposition': `attachment; filename=\"${safeName}\"`,\n ...SANDBOXED_DOWNLOAD_HEADERS,\n },\n })\n } catch (error) {\n return mapAttachmentError(error)\n }\n}\n\nconst getMethodDoc: OpenApiMethodDoc = {\n summary: 'Download a submission attachment (public/anonymous)',\n description: 'Streams decrypted user-upload bytes only when the participant role can read the attachment field in the submission\u2019s pinned version. Authorized via access token or portal session.',\n tags: ['Forms Public Runtime'],\n responses: [{ status: 200, description: 'Attachment stream', mediaType: 'application/octet-stream' }],\n errors: [\n { status: 401, description: 'Missing or invalid access token / session' },\n { status: 404, description: 'Attachment not found' },\n { status: 429, description: 'Rate limit exceeded' },\n { status: 503, description: 'Rate limiting unavailable' },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Public attachment download',\n methods: { GET: getMethodDoc },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -5,6 +5,7 @@ import { resolveRuntimePrincipal } from "../../../../../lib/runtime-principal.js
|
|
|
5
5
|
import {
|
|
6
6
|
mapAttachmentError,
|
|
7
7
|
parseUploadBody,
|
|
8
|
+
resolveAttachmentRolePolicy,
|
|
8
9
|
resolveFieldUploadConfig
|
|
9
10
|
} from "../../../../attachment-helpers.js";
|
|
10
11
|
import { buildPublicRateLimitKey, enforcePublicRateLimit } from "../../../rate-limit.js";
|
|
@@ -26,6 +27,20 @@ async function POST(req, context) {
|
|
|
26
27
|
if (!principal) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
|
27
28
|
const parsed = await parseUploadBody(req);
|
|
28
29
|
if (parsed instanceof NextResponse) return parsed;
|
|
30
|
+
const policy = await resolveAttachmentRolePolicy(
|
|
31
|
+
em,
|
|
32
|
+
{
|
|
33
|
+
organizationId: principal.organizationId,
|
|
34
|
+
tenantId: principal.tenantId,
|
|
35
|
+
submissionId,
|
|
36
|
+
role: principal.role
|
|
37
|
+
},
|
|
38
|
+
container.resolve("formVersionCompiler"),
|
|
39
|
+
container.resolve("formsRolePolicyService")
|
|
40
|
+
);
|
|
41
|
+
if (!policy?.canWrite(parsed.fieldKey)) {
|
|
42
|
+
return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
|
|
43
|
+
}
|
|
29
44
|
const config = await resolveFieldUploadConfig(em, {
|
|
30
45
|
organizationId: principal.organizationId,
|
|
31
46
|
tenantId: principal.tenantId,
|
|
@@ -62,11 +77,12 @@ const uploadResponseSchema = z.object({
|
|
|
62
77
|
});
|
|
63
78
|
const postMethodDoc = {
|
|
64
79
|
summary: "Upload an attachment (public/anonymous)",
|
|
65
|
-
description: "Stores an encrypted file for a file-typed field. Authorized via access token or portal session.",
|
|
80
|
+
description: "Stores an encrypted file for a file-typed field the participant role can edit in the submission\u2019s pinned version. Authorized via access token or portal session.",
|
|
66
81
|
tags: ["Forms Public Runtime"],
|
|
67
82
|
responses: [{ status: 201, description: "Attachment stored", schema: uploadResponseSchema }],
|
|
68
83
|
errors: [
|
|
69
84
|
{ status: 401, description: "Missing or invalid access token / session", schema: errorSchema },
|
|
85
|
+
{ status: 404, description: "Field not found or not editable by the participant role", schema: errorSchema },
|
|
70
86
|
{ status: 413, description: "File empty or oversize", schema: errorSchema },
|
|
71
87
|
{ status: 422, description: "Disallowed type / invalid field / scan rejected", schema: errorSchema },
|
|
72
88
|
{ status: 429, description: "Rate limit exceeded", schema: errorSchema },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/forms/api/public/submissions/%5Bid%5D/attachments/route.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Public/anonymous attachment upload \u2014\n * POST /api/forms/public/submissions/:id/attachments\n *\n * Token- or portal-authenticated participants upload a file for a `file`-typed\n * field. Authorization goes through `resolveRuntimePrincipal` (bearer access\n * token OR customer session). Org/tenant are ALWAYS re-derived from the\n * persisted submission row (R-2d-4) \u2014 never trusted from the client. Bytes are\n * encrypted at rest and gated by MIME allowlist, size ceiling, and the\n * pluggable virus scanner (W4 / SEC-4).\n */\n\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { AttachmentService } from '../../../../../services/attachment-service'\nimport { resolveRuntimePrincipal } from '../../../../../lib/runtime-principal'\nimport {\n mapAttachmentError,\n parseUploadBody,\n resolveFieldUploadConfig,\n} from '../../../../attachment-helpers'\nimport { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../rate-limit'\n\nexport const metadata = {\n POST: { requireAuth: false },\n}\n\nexport async function POST(\n req: NextRequest,\n context: { params: { id: string } | Promise<{ id: string }> },\n) {\n const params = await Promise.resolve(context.params)\n const submissionId = String(params.id)\n\n const limited = await enforcePublicRateLimit(\n req,\n buildPublicRateLimitKey('submission-upload', submissionId),\n )\n if (limited) return limited\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = container.resolve('formsAttachmentService') as AttachmentService\n\n const principal = await resolveRuntimePrincipal({ req, submissionId, em })\n if (!principal) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 })\n\n const parsed = await parseUploadBody(req)\n if (parsed instanceof NextResponse) return parsed\n\n const config = await resolveFieldUploadConfig(em, {\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n fieldKey: parsed.fieldKey,\n })\n if (!config) {\n return NextResponse.json({ error: 'INVALID_FIELD' }, { status: 422 })\n }\n\n try {\n const stored = await service.storeUpload({\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n fieldKey: parsed.fieldKey,\n filename: parsed.filename,\n contentType: parsed.contentType,\n bytes: parsed.bytes,\n uploadedBy: principal.source === 'customer' ? principal.principal : null,\n accept: config.accept,\n fieldMaxSizeBytes: config.maxSizeBytes,\n })\n return NextResponse.json(stored, { status: 201 })\n } catch (error) {\n return mapAttachmentError(error)\n }\n}\n\nconst errorSchema = z.object({ error: z.string(), message: z.string().optional() })\nconst uploadResponseSchema = z.object({\n id: z.string(),\n filename: z.string(),\n contentType: z.string(),\n sizeBytes: z.number(),\n})\n\nconst postMethodDoc: OpenApiMethodDoc = {\n summary: 'Upload an attachment (public/anonymous)',\n description: 'Stores an encrypted file for a file-typed field. Authorized via access token or portal session.',\n tags: ['Forms Public Runtime'],\n responses: [{ status: 201, description: 'Attachment stored', schema: uploadResponseSchema }],\n errors: [\n { status: 401, description: 'Missing or invalid access token / session', schema: errorSchema },\n { status: 413, description: 'File empty or oversize', schema: errorSchema },\n { status: 422, description: 'Disallowed type / invalid field / scan rejected', schema: errorSchema },\n { status: 429, description: 'Rate limit exceeded', schema: errorSchema },\n { status: 503, description: 'Rate limiting unavailable', schema: errorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Public attachment upload',\n methods: { POST: postMethodDoc },\n}\n"],
|
|
5
|
-
"mappings": "AAYA,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAGlB,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["/**\n * Public/anonymous attachment upload \u2014\n * POST /api/forms/public/submissions/:id/attachments\n *\n * Token- or portal-authenticated participants upload a file for a `file`-typed\n * field. Authorization goes through `resolveRuntimePrincipal` (bearer access\n * token OR customer session). Org/tenant are ALWAYS re-derived from the\n * persisted submission row (R-2d-4) \u2014 never trusted from the client. Bytes are\n * encrypted at rest and gated by MIME allowlist, size ceiling, and the\n * pluggable virus scanner (W4 / SEC-4).\n */\n\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { AttachmentService } from '../../../../../services/attachment-service'\nimport type { FormVersionCompiler } from '../../../../../services/form-version-compiler'\nimport type { RolePolicyService } from '../../../../../services/role-policy-service'\nimport { resolveRuntimePrincipal } from '../../../../../lib/runtime-principal'\nimport {\n mapAttachmentError,\n parseUploadBody,\n resolveAttachmentRolePolicy,\n resolveFieldUploadConfig,\n} from '../../../../attachment-helpers'\nimport { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../rate-limit'\n\nexport const metadata = {\n POST: { requireAuth: false },\n}\n\nexport async function POST(\n req: NextRequest,\n context: { params: { id: string } | Promise<{ id: string }> },\n) {\n const params = await Promise.resolve(context.params)\n const submissionId = String(params.id)\n\n const limited = await enforcePublicRateLimit(\n req,\n buildPublicRateLimitKey('submission-upload', submissionId),\n )\n if (limited) return limited\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = container.resolve('formsAttachmentService') as AttachmentService\n\n const principal = await resolveRuntimePrincipal({ req, submissionId, em })\n if (!principal) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 })\n\n const parsed = await parseUploadBody(req)\n if (parsed instanceof NextResponse) return parsed\n\n const policy = await resolveAttachmentRolePolicy(em, {\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n role: principal.role,\n }, container.resolve('formVersionCompiler') as FormVersionCompiler,\n container.resolve('formsRolePolicyService') as RolePolicyService)\n if (!policy?.canWrite(parsed.fieldKey)) {\n return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })\n }\n\n const config = await resolveFieldUploadConfig(em, {\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n fieldKey: parsed.fieldKey,\n })\n if (!config) {\n return NextResponse.json({ error: 'INVALID_FIELD' }, { status: 422 })\n }\n\n try {\n const stored = await service.storeUpload({\n organizationId: principal.organizationId,\n tenantId: principal.tenantId,\n submissionId,\n fieldKey: parsed.fieldKey,\n filename: parsed.filename,\n contentType: parsed.contentType,\n bytes: parsed.bytes,\n uploadedBy: principal.source === 'customer' ? principal.principal : null,\n accept: config.accept,\n fieldMaxSizeBytes: config.maxSizeBytes,\n })\n return NextResponse.json(stored, { status: 201 })\n } catch (error) {\n return mapAttachmentError(error)\n }\n}\n\nconst errorSchema = z.object({ error: z.string(), message: z.string().optional() })\nconst uploadResponseSchema = z.object({\n id: z.string(),\n filename: z.string(),\n contentType: z.string(),\n sizeBytes: z.number(),\n})\n\nconst postMethodDoc: OpenApiMethodDoc = {\n summary: 'Upload an attachment (public/anonymous)',\n description: 'Stores an encrypted file for a file-typed field the participant role can edit in the submission\u2019s pinned version. Authorized via access token or portal session.',\n tags: ['Forms Public Runtime'],\n responses: [{ status: 201, description: 'Attachment stored', schema: uploadResponseSchema }],\n errors: [\n { status: 401, description: 'Missing or invalid access token / session', schema: errorSchema },\n { status: 404, description: 'Field not found or not editable by the participant role', schema: errorSchema },\n { status: 413, description: 'File empty or oversize', schema: errorSchema },\n { status: 422, description: 'Disallowed type / invalid field / scan rejected', schema: errorSchema },\n { status: 429, description: 'Rate limit exceeded', schema: errorSchema },\n { status: 503, description: 'Rate limiting unavailable', schema: errorSchema },\n ],\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'Public attachment upload',\n methods: { POST: postMethodDoc },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7799.1.d9cd0c8e41",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -266,16 +266,16 @@
|
|
|
266
266
|
"zod": "^4.4.3"
|
|
267
267
|
},
|
|
268
268
|
"peerDependencies": {
|
|
269
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7799.1.d9cd0c8e41",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7799.1.d9cd0c8e41",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7799.1.d9cd0c8e41",
|
|
272
272
|
"react": "^19.0.0",
|
|
273
273
|
"react-dom": "^19.0.0"
|
|
274
274
|
},
|
|
275
275
|
"devDependencies": {
|
|
276
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7799.1.d9cd0c8e41",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7799.1.d9cd0c8e41",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7799.1.d9cd0c8e41",
|
|
279
279
|
"@testing-library/dom": "^10.4.1",
|
|
280
280
|
"@testing-library/jest-dom": "^7.0.1",
|
|
281
281
|
"@testing-library/react": "^16.3.3",
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import { NextResponse } from 'next/server'
|
|
10
10
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
11
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
11
12
|
import { FormSubmission, FormVersion } from '../data/entities'
|
|
13
|
+
import type { FormVersionCompiler } from '../services/form-version-compiler'
|
|
14
|
+
import type { RolePolicyResolution, RolePolicyService } from '../services/role-policy-service'
|
|
12
15
|
import { AttachmentServiceError, isAttachmentServiceError } from '../services/attachment-service'
|
|
13
16
|
import { resolveMaxUploadBytes } from '../services/upload-validation'
|
|
14
17
|
|
|
@@ -34,6 +37,37 @@ export function mapAttachmentError(error: unknown): NextResponse {
|
|
|
34
37
|
return NextResponse.json({ error: 'INTERNAL_ERROR', message }, { status: 500 })
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
export async function resolveAttachmentRolePolicy(
|
|
41
|
+
em: EntityManager,
|
|
42
|
+
args: { organizationId: string; tenantId: string; submissionId: string; role: string | null },
|
|
43
|
+
compiler: FormVersionCompiler,
|
|
44
|
+
rolePolicy: RolePolicyService,
|
|
45
|
+
): Promise<RolePolicyResolution | null> {
|
|
46
|
+
if (!args.role) return null
|
|
47
|
+
|
|
48
|
+
const scope = { organizationId: args.organizationId, tenantId: args.tenantId }
|
|
49
|
+
const submission = await findOneWithDecryption(em, FormSubmission, {
|
|
50
|
+
id: args.submissionId,
|
|
51
|
+
...scope,
|
|
52
|
+
deletedAt: null,
|
|
53
|
+
}, undefined, scope)
|
|
54
|
+
if (!submission) return null
|
|
55
|
+
|
|
56
|
+
const version = await findOneWithDecryption(em, FormVersion, {
|
|
57
|
+
id: submission.formVersionId,
|
|
58
|
+
...scope,
|
|
59
|
+
}, undefined, scope)
|
|
60
|
+
if (!version) return null
|
|
61
|
+
|
|
62
|
+
const compiled = compiler.compile({
|
|
63
|
+
id: version.id,
|
|
64
|
+
updatedAt: version.updatedAt,
|
|
65
|
+
schema: version.schema,
|
|
66
|
+
uiSchema: version.uiSchema,
|
|
67
|
+
})
|
|
68
|
+
return rolePolicy.resolve(compiled, args.role)
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
/**
|
|
38
72
|
* Looks up the `file`-typed field node on the submission's pinned form version
|
|
39
73
|
* and reads its upload config. Returns `null` when the submission, version, or
|
|
@@ -13,10 +13,15 @@ import { NextResponse, type NextRequest } from 'next/server'
|
|
|
13
13
|
import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib/openapi'
|
|
14
14
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
15
15
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
16
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
17
|
+
import { FormAttachment } from '../../../../../../data/entities'
|
|
16
18
|
import type { AttachmentService } from '../../../../../../services/attachment-service'
|
|
19
|
+
import type { FormVersionCompiler } from '../../../../../../services/form-version-compiler'
|
|
20
|
+
import type { RolePolicyService } from '../../../../../../services/role-policy-service'
|
|
17
21
|
import { resolveRuntimePrincipal } from '../../../../../../lib/runtime-principal'
|
|
18
22
|
import {
|
|
19
23
|
mapAttachmentError,
|
|
24
|
+
resolveAttachmentRolePolicy,
|
|
20
25
|
SANDBOXED_DOWNLOAD_HEADERS,
|
|
21
26
|
} from '../../../../../attachment-helpers'
|
|
22
27
|
import { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../../rate-limit'
|
|
@@ -47,6 +52,26 @@ export async function GET(
|
|
|
47
52
|
if (!principal) return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 })
|
|
48
53
|
|
|
49
54
|
try {
|
|
55
|
+
const scope = { organizationId: principal.organizationId, tenantId: principal.tenantId }
|
|
56
|
+
const policy = await resolveAttachmentRolePolicy(em, {
|
|
57
|
+
...scope,
|
|
58
|
+
submissionId,
|
|
59
|
+
role: principal.role,
|
|
60
|
+
}, container.resolve('formVersionCompiler') as FormVersionCompiler,
|
|
61
|
+
container.resolve('formsRolePolicyService') as RolePolicyService)
|
|
62
|
+
if (!policy) return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })
|
|
63
|
+
|
|
64
|
+
const attachment = await findOneWithDecryption(em, FormAttachment, {
|
|
65
|
+
id: attachmentId,
|
|
66
|
+
submissionId,
|
|
67
|
+
organizationId: principal.organizationId,
|
|
68
|
+
kind: 'user_upload',
|
|
69
|
+
removedAt: null,
|
|
70
|
+
}, { fields: ['fieldKey'] }, scope)
|
|
71
|
+
if (!attachment || !policy.canRead(attachment.fieldKey)) {
|
|
72
|
+
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
const result = await service.readUpload({
|
|
51
76
|
organizationId: principal.organizationId,
|
|
52
77
|
tenantId: principal.tenantId,
|
|
@@ -69,7 +94,7 @@ export async function GET(
|
|
|
69
94
|
|
|
70
95
|
const getMethodDoc: OpenApiMethodDoc = {
|
|
71
96
|
summary: 'Download a submission attachment (public/anonymous)',
|
|
72
|
-
description: 'Streams
|
|
97
|
+
description: 'Streams decrypted user-upload bytes only when the participant role can read the attachment field in the submission’s pinned version. Authorized via access token or portal session.',
|
|
73
98
|
tags: ['Forms Public Runtime'],
|
|
74
99
|
responses: [{ status: 200, description: 'Attachment stream', mediaType: 'application/octet-stream' }],
|
|
75
100
|
errors: [
|
|
@@ -16,10 +16,13 @@ import type { OpenApiRouteDoc, OpenApiMethodDoc } from '@open-mercato/shared/lib
|
|
|
16
16
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
17
17
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
18
18
|
import type { AttachmentService } from '../../../../../services/attachment-service'
|
|
19
|
+
import type { FormVersionCompiler } from '../../../../../services/form-version-compiler'
|
|
20
|
+
import type { RolePolicyService } from '../../../../../services/role-policy-service'
|
|
19
21
|
import { resolveRuntimePrincipal } from '../../../../../lib/runtime-principal'
|
|
20
22
|
import {
|
|
21
23
|
mapAttachmentError,
|
|
22
24
|
parseUploadBody,
|
|
25
|
+
resolveAttachmentRolePolicy,
|
|
23
26
|
resolveFieldUploadConfig,
|
|
24
27
|
} from '../../../../attachment-helpers'
|
|
25
28
|
import { buildPublicRateLimitKey, enforcePublicRateLimit } from '../../../rate-limit'
|
|
@@ -51,6 +54,17 @@ export async function POST(
|
|
|
51
54
|
const parsed = await parseUploadBody(req)
|
|
52
55
|
if (parsed instanceof NextResponse) return parsed
|
|
53
56
|
|
|
57
|
+
const policy = await resolveAttachmentRolePolicy(em, {
|
|
58
|
+
organizationId: principal.organizationId,
|
|
59
|
+
tenantId: principal.tenantId,
|
|
60
|
+
submissionId,
|
|
61
|
+
role: principal.role,
|
|
62
|
+
}, container.resolve('formVersionCompiler') as FormVersionCompiler,
|
|
63
|
+
container.resolve('formsRolePolicyService') as RolePolicyService)
|
|
64
|
+
if (!policy?.canWrite(parsed.fieldKey)) {
|
|
65
|
+
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 })
|
|
66
|
+
}
|
|
67
|
+
|
|
54
68
|
const config = await resolveFieldUploadConfig(em, {
|
|
55
69
|
organizationId: principal.organizationId,
|
|
56
70
|
tenantId: principal.tenantId,
|
|
@@ -90,11 +104,12 @@ const uploadResponseSchema = z.object({
|
|
|
90
104
|
|
|
91
105
|
const postMethodDoc: OpenApiMethodDoc = {
|
|
92
106
|
summary: 'Upload an attachment (public/anonymous)',
|
|
93
|
-
description: 'Stores an encrypted file for a file-typed field. Authorized via access token or portal session.',
|
|
107
|
+
description: 'Stores an encrypted file for a file-typed field the participant role can edit in the submission’s pinned version. Authorized via access token or portal session.',
|
|
94
108
|
tags: ['Forms Public Runtime'],
|
|
95
109
|
responses: [{ status: 201, description: 'Attachment stored', schema: uploadResponseSchema }],
|
|
96
110
|
errors: [
|
|
97
111
|
{ status: 401, description: 'Missing or invalid access token / session', schema: errorSchema },
|
|
112
|
+
{ status: 404, description: 'Field not found or not editable by the participant role', schema: errorSchema },
|
|
98
113
|
{ status: 413, description: 'File empty or oversize', schema: errorSchema },
|
|
99
114
|
{ status: 422, description: 'Disallowed type / invalid field / scan rejected', schema: errorSchema },
|
|
100
115
|
{ status: 429, description: 'Rate limit exceeded', schema: errorSchema },
|