@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
|
@@ -1,60 +1,354 @@
|
|
|
1
|
-
|
|
1
|
+
// media-processing-worker.ts — the P0b media-processing orchestration SHELL.
|
|
2
|
+
//
|
|
3
|
+
// This is the imperative shell over the pure functional-core media units. It is
|
|
4
|
+
// NOT itself a functional-core unit: it performs I/O (object storage, transcode,
|
|
5
|
+
// transcription, moderation, DB writes). BUT all of that I/O arrives through
|
|
6
|
+
// INJECTED capability seams (TranscodePort / StoragePort / TranscribePort /
|
|
7
|
+
// MediaModerationProvider) and a Prisma-shaped persistence port, so the
|
|
8
|
+
// orchestration logic is exercised in unit tests against the B0 in-memory Mocks
|
|
9
|
+
// — no real cloud, no real encoder, no real DB.
|
|
10
|
+
//
|
|
11
|
+
// Per the seam discipline (see lib/media/media-ports.ts and
|
|
12
|
+
// lib/media/moderation-provider.ts): CORE ships the interfaces + mocks; the
|
|
13
|
+
// consuming app (Skybber) injects the concrete cloud adapters at startup via
|
|
14
|
+
// `setMediaProcessingDeps()`. Until they are injected, the handler fails CLOSED
|
|
15
|
+
// (throws → SQS retry), never silently approves or drops work.
|
|
16
|
+
//
|
|
17
|
+
// Fail-closed posture, end to end:
|
|
18
|
+
// - A key that is not a well-formed `pending/{tenant}/{upload}` key is dropped
|
|
19
|
+
// (ack) and NEVER written under — the re-trigger-loop guard.
|
|
20
|
+
// - The tenant is re-derived FROM THE ROW, and the triggering key must equal
|
|
21
|
+
// pendingKey(rowTenant, uploadId); a mismatch is a hard reject (poison →
|
|
22
|
+
// REVIEW + ack), so a forged/odd key cannot make us moderate the wrong cas/.
|
|
23
|
+
// - Over-cap duration is poison → REVIEW + ack (no transcode attempted).
|
|
24
|
+
// - The worker ONLY starts moderation jobs + persists their jobIds; it never
|
|
25
|
+
// fetches verdicts (a separate poller owns fan-in). Moderation runs on the
|
|
26
|
+
// cleaned bytes at the STAGING key, NOT the raw pending upload — and the
|
|
27
|
+
// cleaned bytes are NOT written to cas/ here. cas/ is the CDN-served prefix,
|
|
28
|
+
// so it must only ever hold APPROVED cleaned bytes; the completion worker
|
|
29
|
+
// promotes staging -> cas/ on approval ("cleaned-staging, promote-on-approval").
|
|
30
|
+
// - classifyWorkerError() splits permanent media/payload defects (poison →
|
|
31
|
+
// REVIEW + ack, no DLQ loop) from transient infra faults (retryable → throw
|
|
32
|
+
// → SQS retry → DLQ + alert backstop).
|
|
2
33
|
import { Logger } from "@aws-lambda-powertools/logger";
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import { pendingKey, casKey, isCasKeyError, } from "../lib/media/cas-keys.js";
|
|
36
|
+
import { exceedsDurationCap } from "../lib/media/duration-cap.js";
|
|
37
|
+
import { classifyWorkerError } from "../lib/media/classify-worker-error.js";
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Key parsing — pending/{tenantId}/{uploadId}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* Parse a triggering key as a `pending/{tenantId}/{uploadId}` key, validating
|
|
43
|
+
* the FORM by round-tripping the parsed parts back through `pendingKey()`. A
|
|
44
|
+
* key only parses if rebuilding it from its parts yields the identical string —
|
|
45
|
+
* so a path-traversal payload, extra segments, or a malformed id can never pass
|
|
46
|
+
* (cas-keys.ts owns the anchored allowlists).
|
|
47
|
+
*
|
|
48
|
+
* @returns the {tenantId, uploadId} when the key is a canonical pending key,
|
|
49
|
+
* or null for ANY other key (which the caller ack-drops; we never
|
|
50
|
+
* write outputs under pending/, so a non-pending key is not our work).
|
|
51
|
+
*/
|
|
52
|
+
export function parsePendingKey(key) {
|
|
53
|
+
const parts = key.split("/");
|
|
54
|
+
if (parts.length !== 3 || parts[0] !== "pending") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const tenantId = parts[1];
|
|
58
|
+
const uploadId = parts[2];
|
|
59
|
+
const rebuilt = pendingKey(tenantId, uploadId);
|
|
60
|
+
if (isCasKeyError(rebuilt) || rebuilt !== key) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return { tenantId, uploadId };
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// S3-event-over-SQS extraction
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
/** Every object key referenced by one SQS record's S3 event notification. */
|
|
69
|
+
export function extractObjectKeys(recordBody) {
|
|
70
|
+
const parsed = JSON.parse(recordBody);
|
|
71
|
+
const s3Records = parsed.Records ?? [];
|
|
72
|
+
const keys = [];
|
|
73
|
+
for (const r of s3Records) {
|
|
74
|
+
const raw = r?.s3?.object?.key;
|
|
75
|
+
if (typeof raw === "string") {
|
|
76
|
+
// S3 URL-encodes keys and uses '+' for spaces in notifications.
|
|
77
|
+
keys.push(decodeURIComponent(raw.replace(/\+/g, " ")));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return keys;
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Typed errors the orchestration core throws internally
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/** A permanent payload defect: the key did not match the row's tenant/upload. */
|
|
86
|
+
class KeyTenantMismatchError extends Error {
|
|
87
|
+
constructor() {
|
|
88
|
+
// The name is in classify-worker-error's poison fragment set ("validation").
|
|
89
|
+
super("media key/tenant validation mismatch: triggering key does not match the row");
|
|
90
|
+
this.name = "ValidationError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** A permanent payload defect: the probed duration exceeds the configured cap. */
|
|
94
|
+
class DurationCapExceededError extends Error {
|
|
95
|
+
constructor() {
|
|
96
|
+
super("media duration cap exceeded");
|
|
97
|
+
this.name = "DurationCapExceeded";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Orchestration core — testable against the B0 Mocks
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
/**
|
|
104
|
+
* Orchestrate processing for ONE already-extracted object key.
|
|
105
|
+
*
|
|
106
|
+
* Steps (every uncertainty fails closed; nothing here can yield APPROVED):
|
|
107
|
+
* 1. Reject any key that is not a canonical `pending/{tenant}/{upload}` key —
|
|
108
|
+
* ack-drop it; outputs are NEVER written under pending/.
|
|
109
|
+
* 2. Load the MediaFile row by uploadId; re-derive tenant FROM THE ROW and
|
|
110
|
+
* assert pendingKey(rowTenant, uploadId) === the triggering key. Mismatch
|
|
111
|
+
* (or missing/uploadId-less row) is a hard reject → REVIEW + ack.
|
|
112
|
+
* 3. Probe duration; over-cap ⇒ poison ⇒ REVIEW + ack (no transcode).
|
|
113
|
+
* 4. Transcode-and-discard ⇒ cleaned bytes at the STAGING key (read back from
|
|
114
|
+
* the cleaned key). The cleaned bytes are NOT written to cas/ here — cas/ is
|
|
115
|
+
* the CDN-served prefix and must hold only APPROVED bytes (promotion happens
|
|
116
|
+
* in the completion worker).
|
|
117
|
+
* 5. Hash the cleaned bytes ⇒ realHash; PERSIST {contentHash: realHash,
|
|
118
|
+
* originalKey: casKey(tenant, realHash)} onto the row, replacing the
|
|
119
|
+
* upload-time uploadId placeholder so the completion worker can derive the
|
|
120
|
+
* promote target.
|
|
121
|
+
* 6. START moderation on the cleaned STAGING object (NOT the raw pending upload,
|
|
122
|
+
* NOT a cas/ key) — moderation must run on EXACTLY the bytes that will be
|
|
123
|
+
* served: provider.startVideoModeration ⇒ persist VISUAL job (+ threshold
|
|
124
|
+
* snapshot); transcribe.startTranscription ⇒ persist AUDIO job (+ snapshot).
|
|
125
|
+
* The worker only STARTS jobs + persists jobIds; it never fetches verdicts.
|
|
126
|
+
*/
|
|
127
|
+
export async function processObjectKey(triggeringKey, deps) {
|
|
128
|
+
try {
|
|
129
|
+
// --- 1. Pending-key form gate (re-trigger-loop guard). ---
|
|
130
|
+
const parsed = parsePendingKey(triggeringKey);
|
|
131
|
+
if (parsed === null) {
|
|
132
|
+
deps.logger.info("Dropping non-pending key (not our work)", {
|
|
133
|
+
key: triggeringKey,
|
|
134
|
+
});
|
|
135
|
+
return { disposition: "ack", reason: "non-pending-key" };
|
|
136
|
+
}
|
|
137
|
+
const { uploadId } = parsed;
|
|
138
|
+
// --- 2. Load row; re-derive tenant FROM THE ROW; assert key match. ---
|
|
139
|
+
const row = await deps.persistence.findMediaByUploadId(uploadId);
|
|
140
|
+
if (row === null || row.uploadId === null) {
|
|
141
|
+
// No row, or a row that lost its upload session — cannot certify this
|
|
142
|
+
// object. Permanent w.r.t. these bytes: fail closed to human review.
|
|
143
|
+
throw new KeyTenantMismatchError();
|
|
144
|
+
}
|
|
145
|
+
const rowTenant = row.tenantId;
|
|
146
|
+
const expectedKey = pendingKey(rowTenant, uploadId);
|
|
147
|
+
if (isCasKeyError(expectedKey) || expectedKey !== triggeringKey) {
|
|
148
|
+
// The triggering key's tenant segment disagrees with the owning tenant,
|
|
149
|
+
// OR the row's tenant is itself malformed. Either way: hard reject.
|
|
150
|
+
throw new KeyTenantMismatchError();
|
|
151
|
+
}
|
|
152
|
+
// --- 3. Duration cap (probe BEFORE transcoding — cost + abuse guard). ---
|
|
153
|
+
const probed = await deps.transcode.probeDurationSeconds(triggeringKey);
|
|
154
|
+
if (exceedsDurationCap(probed, deps.config.maxDurationSeconds)) {
|
|
155
|
+
throw new DurationCapExceededError();
|
|
156
|
+
}
|
|
157
|
+
// --- 4. Transcode-and-discard ⇒ cleaned bytes. ---
|
|
158
|
+
// The cleaned output is written to a transient staging key OUTSIDE pending/
|
|
159
|
+
// (so re-uploading the cleaned bytes can never re-trigger this worker).
|
|
160
|
+
const cleanedStagingKey = `processing/${rowTenant}/${uploadId}`;
|
|
161
|
+
const posterStagingKey = `processing/${rowTenant}/${uploadId}.poster`;
|
|
162
|
+
const transcodeResult = await deps.transcode.transcodeVideo({
|
|
163
|
+
inputPath: triggeringKey,
|
|
164
|
+
outputPath: cleanedStagingKey,
|
|
165
|
+
posterPath: posterStagingKey,
|
|
166
|
+
maxDurationSeconds: deps.config.maxDurationSeconds,
|
|
167
|
+
});
|
|
168
|
+
const cleanedStagingKeyOut = transcodeResult.cleanedPath;
|
|
169
|
+
const cleanedBytes = await deps.storage.getObject(cleanedStagingKeyOut);
|
|
170
|
+
// --- 5. Hash the CLEANED bytes ⇒ real content identity; persist it. ---
|
|
171
|
+
// We do NOT write the cleaned bytes to cas/ here: they already live at the
|
|
172
|
+
// STAGING key, and cas/ (the CDN-served prefix) must only ever hold APPROVED
|
|
173
|
+
// bytes. We persist the real hash + future serve key so the completion
|
|
174
|
+
// worker can promote staging -> cas/ on approval.
|
|
175
|
+
const contentHash = createHash("sha256").update(cleanedBytes).digest("hex");
|
|
176
|
+
const cleanedCasKey = casKey(rowTenant, contentHash);
|
|
177
|
+
if (isCasKeyError(cleanedCasKey)) {
|
|
178
|
+
// The hash/tenant failed the CAS allowlist — a permanent defect in our own
|
|
179
|
+
// derivation inputs (e.g. a malformed tenant that slipped the row check).
|
|
180
|
+
// Fail closed: route to review rather than serve un-addressable bytes.
|
|
181
|
+
throw new KeyTenantMismatchError();
|
|
182
|
+
}
|
|
183
|
+
// Replace the upload-time uploadId placeholder contentHash with the REAL
|
|
184
|
+
// hash and record the future serve key (cas/{tenant}/{hash}).
|
|
185
|
+
await deps.persistence.persistCleanedContent(row.id, {
|
|
186
|
+
contentHash,
|
|
187
|
+
originalKey: cleanedCasKey,
|
|
188
|
+
});
|
|
189
|
+
// --- 6. START moderation on the CLEANED STAGING object (the exact bytes ---
|
|
190
|
+
// that will be served), NOT the raw pending upload and NOT a cas/ key.
|
|
191
|
+
const stagingRef = { bucket: deps.bucket, key: cleanedStagingKeyOut };
|
|
192
|
+
const visual = await deps.moderation.startVideoModeration(stagingRef);
|
|
193
|
+
await deps.persistence.createModerationJob({
|
|
194
|
+
mediaId: row.id,
|
|
195
|
+
track: "VISUAL",
|
|
196
|
+
jobId: visual.jobId,
|
|
197
|
+
// Snapshot the CURRENT operative thresholds onto the job at submission.
|
|
198
|
+
thresholdSnapshot: deps.config.thresholds,
|
|
199
|
+
});
|
|
200
|
+
const audio = await deps.transcribe.startTranscription({
|
|
201
|
+
key: cleanedStagingKeyOut,
|
|
202
|
+
jobName: deps.newJobName(cleanedStagingKeyOut),
|
|
203
|
+
});
|
|
204
|
+
await deps.persistence.createModerationJob({
|
|
205
|
+
mediaId: row.id,
|
|
206
|
+
track: "AUDIO",
|
|
207
|
+
jobId: audio.jobId,
|
|
208
|
+
thresholdSnapshot: deps.config.thresholds,
|
|
209
|
+
});
|
|
210
|
+
deps.logger.info("Started per-track moderation jobs", {
|
|
211
|
+
mediaId: row.id,
|
|
212
|
+
stagingKey: cleanedStagingKeyOut,
|
|
213
|
+
casKey: cleanedCasKey,
|
|
214
|
+
visualJobId: visual.jobId,
|
|
215
|
+
audioJobId: audio.jobId,
|
|
216
|
+
});
|
|
217
|
+
return { disposition: "ack", reason: "started-moderation" };
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
// Single classification point: poison ⇒ REVIEW + ack; retryable ⇒ fail.
|
|
221
|
+
const klass = classifyWorkerError(err);
|
|
222
|
+
if (klass === "poison") {
|
|
223
|
+
// Best-effort route to REVIEW. If we can identify the row, mark it; if we
|
|
224
|
+
// cannot (e.g. the failure was the row lookup itself), there is nothing to
|
|
225
|
+
// mark and the ack simply drops a message that would loop forever.
|
|
226
|
+
const reviewReason = await routePoisonToReview(triggeringKey, deps, err);
|
|
227
|
+
return { disposition: "ack", reason: reviewReason, poison: true };
|
|
228
|
+
}
|
|
229
|
+
deps.logger.error("Retryable media-processing fault — letting SQS retry", {
|
|
230
|
+
key: triggeringKey,
|
|
231
|
+
error: err,
|
|
232
|
+
});
|
|
233
|
+
return { disposition: "fail", reason: "retryable" };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Best-effort: drive the owning MediaFile to REVIEW for a poison failure. Never
|
|
238
|
+
* throws — a failure to mark must not convert a poison ack into an infinite
|
|
239
|
+
* retry. Returns an observability reason string.
|
|
240
|
+
*/
|
|
241
|
+
async function routePoisonToReview(triggeringKey, deps, cause) {
|
|
242
|
+
deps.logger.warn("Poison media — routing to REVIEW + ack", {
|
|
243
|
+
key: triggeringKey,
|
|
244
|
+
error: cause,
|
|
245
|
+
});
|
|
246
|
+
const parsed = parsePendingKey(triggeringKey);
|
|
247
|
+
if (parsed === null) {
|
|
248
|
+
return "poison-no-row";
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const row = await deps.persistence.findMediaByUploadId(parsed.uploadId);
|
|
252
|
+
if (row === null) {
|
|
253
|
+
return "poison-no-row";
|
|
254
|
+
}
|
|
255
|
+
await deps.persistence.markMediaForReview(row.id);
|
|
256
|
+
return "poison-review";
|
|
257
|
+
}
|
|
258
|
+
catch (markErr) {
|
|
259
|
+
deps.logger.error("Failed to mark poison media for REVIEW (acking anyway)", {
|
|
260
|
+
key: triggeringKey,
|
|
261
|
+
error: markErr,
|
|
262
|
+
});
|
|
263
|
+
return "poison-mark-failed";
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Process one SQS record (which may carry several S3 object keys). The record
|
|
268
|
+
* fails (SQS retry) iff ANY of its keys produced a retryable fault; otherwise
|
|
269
|
+
* it is acked. Per-key poison is acked, never failed.
|
|
270
|
+
*/
|
|
271
|
+
export async function processRecord(record, deps) {
|
|
272
|
+
let keys;
|
|
273
|
+
try {
|
|
274
|
+
keys = extractObjectKeys(record.body);
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
// A body we cannot even parse is a permanent payload defect (poison): a
|
|
278
|
+
// retry re-parses the same bytes to the same failure. Ack to avoid a loop.
|
|
279
|
+
deps.logger.warn("Unparseable SQS record body — acking as poison", {
|
|
280
|
+
messageId: record.messageId,
|
|
281
|
+
error: err,
|
|
282
|
+
});
|
|
283
|
+
return { disposition: "ack", reason: "unparseable-body", poison: true };
|
|
284
|
+
}
|
|
285
|
+
for (const key of keys) {
|
|
286
|
+
const outcome = await processObjectKey(key, deps);
|
|
287
|
+
if (outcome.disposition === "fail") {
|
|
288
|
+
// First retryable key fails the whole record; SQS redelivers it. Already-
|
|
289
|
+
// started keys are idempotent on the dedupe path (deriveDedupeKey).
|
|
290
|
+
return outcome;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { disposition: "ack", reason: "record-complete" };
|
|
294
|
+
}
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Deps injection seam (consuming app wires concrete adapters at startup)
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
let injectedDeps;
|
|
299
|
+
/**
|
|
300
|
+
* Inject the concrete media-processing seams. The consuming app (Skybber) calls
|
|
301
|
+
* this once at Lambda cold start with its ffmpeg/MediaConvert TranscodePort, S3
|
|
302
|
+
* StoragePort, Transcribe TranscribePort, injected MediaModerationProvider, and
|
|
303
|
+
* a Prisma-backed MediaPersistencePort. Core ships NO concrete adapters.
|
|
304
|
+
*/
|
|
305
|
+
export function setMediaProcessingDeps(deps) {
|
|
306
|
+
injectedDeps = deps;
|
|
307
|
+
}
|
|
308
|
+
/** Test helper: clear injected deps between cases. */
|
|
309
|
+
export function __resetMediaProcessingDeps() {
|
|
310
|
+
injectedDeps = undefined;
|
|
311
|
+
}
|
|
3
312
|
const logger = new Logger({ serviceName: "media-processing-worker" });
|
|
4
|
-
|
|
313
|
+
/**
|
|
314
|
+
* The SQS entry point. Preserves `reportBatchItemFailures` semantics: only the
|
|
315
|
+
* messageIds whose records produced a retryable fault are returned as batch
|
|
316
|
+
* item failures; everything else (success / drop / poison→REVIEW) is acked by
|
|
317
|
+
* omission.
|
|
318
|
+
*
|
|
319
|
+
* If no concrete deps were injected, the handler fails CLOSED: it throws, so the
|
|
320
|
+
* whole batch is retried rather than silently dropped. An un-wired worker must
|
|
321
|
+
* never ack-drop real uploads.
|
|
322
|
+
*/
|
|
5
323
|
export const handler = async (event) => {
|
|
6
|
-
|
|
324
|
+
if (injectedDeps === undefined) {
|
|
325
|
+
// Fail closed: no backend wired ⇒ retry the batch, never drop. The
|
|
326
|
+
// consuming app must call setMediaProcessingDeps() at startup.
|
|
327
|
+
logger.error("media-processing-worker invoked with no injected deps — refusing to" +
|
|
328
|
+
" process. Call setMediaProcessingDeps() at cold start.");
|
|
329
|
+
throw new Error("media-processing-worker: deps not injected");
|
|
330
|
+
}
|
|
331
|
+
const deps = injectedDeps;
|
|
332
|
+
const batchItemFailures = [];
|
|
7
333
|
for (const record of event.Records) {
|
|
334
|
+
let outcome;
|
|
8
335
|
try {
|
|
9
|
-
|
|
10
|
-
const s3Event = JSON.parse(record.body);
|
|
11
|
-
const s3Records = s3Event.Records || [];
|
|
12
|
-
for (const s3Record of s3Records) {
|
|
13
|
-
const bucket = s3Record.s3.bucket.name;
|
|
14
|
-
const key = decodeURIComponent(s3Record.s3.object.key.replace(/\+/g, " "));
|
|
15
|
-
if (!key.startsWith("originals/"))
|
|
16
|
-
continue;
|
|
17
|
-
// Get original
|
|
18
|
-
const original = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
19
|
-
const chunks = [];
|
|
20
|
-
for await (const chunk of original.Body) {
|
|
21
|
-
chunks.push(chunk);
|
|
22
|
-
}
|
|
23
|
-
const buffer = Buffer.concat(chunks);
|
|
24
|
-
// Process with Sharp (must be installed as ARM64 binary)
|
|
25
|
-
// dynamic import to avoid bundling issues
|
|
26
|
-
const sharp = (await import("sharp")).default;
|
|
27
|
-
const hash = key.split("/").pop().replace(/\.[^.]+$/, "");
|
|
28
|
-
// Thumbnail: 300px WebP
|
|
29
|
-
const thumbnail = await sharp(buffer)
|
|
30
|
-
.resize(300, 300, { fit: "cover" })
|
|
31
|
-
.webp({ quality: 80 })
|
|
32
|
-
.toBuffer();
|
|
33
|
-
// Optimized: 1200px WebP
|
|
34
|
-
const optimized = await sharp(buffer)
|
|
35
|
-
.resize(1200, 1200, { fit: "inside", withoutEnlargement: true })
|
|
36
|
-
.webp({ quality: 85 })
|
|
37
|
-
.toBuffer();
|
|
38
|
-
await Promise.all([
|
|
39
|
-
s3.send(new PutObjectCommand({
|
|
40
|
-
Bucket: bucket, Key: `thumbnails/${hash}.webp`,
|
|
41
|
-
Body: thumbnail, ContentType: "image/webp",
|
|
42
|
-
})),
|
|
43
|
-
s3.send(new PutObjectCommand({
|
|
44
|
-
Bucket: bucket, Key: `optimized/${hash}.webp`,
|
|
45
|
-
Body: optimized, ContentType: "image/webp",
|
|
46
|
-
})),
|
|
47
|
-
]);
|
|
48
|
-
logger.info("Media processed", { key, hash });
|
|
49
|
-
}
|
|
336
|
+
outcome = await processRecord(record, deps);
|
|
50
337
|
}
|
|
51
338
|
catch (err) {
|
|
52
|
-
|
|
53
|
-
|
|
339
|
+
// Defensive: processRecord is designed not to throw, but if it does, treat
|
|
340
|
+
// it as retryable (fail closed for retry; DLQ + alert is the backstop).
|
|
341
|
+
logger.error("Unexpected throw from processRecord — retrying record", {
|
|
342
|
+
messageId: record.messageId,
|
|
343
|
+
error: err,
|
|
344
|
+
});
|
|
345
|
+
batchItemFailures.push({ itemIdentifier: record.messageId });
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (outcome.disposition === "fail") {
|
|
349
|
+
batchItemFailures.push({ itemIdentifier: record.messageId });
|
|
54
350
|
}
|
|
55
351
|
}
|
|
56
|
-
|
|
57
|
-
return { batchItemFailures: failedIds.map((id) => ({ itemIdentifier: id })) };
|
|
58
|
-
}
|
|
352
|
+
return { batchItemFailures };
|
|
59
353
|
};
|
|
60
354
|
//# sourceMappingURL=media-processing-worker.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"media-processing-worker.js","sourceRoot":"","sources":["../../src/lambda/media-processing-worker.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"media-processing-worker.js","sourceRoot":"","sources":["../../src/lambda/media-processing-worker.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* EXIF
|
|
2
|
+
* EXIF strip verification helper (T6).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The byte-level strip is a CONSEQUENCE of T7's re-encode: sharp re-encoding
|
|
5
|
+
* without `.withMetadata()` drops all embedded metadata (EXIF, GPS, ICC,
|
|
6
|
+
* maker-notes). This module provides the POST-encode assertion that the strip
|
|
7
|
+
* actually happened — used defensively at runtime and as the verification
|
|
8
|
+
* contract in tests.
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
10
|
+
* The old placeholder `stripEXIF()` is preserved below with a narrowed
|
|
11
|
+
* signature so existing callers compile; it delegates to the re-encode and is
|
|
12
|
+
* deprecated. New code must call `assertNoExif` after `reencodeImage`.
|
|
9
13
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - Prevents location data, device info, and timestamps from being embedded in images
|
|
14
|
-
* - Helps protect user privacy, especially for at-risk users
|
|
14
|
+
* GPS coordinates are NOT persisted: `gpsLatitude`/`gpsLongitude` columns
|
|
15
|
+
* were removed in T8's schema migration. Nothing in this file or the upload
|
|
16
|
+
* handler writes those fields.
|
|
15
17
|
*/
|
|
18
|
+
/**
|
|
19
|
+
* Parse the EXIF/IPTC/GPS/ICC metadata embedded in `imageBytes` and return
|
|
20
|
+
* it as a flat object. Returns `undefined` when exifr finds nothing.
|
|
21
|
+
*
|
|
22
|
+
* Exported for use in tests (assert the object is empty/undefined after
|
|
23
|
+
* re-encode) and as an optional runtime defensive check.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseMetadata(imageBytes: ArrayBuffer | Buffer | Uint8Array): Promise<Record<string, unknown> | undefined>;
|
|
26
|
+
/**
|
|
27
|
+
* Assert that `imageBytes` contains NO privacy-sensitive embedded metadata
|
|
28
|
+
* (EXIF GPS, ICC, maker-notes, camera info). Benign PNG structural fields
|
|
29
|
+
* (ImageWidth, ColorType, etc.) are excluded from this check since exifr
|
|
30
|
+
* parses them from the PNG format header, not from EXIF APP1 segments.
|
|
31
|
+
*
|
|
32
|
+
* Call this on the OUTPUT of `reencodeImage` to confirm the re-encode dropped
|
|
33
|
+
* all user/device metadata. Useful both in tests and as an optional defensive
|
|
34
|
+
* runtime check.
|
|
35
|
+
*
|
|
36
|
+
* @throws `Error` when privacy-sensitive metadata is present.
|
|
37
|
+
*/
|
|
38
|
+
export declare function assertNoExif(imageBytes: ArrayBuffer | Buffer | Uint8Array): Promise<void>;
|
|
39
|
+
/** @deprecated Legacy config interface — no longer has any effect. */
|
|
16
40
|
export interface EXIFStripperConfig {
|
|
17
41
|
enabled: boolean;
|
|
18
42
|
removeLocation: boolean;
|
|
@@ -20,18 +44,9 @@ export interface EXIFStripperConfig {
|
|
|
20
44
|
removeTimestamp: boolean;
|
|
21
45
|
}
|
|
22
46
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* to remove EXIF data from images before storing them in R2.
|
|
27
|
-
*
|
|
28
|
-
* NOTE: This is currently a placeholder implementation.
|
|
29
|
-
* When implementing, use a library like 'piexifjs' or 'exifr' to actually
|
|
30
|
-
* remove EXIF data from the image buffer.
|
|
31
|
-
*
|
|
32
|
-
* @param imageBuffer - Image buffer to process
|
|
33
|
-
* @param config - Stripping configuration
|
|
34
|
-
* @returns Processed image buffer (with EXIF removed if enabled)
|
|
47
|
+
* @deprecated The strip now happens as a side-effect of `reencodeImage`
|
|
48
|
+
* (T7). This function is a no-op passthrough kept solely so the existing
|
|
49
|
+
* test suite compiles without change. Do not call in new code.
|
|
35
50
|
*/
|
|
36
51
|
export declare function stripEXIF(imageBuffer: ArrayBuffer, config?: EXIFStripperConfig): Promise<ArrayBuffer>;
|
|
37
52
|
//# sourceMappingURL=exif-stripper.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"exif-stripper.d.ts","sourceRoot":"","sources":["../../src/lib/exif-stripper.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"exif-stripper.d.ts","sourceRoot":"","sources":["../../src/lib/exif-stripper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAIH;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,UAAU,EAAE,WAAW,GAAG,MAAM,GAAG,UAAU,GAC5C,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC,CA2B9C;AAiCD;;;;;;;;;;;GAWG;AACH,wBAAsB,YAAY,CAChC,UAAU,EAAE,WAAW,GAAG,MAAM,GAAG,UAAU,GAC5C,OAAO,CAAC,IAAI,CAAC,CAgBf;AAQD,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,EAAE,OAAO,CAAC;IACxB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAC7B,WAAW,EAAE,WAAW,EACxB,MAAM,GAAE,kBAKP,GACA,OAAO,CAAC,WAAW,CAAC,CAKtB"}
|