@open-mercato/core 0.10.1-develop.7736.1.88e07906bf → 0.10.1-develop.7748.1.d745ba61aa
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/generated/entities/api_key/index.js +2 -0
- package/dist/generated/entities/api_key/index.js.map +2 -2
- package/dist/generated/entities/attachment_partition/index.js +2 -0
- package/dist/generated/entities/attachment_partition/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/attachmentAccessFixtures.js +144 -0
- package/dist/helpers/integration/attachmentAccessFixtures.js.map +7 -0
- package/dist/modules/api_keys/data/entities.js +7 -0
- package/dist/modules/api_keys/data/entities.js.map +2 -2
- package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js +30 -0
- package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js.map +7 -0
- package/dist/modules/api_keys/services/apiKeyService.js +31 -9
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/attachments/api/file/[id]/route.js +16 -10
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +15 -9
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/api/library/[id]/route.js +100 -56
- package/dist/modules/attachments/api/library/[id]/route.js.map +3 -3
- package/dist/modules/attachments/api/library/route.js +56 -17
- package/dist/modules/attachments/api/library/route.js.map +2 -2
- package/dist/modules/attachments/api/partitions/route.js +2 -0
- package/dist/modules/attachments/api/partitions/route.js.map +2 -2
- package/dist/modules/attachments/api/route.js +74 -34
- package/dist/modules/attachments/api/route.js.map +3 -3
- package/dist/modules/attachments/api/transfer/route.js +63 -37
- package/dist/modules/attachments/api/transfer/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +3 -0
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/data/validators.js +41 -0
- package/dist/modules/attachments/data/validators.js.map +7 -0
- package/dist/modules/attachments/di.js +5 -2
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/generators.js +29 -0
- package/dist/modules/attachments/generators.js.map +7 -0
- package/dist/modules/attachments/index.js +8 -1
- package/dist/modules/attachments/index.js.map +2 -2
- package/dist/modules/attachments/lib/access-errors.js +35 -0
- package/dist/modules/attachments/lib/access-errors.js.map +7 -0
- package/dist/modules/attachments/lib/access-mutation.js +23 -0
- package/dist/modules/attachments/lib/access-mutation.js.map +7 -0
- package/dist/modules/attachments/lib/access-protection.js +73 -0
- package/dist/modules/attachments/lib/access-protection.js.map +7 -0
- package/dist/modules/attachments/lib/access-query.js +74 -0
- package/dist/modules/attachments/lib/access-query.js.map +7 -0
- package/dist/modules/attachments/lib/access-registry.js +87 -0
- package/dist/modules/attachments/lib/access-registry.js.map +7 -0
- package/dist/modules/attachments/lib/access-runner.js +161 -0
- package/dist/modules/attachments/lib/access-runner.js.map +7 -0
- package/dist/modules/attachments/lib/access-types.js +1 -0
- package/dist/modules/attachments/lib/access-types.js.map +7 -0
- package/dist/modules/attachments/lib/attachment-service.js +14 -6
- package/dist/modules/attachments/lib/attachment-service.js.map +2 -2
- package/dist/modules/attachments/lib/createFromBuffer.js +2 -0
- package/dist/modules/attachments/lib/createFromBuffer.js.map +2 -2
- package/dist/modules/attachments/lib/partitions.js +3 -2
- package/dist/modules/attachments/lib/partitions.js.map +2 -2
- package/dist/modules/attachments/lib/scoped-upload-service.js +2 -0
- package/dist/modules/attachments/lib/scoped-upload-service.js.map +2 -2
- package/dist/modules/attachments/lib/target-access-service.js +12 -3
- package/dist/modules/attachments/lib/target-access-service.js.map +2 -2
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js +38 -0
- package/dist/modules/attachments/migrations/Migration20261001120000_attachments.js.map +7 -0
- package/dist/modules/attachments/setup.js +10 -0
- package/dist/modules/attachments/setup.js.map +2 -2
- package/dist/modules/notifications/api/[id]/action/route.js +8 -0
- package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
- package/dist/modules/notifications/api/route.js +17 -0
- package/dist/modules/notifications/api/route.js.map +2 -2
- package/dist/modules/notifications/data/validators.js +1 -1
- package/dist/modules/notifications/data/validators.js.map +2 -2
- package/dist/modules/notifications/lib/notificationService.js +45 -3
- package/dist/modules/notifications/lib/notificationService.js.map +2 -2
- package/dist/modules/notifications/lib/routeHelpers.js +27 -0
- package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +50 -34
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/status-machine.js +10 -1
- package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
- package/dist/modules/payment_gateways/lib/webhook-processor.js +1 -1
- package/dist/modules/payment_gateways/lib/webhook-processor.js.map +2 -2
- package/dist/modules/staff/notifications.js +4 -2
- package/dist/modules/staff/notifications.js.map +2 -2
- package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
- package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
- package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
- package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
- package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
- package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
- package/dist/modules/workflows/data/entities.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +18 -4
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/activity-types.js +2 -2
- package/dist/modules/workflows/lib/activity-types.js.map +2 -2
- package/dist/modules/workflows/lib/condition-handler.js +7 -1
- package/dist/modules/workflows/lib/condition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/duration.js +13 -0
- package/dist/modules/workflows/lib/duration.js.map +2 -2
- package/dist/modules/workflows/lib/instance-status.js +16 -0
- package/dist/modules/workflows/lib/instance-status.js.map +7 -0
- package/dist/modules/workflows/lib/step-handler.js +9 -2
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-handler.js +16 -8
- package/dist/modules/workflows/lib/task-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-quick-action.js +13 -10
- package/dist/modules/workflows/lib/task-quick-action.js.map +2 -2
- package/dist/modules/workflows/lib/task-sla.js +10 -0
- package/dist/modules/workflows/lib/task-sla.js.map +2 -2
- package/dist/modules/workflows/lib/transition-handler.js +2 -1
- package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +29 -1
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/dist/modules/workflows/notifications.js +4 -0
- package/dist/modules/workflows/notifications.js.map +2 -2
- package/generated/entities/api_key/index.ts +1 -0
- package/generated/entities/attachment_partition/index.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +7 -7
- package/src/helpers/integration/attachmentAccessFixtures.ts +141 -0
- package/src/modules/api_keys/data/entities.ts +25 -1
- package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
- package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
- package/src/modules/api_keys/services/apiKeyService.ts +55 -9
- package/src/modules/attachments/api/file/[id]/route.ts +12 -9
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +10 -8
- package/src/modules/attachments/api/library/[id]/route.ts +87 -58
- package/src/modules/attachments/api/library/route.ts +40 -11
- package/src/modules/attachments/api/partitions/route.ts +2 -0
- package/src/modules/attachments/api/route.ts +68 -35
- package/src/modules/attachments/api/transfer/route.ts +65 -42
- package/src/modules/attachments/data/entities.ts +4 -0
- package/src/modules/attachments/data/validators.ts +36 -0
- package/src/modules/attachments/di.ts +4 -1
- package/src/modules/attachments/generators.ts +27 -0
- package/src/modules/attachments/i18n/de.json +4 -0
- package/src/modules/attachments/i18n/en.json +4 -0
- package/src/modules/attachments/i18n/es.json +4 -0
- package/src/modules/attachments/i18n/ko.json +4 -0
- package/src/modules/attachments/i18n/pl.json +4 -0
- package/src/modules/attachments/index.ts +18 -0
- package/src/modules/attachments/lib/access-errors.ts +32 -0
- package/src/modules/attachments/lib/access-mutation.ts +26 -0
- package/src/modules/attachments/lib/access-protection.ts +83 -0
- package/src/modules/attachments/lib/access-query.ts +86 -0
- package/src/modules/attachments/lib/access-registry.ts +117 -0
- package/src/modules/attachments/lib/access-runner.ts +183 -0
- package/src/modules/attachments/lib/access-types.ts +78 -0
- package/src/modules/attachments/lib/attachment-service.ts +8 -4
- package/src/modules/attachments/lib/createFromBuffer.ts +3 -0
- package/src/modules/attachments/lib/partitions.ts +3 -2
- package/src/modules/attachments/lib/scoped-upload-service.ts +2 -0
- package/src/modules/attachments/lib/target-access-service.ts +10 -3
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +17 -0
- package/src/modules/attachments/migrations/Migration20261001120000_attachments.ts +35 -0
- package/src/modules/attachments/setup.ts +10 -0
- package/src/modules/auth/i18n/de.json +3 -0
- package/src/modules/auth/i18n/en.json +3 -0
- package/src/modules/auth/i18n/es.json +3 -0
- package/src/modules/auth/i18n/ko.json +3 -0
- package/src/modules/auth/i18n/pl.json +3 -0
- package/src/modules/notifications/api/[id]/action/route.ts +8 -0
- package/src/modules/notifications/api/route.ts +17 -0
- package/src/modules/notifications/data/validators.ts +1 -1
- package/src/modules/notifications/i18n/de.json +2 -0
- package/src/modules/notifications/i18n/en.json +2 -0
- package/src/modules/notifications/i18n/es.json +2 -0
- package/src/modules/notifications/i18n/ko.json +2 -0
- package/src/modules/notifications/i18n/pl.json +2 -0
- package/src/modules/notifications/lib/notificationService.ts +67 -1
- package/src/modules/notifications/lib/routeHelpers.ts +38 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +50 -33
- package/src/modules/payment_gateways/lib/status-machine.ts +19 -0
- package/src/modules/payment_gateways/lib/webhook-processor.ts +1 -1
- package/src/modules/staff/notifications.ts +2 -0
- package/src/modules/workflows/AGENTS.md +2 -2
- package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
- package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
- package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
- package/src/modules/workflows/data/entities.ts +6 -0
- package/src/modules/workflows/i18n/de.json +2 -0
- package/src/modules/workflows/i18n/en.json +2 -0
- package/src/modules/workflows/i18n/es.json +2 -0
- package/src/modules/workflows/i18n/ko.json +2 -0
- package/src/modules/workflows/i18n/pl.json +2 -0
- package/src/modules/workflows/lib/activity-executor.ts +36 -4
- package/src/modules/workflows/lib/activity-types.ts +2 -2
- package/src/modules/workflows/lib/condition-handler.ts +16 -1
- package/src/modules/workflows/lib/duration.ts +25 -0
- package/src/modules/workflows/lib/instance-status.ts +29 -0
- package/src/modules/workflows/lib/step-handler.ts +16 -1
- package/src/modules/workflows/lib/task-handler.ts +18 -10
- package/src/modules/workflows/lib/task-quick-action.ts +19 -10
- package/src/modules/workflows/lib/task-sla.ts +13 -0
- package/src/modules/workflows/lib/transition-handler.ts +2 -1
- package/src/modules/workflows/lib/workflow-executor.ts +44 -1
- package/src/modules/workflows/notifications.ts +4 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto'
|
|
1
|
+
import { randomBytes, createHash } from 'node:crypto'
|
|
2
2
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
3
|
import { hash, compare } from 'bcryptjs'
|
|
4
4
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
@@ -227,6 +227,17 @@ export function generateSessionToken(): string {
|
|
|
227
227
|
return `sess_${randomBytes(16).toString('hex')}`
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Hash a session token for storage/lookup (#2254). The session token is a bearer credential —
|
|
232
|
+
* it unlocks the decrypted API key secret and the user's ACL on the MCP server — so, mirroring
|
|
233
|
+
* `customer_accounts`' `hashToken`, only its hash is ever persisted. 128 bits of entropy from
|
|
234
|
+
* `generateSessionToken` is sufficient for a plain SHA-256 (no bcrypt cost needed, and a slow
|
|
235
|
+
* hash would just add latency to every MCP tool call that resolves a session).
|
|
236
|
+
*/
|
|
237
|
+
export function hashSessionToken(sessionToken: string): string {
|
|
238
|
+
return createHash('sha256').update(sessionToken).digest('hex')
|
|
239
|
+
}
|
|
240
|
+
|
|
230
241
|
/**
|
|
231
242
|
* Create an ephemeral API key scoped to a chat session.
|
|
232
243
|
* The key inherits the user's roles and expires after ttlMinutes (default 30).
|
|
@@ -244,12 +255,16 @@ export async function createSessionApiKey(
|
|
|
244
255
|
// Encrypt the secret for later retrieval (used by MCP server for API calls)
|
|
245
256
|
const encryptedSecret = await encryptSessionSecret(secret, input.tenantId ?? null)
|
|
246
257
|
|
|
258
|
+
// Hash before storage (#2254) — the row (and its `name` column, previously embedding the raw
|
|
259
|
+
// token for operator visibility) must never carry the live bearer credential in the clear.
|
|
260
|
+
const sessionTokenHash = hashSessionToken(input.sessionToken)
|
|
261
|
+
|
|
247
262
|
const roleIds = Array.from(new Set(input.userRoles.filter(Boolean))).sort((left, right) => left.localeCompare(right))
|
|
248
263
|
let record!: ApiKey
|
|
249
264
|
await withAtomicFlush(em, [async () => {
|
|
250
265
|
await lockRoleWriterAuthorizationState(em, roleIds)
|
|
251
266
|
record = em.create(ApiKey, {
|
|
252
|
-
name: `__session_${
|
|
267
|
+
name: `__session_${sessionTokenHash.slice(0, 12)}__`,
|
|
253
268
|
description: 'Ephemeral session API key for AI chat',
|
|
254
269
|
tenantId: input.tenantId ?? null,
|
|
255
270
|
organizationId: input.organizationId ?? null,
|
|
@@ -257,7 +272,7 @@ export async function createSessionApiKey(
|
|
|
257
272
|
keyPrefix: prefix,
|
|
258
273
|
rolesJson: roleIds,
|
|
259
274
|
createdBy: input.userId,
|
|
260
|
-
|
|
275
|
+
sessionTokenHash,
|
|
261
276
|
sessionUserId: input.userId,
|
|
262
277
|
sessionSecretEncrypted: encryptedSecret,
|
|
263
278
|
expiresAt,
|
|
@@ -283,10 +298,26 @@ export async function findApiKeyBySessionToken(
|
|
|
283
298
|
): Promise<ApiKey | null> {
|
|
284
299
|
if (!sessionToken) return null
|
|
285
300
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
301
|
+
// Looked up by hash, never by the raw token (#2254).
|
|
302
|
+
const sessionTokenHash = hashSessionToken(sessionToken)
|
|
303
|
+
let record = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })
|
|
304
|
+
|
|
305
|
+
if (!record) {
|
|
306
|
+
// Version-skew fallback (#7304): during a rolling deploy/rollback window the app and the
|
|
307
|
+
// separate MCP process can briefly run different code versions. A row minted by
|
|
308
|
+
// pre-#2254 code in that window carries only the plaintext token (sessionTokenHash is
|
|
309
|
+
// still null) — fall back to it so the session still resolves instead of answering a
|
|
310
|
+
// false SESSION_EXPIRED, then self-heal the row onto the hash immediately so this is the
|
|
311
|
+
// only lookup that ever sees the plaintext, and the window closes as soon as each
|
|
312
|
+
// outstanding legacy row is touched once.
|
|
313
|
+
const legacyRecord = await em.findOne(ApiKey, { sessionToken, deletedAt: null })
|
|
314
|
+
if (legacyRecord) {
|
|
315
|
+
legacyRecord.sessionTokenHash = sessionTokenHash
|
|
316
|
+
legacyRecord.sessionToken = null
|
|
317
|
+
await em.persist(legacyRecord).flush()
|
|
318
|
+
record = legacyRecord
|
|
319
|
+
}
|
|
320
|
+
}
|
|
290
321
|
|
|
291
322
|
if (!record) return null
|
|
292
323
|
if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null
|
|
@@ -392,14 +423,29 @@ export async function deleteSessionApiKey(
|
|
|
392
423
|
em: EntityManager,
|
|
393
424
|
sessionToken: string
|
|
394
425
|
): Promise<void> {
|
|
395
|
-
|
|
426
|
+
// Looked up and re-checked by hash, never by the raw token (#2254). Deliberately does not
|
|
427
|
+
// call findApiKeyBySessionToken: that helper also drops already-expired rows, but deleting
|
|
428
|
+
// an expired-but-not-yet-cleaned-up row must still work here.
|
|
429
|
+
const sessionTokenHash = hashSessionToken(sessionToken)
|
|
430
|
+
let candidate = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })
|
|
431
|
+
if (!candidate) {
|
|
432
|
+
// Version-skew fallback (#7304): see findApiKeyBySessionToken for the full rationale — a
|
|
433
|
+
// row minted by pre-#2254 code during a deploy window is plaintext-only until touched.
|
|
434
|
+
const legacyCandidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null })
|
|
435
|
+
if (legacyCandidate) {
|
|
436
|
+
legacyCandidate.sessionTokenHash = sessionTokenHash
|
|
437
|
+
legacyCandidate.sessionToken = null
|
|
438
|
+
await em.persist(legacyCandidate).flush()
|
|
439
|
+
candidate = legacyCandidate
|
|
440
|
+
}
|
|
441
|
+
}
|
|
396
442
|
if (!candidate) return
|
|
397
443
|
let deletedId: string | null = null
|
|
398
444
|
await withAtomicFlush(em, [async () => {
|
|
399
445
|
const records = await lockAuthorizationApiKeyRows(em, [String(candidate.id)])
|
|
400
446
|
const record = records.find((entry) => (
|
|
401
447
|
String(entry.id) === String(candidate.id)
|
|
402
|
-
&& entry.
|
|
448
|
+
&& entry.sessionTokenHash === sessionTokenHash
|
|
403
449
|
&& !entry.deletedAt
|
|
404
450
|
))
|
|
405
451
|
if (!record) return
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createAttachmentAccessContext, evaluateAttachmentAccess } from '../../../lib/access-runner'
|
|
2
|
+
import { attachmentAccessErrorResponse } from '../../../lib/access-errors'
|
|
1
3
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
4
|
import type { OpenApiRouteDoc } from "@open-mercato/shared/lib/openapi";
|
|
3
5
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
@@ -69,11 +71,14 @@ export async function GET(
|
|
|
69
71
|
);
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
const url = new URL(req.url);
|
|
75
|
+
const forceDownload = url.searchParams.get("download") === "1";
|
|
76
|
+
const renderInline = !forceDownload && canRenderInlineAttachment(attachment.mimeType);
|
|
77
|
+
const access = await evaluateAttachmentAccess({
|
|
78
|
+
auth: scopedAuth, attachment, partition, action: renderInline ? 'render' : 'read',
|
|
79
|
+
context: createAttachmentAccessContext(container),
|
|
80
|
+
});
|
|
81
|
+
if (!access.ok) return attachmentAccessErrorResponse(access);
|
|
77
82
|
|
|
78
83
|
const driver = await storageDriverFactory.resolveForPartition(attachment.partitionCode, {
|
|
79
84
|
tenantId: attachment.tenantId ?? '',
|
|
@@ -87,11 +92,8 @@ export async function GET(
|
|
|
87
92
|
return NextResponse.json({ error: "File not available" }, { status: 404 });
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
const url = new URL(req.url);
|
|
91
|
-
const forceDownload = url.searchParams.get("download") === "1";
|
|
92
|
-
const renderInline = !forceDownload && canRenderInlineAttachment(attachment.mimeType);
|
|
93
95
|
const headers: Record<string, string> = {
|
|
94
|
-
"Cache-Control": checkAttachmentAccess(null, attachment, partition).ok
|
|
96
|
+
"Cache-Control": access.protected ? "private, no-store" : checkAttachmentAccess(null, attachment, partition).ok
|
|
95
97
|
? "public, max-age=86400"
|
|
96
98
|
: "private, max-age=60",
|
|
97
99
|
"Content-Security-Policy": "default-src 'none'; sandbox",
|
|
@@ -155,6 +157,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
155
157
|
description: "Partition misconfigured",
|
|
156
158
|
schema: attachmentErrorSchema,
|
|
157
159
|
},
|
|
160
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
158
161
|
],
|
|
159
162
|
},
|
|
160
163
|
},
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createAttachmentAccessContext, evaluateAttachmentAccess } from '../../../../lib/access-runner'
|
|
2
|
+
import { attachmentAccessErrorResponse } from '../../../../lib/access-errors'
|
|
1
3
|
import { NextRequest, NextResponse } from 'next/server'
|
|
2
4
|
import sharp, { type ResizeOptions } from 'sharp'
|
|
3
5
|
import { z } from 'zod'
|
|
@@ -71,17 +73,16 @@ export async function GET(
|
|
|
71
73
|
if (!attachment) {
|
|
72
74
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
73
75
|
}
|
|
74
|
-
if (!canRenderInlineAttachment(attachment.mimeType)) {
|
|
75
|
-
return NextResponse.json({ error: 'Unsupported media type' }, { status: 400 })
|
|
76
|
-
}
|
|
77
76
|
const partition = await em.findOne(AttachmentPartition, { code: attachment.partitionCode })
|
|
78
77
|
if (!partition) {
|
|
79
78
|
return NextResponse.json({ error: 'Partition misconfigured' }, { status: 500 })
|
|
80
79
|
}
|
|
81
|
-
const access =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
const access = await evaluateAttachmentAccess({
|
|
81
|
+
auth: scopedAuth, attachment, partition, action: 'render', context: createAttachmentAccessContext(container),
|
|
82
|
+
})
|
|
83
|
+
if (!access.ok) return attachmentAccessErrorResponse(access)
|
|
84
|
+
if (!canRenderInlineAttachment(attachment.mimeType)) {
|
|
85
|
+
return NextResponse.json({ error: 'Unsupported media type' }, { status: 400 })
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
const driver = await storageDriverFactory.resolveForPartition(attachment.partitionCode, {
|
|
@@ -136,7 +137,7 @@ export async function GET(
|
|
|
136
137
|
return new NextResponse(responseBody, {
|
|
137
138
|
headers: {
|
|
138
139
|
'Content-Type': attachment.mimeType || 'image/jpeg',
|
|
139
|
-
'Cache-Control': checkAttachmentAccess(null, attachment, partition).ok ? 'public, max-age=3600' : 'private, max-age=60',
|
|
140
|
+
'Cache-Control': access.protected ? 'private, no-store' : checkAttachmentAccess(null, attachment, partition).ok ? 'public, max-age=3600' : 'private, max-age=60',
|
|
140
141
|
'X-Content-Type-Options': 'nosniff',
|
|
141
142
|
},
|
|
142
143
|
})
|
|
@@ -166,6 +167,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
166
167
|
{ status: 403, description: 'Forbidden - insufficient permissions', schema: attachmentErrorSchema },
|
|
167
168
|
{ status: 404, description: 'Image not found', schema: attachmentErrorSchema },
|
|
168
169
|
{ status: 500, description: 'Partition misconfigured or image rendering failed', schema: attachmentErrorSchema },
|
|
170
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
169
171
|
],
|
|
170
172
|
},
|
|
171
173
|
},
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { LockMode } from '@mikro-orm/core'
|
|
2
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
4
|
+
import { prepareAttachmentMutation } from '../../../lib/access-mutation'
|
|
5
|
+
import { throwAttachmentAccessError } from '../../../lib/access-errors'
|
|
6
|
+
import { createAttachmentAccessContext } from '../../../lib/access-runner'
|
|
7
|
+
import { assertAttachmentOwnerAccess } from '../../../lib/access-query'
|
|
8
|
+
import { withAttachmentAccessErrors } from '../../../lib/access-errors'
|
|
1
9
|
import { NextRequest, NextResponse } from 'next/server'
|
|
2
10
|
import { z } from 'zod'
|
|
3
11
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
@@ -12,7 +20,7 @@ import {
|
|
|
12
20
|
readAttachmentMetadata,
|
|
13
21
|
} from '../../../lib/metadata'
|
|
14
22
|
import type { StorageDriverFactory } from '../../../lib/drivers'
|
|
15
|
-
import {
|
|
23
|
+
import { lockAttachmentStorageReferences, resolveAttachmentStorageRelease } from '../../../lib/storageReferences'
|
|
16
24
|
import { splitCustomFieldPayload, loadCustomFieldValues } from '@open-mercato/shared/lib/crud/custom-fields'
|
|
17
25
|
import { emitCrudSideEffects, setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
|
|
18
26
|
import { normalizeCustomFieldResponse } from '@open-mercato/shared/lib/custom-fields/normalize'
|
|
@@ -26,9 +34,6 @@ import {
|
|
|
26
34
|
attachmentDetailResponseSchema,
|
|
27
35
|
attachmentErrorSchema,
|
|
28
36
|
} from '../../openapi'
|
|
29
|
-
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
30
|
-
|
|
31
|
-
const logger = createLogger('attachments').child({ component: 'library' })
|
|
32
37
|
|
|
33
38
|
const updateSchema = z.object({
|
|
34
39
|
tags: z.array(z.string()).optional(),
|
|
@@ -66,7 +71,7 @@ async function resolveAttachmentId(ctx: RouteContext): Promise<string | null> {
|
|
|
66
71
|
}
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
|
|
74
|
+
async function getAttachment(req: NextRequest, ctx: RouteContext) {
|
|
70
75
|
const auth = await getAuthFromRequest(req)
|
|
71
76
|
if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) {
|
|
72
77
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
@@ -75,7 +80,8 @@ export async function GET(req: NextRequest, ctx: RouteContext) {
|
|
|
75
80
|
if (!attachmentId) {
|
|
76
81
|
return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
|
|
77
82
|
}
|
|
78
|
-
const
|
|
83
|
+
const container = await createRequestContainer()
|
|
84
|
+
const { resolve } = container
|
|
79
85
|
const em = resolve('em') as EntityManager
|
|
80
86
|
let queryEngine: QueryEngine | null = null
|
|
81
87
|
try {
|
|
@@ -94,6 +100,7 @@ export async function GET(req: NextRequest, ctx: RouteContext) {
|
|
|
94
100
|
if (!record) {
|
|
95
101
|
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
|
|
96
102
|
}
|
|
103
|
+
await assertAttachmentOwnerAccess({ em, context: createAttachmentAccessContext(container), auth, attachment: record, action: 'metadata' })
|
|
97
104
|
const metadata = readAttachmentMetadata(record.storageMetadata)
|
|
98
105
|
const partition = record.partitionCode
|
|
99
106
|
? await em.findOne(AttachmentPartition, { code: record.partitionCode })
|
|
@@ -127,10 +134,12 @@ export async function GET(req: NextRequest, ctx: RouteContext) {
|
|
|
127
134
|
content: record.content && record.content.trim() ? record.content : null,
|
|
128
135
|
customFields,
|
|
129
136
|
},
|
|
130
|
-
})
|
|
137
|
+
}, { headers: { 'Cache-Control': 'private, no-store' } })
|
|
131
138
|
}
|
|
132
139
|
|
|
133
|
-
export
|
|
140
|
+
export const GET = withAttachmentAccessErrors(getAttachment)
|
|
141
|
+
|
|
142
|
+
async function updateAttachment(req: NextRequest, ctx: RouteContext) {
|
|
134
143
|
const auth = await getAuthFromRequest(req)
|
|
135
144
|
if (!auth || !auth.tenantId || !auth.orgId) {
|
|
136
145
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
@@ -139,13 +148,14 @@ export async function PATCH(req: NextRequest, ctx: RouteContext) {
|
|
|
139
148
|
if (!attachmentId) {
|
|
140
149
|
return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
|
|
141
150
|
}
|
|
142
|
-
const rawBody = await req
|
|
151
|
+
const rawBody = await readJsonSafe(req, null)
|
|
143
152
|
const { base, custom } = splitCustomFieldPayload(rawBody)
|
|
144
153
|
const parsed = updateSchema.safeParse(base)
|
|
145
154
|
if (!parsed.success) {
|
|
146
155
|
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
|
147
156
|
}
|
|
148
|
-
const
|
|
157
|
+
const container = await createRequestContainer()
|
|
158
|
+
const { resolve } = container
|
|
149
159
|
const em = resolve('em') as EntityManager
|
|
150
160
|
let queryEngine: QueryEngine | null = null
|
|
151
161
|
try {
|
|
@@ -159,34 +169,38 @@ export async function PATCH(req: NextRequest, ctx: RouteContext) {
|
|
|
159
169
|
tenantId: auth.tenantId,
|
|
160
170
|
organizationId: auth.orgId,
|
|
161
171
|
}
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// custom-field failure cannot leave the attachment metadata partially updated.
|
|
172
|
-
try {
|
|
173
|
-
await em.transactional(async (tx) => {
|
|
174
|
-
await tx.flush()
|
|
175
|
-
if (dataEngine && custom && Object.keys(custom).length) {
|
|
176
|
-
await setCustomFieldsIfAny({
|
|
177
|
-
dataEngine,
|
|
178
|
-
entityId: E.attachments.attachment,
|
|
179
|
-
recordId: record.id,
|
|
180
|
-
tenantId: record.tenantId ?? auth.tenantId ?? null,
|
|
181
|
-
organizationId: record.organizationId ?? auth.orgId ?? null,
|
|
182
|
-
values: custom,
|
|
183
|
-
})
|
|
184
|
-
}
|
|
172
|
+
const accessContext = createAttachmentAccessContext(container)
|
|
173
|
+
const outcome = await em.transactional(async (tx) => {
|
|
174
|
+
const record = await findOneWithDecryption(tx, Attachment, patchFilter, {
|
|
175
|
+
lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,
|
|
176
|
+
}, { tenantId: auth.tenantId, organizationId: auth.orgId })
|
|
177
|
+
if (!record) return throwAttachmentAccessError(404)
|
|
178
|
+
const guard = await prepareAttachmentMutation({
|
|
179
|
+
container, req, auth, recordId: record.id, operation: 'update',
|
|
180
|
+
payload: { ...parsed.data, customFields: custom },
|
|
185
181
|
})
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
182
|
+
const effective = splitCustomFieldPayload(guard.modifiedPayload ?? { ...parsed.data, customFields: custom })
|
|
183
|
+
const effectiveParsed = updateSchema.safeParse(effective.base)
|
|
184
|
+
if (!effectiveParsed.success) return throwAttachmentAccessError(403)
|
|
185
|
+
const patch: Record<string, unknown> = {}
|
|
186
|
+
if (effectiveParsed.data.tags) patch.tags = normalizeAttachmentTags(effectiveParsed.data.tags)
|
|
187
|
+
if (effectiveParsed.data.assignments) patch.assignments = normalizeAttachmentAssignments(effectiveParsed.data.assignments)
|
|
188
|
+
const nextMetadata = mergeAttachmentMetadata(record.storageMetadata, patch)
|
|
189
|
+
await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: record, action: 'reassign', persistProtection: true })
|
|
190
|
+
await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record, storageMetadata: nextMetadata }, action: 'reassign' })
|
|
191
|
+
await assertAttachmentOwnerAccess({ em: tx, context: accessContext, auth, attachment: { ...record, storageMetadata: nextMetadata }, action: 'metadata' })
|
|
192
|
+
record.storageMetadata = nextMetadata
|
|
193
|
+
await tx.flush()
|
|
194
|
+
if (dataEngine && Object.keys(effective.custom).length) {
|
|
195
|
+
await setCustomFieldsIfAny({ dataEngine, entityId: E.attachments.attachment, recordId: record.id,
|
|
196
|
+
tenantId: record.tenantId ?? auth.tenantId, organizationId: record.organizationId ?? auth.orgId,
|
|
197
|
+
values: effective.custom,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
return { record, custom: effective.custom, guard }
|
|
201
|
+
})
|
|
202
|
+
const { record } = outcome
|
|
203
|
+
await outcome.guard.runAfterSuccess()
|
|
190
204
|
|
|
191
205
|
if (dataEngine) {
|
|
192
206
|
await emitCrudSideEffects({
|
|
@@ -218,12 +232,12 @@ export async function PATCH(req: NextRequest, ctx: RouteContext) {
|
|
|
218
232
|
id: record.id,
|
|
219
233
|
tags: metadata.tags ?? [],
|
|
220
234
|
assignments: enrichedAssignments,
|
|
221
|
-
customFields: normalizeCustomFieldResponse(custom ?? null),
|
|
235
|
+
customFields: normalizeCustomFieldResponse(outcome.custom ?? null),
|
|
222
236
|
},
|
|
223
237
|
})
|
|
224
238
|
}
|
|
225
239
|
|
|
226
|
-
|
|
240
|
+
async function deleteAttachment(req: NextRequest, ctx: RouteContext) {
|
|
227
241
|
const auth = await getAuthFromRequest(req)
|
|
228
242
|
if (!auth || !auth.tenantId || !auth.orgId) {
|
|
229
243
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
@@ -232,7 +246,8 @@ export async function DELETE(req: NextRequest, ctx: RouteContext) {
|
|
|
232
246
|
if (!attachmentId) {
|
|
233
247
|
return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
|
|
234
248
|
}
|
|
235
|
-
const
|
|
249
|
+
const container = await createRequestContainer()
|
|
250
|
+
const { resolve } = container
|
|
236
251
|
const em = resolve('em') as EntityManager
|
|
237
252
|
const dataEngine = resolve('dataEngine') as DataEngine
|
|
238
253
|
const storageDriverFactory = resolve('storageDriverFactory') as StorageDriverFactory
|
|
@@ -241,25 +256,30 @@ export async function DELETE(req: NextRequest, ctx: RouteContext) {
|
|
|
241
256
|
tenantId: auth.tenantId,
|
|
242
257
|
organizationId: auth.orgId,
|
|
243
258
|
}
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
259
|
+
const outcome = await em.transactional(async (tx) => {
|
|
260
|
+
const candidate = await findOneWithDecryption(tx, Attachment, deleteFilter, undefined, {
|
|
261
|
+
tenantId: auth.tenantId, organizationId: auth.orgId,
|
|
262
|
+
})
|
|
263
|
+
if (!candidate) return throwAttachmentAccessError(404)
|
|
264
|
+
const referenceIds = await lockAttachmentStorageReferences(tx, candidate)
|
|
265
|
+
const removal = resolveAttachmentStorageRelease(referenceIds, candidate.id)
|
|
266
|
+
if (!removal.removed) return throwAttachmentAccessError(404)
|
|
267
|
+
const record = await findOneWithDecryption(tx, Attachment, deleteFilter, {
|
|
268
|
+
lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true,
|
|
269
|
+
}, { tenantId: auth.tenantId, organizationId: auth.orgId })
|
|
270
|
+
if (!record) return throwAttachmentAccessError(404)
|
|
271
|
+
const guard = await prepareAttachmentMutation({ container, req, auth, recordId: record.id, operation: 'delete' })
|
|
272
|
+
await assertAttachmentOwnerAccess({ em: tx, context: createAttachmentAccessContext(container), auth, attachment: record, action: 'delete' })
|
|
273
|
+
const driver = await storageDriverFactory.resolveForPartition(record.partitionCode, {
|
|
274
|
+
tenantId: record.tenantId ?? auth.tenantId!, organizationId: record.organizationId ?? auth.orgId!,
|
|
275
|
+
})
|
|
276
|
+
tx.remove(record)
|
|
277
|
+
await tx.flush()
|
|
278
|
+
return { record, driver, guard, releaseStorage: removal.releaseStorage }
|
|
251
279
|
})
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const recordStoragePath = record.storagePath
|
|
256
|
-
const removal = await removeAttachmentRecord(em, record)
|
|
257
|
-
if (!removal.removed) {
|
|
258
|
-
return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
|
|
259
|
-
}
|
|
260
|
-
if (removal.releaseStorage) {
|
|
261
|
-
await deleteDriver.delete(recordPartitionCode, recordStoragePath)
|
|
262
|
-
}
|
|
280
|
+
const { record } = outcome
|
|
281
|
+
if (outcome.releaseStorage) await outcome.driver.delete(record.partitionCode, record.storagePath)
|
|
282
|
+
await outcome.guard.runAfterSuccess()
|
|
263
283
|
|
|
264
284
|
if (dataEngine) {
|
|
265
285
|
await emitCrudSideEffects({
|
|
@@ -280,6 +300,9 @@ export async function DELETE(req: NextRequest, ctx: RouteContext) {
|
|
|
280
300
|
return NextResponse.json({ ok: true })
|
|
281
301
|
}
|
|
282
302
|
|
|
303
|
+
export const PATCH = withAttachmentAccessErrors(updateAttachment)
|
|
304
|
+
export const DELETE = withAttachmentAccessErrors(deleteAttachment)
|
|
305
|
+
|
|
283
306
|
export const openApi: OpenApiRouteDoc = {
|
|
284
307
|
tag: attachmentsTag,
|
|
285
308
|
summary: 'Attachment detail management',
|
|
@@ -294,6 +317,8 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
294
317
|
{ status: 400, description: 'Invalid attachment ID', schema: attachmentErrorSchema },
|
|
295
318
|
{ status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
|
|
296
319
|
{ status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },
|
|
320
|
+
{ status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
|
|
321
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
297
322
|
],
|
|
298
323
|
},
|
|
299
324
|
PATCH: {
|
|
@@ -311,6 +336,8 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
311
336
|
{ status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
|
|
312
337
|
{ status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },
|
|
313
338
|
{ status: 500, description: 'Failed to save attributes', schema: attachmentErrorSchema },
|
|
339
|
+
{ status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
|
|
340
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
314
341
|
],
|
|
315
342
|
},
|
|
316
343
|
DELETE: {
|
|
@@ -323,6 +350,8 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
323
350
|
{ status: 400, description: 'Invalid attachment ID', schema: attachmentErrorSchema },
|
|
324
351
|
{ status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
|
|
325
352
|
{ status: 404, description: 'Attachment not found', schema: attachmentErrorSchema },
|
|
353
|
+
{ status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
|
|
354
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
326
355
|
],
|
|
327
356
|
},
|
|
328
357
|
},
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { createAttachmentAccessContext } from '../../lib/access-runner'
|
|
2
|
+
import { requiresAttachmentAccessScan, scanAuthorizedAttachments } from '../../lib/access-query'
|
|
3
|
+
import { withAttachmentAccessErrors } from '../../lib/access-errors'
|
|
1
4
|
import { NextResponse } from 'next/server'
|
|
2
5
|
import { z } from 'zod'
|
|
3
6
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
@@ -55,7 +58,7 @@ function formatDateValue(value: unknown): string {
|
|
|
55
58
|
return toDate().toISOString()
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
async function listAttachments(req: Request) {
|
|
59
62
|
const auth = await getAuthFromRequest(req)
|
|
60
63
|
if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) {
|
|
61
64
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
@@ -69,7 +72,8 @@ export async function GET(req: Request) {
|
|
|
69
72
|
const { page, pageSize, search, partition, tags, sortField, sortDir } = parsed.data
|
|
70
73
|
const tagList = buildTagFilter(tags)
|
|
71
74
|
const offset = (page - 1) * pageSize
|
|
72
|
-
const
|
|
75
|
+
const container = await createRequestContainer()
|
|
76
|
+
const { resolve } = container
|
|
73
77
|
const em = resolve('em') as EntityManager
|
|
74
78
|
await ensureDefaultPartitions(em)
|
|
75
79
|
let queryEngine: QueryEngine | null = null
|
|
@@ -100,15 +104,32 @@ export async function GET(req: Request) {
|
|
|
100
104
|
createdAt: 'a.created_at',
|
|
101
105
|
}
|
|
102
106
|
const orderColumn = orderMap[sortField ?? 'createdAt'] ?? 'a.created_at'
|
|
103
|
-
qb.orderBy({ [orderColumn]: sortDir === 'asc' ? 'asc' : 'desc' })
|
|
107
|
+
qb.orderBy({ [orderColumn]: sortDir === 'asc' ? 'asc' : 'desc', 'a.id': 'asc' })
|
|
104
108
|
qb.limit(pageSize).offset(offset)
|
|
105
109
|
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
const partitions = await em.find(AttachmentPartition, {}, { orderBy: { title: 'asc' } })
|
|
111
|
+
const protectedScan = requiresAttachmentAccessScan(partitions)
|
|
112
|
+
const accessContext = createAttachmentAccessContext(container)
|
|
113
|
+
const deadline = Date.now() + 15_000
|
|
114
|
+
let records: Attachment[]
|
|
115
|
+
let total: number
|
|
116
|
+
let authorizedTags: string[] | null = null
|
|
117
|
+
if (protectedScan) {
|
|
118
|
+
const scanned = await scanAuthorizedAttachments({
|
|
119
|
+
auth, context: accessContext, partitions, offset, limit: pageSize, deadline,
|
|
120
|
+
loadBatch: (batchOffset, limit) => qb.clone().limit(limit).offset(batchOffset).getResultList(),
|
|
121
|
+
})
|
|
122
|
+
records = scanned.records
|
|
123
|
+
total = scanned.total
|
|
124
|
+
const facetQuery = em.createQueryBuilder(Attachment, 'a').where(baseFilter).orderBy({ 'a.id': 'asc' })
|
|
125
|
+
const facets = await scanAuthorizedAttachments({
|
|
126
|
+
auth, context: accessContext, partitions, limit: 0, collectTags: true, deadline,
|
|
127
|
+
loadBatch: (batchOffset, limit) => facetQuery.clone().limit(limit).offset(batchOffset).getResultList(),
|
|
128
|
+
})
|
|
129
|
+
authorizedTags = facets.tags
|
|
130
|
+
} else {
|
|
131
|
+
;[records, total] = await Promise.all([qb.getResultList(), countQb.getCount('a.id', true)])
|
|
132
|
+
}
|
|
112
133
|
const partitionTitleByCode = partitions.reduce<Record<string, string>>((acc, entry) => {
|
|
113
134
|
if (entry.code) acc[entry.code] = entry.title ?? entry.code
|
|
114
135
|
return acc
|
|
@@ -154,6 +175,8 @@ export async function GET(req: Request) {
|
|
|
154
175
|
: items
|
|
155
176
|
|
|
156
177
|
const totalPages = Math.max(1, Math.ceil(Number(total) / pageSize))
|
|
178
|
+
let availableTags = authorizedTags ?? []
|
|
179
|
+
if (!protectedScan) {
|
|
157
180
|
const db = em.getKysely<any>() as any
|
|
158
181
|
let tagQuery = db
|
|
159
182
|
.selectFrom('attachments')
|
|
@@ -163,9 +186,10 @@ export async function GET(req: Request) {
|
|
|
163
186
|
tagQuery = tagQuery.where('organization_id', '=', auth.orgId)
|
|
164
187
|
}
|
|
165
188
|
const tagRows = await tagQuery.orderBy('tag', 'asc').execute() as Array<{ tag?: string | null }>
|
|
166
|
-
|
|
189
|
+
availableTags = tagRows
|
|
167
190
|
.map((row) => (typeof row.tag === 'string' ? row.tag.trim() : ''))
|
|
168
191
|
.filter((tag) => tag.length > 0)
|
|
192
|
+
}
|
|
169
193
|
|
|
170
194
|
return NextResponse.json({
|
|
171
195
|
items: enrichedItems,
|
|
@@ -180,9 +204,11 @@ export async function GET(req: Request) {
|
|
|
180
204
|
description: entry.description ?? null,
|
|
181
205
|
isPublic: entry.isPublic ?? false,
|
|
182
206
|
})),
|
|
183
|
-
})
|
|
207
|
+
}, { headers: protectedScan ? { 'Cache-Control': 'private, no-store' } : undefined })
|
|
184
208
|
}
|
|
185
209
|
|
|
210
|
+
export const GET = withAttachmentAccessErrors(listAttachments)
|
|
211
|
+
|
|
186
212
|
export const openApi: OpenApiRouteDoc = {
|
|
187
213
|
tag: attachmentsTag,
|
|
188
214
|
summary: 'Attachment library management',
|
|
@@ -197,6 +223,9 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
197
223
|
errors: [
|
|
198
224
|
{ status: 400, description: 'Invalid query parameters', schema: attachmentErrorSchema },
|
|
199
225
|
{ status: 401, description: 'Unauthorized', schema: attachmentErrorSchema },
|
|
226
|
+
{ status: 403, description: 'Owner policy denies access', schema: attachmentErrorSchema },
|
|
227
|
+
{ status: 404, description: 'Attachment or owner is unavailable', schema: attachmentErrorSchema },
|
|
228
|
+
{ status: 504, description: 'Owner authorization timed out', schema: attachmentErrorSchema },
|
|
200
229
|
],
|
|
201
230
|
},
|
|
202
231
|
},
|
|
@@ -7,6 +7,7 @@ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/
|
|
|
7
7
|
import { Attachment, AttachmentPartition } from '../../data/entities'
|
|
8
8
|
import { ensureDefaultPartitions, DEFAULT_ATTACHMENT_PARTITIONS, sanitizePartitionCode, isPartitionSettingsLocked } from '../../lib/partitions'
|
|
9
9
|
import { resolvePartitionEnvKey } from '../../lib/partitionEnv'
|
|
10
|
+
import { getDeclaredAttachmentAccessRequirements } from '../../lib/access-protection'
|
|
10
11
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
11
12
|
import { resolveDefaultAttachmentOcrEnabled } from '../../lib/ocrConfig'
|
|
12
13
|
import {
|
|
@@ -127,6 +128,7 @@ export async function POST(req: Request) {
|
|
|
127
128
|
}
|
|
128
129
|
const entry = em.create(AttachmentPartition, {
|
|
129
130
|
code,
|
|
131
|
+
accessResolverRequirements: getDeclaredAttachmentAccessRequirements(code),
|
|
130
132
|
title: parsed.data.title.trim(),
|
|
131
133
|
description: parsed.data.description?.trim() ?? null,
|
|
132
134
|
storageDriver: parsed.data.storageDriver ?? 'local',
|