@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.
- package/dist/env.d.ts +168 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +155 -0
- package/dist/env.js.map +1 -1
- package/dist/lambda/media-completion-worker.d.ts +175 -0
- package/dist/lambda/media-completion-worker.d.ts.map +1 -0
- package/dist/lambda/media-completion-worker.js +373 -0
- package/dist/lambda/media-completion-worker.js.map +1 -0
- package/dist/lambda/media-processing-worker.d.ts +172 -1
- package/dist/lambda/media-processing-worker.d.ts.map +1 -1
- package/dist/lambda/media-processing-worker.js +343 -49
- package/dist/lambda/media-processing-worker.js.map +1 -1
- package/dist/lib/exif-stripper.d.ts +37 -22
- package/dist/lib/exif-stripper.d.ts.map +1 -1
- package/dist/lib/exif-stripper.js +101 -41
- package/dist/lib/exif-stripper.js.map +1 -1
- package/dist/lib/media/cas-keys.d.ts +63 -0
- package/dist/lib/media/cas-keys.d.ts.map +1 -0
- package/dist/lib/media/cas-keys.js +102 -0
- package/dist/lib/media/cas-keys.js.map +1 -0
- package/dist/lib/media/classify-worker-error.d.ts +48 -0
- package/dist/lib/media/classify-worker-error.d.ts.map +1 -0
- package/dist/lib/media/classify-worker-error.js +319 -0
- package/dist/lib/media/classify-worker-error.js.map +1 -0
- package/dist/lib/media/dedupe-key.d.ts +29 -0
- package/dist/lib/media/dedupe-key.d.ts.map +1 -0
- package/dist/lib/media/dedupe-key.js +49 -0
- package/dist/lib/media/dedupe-key.js.map +1 -0
- package/dist/lib/media/duration-cap.d.ts +30 -0
- package/dist/lib/media/duration-cap.d.ts.map +1 -0
- package/dist/lib/media/duration-cap.js +37 -0
- package/dist/lib/media/duration-cap.js.map +1 -0
- package/dist/lib/media/ffmpeg-args.d.ts +83 -0
- package/dist/lib/media/ffmpeg-args.d.ts.map +1 -0
- package/dist/lib/media/ffmpeg-args.js +119 -0
- package/dist/lib/media/ffmpeg-args.js.map +1 -0
- package/dist/lib/media/media-ports.d.ts +126 -0
- package/dist/lib/media/media-ports.d.ts.map +1 -0
- package/dist/lib/media/media-ports.js +129 -0
- package/dist/lib/media/media-ports.js.map +1 -0
- package/dist/lib/media/media-upsert.d.ts +55 -0
- package/dist/lib/media/media-upsert.d.ts.map +1 -0
- package/dist/lib/media/media-upsert.js +38 -0
- package/dist/lib/media/media-upsert.js.map +1 -0
- package/dist/lib/media/moderation-provider.d.ts +111 -0
- package/dist/lib/media/moderation-provider.d.ts.map +1 -0
- package/dist/lib/media/moderation-provider.js +130 -0
- package/dist/lib/media/moderation-provider.js.map +1 -0
- package/dist/lib/media/moderation-resolved-payload.d.ts +48 -0
- package/dist/lib/media/moderation-resolved-payload.d.ts.map +1 -0
- package/dist/lib/media/moderation-resolved-payload.js +37 -0
- package/dist/lib/media/moderation-resolved-payload.js.map +1 -0
- package/dist/lib/media/moderation-status.d.ts +98 -0
- package/dist/lib/media/moderation-status.d.ts.map +1 -0
- package/dist/lib/media/moderation-status.js +122 -0
- package/dist/lib/media/moderation-status.js.map +1 -0
- package/dist/lib/media/processing-types.d.ts +45 -0
- package/dist/lib/media/processing-types.d.ts.map +1 -0
- package/dist/lib/media/processing-types.js +9 -0
- package/dist/lib/media/processing-types.js.map +1 -0
- package/dist/lib/media/promote-decision.d.ts +64 -0
- package/dist/lib/media/promote-decision.d.ts.map +1 -0
- package/dist/lib/media/promote-decision.js +76 -0
- package/dist/lib/media/promote-decision.js.map +1 -0
- package/dist/lib/media/quota-check.d.ts +22 -0
- package/dist/lib/media/quota-check.d.ts.map +1 -0
- package/dist/lib/media/quota-check.js +42 -0
- package/dist/lib/media/quota-check.js.map +1 -0
- package/dist/lib/media/quota-types.d.ts +15 -0
- package/dist/lib/media/quota-types.d.ts.map +1 -0
- package/dist/lib/media/quota-types.js +9 -0
- package/dist/lib/media/quota-types.js.map +1 -0
- package/dist/lib/media/route-upload.d.ts +58 -0
- package/dist/lib/media/route-upload.d.ts.map +1 -0
- package/dist/lib/media/route-upload.js +80 -0
- package/dist/lib/media/route-upload.js.map +1 -0
- package/dist/lib/media/serve-gate.d.ts +51 -0
- package/dist/lib/media/serve-gate.d.ts.map +1 -0
- package/dist/lib/media/serve-gate.js +68 -0
- package/dist/lib/media/serve-gate.js.map +1 -0
- package/dist/lib/media/tenant-resolution.d.ts +42 -0
- package/dist/lib/media/tenant-resolution.d.ts.map +1 -0
- package/dist/lib/media/tenant-resolution.js +45 -0
- package/dist/lib/media/tenant-resolution.js.map +1 -0
- package/dist/lib/media/text-moderation.d.ts +28 -0
- package/dist/lib/media/text-moderation.d.ts.map +1 -0
- package/dist/lib/media/text-moderation.js +62 -0
- package/dist/lib/media/text-moderation.js.map +1 -0
- package/dist/lib/media/track-verdict.d.ts +45 -0
- package/dist/lib/media/track-verdict.d.ts.map +1 -0
- package/dist/lib/media/track-verdict.js +52 -0
- package/dist/lib/media/track-verdict.js.map +1 -0
- package/dist/lib/media/transcript-moderation.d.ts +47 -0
- package/dist/lib/media/transcript-moderation.d.ts.map +1 -0
- package/dist/lib/media/transcript-moderation.js +70 -0
- package/dist/lib/media/transcript-moderation.js.map +1 -0
- package/dist/lib/media-handler.d.ts.map +1 -1
- package/dist/lib/media-handler.js +15 -9
- package/dist/lib/media-handler.js.map +1 -1
- package/dist/lib/post-handler.d.ts.map +1 -1
- package/dist/lib/post-handler.js +4 -1
- package/dist/lib/post-handler.js.map +1 -1
- package/dist/lib/route-helpers.d.ts.map +1 -1
- package/dist/lib/route-helpers.js +9 -1
- package/dist/lib/route-helpers.js.map +1 -1
- package/dist/lib/routes/media.d.ts +21 -0
- package/dist/lib/routes/media.d.ts.map +1 -1
- package/dist/lib/routes/media.js +584 -483
- package/dist/lib/routes/media.js.map +1 -1
- package/dist/lib/services/image-normalizer.d.ts +64 -6
- package/dist/lib/services/image-normalizer.d.ts.map +1 -1
- package/dist/lib/services/image-normalizer.js +88 -6
- package/dist/lib/services/image-normalizer.js.map +1 -1
- package/dist/lib/services/media-upload-service.d.ts +2 -2
- package/dist/lib/services/media-upload-service.d.ts.map +1 -1
- package/dist/lib/services/media-upload-service.js +22 -21
- package/dist/lib/services/media-upload-service.js.map +1 -1
- package/dist/lib/tenant-scope.d.ts.map +1 -1
- package/dist/lib/tenant-scope.js +16 -1
- package/dist/lib/tenant-scope.js.map +1 -1
- package/package.json +2 -1
- package/prisma/migrations/20260625000000_media_tenant_scope_and_moderation_status/migration.sql +49 -0
- package/prisma/migrations/20260625000001_p0b_moderation_jobs/migration.sql +73 -0
- package/prisma/schema.prisma +95 -17
- package/src/lambda/media-completion-worker.ts +567 -0
- package/src/lambda/media-processing-worker.ts +508 -59
package/dist/lib/routes/media.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
135
|
-
*
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
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: {
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
//
|
|
506
|
-
|
|
507
|
-
//
|
|
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":
|
|
511
|
-
"
|
|
512
|
-
|
|
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
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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:
|
|
612
|
-
const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/upload",
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
//
|
|
746
|
-
|
|
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 (
|
|
792
|
-
const
|
|
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
|
-
|
|
840
|
-
|
|
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
|
-
//
|
|
843
|
-
|
|
844
|
-
//
|
|
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(
|
|
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
|
|
868
|
-
//
|
|
869
|
-
//
|
|
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,
|
|
872
|
-
//
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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:
|
|
1015
|
-
const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/upload/batch",
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
1118
|
-
const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/grouped",
|
|
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:
|
|
1235
|
-
const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/stats",
|
|
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:
|
|
1305
|
-
const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media/:mediaId",
|
|
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:
|
|
1490
|
-
const rateLimitResponse = await rateLimiter.applyRateLimitKV(env, request, "/api/media",
|
|
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);
|