@de-otio/trellis 0.11.0 → 0.12.1

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.
Files changed (126) hide show
  1. package/dist/env.d.ts +168 -0
  2. package/dist/env.d.ts.map +1 -1
  3. package/dist/env.js +155 -0
  4. package/dist/env.js.map +1 -1
  5. package/dist/lambda/media-completion-worker.d.ts +175 -0
  6. package/dist/lambda/media-completion-worker.d.ts.map +1 -0
  7. package/dist/lambda/media-completion-worker.js +373 -0
  8. package/dist/lambda/media-completion-worker.js.map +1 -0
  9. package/dist/lambda/media-processing-worker.d.ts +172 -1
  10. package/dist/lambda/media-processing-worker.d.ts.map +1 -1
  11. package/dist/lambda/media-processing-worker.js +343 -49
  12. package/dist/lambda/media-processing-worker.js.map +1 -1
  13. package/dist/lib/exif-stripper.d.ts +37 -22
  14. package/dist/lib/exif-stripper.d.ts.map +1 -1
  15. package/dist/lib/exif-stripper.js +101 -41
  16. package/dist/lib/exif-stripper.js.map +1 -1
  17. package/dist/lib/media/cas-keys.d.ts +63 -0
  18. package/dist/lib/media/cas-keys.d.ts.map +1 -0
  19. package/dist/lib/media/cas-keys.js +102 -0
  20. package/dist/lib/media/cas-keys.js.map +1 -0
  21. package/dist/lib/media/classify-worker-error.d.ts +48 -0
  22. package/dist/lib/media/classify-worker-error.d.ts.map +1 -0
  23. package/dist/lib/media/classify-worker-error.js +319 -0
  24. package/dist/lib/media/classify-worker-error.js.map +1 -0
  25. package/dist/lib/media/dedupe-key.d.ts +29 -0
  26. package/dist/lib/media/dedupe-key.d.ts.map +1 -0
  27. package/dist/lib/media/dedupe-key.js +49 -0
  28. package/dist/lib/media/dedupe-key.js.map +1 -0
  29. package/dist/lib/media/duration-cap.d.ts +30 -0
  30. package/dist/lib/media/duration-cap.d.ts.map +1 -0
  31. package/dist/lib/media/duration-cap.js +37 -0
  32. package/dist/lib/media/duration-cap.js.map +1 -0
  33. package/dist/lib/media/ffmpeg-args.d.ts +83 -0
  34. package/dist/lib/media/ffmpeg-args.d.ts.map +1 -0
  35. package/dist/lib/media/ffmpeg-args.js +119 -0
  36. package/dist/lib/media/ffmpeg-args.js.map +1 -0
  37. package/dist/lib/media/media-ports.d.ts +126 -0
  38. package/dist/lib/media/media-ports.d.ts.map +1 -0
  39. package/dist/lib/media/media-ports.js +129 -0
  40. package/dist/lib/media/media-ports.js.map +1 -0
  41. package/dist/lib/media/media-upsert.d.ts +55 -0
  42. package/dist/lib/media/media-upsert.d.ts.map +1 -0
  43. package/dist/lib/media/media-upsert.js +38 -0
  44. package/dist/lib/media/media-upsert.js.map +1 -0
  45. package/dist/lib/media/moderation-provider.d.ts +111 -0
  46. package/dist/lib/media/moderation-provider.d.ts.map +1 -0
  47. package/dist/lib/media/moderation-provider.js +130 -0
  48. package/dist/lib/media/moderation-provider.js.map +1 -0
  49. package/dist/lib/media/moderation-resolved-payload.d.ts +48 -0
  50. package/dist/lib/media/moderation-resolved-payload.d.ts.map +1 -0
  51. package/dist/lib/media/moderation-resolved-payload.js +37 -0
  52. package/dist/lib/media/moderation-resolved-payload.js.map +1 -0
  53. package/dist/lib/media/moderation-status.d.ts +98 -0
  54. package/dist/lib/media/moderation-status.d.ts.map +1 -0
  55. package/dist/lib/media/moderation-status.js +122 -0
  56. package/dist/lib/media/moderation-status.js.map +1 -0
  57. package/dist/lib/media/processing-types.d.ts +45 -0
  58. package/dist/lib/media/processing-types.d.ts.map +1 -0
  59. package/dist/lib/media/processing-types.js +9 -0
  60. package/dist/lib/media/processing-types.js.map +1 -0
  61. package/dist/lib/media/promote-decision.d.ts +64 -0
  62. package/dist/lib/media/promote-decision.d.ts.map +1 -0
  63. package/dist/lib/media/promote-decision.js +76 -0
  64. package/dist/lib/media/promote-decision.js.map +1 -0
  65. package/dist/lib/media/quota-check.d.ts +22 -0
  66. package/dist/lib/media/quota-check.d.ts.map +1 -0
  67. package/dist/lib/media/quota-check.js +42 -0
  68. package/dist/lib/media/quota-check.js.map +1 -0
  69. package/dist/lib/media/quota-types.d.ts +15 -0
  70. package/dist/lib/media/quota-types.d.ts.map +1 -0
  71. package/dist/lib/media/quota-types.js +9 -0
  72. package/dist/lib/media/quota-types.js.map +1 -0
  73. package/dist/lib/media/route-upload.d.ts +58 -0
  74. package/dist/lib/media/route-upload.d.ts.map +1 -0
  75. package/dist/lib/media/route-upload.js +80 -0
  76. package/dist/lib/media/route-upload.js.map +1 -0
  77. package/dist/lib/media/serve-gate.d.ts +51 -0
  78. package/dist/lib/media/serve-gate.d.ts.map +1 -0
  79. package/dist/lib/media/serve-gate.js +68 -0
  80. package/dist/lib/media/serve-gate.js.map +1 -0
  81. package/dist/lib/media/tenant-resolution.d.ts +42 -0
  82. package/dist/lib/media/tenant-resolution.d.ts.map +1 -0
  83. package/dist/lib/media/tenant-resolution.js +45 -0
  84. package/dist/lib/media/tenant-resolution.js.map +1 -0
  85. package/dist/lib/media/text-moderation.d.ts +28 -0
  86. package/dist/lib/media/text-moderation.d.ts.map +1 -0
  87. package/dist/lib/media/text-moderation.js +62 -0
  88. package/dist/lib/media/text-moderation.js.map +1 -0
  89. package/dist/lib/media/track-verdict.d.ts +45 -0
  90. package/dist/lib/media/track-verdict.d.ts.map +1 -0
  91. package/dist/lib/media/track-verdict.js +52 -0
  92. package/dist/lib/media/track-verdict.js.map +1 -0
  93. package/dist/lib/media/transcript-moderation.d.ts +47 -0
  94. package/dist/lib/media/transcript-moderation.d.ts.map +1 -0
  95. package/dist/lib/media/transcript-moderation.js +70 -0
  96. package/dist/lib/media/transcript-moderation.js.map +1 -0
  97. package/dist/lib/media-handler.d.ts.map +1 -1
  98. package/dist/lib/media-handler.js +15 -9
  99. package/dist/lib/media-handler.js.map +1 -1
  100. package/dist/lib/post-handler.d.ts.map +1 -1
  101. package/dist/lib/post-handler.js +4 -1
  102. package/dist/lib/post-handler.js.map +1 -1
  103. package/dist/lib/route-helpers.d.ts.map +1 -1
  104. package/dist/lib/route-helpers.js +9 -1
  105. package/dist/lib/route-helpers.js.map +1 -1
  106. package/dist/lib/routes/media.d.ts +21 -0
  107. package/dist/lib/routes/media.d.ts.map +1 -1
  108. package/dist/lib/routes/media.js +584 -483
  109. package/dist/lib/routes/media.js.map +1 -1
  110. package/dist/lib/services/image-normalizer.d.ts +64 -6
  111. package/dist/lib/services/image-normalizer.d.ts.map +1 -1
  112. package/dist/lib/services/image-normalizer.js +88 -6
  113. package/dist/lib/services/image-normalizer.js.map +1 -1
  114. package/dist/lib/services/media-upload-service.d.ts +2 -2
  115. package/dist/lib/services/media-upload-service.d.ts.map +1 -1
  116. package/dist/lib/services/media-upload-service.js +22 -21
  117. package/dist/lib/services/media-upload-service.js.map +1 -1
  118. package/dist/lib/tenant-scope.d.ts.map +1 -1
  119. package/dist/lib/tenant-scope.js +16 -1
  120. package/dist/lib/tenant-scope.js.map +1 -1
  121. package/package.json +2 -1
  122. package/prisma/migrations/20260625000000_media_tenant_scope_and_moderation_status/migration.sql +49 -0
  123. package/prisma/migrations/20260625000001_p0b_moderation_jobs/migration.sql +73 -0
  124. package/prisma/schema.prisma +95 -17
  125. package/src/lambda/media-completion-worker.ts +567 -0
  126. package/src/lambda/media-processing-worker.ts +508 -59
@@ -4,17 +4,76 @@
4
4
  * Handles media file uploads (images, videos) for posts and profiles.
5
5
  * Implements content-addressed storage (CAS) with SHA-256 hashing for deduplication.
6
6
  */
7
+ import { randomBytes as cryptoRandomBytes } from "node:crypto";
7
8
  import { CorsHandler } from "../cors-handler.js";
8
9
  import { sharedDatabaseConnectionManager } from "../database-connection-manager.js";
9
10
  import { QueryTimeoutPresets, withQueryTimeoutAndRetry, } from "../db-query-helper.js";
10
11
  import { getLogger } from "../logger.js";
12
+ import { casKey, isCasKeyError, pendingKey, validateContentHash } from "../media/cas-keys.js";
13
+ import { buildMediaUpsertArgs } from "../media/media-upsert.js";
14
+ import { checkUploadQuota } from "../media/quota-check.js";
15
+ import { canonicalContentType, isServable, } from "../media/serve-gate.js";
16
+ import { resolveMediaTenantId } from "../media/tenant-resolution.js";
17
+ import { routeUpload } from "../media/route-upload.js";
11
18
  import { MediaHandler } from "../media-handler.js";
12
19
  import { corsMiddleware, csrfMiddleware } from "../middleware.js";
13
20
  import { RateLimiter } from "../rate-limit.js";
14
21
  import { SecurityHeaders } from "../security-headers.js";
15
22
  import { SessionManager } from "../session-cookie.js";
16
- import { ImageNormalizer } from "../services/image-normalizer.js";
23
+ import { REENCODABLE_IMAGE_TYPES, reencodeImage, } from "../services/image-normalizer.js";
17
24
  import { MediaUploadService } from "../services/media-upload-service.js";
25
+ /**
26
+ * Resolve the tenant id that scopes a media upload (T9 / D18) — the imperative
27
+ * shell around the pure `resolveMediaTenantId` decision.
28
+ *
29
+ * Reads the ambient tenant (auth seam ALS); with `TENANT_SCOPE_MODE="off"` (the
30
+ * default) no ambient tenant is set, so we load the uploader's
31
+ * `personalTenantId` from the DB and fall back to it. Returns null when no
32
+ * tenant can be resolved (caller fails closed). See media/tenant-resolution.ts
33
+ * for the recorded assumption.
34
+ */
35
+ async function resolveUploadTenantId(userId, region, env) {
36
+ const { getCurrentTenantId } = await import("@de-otio/saas-foundation/tenant");
37
+ const { resolveTenantScopeMode } = await import("../tenant-scope.js");
38
+ const scopeMode = resolveTenantScopeMode();
39
+ const ambient = getCurrentTenantId();
40
+ // Only hit the DB for the personal-tenant fallback when scope is off and
41
+ // there is no ambient tenant (the common dev/default case).
42
+ let personalTenantId = null;
43
+ if (!ambient && scopeMode === "off") {
44
+ try {
45
+ personalTenantId = await withQueryTimeoutAndRetry(sharedDatabaseConnectionManager, region, env, async (db) => {
46
+ const user = await db.user.findUnique({
47
+ where: { id: userId },
48
+ select: { personalTenantId: true },
49
+ });
50
+ return user?.personalTenantId ?? null;
51
+ }, {
52
+ ...QueryTimeoutPresets.USER_FACING,
53
+ maxRetries: 1,
54
+ context: { operation: "media_resolve_tenant", userId },
55
+ });
56
+ }
57
+ catch {
58
+ personalTenantId = null;
59
+ }
60
+ }
61
+ const resolution = resolveMediaTenantId(ambient, personalTenantId, scopeMode);
62
+ return resolution.ok ? resolution.tenantId : null;
63
+ }
64
+ /**
65
+ * Generate a CUID v1-shaped upload ID suitable for `pendingKey`.
66
+ *
67
+ * Uses `node:crypto` randomBytes — the ONLY source of non-determinism allowed
68
+ * in this module (imperative shell; not a pure-core unit). The shape
69
+ * `c[a-z0-9]{24}` matches the UPLOAD_ID_RE used by `pendingKey`.
70
+ */
71
+ function generateUploadId() {
72
+ // 12 random bytes → 24 lowercase hex chars → prepend 'c' = 25-char cuid-shaped id
73
+ // matching UPLOAD_ID_RE = /^c[a-z0-9]{24}$/ in cas-keys.ts.
74
+ const hex = cryptoRandomBytes(12).toString("hex"); // exactly 24 [0-9a-f] chars
75
+ return `c${hex}`;
76
+ }
18
77
  /**
19
78
  * Generate SHA-256 content hash for content-addressed storage
20
79
  */
@@ -131,30 +190,87 @@ function validateMagicNumbers(bytes, declaredMimeType) {
131
190
  return false;
132
191
  }
133
192
  /**
134
- * Serve media file by content hash with variant support
135
- * Shared function used by both /api/media/:mediaId and /api/media/:hash routes
193
+ * The single, byte-identical "deny" response (T5 anti-oracle).
194
+ *
195
+ * EVERY non-APPROVED outcome — PENDING/REVIEW/QUARANTINED/REJECTED, not-found,
196
+ * DB-error, hidden, soft-deleted, and the unexpected-error/catch path — returns
197
+ * exactly this: the same status code, the same byte-identical body, and the
198
+ * same fixed minimal viewer-independent header set. No `contentHash`, no
199
+ * `variant`, no `userId`, no `source`, no `error.message`, no `codeVersion`, no
200
+ * `X-Debug-*`, no per-user `Cache-Key`. A prober cannot distinguish "absent"
201
+ * from "exists-but-not-approved" from "DB down" — they are the same bytes.
202
+ *
203
+ * The body and header set are constants (no `Date.now()`, no request-derived
204
+ * values beyond the constant CORS reflection applied uniformly below), so two
205
+ * deny outcomes are indistinguishable at the byte level.
136
206
  */
137
- async function serveMediaByHash(contentHash, variant, request, env, session) {
138
- const logger = getLogger();
139
- logger.debug("SERVE MEDIA BY HASH: Starting", {
140
- contentHash,
141
- variant,
142
- userId: session.userId,
207
+ const MEDIA_DENY_BODY = JSON.stringify({ error: "Media not found" });
208
+ async function mediaDenyResponse(request, env) {
209
+ const response = new Response(MEDIA_DENY_BODY, {
210
+ status: 404,
211
+ headers: {
212
+ "Content-Type": "application/json",
213
+ "Cache-Control": "no-store",
214
+ "X-Content-Type-Options": "nosniff",
215
+ },
143
216
  });
144
- const securityHeaders = new SecurityHeaders(env);
217
+ return CorsHandler.addCorsHeaders(response, request, env);
218
+ }
219
+ /**
220
+ * Serve media file by content hash (T5: fail-closed APPROVED-only gate).
221
+ *
222
+ * Shared function used by both /api/media/:mediaId and /api/media/:hash routes.
223
+ *
224
+ * Decision flow (functional core in `media/serve-gate.ts`):
225
+ * 1. Validate the inbound URL hash via `validateContentHash` BEFORE any lookup.
226
+ * 2. Look up the DB record (the ONLY source of a servable key — the no-DB
227
+ * storage-probe maze was deleted in T9; storage is never probed for
228
+ * un-recorded bytes).
229
+ * 3. `isServable` gate: serve ONLY when `moderationStatus === "APPROVED"` AND
230
+ * not `hidden` AND not soft-deleted — for EVERY viewer incl. the owner.
231
+ * 4. Every other outcome (incl. not-found / DB-error / invalid-hash / error
232
+ * path) returns the single byte-identical {@link mediaDenyResponse}.
233
+ *
234
+ * `variant` is retained for call-site compatibility but never influences the
235
+ * gate; the served key is always the canonical `originalKey` from the DB record.
236
+ */
237
+ export async function serveMediaByHash(contentHash, variant, request, env, session) {
238
+ const logger = getLogger();
145
239
  try {
146
- // Wrap entire function in try-catch to catch any unexpected errors
147
- logger.debug("SERVE MEDIA: Inside try block");
148
- // Find media file in database - using retry logic
240
+ // (1) Validate the inbound URL hash BEFORE any lookup. A malformed hash is
241
+ // indistinguishable from a not-found object — same deny response.
242
+ const normalizedHash = validateContentHash(contentHash);
243
+ if (isCasKeyError(normalizedHash)) {
244
+ return mediaDenyResponse(request, env);
245
+ }
246
+ // (2) Look up the DB record. A null record (not-found) or a thrown query
247
+ // (DB-error) both resolve to `mediaFile = null` and deny identically — no
248
+ // separate I/O shape on either branch (no extra audit/DB write).
149
249
  let mediaFile = null;
250
+ const region = "US"; // TODO: derive from session/request residency
251
+ // Media is tenant-scoped (D18): the canonical identity is
252
+ // (tenantId, contentHash), so a bare hash is NOT a unique key. Scope the
253
+ // lookup to the VIEWER's resolved tenant — fail-closed and isolation-safe:
254
+ // a wrong-tenant hash simply misses -> uniform deny, never a cross-tenant
255
+ // read. NOTE (P0c design decision): cross-tenant "social" viewing by bare
256
+ // hash is intentionally NOT supported here; that read-addressing belongs to
257
+ // the P0c tenant-aware delivery seam (per-tenant domains, D9) or a
258
+ // mediaId/post-scoped lookup that carries the owner's tenant.
259
+ const viewerTenantId = await resolveUploadTenantId(session.userId, region, env);
260
+ if (!viewerTenantId) {
261
+ return mediaDenyResponse(request, env);
262
+ }
150
263
  try {
151
- const region = "US"; // TODO: Get from session or request
152
- // Using retry logic with exponential backoff for connection resilience
153
264
  mediaFile = await withQueryTimeoutAndRetry(sharedDatabaseConnectionManager, region, env, async (db) => {
154
265
  const dbAny = db;
155
266
  if (dbAny.mediaFile) {
156
267
  return await dbAny.mediaFile.findUnique({
157
- where: { contentHash },
268
+ where: {
269
+ tenantId_contentHash: {
270
+ tenantId: viewerTenantId,
271
+ contentHash: normalizedHash,
272
+ },
273
+ },
158
274
  });
159
275
  }
160
276
  return null;
@@ -162,379 +278,64 @@ async function serveMediaByHash(contentHash, variant, request, env, session) {
162
278
  ...QueryTimeoutPresets.USER_FACING,
163
279
  maxRetries: 3,
164
280
  baseDelayMs: 100,
165
- context: {
166
- operation: "media_get",
167
- contentHash,
168
- },
281
+ context: { operation: "media_get" },
169
282
  });
170
- if (!mediaFile) {
171
- logger.debug("MediaFile not found in database, using fallback key lookup", {
172
- contentHash,
173
- variant,
174
- });
175
- }
176
- else {
177
- logger.debug("MediaFile found in database", {
178
- contentHash,
179
- variant,
180
- originalKey: mediaFile.originalKey,
181
- optimizedKey: mediaFile.optimizedKey,
182
- thumbnailKey: mediaFile.thumbnailKey,
183
- });
184
- }
185
283
  }
186
- catch (error) {
187
- logger.warn("Failed to query MediaFile, using fallback key lookup", {
188
- error: error.message,
189
- contentHash,
190
- variant,
191
- });
284
+ catch {
285
+ // DB-error: deny exactly like not-found (anti-oracle). No error detail
286
+ // leaks to the caller; logging stays internal and carries no per-viewer
287
+ // identity.
288
+ logger.warn("Failed to query MediaFile; serving uniform deny");
289
+ mediaFile = null;
192
290
  }
193
- // Fetch from R2
194
- const r2Bucket = env.MEDIA_BUCKET_R2 || env.R2_BUCKET;
195
- if (!r2Bucket) {
196
- const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Media storage not configured" }), { status: 503, headers: { "content-type": "application/json" } });
197
- return CorsHandler.addCorsHeaders(errorResponse, request, env);
291
+ // (3) Fail-closed gate. No record → deny. Record present → serve ONLY when
292
+ // APPROVED and not hidden and not soft-deleted. Owner-vs-other never changes
293
+ // this decision (no owner exception). With no P0b worker, video/audio remain
294
+ // PENDING and are denied here.
295
+ if (!mediaFile ||
296
+ !isServable({
297
+ moderationStatus: mediaFile.moderationStatus,
298
+ hidden: mediaFile.hidden,
299
+ deletedAt: mediaFile.deletedAt,
300
+ })) {
301
+ return mediaDenyResponse(request, env);
198
302
  }
199
- // Determine which R2 key to serve
200
- let r2Key = null;
201
- let contentType = "application/octet-stream";
202
- if (mediaFile) {
203
- logger.debug("SERVE MEDIA: Found database record", {
204
- contentHash,
205
- variant,
206
- hasOriginalKey: !!mediaFile.originalKey,
207
- hasOptimizedKey: !!mediaFile.optimizedKey,
208
- hasThumbnailKey: !!mediaFile.thumbnailKey,
209
- originalKey: mediaFile.originalKey,
210
- optimizedKey: mediaFile.optimizedKey,
211
- thumbnailKey: mediaFile.thumbnailKey,
212
- });
213
- switch (variant) {
214
- case "thumbnail":
215
- r2Key =
216
- mediaFile.thumbnailKey ||
217
- mediaFile.optimizedKey ||
218
- mediaFile.originalKey;
219
- contentType =
220
- mediaFile.thumbnailKey || mediaFile.optimizedKey
221
- ? "image/webp"
222
- : mediaFile.mimeType || "application/octet-stream";
223
- break;
224
- case "optimized":
225
- // For optimized variant, prefer optimized but ALWAYS fall back to original
226
- r2Key = mediaFile.optimizedKey || mediaFile.originalKey;
227
- // If still no key, this is a data integrity issue - log and continue to fallback
228
- if (!r2Key) {
229
- logger.error("SERVE MEDIA: Database record has no keys!", {
230
- contentHash,
231
- mediaFileId: mediaFile.id,
232
- });
233
- }
234
- contentType = mediaFile.optimizedKey
235
- ? "image/webp"
236
- : mediaFile.mimeType || "application/octet-stream";
237
- break;
238
- case "original":
239
- r2Key = mediaFile.originalKey;
240
- contentType = mediaFile.mimeType || "application/octet-stream";
241
- break;
242
- default:
243
- // Default to optimized with fallback to original
244
- r2Key = mediaFile.optimizedKey || mediaFile.originalKey;
245
- contentType = mediaFile.optimizedKey
246
- ? "image/webp"
247
- : mediaFile.mimeType || "application/octet-stream";
248
- }
249
- logger.debug("SERVE MEDIA: Using database record", {
250
- contentHash,
251
- variant,
252
- r2Key,
253
- contentType,
254
- hasOptimized: !!mediaFile.optimizedKey,
255
- hasOriginal: !!mediaFile.originalKey,
256
- });
257
- // CRITICAL FIX: If r2Key is still null after using database record,
258
- // fall back to R2 key lookup
259
- if (!r2Key) {
260
- logger.debug("SERVE MEDIA: Database record has no keys, falling back to R2 lookup", {
261
- contentHash,
262
- variant,
263
- });
264
- // Set mediaFile to null to trigger fallback logic
265
- mediaFile = null;
266
- }
267
- }
268
- if (!mediaFile) {
269
- // Fallback: construct key from hash and variant
270
- logger.debug("SERVE MEDIA: Using R2 fallback key lookup", {
271
- contentHash,
272
- variant,
273
- });
274
- const commonExtensions = ["jpg", "jpeg", "png", "webp", "gif"];
275
- // First, try to find the original file with any extension
276
- let foundOriginalKey = null;
277
- let foundContentType = "image/jpeg";
278
- for (const ext of commonExtensions) {
279
- const testKey = `media/${contentHash}.${ext}`;
280
- logger.debug("SERVE MEDIA: Trying R2 key", { testKey });
281
- try {
282
- const testObject = await r2Bucket.head(testKey);
283
- if (testObject) {
284
- foundOriginalKey = testKey;
285
- foundContentType = `image/${ext === "jpg" ? "jpeg" : ext}`;
286
- logger.debug("SERVE MEDIA: Found media file in R2 fallback", {
287
- contentHash,
288
- key: testKey,
289
- contentType: foundContentType,
290
- });
291
- break;
292
- }
293
- }
294
- catch (error) {
295
- // head() can throw errors, continue trying other extensions
296
- logger.debug("SERVE MEDIA: R2 head() failed for key", {
297
- key: testKey,
298
- error: error.message,
299
- });
300
- }
301
- }
302
- logger.debug("SERVE MEDIA: Original file search complete", {
303
- foundOriginalKey,
304
- foundContentType,
305
- });
306
- // Now determine which key to use based on variant
307
- if (variant === "thumbnail") {
308
- // Try thumbnail first
309
- let foundThumb = false;
310
- for (const ext of ["webp", ...commonExtensions]) {
311
- const testKey = `media/${contentHash}_thumb.${ext}`;
312
- try {
313
- const testObject = await r2Bucket.head(testKey);
314
- if (testObject) {
315
- r2Key = testKey;
316
- contentType = "image/webp";
317
- foundThumb = true;
318
- break;
319
- }
320
- }
321
- catch (error) {
322
- // head() can throw errors, continue trying other extensions
323
- logger.debug("R2 head() failed for thumbnail key", {
324
- key: testKey,
325
- error: error.message,
326
- });
327
- }
328
- }
329
- // Fall back to optimized if no thumbnail
330
- if (!foundThumb) {
331
- for (const ext of ["webp", ...commonExtensions]) {
332
- const testKey = `media/${contentHash}_opt.${ext}`;
333
- try {
334
- const testObject = await r2Bucket.head(testKey);
335
- if (testObject) {
336
- r2Key = testKey;
337
- contentType = "image/webp";
338
- foundThumb = true;
339
- break;
340
- }
341
- }
342
- catch (error) {
343
- // head() can throw errors, continue trying other extensions
344
- logger.debug("R2 head() failed for optimized key", {
345
- key: testKey,
346
- error: error.message,
347
- });
348
- }
349
- }
350
- }
351
- // Fall back to original if no thumbnail or optimized
352
- if (!foundThumb && foundOriginalKey) {
353
- r2Key = foundOriginalKey;
354
- contentType = foundContentType;
355
- }
356
- }
357
- else if (variant === "optimized") {
358
- // Try optimized first
359
- logger.debug("SERVE MEDIA: Looking for optimized variant", {
360
- contentHash,
361
- });
362
- let foundOpt = false;
363
- for (const ext of ["webp", ...commonExtensions]) {
364
- const testKey = `media/${contentHash}_opt.${ext}`;
365
- logger.debug("SERVE MEDIA: Trying optimized key", { testKey });
366
- try {
367
- const testObject = await r2Bucket.head(testKey);
368
- if (testObject) {
369
- r2Key = testKey;
370
- contentType = "image/webp";
371
- foundOpt = true;
372
- logger.debug("SERVE MEDIA: Found optimized variant", { testKey });
373
- break;
374
- }
375
- }
376
- catch (error) {
377
- // head() can throw errors, continue trying other extensions
378
- logger.debug("SERVE MEDIA: R2 head() failed for optimized key", {
379
- key: testKey,
380
- error: error.message,
381
- });
382
- }
383
- }
384
- // Fall back to original if no optimized version
385
- // ALWAYS fall back to original if we didn't find an optimized version
386
- if (!foundOpt) {
387
- if (foundOriginalKey) {
388
- r2Key = foundOriginalKey;
389
- contentType = foundContentType;
390
- logger.debug("SERVE MEDIA: Falling back to original for optimized variant", {
391
- r2Key,
392
- contentType,
393
- });
394
- }
395
- else {
396
- // If we still haven't found the original, log it for debugging
397
- logger.debug("SERVE MEDIA: No optimized version found and foundOriginalKey is null", {
398
- contentHash,
399
- variant,
400
- });
401
- }
402
- }
403
- }
404
- else {
405
- // Original variant or any other variant: use the found original key
406
- if (foundOriginalKey) {
407
- r2Key = foundOriginalKey;
408
- contentType = foundContentType;
409
- }
410
- }
411
- }
412
- logger.debug("SERVE MEDIA: Final R2 key selection", {
413
- r2Key,
414
- contentType,
415
- variant,
416
- contentHash,
417
- hasMediaFile: !!mediaFile,
418
- });
419
- // CRITICAL FIX: If r2Key is still null at this point, try one more fallback
420
- // This handles edge cases where the database record exists but has null keys,
421
- // or the fallback R2 lookup failed for some reason
303
+ // The canonical key is the DB record's originalKey (== cas/{tenantId}/{hash}).
304
+ // No key → not servable (deny), never a storage probe.
305
+ const r2Key = mediaFile.originalKey;
422
306
  if (!r2Key) {
423
- logger.debug("SERVE MEDIA: r2Key is null, attempting final fallback", {
424
- contentHash,
425
- variant,
426
- });
427
- // Try to find the file with common extensions
428
- const commonExtensions = ["png", "jpg", "jpeg", "webp", "gif"];
429
- for (const ext of commonExtensions) {
430
- const testKey = `media/${contentHash}.${ext}`;
431
- logger.debug("SERVE MEDIA: Final fallback trying key", { testKey });
432
- try {
433
- const testObject = await r2Bucket.head(testKey);
434
- if (testObject) {
435
- r2Key = testKey;
436
- contentType = `image/${ext === "jpg" ? "jpeg" : ext}`;
437
- logger.debug("SERVE MEDIA: Final fallback found file", {
438
- r2Key,
439
- contentType,
440
- });
441
- break;
442
- }
443
- }
444
- catch (error) {
445
- logger.debug("SERVE MEDIA: Final fallback head() failed", {
446
- key: testKey,
447
- error: error.message,
448
- });
449
- }
450
- }
307
+ return mediaDenyResponse(request, env);
451
308
  }
452
- if (!r2Key) {
453
- logger.debug("SERVE MEDIA: No R2 key found, returning 404", {
454
- contentHash,
455
- variant,
456
- hasMediaFile: !!mediaFile,
457
- });
458
- // In dev, return detailed debug info
459
- const debugInfo = env.ENVIRONMENT === "dev"
460
- ? {
461
- contentHash,
462
- variant,
463
- hasMediaFile: !!mediaFile,
464
- mediaFileKeys: mediaFile
465
- ? {
466
- original: mediaFile.originalKey,
467
- optimized: mediaFile.optimizedKey,
468
- thumbnail: mediaFile.thumbnailKey,
469
- }
470
- : null,
471
- codeVersion: "v2-with-debug",
472
- }
473
- : undefined;
474
- const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({
475
- error: "Media not found",
476
- source: "serveMediaByHash-noKey",
477
- ...(debugInfo && { debug: debugInfo }),
478
- }), { status: 404, headers: { "content-type": "application/json" } });
479
- return CorsHandler.addCorsHeaders(errorResponse, request, env);
309
+ const r2Bucket = env.MEDIA_BUCKET_R2 || env.R2_BUCKET;
310
+ if (!r2Bucket) {
311
+ // Misconfiguration is also a non-serve outcome: deny uniformly rather than
312
+ // emit a distinguishing 503 (which would itself be an oracle).
313
+ return mediaDenyResponse(request, env);
480
314
  }
481
315
  const object = await r2Bucket.get(r2Key);
482
316
  if (!object) {
483
- logger.debug("SERVE MEDIA: R2 object not found", {
484
- r2Key,
485
- contentHash,
486
- variant,
487
- });
488
- // In dev, return detailed debug info
489
- const debugInfo = env.ENVIRONMENT === "dev"
490
- ? {
491
- r2Key,
492
- contentHash,
493
- variant,
494
- message: "R2 object not found at key",
495
- codeVersion: "v2-with-debug",
496
- }
497
- : undefined;
498
- const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({
499
- error: "Media not found",
500
- source: "serveMediaByHash",
501
- ...(debugInfo && { debug: debugInfo }),
502
- }), { status: 404, headers: { "content-type": "application/json" } });
503
- return CorsHandler.addCorsHeaders(errorResponse, request, env);
317
+ // DB says APPROVED but the bytes are absent: still deny uniformly.
318
+ return mediaDenyResponse(request, env);
504
319
  }
505
- // Get content type from object metadata or use determined type
506
- const objectContentType = object.httpMetadata?.contentType || contentType;
507
- // Return file with appropriate cache headers
320
+ // (4) APPROVED serve. Content-Disposition: attachment (same-origin until
321
+ // P0c's isolated CloudFront origin). Content-type derives ONLY from the
322
+ // re-encoded canonical format (T7) NEVER from object.httpMetadata or the
323
+ // stored mimeType (attacker-influenced).
508
324
  const response = new Response(object.body, {
509
325
  headers: {
510
- "Content-Type": objectContentType,
511
- "Cache-Control": `no-cache, no-store, must-revalidate`,
512
- Pragma: "no-cache",
513
- Expires: "0",
514
- "Cache-Key": `media:${session.userId}:${contentHash}:${variant}`,
326
+ "Content-Type": canonicalContentType(env.media.canonicalFormat),
327
+ "Content-Disposition": "attachment",
328
+ "Cache-Control": "no-store",
515
329
  "X-Content-Type-Options": "nosniff",
516
- "X-Debug-Variant": variant, // Simple debug header
517
- "X-Debug-Timestamp": Date.now().toString(), // Unique per request
518
330
  },
519
331
  });
520
332
  return CorsHandler.addCorsHeaders(response, request, env);
521
333
  }
522
- catch (unexpectedError) {
523
- // Catch any unexpected errors in serveMediaByHash
524
- logger.error("SERVE MEDIA BY HASH: Unexpected error", {
525
- error: unexpectedError.message,
526
- stack: unexpectedError.stack,
527
- contentHash,
528
- variant,
529
- });
530
- const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({
531
- error: "Internal server error",
532
- message: unexpectedError.message || "An unexpected error occurred",
533
- source: "serveMediaByHash-unexpected",
534
- contentHash,
535
- variant,
536
- }), { status: 500, headers: { "content-type": "application/json" } });
537
- return CorsHandler.addCorsHeaders(errorResponse, request, env);
334
+ catch {
335
+ // Any unexpected error returns the SAME placeholder — no message, no source,
336
+ // no contentHash, no codeVersion.
337
+ logger.error("SERVE MEDIA BY HASH: serving uniform deny on error");
338
+ return mediaDenyResponse(request, env);
538
339
  }
539
340
  }
540
341
  /**
@@ -608,8 +409,8 @@ export const mediaRoutes = [
608
409
  const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "content-type": "application/json" } });
609
410
  return CorsHandler.addCorsHeaders(errorResponse, request, env);
610
411
  }
611
- // Apply rate limiting: 10 uploads per 60s per user
612
- const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/upload", 10, 60, session.userId);
412
+ // Apply rate limiting: uploads per minute per user (from env.media.rateLimits)
413
+ const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/upload", env.media.rateLimits.uploadPerMin, 60, session.userId);
613
414
  if (rateLimitResponse) {
614
415
  return securityHeaders.addSecurityHeaders(rateLimitResponse);
615
416
  }
@@ -654,21 +455,16 @@ export const mediaRoutes = [
654
455
  }), { status: 400, headers: { "content-type": "application/json" } });
655
456
  return CorsHandler.addCorsHeaders(errorResponse, request, env);
656
457
  }
657
- // Validate file type
658
- const allowedImageTypes = [
659
- "image/jpeg",
660
- "image/jpg",
661
- "image/png",
662
- "image/gif",
663
- "image/webp",
664
- "image/heic",
665
- "image/heif",
666
- ];
667
- const allowedVideoTypes = [
668
- "video/mp4",
669
- "video/webm",
670
- "video/quicktime",
671
- ];
458
+ // Validate file type.
459
+ // Image allowlist is the sharp-re-encodable set (REENCODABLE_IMAGE_TYPES).
460
+ // HEIC/HEIF are excluded because sharp write support requires the optional
461
+ // libheif native module (absent in this build); full HEIC support is P1/D12.
462
+ // SVG is excluded — no safe raster transcode.
463
+ // Video is accepted (stored PENDING, served only after P0b worker approves).
464
+ const allowedImageTypes = Array.from(REENCODABLE_IMAGE_TYPES);
465
+ const allowedVideoTypes = env.media.allowlist.video.length > 0
466
+ ? env.media.allowlist.video
467
+ : ["video/mp4", "video/webm", "video/quicktime"];
672
468
  // Read file bytes first to detect MIME type if not provided
673
469
  let fileBuffer;
674
470
  try {
@@ -742,8 +538,37 @@ export const mediaRoutes = [
742
538
  bytes[5] === 0x74 &&
743
539
  bytes[6] === 0x79 &&
744
540
  bytes[7] === 0x70) {
745
- // HEIC/HEIF: ISO Base Media File Format with ftyp box
746
- detectedMimeType = "image/heic";
541
+ // ISO Base Media File Format (ftyp box). The container is shared by
542
+ // MP4/QuickTime *video* and HEIC/HEIF *images*, so disambiguate by the
543
+ // major brand (bytes 8-11) instead of assuming HEIC — otherwise every
544
+ // mp4 is misdetected as image/heic and rejected once HEIC leaves the
545
+ // re-encodable image allowlist (T7).
546
+ const brand = bytes.length >= 12
547
+ ? String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11])
548
+ : "";
549
+ const heifBrands = new Set([
550
+ "heic",
551
+ "heix",
552
+ "hevc",
553
+ "hevx",
554
+ "heim",
555
+ "heis",
556
+ "hevm",
557
+ "hevs",
558
+ "mif1",
559
+ "msf1",
560
+ ]);
561
+ if (heifBrands.has(brand)) {
562
+ detectedMimeType = "image/heic";
563
+ }
564
+ else if (brand === "qt ") {
565
+ detectedMimeType = "video/quicktime";
566
+ }
567
+ else {
568
+ // Default ISO-BMFF container (isom/iso2/mp41/mp42/avc1/…, or a
569
+ // brand-less minimal ftyp) → MP4-family video.
570
+ detectedMimeType = "video/mp4";
571
+ }
747
572
  }
748
573
  // Use detected type if we found one, otherwise fall back to declared type
749
574
  const declaredMimeType = file.type || "application/octet-stream";
@@ -788,10 +613,8 @@ export const mediaRoutes = [
788
613
  }), { status: 400, headers: { "content-type": "application/json" } });
789
614
  return CorsHandler.addCorsHeaders(errorResponse, request, env);
790
615
  }
791
- // Validate file size (per documentation: 10MB for images, 100MB for videos)
792
- const maxImageSize = 10 * 1024 * 1024; // 10MB
793
- const maxVideoSize = 100 * 1024 * 1024; // 100MB
794
- const maxSize = isImage ? maxImageSize : maxVideoSize;
616
+ // Validate file size (from env.media.maxBytes config)
617
+ const maxSize = isImage ? env.media.maxBytes.image : env.media.maxBytes.video;
795
618
  if (file.size > maxSize) {
796
619
  const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({
797
620
  error: "File too large",
@@ -827,26 +650,243 @@ export const mediaRoutes = [
827
650
  }), { status: 400, headers: { "content-type": "application/json" } });
828
651
  return CorsHandler.addCorsHeaders(errorResponse, request, env);
829
652
  }
830
- // Check for suspicious patterns
653
+ // Check for suspicious patterns. For types that are not re-encoded in
654
+ // P0a (video/audio whose transcode is P0b), any suspicious pattern is
655
+ // an immediate reject. For re-encodable images the re-encode pipeline
656
+ // below strips the payload, but we still reject pre-encode to avoid
657
+ // storing the raw polyglot bytes even briefly.
831
658
  const suspicious = checkSuspiciousContent(bytes, mimeType);
832
659
  if (suspicious.length > 0) {
833
- logger.warn("Suspicious file detected", {
660
+ logger.warn("Suspicious file detected — rejecting", {
834
661
  userId: session.userId,
835
662
  fileName: file.name,
836
663
  mimeType,
837
664
  suspicious,
838
665
  });
839
- // For now, log but don't reject (can be made stricter later)
840
- // In production, you might want to reject or quarantine
666
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({
667
+ error: "Suspicious content detected",
668
+ message: "The uploaded file contains unexpected content patterns.",
669
+ }), { status: 400, headers: { "content-type": "application/json" } });
670
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
671
+ }
672
+ // Resolve the tenant that scopes this media object (T9 / D18).
673
+ // Moved before quota check and route decision so both use the same
674
+ // resolved tenantId.
675
+ const uploadRegion = "US"; // TODO: Get from session or request
676
+ const tenantId = await resolveUploadTenantId(session.userId, uploadRegion, env);
677
+ if (!tenantId) {
678
+ logger.error("[Media Upload] No tenant context for upload", {
679
+ userId: session.userId,
680
+ });
681
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({
682
+ error: "Tenant resolution failed",
683
+ message: "Could not resolve a tenant for this upload.",
684
+ }), { status: 500, headers: { "content-type": "application/json" } });
685
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
841
686
  }
842
- // Get extension from MIME type
843
- const ext = getExtensionFromMimeType(mimeType);
844
- // Extract metadata (best effort, non-fatal)
687
+ // Quota check (P0b): count + size-sum for the tenant from the DB.
688
+ // ASSUMPTION: quota usage = all non-deleted MediaFile rows for the
689
+ // tenant (count = currentObjects, sum(size) = currentBytes).
690
+ // checkUploadQuota is fail-closed: any bad number => denied.
691
+ {
692
+ let quotaState = { currentObjects: 0, currentBytes: 0 };
693
+ try {
694
+ const raw = await withQueryTimeoutAndRetry(sharedDatabaseConnectionManager, uploadRegion, env, async (db) => {
695
+ const dbAny = db;
696
+ if (!dbAny.mediaFile)
697
+ return null;
698
+ const [countResult, sumResult] = await Promise.all([
699
+ dbAny.mediaFile.count({
700
+ where: { tenantId, deletedAt: null },
701
+ }),
702
+ dbAny.mediaFile.aggregate({
703
+ where: { tenantId, deletedAt: null },
704
+ _sum: { size: true },
705
+ }),
706
+ ]);
707
+ return { count: countResult, sumBytes: (sumResult?._sum?.size ?? 0) };
708
+ }, {
709
+ ...QueryTimeoutPresets.USER_FACING,
710
+ maxRetries: 1,
711
+ context: { operation: "mediaUpload_quotaCheck", userId: session.userId },
712
+ });
713
+ if (raw) {
714
+ quotaState = { currentObjects: raw.count, currentBytes: raw.sumBytes };
715
+ }
716
+ }
717
+ catch {
718
+ // Quota DB read failure — fail-closed: deny the upload.
719
+ logger.warn("[Media Upload] Quota check DB query failed — denying upload", {
720
+ userId: session.userId,
721
+ tenantId,
722
+ });
723
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Upload unavailable" }), { status: 503, headers: { "content-type": "application/json" } });
724
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
725
+ }
726
+ const quotaResult = checkUploadQuota(quotaState, file.size, env.media.uploadQuota);
727
+ if (!quotaResult.allowed) {
728
+ logger.warn("[Media Upload] Quota exceeded", {
729
+ userId: session.userId,
730
+ tenantId,
731
+ reason: quotaResult.reason,
732
+ });
733
+ // Use 413 for byte-cap (payload too large) and 429 for object-cap
734
+ // (too many requests semantically — too many objects stored).
735
+ const quotaStatus = quotaResult.reason === "byte-cap" ? 413 : 429;
736
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Upload quota exceeded" }), { status: quotaStatus, headers: { "content-type": "application/json" } });
737
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
738
+ }
739
+ }
740
+ // Route the upload: sync-image (P0a re-encode path) vs async-pending
741
+ // (video/audio → land in pending/ staging, P0b worker picks it up) vs
742
+ // reject (fail-closed — unknown type not caught by earlier type check).
743
+ const ingestRoute = routeUpload(mimeType);
744
+ if (ingestRoute.kind === "reject") {
745
+ // Should not normally reach here (type check above is stricter), but
746
+ // routeUpload is the authoritative gate — honor it fail-closed.
747
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Unsupported media type" }), { status: 400, headers: { "content-type": "application/json" } });
748
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
749
+ }
750
+ if (ingestRoute.kind === "async-pending") {
751
+ // --- Async-pending path (video / audio) --------------------------------
752
+ // 1. Write RAW bytes to pending/{tenantId}/{uploadId} in R2.
753
+ // 2. Create a MediaFile DB row: moderationStatus=PENDING,
754
+ // originalKey=null, uploadId=<generated>.
755
+ // No inline hashing, no transcoding, no moderation — the P0b worker
756
+ // picks up the staged object via the S3 trigger on the pending/ prefix.
757
+ const uploadId = generateUploadId();
758
+ const stagingKey = pendingKey(tenantId, uploadId);
759
+ if (isCasKeyError(stagingKey)) {
760
+ logger.error("[Media Upload] Failed to build pending key", {
761
+ userId: session.userId,
762
+ kind: stagingKey.kind,
763
+ });
764
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Upload error" }), { status: 500, headers: { "content-type": "application/json" } });
765
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
766
+ }
767
+ const r2Bucket = env.MEDIA_BUCKET_R2 || env.R2_BUCKET;
768
+ if (!r2Bucket) {
769
+ logger.error("[Media Upload] No R2 bucket configured for pending write", {
770
+ userId: session.userId,
771
+ });
772
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Upload unavailable" }), { status: 503, headers: { "content-type": "application/json" } });
773
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
774
+ }
775
+ try {
776
+ await r2Bucket.put(stagingKey, fileBuffer, {
777
+ httpMetadata: { contentType: mimeType },
778
+ });
779
+ }
780
+ catch (r2Error) {
781
+ logger.error("[Media Upload] R2 pending write failed", {
782
+ userId: session.userId,
783
+ error: r2Error.message,
784
+ });
785
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Upload failed" }), { status: 500, headers: { "content-type": "application/json" } });
786
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
787
+ }
788
+ // Create the MediaFile row: PENDING + null originalKey + uploadId.
789
+ // The row exists immediately so the client can track the upload by
790
+ // uploadId; originalKey is filled by the P0b worker after transcoding.
791
+ try {
792
+ await withQueryTimeoutAndRetry(sharedDatabaseConnectionManager, uploadRegion, env, async (db) => {
793
+ const dbAny = db;
794
+ if (!dbAny.mediaFile)
795
+ throw new Error("mediaFile model unavailable");
796
+ return await dbAny.mediaFile.create({
797
+ data: {
798
+ tenantId,
799
+ // contentHash is null until known: the P0b worker computes
800
+ // the real SHA-256 of the transcoded bytes and sets it via
801
+ // persistCleanedContent. The within-tenant unique tolerates
802
+ // many NULL content_hash rows (distinct NULLs in Postgres).
803
+ contentHash: null,
804
+ mimeType,
805
+ size: file.size,
806
+ originalKey: null,
807
+ uploadId,
808
+ uploadStatus: "PENDING",
809
+ uploadedBy: session.userId,
810
+ // moderationStatus defaults to PENDING in the schema.
811
+ },
812
+ });
813
+ }, {
814
+ ...QueryTimeoutPresets.USER_FACING,
815
+ maxRetries: 1,
816
+ context: { operation: "mediaUpload_createPendingRecord", userId: session.userId },
817
+ });
818
+ }
819
+ catch (dbError) {
820
+ logger.error("[Media Upload] Pending DB record creation failed", {
821
+ uploadId,
822
+ error: dbError.message,
823
+ });
824
+ // Best-effort: attempt to remove the orphaned R2 object so the
825
+ // pending/ prefix stays clean. Non-fatal if this fails.
826
+ try {
827
+ await r2Bucket.delete(stagingKey);
828
+ }
829
+ catch { /* ignore */ }
830
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Database error" }), { status: 500, headers: { "content-type": "application/json" } });
831
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
832
+ }
833
+ logger.info("[Media Upload] Async-pending upload accepted", {
834
+ userId: session.userId,
835
+ uploadId,
836
+ stagingKey,
837
+ mimeType,
838
+ size: file.size,
839
+ });
840
+ const pendingResponse = securityHeaders.createSecureResponse(JSON.stringify({
841
+ uploadId,
842
+ status: "pending",
843
+ }), { status: 202, headers: { "content-type": "application/json" } });
844
+ return CorsHandler.addCorsHeaders(pendingResponse, request, env);
845
+ }
846
+ // --- Sync-image path (ingestRoute.kind === "sync-image") ---------------
847
+ // Re-encode images to canonical safe raster format (T7).
848
+ // This is the polyglot + pixel-bomb defense: re-encoding strips any
849
+ // embedded script payload, bakes EXIF orientation into pixels, and
850
+ // drops all metadata (EXIF/GPS/ICC/maker-notes). The hash is computed
851
+ // from the re-encoded bytes so the CAS key is of clean output only.
852
+ // uploadBuffer is re-typed to ArrayBuffer for the upload service;
853
+ // Buffer (a Uint8Array subclass) is structurally compatible at runtime
854
+ // with all consumers (crypto.subtle.digest, R2 put, etc.).
855
+ let uploadBuffer = fileBuffer;
856
+ try {
857
+ const reencoded = await reencodeImage(fileBuffer, env);
858
+ // Buffer is a Uint8Array subclass — cast is safe for all consumers.
859
+ uploadBuffer = reencoded.buffer;
860
+ // Use the canonical MIME type from the re-encode output
861
+ mimeType = reencoded.canonicalMimeType;
862
+ logger.info("image_reencode.completed", {
863
+ userId: session.userId,
864
+ canonicalMimeType: mimeType,
865
+ inputSize: fileBuffer.byteLength,
866
+ outputSize: reencoded.buffer.byteLength,
867
+ });
868
+ }
869
+ catch (reencodeError) {
870
+ logger.warn("image_reencode.failed — rejecting upload", {
871
+ userId: session.userId,
872
+ fileName: file.name,
873
+ error: reencodeError.message,
874
+ });
875
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({
876
+ error: "Image processing failed",
877
+ message: "The uploaded image could not be processed. It may be corrupt, " +
878
+ "an unsupported format, or exceed the maximum image dimensions.",
879
+ }), { status: 400, headers: { "content-type": "application/json" } });
880
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
881
+ }
882
+ // Extract metadata (best effort, non-fatal).
883
+ // Extraction runs on the re-encoded bytes for images so we get
884
+ // dimensions from the clean output (EXIF orientation already baked).
845
885
  let extracted = {};
846
886
  try {
847
887
  const { MetadataExtractor } = await import("../metadata/metadata-extractor.js");
848
888
  const extractor = new MetadataExtractor(env);
849
- extracted = await extractor.extractAll(fileBuffer, mimeType);
889
+ extracted = await extractor.extractAll(uploadBuffer, mimeType);
850
890
  }
851
891
  catch (metaError) {
852
892
  logger.warn("[Media Upload] Metadata extraction failed", {
@@ -863,80 +903,48 @@ export const mediaRoutes = [
863
903
  height: extracted?.exifData?.height || extracted?.videoMetadata?.height,
864
904
  duration: extracted?.videoMetadata?.duration,
865
905
  };
866
- // Use MediaUploadService for eventual consistency upload
867
- // Pass the already-read fileBuffer to avoid a second file.arrayBuffer() call.
868
- // On Cloudflare Workers, File objects from FormData may not support
869
- // reliable re-reads of arrayBuffer(), causing a different contentHash.
906
+ // Use MediaUploadService for eventual consistency upload.
907
+ // Pass the re-encoded buffer (uploadBuffer) so the content hash is of
908
+ // the clean output bytes, not the raw upload. The service writes the
909
+ // bytes to the canonical CAS key `cas/{tenantId}/{hash}`.
870
910
  const uploadService = new MediaUploadService(env);
871
- const result = await uploadService.uploadSingle(file, session.userId, metadata, fileBuffer);
872
- // Normalize images to sRGB (non-blocking, best effort)
873
- let optimizedKey = null;
874
- if (mimeType.startsWith("image/")) {
875
- if (env.IMAGES && env.MEDIA_BUCKET_R2) {
876
- const normalizer = new ImageNormalizer(env.IMAGES, env.MEDIA_BUCKET_R2);
877
- const startTime = Date.now();
878
- logger.info("image_normalization.started", {
879
- contentHash: result.contentHash,
880
- mimeType,
881
- });
882
- optimizedKey = await normalizer.normalize(`media/${result.contentHash}.${getExtensionFromMimeType(mimeType)}`, result.contentHash);
883
- const durationMs = Date.now() - startTime;
884
- if (optimizedKey) {
885
- logger.info("image_normalization.completed", {
886
- contentHash: result.contentHash,
887
- optimizedKey,
888
- durationMs,
889
- });
890
- }
891
- else {
892
- logger.warn("image_normalization.failed", {
893
- contentHash: result.contentHash,
894
- durationMs,
895
- });
896
- }
897
- }
898
- else {
899
- logger.info("image_normalization.skipped", {
900
- contentHash: result.contentHash,
901
- reason: "images_binding_not_available",
902
- });
903
- }
904
- }
905
- else {
906
- logger.info("image_normalization.skipped", {
907
- contentHash: result.contentHash,
908
- reason: "not_image",
911
+ const result = await uploadService.uploadSingle(file, session.userId, tenantId, metadata, uploadBuffer);
912
+ // T9: the DB originalKey stores the SAME canonical CAS key the bytes
913
+ // were written to, so the serve path reads exactly what upload wrote.
914
+ const uploadOriginalKey = casKey(tenantId, result.contentHash);
915
+ if (isCasKeyError(uploadOriginalKey)) {
916
+ logger.error("[Media Upload] Failed to build CAS key", {
917
+ userId: session.userId,
918
+ kind: uploadOriginalKey.kind,
909
919
  });
920
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({
921
+ error: "Database error",
922
+ message: "Failed to register uploaded media. Please try again.",
923
+ }), { status: 500, headers: { "content-type": "application/json" } });
924
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
910
925
  }
911
926
  // Create MediaFile DB record synchronously so post creation can
912
- // reference it immediately (reconciliation will enrich it later)
913
- const uploadOriginalKey = `media/${result.contentHash}.${getExtensionFromMimeType(mimeType)}`;
914
- const uploadRegion = "US"; // TODO: Get from session or request
927
+ // reference it immediately (reconciliation will enrich it later).
928
+ // Dedup is within-tenant via @@unique([tenantId, contentHash]) (D18).
915
929
  try {
916
930
  await withQueryTimeoutAndRetry(sharedDatabaseConnectionManager, uploadRegion, env, async (db) => {
917
- return await db.mediaFile.upsert({
918
- where: { contentHash: result.contentHash },
919
- create: {
920
- contentHash: result.contentHash,
921
- mimeType: mimeType,
922
- size: file.size,
923
- originalKey: uploadOriginalKey,
924
- optimizedKey: optimizedKey ?? undefined,
925
- uploadStatus: "COMPLETE",
926
- uploadedBy: session.userId,
927
- width: metadata?.width,
928
- height: metadata?.height,
929
- duration: metadata?.duration,
930
- },
931
- update: {
932
- // If record already exists (e.g. re-upload), update status and ownership
933
- // Clear deletedAt so previously-deleted media can be reused
934
- uploadStatus: "COMPLETE",
935
- uploadedBy: session.userId,
936
- optimizedKey: optimizedKey ?? undefined,
937
- deletedAt: null,
938
- },
939
- });
931
+ // T9: a within-tenant dedup hit (identical bytes re-uploaded)
932
+ // must NOT transfer ownership or de-publish the canonical row.
933
+ // buildMediaUpsertArgs guarantees the `update` payload touches
934
+ // neither uploadedBy nor moderationStatus — subsequent uploaders
935
+ // get a reference (via the post→media relation), not a mutation
936
+ // of the shared row.
937
+ return await db.mediaFile.upsert(buildMediaUpsertArgs({
938
+ tenantId,
939
+ contentHash: result.contentHash,
940
+ mimeType,
941
+ size: file.size,
942
+ originalKey: uploadOriginalKey,
943
+ uploadedBy: session.userId,
944
+ width: metadata?.width,
945
+ height: metadata?.height,
946
+ duration: metadata?.duration,
947
+ }));
940
948
  }, {
941
949
  ...QueryTimeoutPresets.USER_FACING,
942
950
  maxRetries: 1,
@@ -1011,8 +1019,8 @@ export const mediaRoutes = [
1011
1019
  const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "content-type": "application/json" } });
1012
1020
  return CorsHandler.addCorsHeaders(errorResponse, request, env);
1013
1021
  }
1014
- // Apply rate limiting: 5 batch uploads per 60s per user (stricter than single)
1015
- const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/upload/batch", 5, 60, session.userId);
1022
+ // Apply rate limiting: batch uploads per minute per user (from env.media.rateLimits)
1023
+ const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/upload/batch", env.media.rateLimits.batchPerMin, 60, session.userId);
1016
1024
  if (rateLimitResponse) {
1017
1025
  return securityHeaders.addSecurityHeaders(rateLimitResponse);
1018
1026
  }
@@ -1062,9 +1070,102 @@ export const mediaRoutes = [
1062
1070
  userId: session.userId,
1063
1071
  fileCount: files.length,
1064
1072
  });
1065
- // Use MediaUploadService for batch upload
1073
+ // Validate and re-encode each file before uploading (T7/T6).
1074
+ // The batch path applies the same pipeline as the single-upload path:
1075
+ // size check → type check → magic-number check → suspicious-content check
1076
+ // → re-encode images → upload with re-encoded buffer.
1077
+ const batchAllowedImageTypes = new Set(REENCODABLE_IMAGE_TYPES);
1078
+ const batchAllowedVideoTypes = new Set(env.media.allowlist.video.length > 0
1079
+ ? env.media.allowlist.video
1080
+ : ["video/mp4", "video/webm", "video/quicktime"]);
1081
+ const processed = [];
1082
+ for (const batchFile of files) {
1083
+ // Size check
1084
+ const batchFileBuffer = await batchFile.arrayBuffer();
1085
+ const batchIsImage = batchAllowedImageTypes.has(batchFile.type) ||
1086
+ // detect from magic bytes for images
1087
+ (() => {
1088
+ const b = new Uint8Array(batchFileBuffer);
1089
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
1090
+ return true; // JPEG
1091
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
1092
+ return true; // PNG
1093
+ if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38)
1094
+ return true; // GIF
1095
+ if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
1096
+ b.length >= 12 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50)
1097
+ return true; // WebP
1098
+ return false;
1099
+ })();
1100
+ const batchIsVideo = batchAllowedVideoTypes.has(batchFile.type);
1101
+ if (!batchIsImage && !batchIsVideo) {
1102
+ processed.push({ error: `Unsupported file type: ${batchFile.type || "unknown"}` });
1103
+ continue;
1104
+ }
1105
+ const maxSize = batchIsImage ? env.media.maxBytes.image : env.media.maxBytes.video;
1106
+ if (batchFile.size > maxSize) {
1107
+ processed.push({ error: `File too large: ${batchFile.name}` });
1108
+ continue;
1109
+ }
1110
+ const batchBytes = new Uint8Array(batchFileBuffer);
1111
+ // Suspicious content check — reject the file
1112
+ const batchSuspicious = checkSuspiciousContent(batchBytes, batchFile.type || "application/octet-stream");
1113
+ if (batchSuspicious.length > 0) {
1114
+ logger.warn("[Media Batch Upload] Suspicious file detected — skipping", {
1115
+ userId: session.userId,
1116
+ fileName: batchFile.name,
1117
+ suspicious: batchSuspicious,
1118
+ });
1119
+ processed.push({ error: "Suspicious content detected" });
1120
+ continue;
1121
+ }
1122
+ // Re-encode images (T7)
1123
+ if (batchIsImage) {
1124
+ try {
1125
+ const reencoded = await reencodeImage(batchFileBuffer, env);
1126
+ // Buffer is a Uint8Array subclass — cast is safe for all consumers.
1127
+ processed.push({ file: batchFile, buffer: reencoded.buffer, mimeType: reencoded.canonicalMimeType });
1128
+ }
1129
+ catch (reencodeErr) {
1130
+ logger.warn("[Media Batch Upload] Re-encode failed — skipping file", {
1131
+ userId: session.userId,
1132
+ fileName: batchFile.name,
1133
+ error: reencodeErr.message,
1134
+ });
1135
+ processed.push({ error: "Image processing failed" });
1136
+ }
1137
+ }
1138
+ else {
1139
+ processed.push({ file: batchFile, buffer: batchFileBuffer, mimeType: batchFile.type });
1140
+ }
1141
+ }
1142
+ // Resolve the tenant that scopes these uploads (T9 / D18) — same
1143
+ // canonical CAS scheme as the single-upload path.
1144
+ const batchTenantId = await resolveUploadTenantId(session.userId, "US", env);
1145
+ if (!batchTenantId) {
1146
+ logger.error("[Media Batch Upload] No tenant context for upload", {
1147
+ userId: session.userId,
1148
+ });
1149
+ const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({
1150
+ error: "Tenant resolution failed",
1151
+ message: "Could not resolve a tenant for this upload.",
1152
+ }), { status: 500, headers: { "content-type": "application/json" } });
1153
+ return CorsHandler.addCorsHeaders(errorResponse, request, env);
1154
+ }
1155
+ // Upload each successfully processed file individually (preserves re-encoded buffer)
1066
1156
  const uploadService = new MediaUploadService(env);
1067
- const results = await uploadService.uploadBatch(files, session.userId);
1157
+ const results = await Promise.all(processed.map(async (item) => {
1158
+ if ("error" in item) {
1159
+ return { success: false, contentHash: "", url: "", status: "failed", warning: item.error };
1160
+ }
1161
+ try {
1162
+ const uploadResult = await uploadService.uploadSingle(item.file, session.userId, batchTenantId, undefined, item.buffer);
1163
+ return uploadResult;
1164
+ }
1165
+ catch (err) {
1166
+ return { success: false, contentHash: "", url: "", status: "failed", warning: `Upload failed: ${err.message}` };
1167
+ }
1168
+ }));
1068
1169
  const successCount = results.filter((r) => r.success).length;
1069
1170
  const failureCount = results.filter((r) => !r.success).length;
1070
1171
  logger.info("[Media Batch Upload] Batch complete", {
@@ -1114,8 +1215,8 @@ export const mediaRoutes = [
1114
1215
  const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "content-type": "application/json" } });
1115
1216
  return CorsHandler.addCorsHeaders(errorResponse, request, env);
1116
1217
  }
1117
- // Apply rate limiting: 60 requests per minute per user
1118
- const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/grouped", 60, 60, session.userId);
1218
+ // Apply rate limiting: serve requests per minute per user (from env.media.rateLimits)
1219
+ const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/grouped", env.media.rateLimits.servePerMin, 60, session.userId);
1119
1220
  if (rateLimitResponse) {
1120
1221
  return securityHeaders.addSecurityHeaders(rateLimitResponse);
1121
1222
  }
@@ -1231,8 +1332,8 @@ export const mediaRoutes = [
1231
1332
  const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "content-type": "application/json" } });
1232
1333
  return CorsHandler.addCorsHeaders(errorResponse, request, env);
1233
1334
  }
1234
- // Apply rate limiting: 60 requests per minute per user
1235
- const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/stats", 60, 60, session.userId);
1335
+ // Apply rate limiting: serve requests per minute per user (from env.media.rateLimits)
1336
+ const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/stats", env.media.rateLimits.servePerMin, 60, session.userId);
1236
1337
  if (rateLimitResponse) {
1237
1338
  return securityHeaders.addSecurityHeaders(rateLimitResponse);
1238
1339
  }
@@ -1301,8 +1402,8 @@ export const mediaRoutes = [
1301
1402
  const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "content-type": "application/json" } });
1302
1403
  return CorsHandler.addCorsHeaders(errorResponse, request, env);
1303
1404
  }
1304
- // Apply rate limiting: 120 requests per minute per user
1305
- const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/:mediaId", 120, 60, session.userId);
1405
+ // Apply rate limiting: serve requests per minute per user (from env.media.rateLimits)
1406
+ const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/:mediaId", env.media.rateLimits.servePerMin, 60, session.userId);
1306
1407
  if (rateLimitResponse) {
1307
1408
  return securityHeaders.addSecurityHeaders(rateLimitResponse);
1308
1409
  }
@@ -1486,8 +1587,8 @@ export const mediaRoutes = [
1486
1587
  const errorResponse = securityHeaders.createSecureResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "content-type": "application/json" } });
1487
1588
  return CorsHandler.addCorsHeaders(errorResponse, request, env);
1488
1589
  }
1489
- // Apply rate limiting: 60 requests per minute per user
1490
- const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media", 60, 60, session.userId);
1590
+ // Apply rate limiting: serve requests per minute per user (from env.media.rateLimits)
1591
+ const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media", env.media.rateLimits.servePerMin, 60, session.userId);
1491
1592
  if (rateLimitResponse) {
1492
1593
  const duration = Date.now() - startTime;
1493
1594
  metrics.trackRateLimit("/api/media", session.userId, undefined, 60, 60);