taskover-mcp 1.0.1 → 1.2.0

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.
@@ -0,0 +1,491 @@
1
+ "use strict";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Constants — Moderation policy
5
+ // ---------------------------------------------------------------------------
6
+ const MODERATION_POLICY = {
7
+ REJECT_CATEGORIES: [
8
+ "explicit_nudity", "sexual_content", "pornography", "sexual_anime",
9
+ "fetish", "graphic_gore", "hate_symbols", "illegal_content", "child_exploitation",
10
+ ],
11
+ REVIEW_CATEGORIES: [
12
+ "suggestive", "partial_nudity", "violence",
13
+ ],
14
+ REJECT_THRESHOLD: 0.70,
15
+ REVIEW_THRESHOLD: 0.40,
16
+ STRICT_TYPES: ["avatar", "project_image", "banner", "public_page_image"],
17
+ STRICT_REVIEW_THRESHOLD: 0.25,
18
+ };
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Classify an array of provider-returned labels against the moderation policy.
26
+ *
27
+ * @param {{ category: string, confidence: number }[]} labels
28
+ * @param {string} imageType - e.g. 'avatar', 'project_image', 'banner'
29
+ * @returns {{ decision: 'approved'|'rejected'|'needs_review', reason: string, labels: string[], score: number }}
30
+ */
31
+ function classifyResponse(labels, imageType) {
32
+ if (!Array.isArray(labels) || labels.length === 0) {
33
+ return { decision: "approved", reason: "no_labels", labels: [], score: 0 };
34
+ }
35
+
36
+ const isStrict = MODERATION_POLICY.STRICT_TYPES.includes(imageType);
37
+ const reviewThreshold = isStrict
38
+ ? MODERATION_POLICY.STRICT_REVIEW_THRESHOLD
39
+ : MODERATION_POLICY.REVIEW_THRESHOLD;
40
+
41
+ let highestScore = 0;
42
+ const flaggedLabels = [];
43
+ let decision = "approved";
44
+ let reason = "clean";
45
+
46
+ for (const label of labels) {
47
+ const cat = label.category;
48
+ const conf = label.confidence;
49
+
50
+ if (conf > highestScore) highestScore = conf;
51
+
52
+ // --- Reject check ---
53
+ if (MODERATION_POLICY.REJECT_CATEGORIES.includes(cat)) {
54
+ if (conf >= MODERATION_POLICY.REJECT_THRESHOLD) {
55
+ decision = "rejected";
56
+ reason = "rejected_category:" + cat;
57
+ flaggedLabels.push(cat);
58
+ continue;
59
+ }
60
+ // Below reject threshold but above review threshold → needs_review
61
+ if (conf >= reviewThreshold) {
62
+ if (decision !== "rejected") {
63
+ decision = "needs_review";
64
+ reason = "review_category:" + cat;
65
+ }
66
+ flaggedLabels.push(cat);
67
+ continue;
68
+ }
69
+ }
70
+
71
+ // --- Review check ---
72
+ if (MODERATION_POLICY.REVIEW_CATEGORIES.includes(cat)) {
73
+ if (conf >= reviewThreshold) {
74
+ if (decision !== "rejected") {
75
+ decision = "needs_review";
76
+ reason = "review_category:" + cat;
77
+ }
78
+ flaggedLabels.push(cat);
79
+ }
80
+ }
81
+ }
82
+
83
+ return { decision, reason, labels: flaggedLabels, score: highestScore };
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Backends
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /** Auto-approves everything. Development and test use only. */
91
+ class NoopBackend {
92
+ async moderate(_buffer, _imageType) {
93
+ return {
94
+ decision: "approved",
95
+ reason: "noop",
96
+ labels: [],
97
+ score: 0,
98
+ provider: "noop",
99
+ };
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Wraps NoopBackend but logs a warning on every moderate() call.
105
+ * Used only when NODE_ENV === 'staging' and provider is 'noop'.
106
+ */
107
+ class WarningNoopBackend {
108
+ constructor() {
109
+ this._inner = new NoopBackend();
110
+ }
111
+
112
+ async moderate(buffer, imageType) {
113
+ console.warn(
114
+ "[image-moderator] WARNING: Using noop moderation backend in staging. " +
115
+ "All images are auto-approved. Set IMAGE_MODERATION_PROVIDER before going to production.",
116
+ );
117
+ return this._inner.moderate(buffer, imageType);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Sends the image to an external moderation API endpoint.
123
+ * Placeholder implementation — wire to SightEngine, AWS Rekognition,
124
+ * or a custom endpoint by adapting parseProviderResponse().
125
+ */
126
+ class ExternalApiBackend {
127
+ /**
128
+ * @param {{ apiKey: string, apiSecret: string, endpoint: string, timeoutMs: number }} opts
129
+ */
130
+ constructor({ apiKey, apiSecret, endpoint, timeoutMs }) {
131
+ if (!endpoint) {
132
+ throw new Error(
133
+ "IMAGE_MODERATION_ENDPOINT is required when using an external moderation provider.",
134
+ );
135
+ }
136
+ this._apiKey = apiKey || "";
137
+ this._apiSecret = apiSecret || "";
138
+ this._endpoint = endpoint;
139
+ this._timeoutMs = timeoutMs || 8000;
140
+ }
141
+
142
+ /**
143
+ * POST the image buffer to the configured endpoint and classify the result.
144
+ *
145
+ * @param {Buffer} buffer - processed image bytes
146
+ * @param {string} imageType - e.g. 'avatar', 'project_image'
147
+ * @returns {Promise<{ decision: string, reason: string, labels: string[], score: number, provider: string }>}
148
+ */
149
+ async moderate(buffer, imageType) {
150
+ const controller = new AbortController();
151
+ const timer = setTimeout(() => controller.abort(), this._timeoutMs);
152
+
153
+ try {
154
+ const base64Body = buffer.toString("base64");
155
+
156
+ const res = await fetch(this._endpoint, {
157
+ method: "POST",
158
+ headers: {
159
+ "Content-Type": "application/json",
160
+ "Authorization": this._apiKey
161
+ ? "Bearer " + this._apiKey
162
+ : undefined,
163
+ "X-Api-Secret": this._apiSecret || undefined,
164
+ },
165
+ body: JSON.stringify({
166
+ image: base64Body,
167
+ image_type: imageType,
168
+ }),
169
+ signal: controller.signal,
170
+ });
171
+
172
+ if (!res.ok) {
173
+ const text = await res.text().catch(() => "");
174
+ console.error(
175
+ "[image-moderator] Provider returned HTTP %d: %s",
176
+ res.status, text.slice(0, 200),
177
+ );
178
+ return {
179
+ decision: "needs_review",
180
+ reason: "provider_error",
181
+ labels: [],
182
+ score: 0,
183
+ provider: "external",
184
+ };
185
+ }
186
+
187
+ const json = await res.json();
188
+ const labels = this._parseProviderResponse(json);
189
+ const classification = classifyResponse(labels, imageType);
190
+
191
+ return {
192
+ ...classification,
193
+ provider: "external",
194
+ };
195
+ } catch (err) {
196
+ if (err.name === "AbortError") {
197
+ console.error(
198
+ "[image-moderator] Provider timed out after %dms",
199
+ this._timeoutMs,
200
+ );
201
+ return {
202
+ decision: "needs_review",
203
+ reason: "provider_timeout",
204
+ labels: [],
205
+ score: 0,
206
+ provider: "external",
207
+ };
208
+ }
209
+
210
+ console.error("[image-moderator] Provider error:", err.message);
211
+ return {
212
+ decision: "needs_review",
213
+ reason: "provider_error",
214
+ labels: [],
215
+ score: 0,
216
+ provider: "external",
217
+ };
218
+ } finally {
219
+ clearTimeout(timer);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Map the raw provider JSON into a normalised label array.
225
+ *
226
+ * Adapt this method when wiring a specific service.
227
+ *
228
+ * Expected output: [{ category: string, confidence: number (0–1) }, ...]
229
+ *
230
+ * --- SightEngine example shape ---
231
+ * { nudity: { sexual_activity: 0.01, ... }, weapon: 0.02, ... }
232
+ *
233
+ * --- AWS Rekognition example shape ---
234
+ * { ModerationLabels: [{ Name: "Explicit Nudity", Confidence: 99.2 }, ...] }
235
+ *
236
+ * --- Generic / custom endpoint shape (what we expect by default) ---
237
+ * { labels: [{ category: "explicit_nudity", confidence: 0.95 }, ...] }
238
+ *
239
+ * @param {object} json
240
+ * @returns {{ category: string, confidence: number }[]}
241
+ */
242
+ _parseProviderResponse(json) {
243
+ // --- Generic / custom endpoint (default) ---------------------------------
244
+ if (Array.isArray(json.labels)) {
245
+ return json.labels.map((l) => ({
246
+ category: String(l.category || "").toLowerCase().replace(/\s+/g, "_"),
247
+ confidence: Number(l.confidence) || 0,
248
+ }));
249
+ }
250
+
251
+ // --- AWS Rekognition shape -----------------------------------------------
252
+ if (Array.isArray(json.ModerationLabels)) {
253
+ return json.ModerationLabels.map((l) => ({
254
+ category: String(l.Name || "").toLowerCase().replace(/\s+/g, "_"),
255
+ confidence: (Number(l.Confidence) || 0) / 100, // Rekognition uses 0–100
256
+ }));
257
+ }
258
+
259
+ // --- SightEngine shape (flat keys with sub-objects) ----------------------
260
+ // SightEngine returns top-level keys like "nudity", "weapon", "alcohol"
261
+ // each containing sub-scores. Flatten the highest sub-score per key.
262
+ if (typeof json === "object" && !json.labels && !json.ModerationLabels) {
263
+ const labels = [];
264
+ const skip = new Set(["status", "request", "media"]);
265
+ for (const [key, val] of Object.entries(json)) {
266
+ if (skip.has(key)) continue;
267
+ if (typeof val === "number") {
268
+ labels.push({ category: key, confidence: val });
269
+ } else if (typeof val === "object" && val !== null) {
270
+ // Take the highest sub-score within the category object
271
+ let maxConf = 0;
272
+ let maxSub = key;
273
+ for (const [sub, score] of Object.entries(val)) {
274
+ if (typeof score === "number" && score > maxConf) {
275
+ maxConf = score;
276
+ maxSub = key + "_" + sub;
277
+ }
278
+ }
279
+ if (maxConf > 0) {
280
+ labels.push({ category: maxSub, confidence: maxConf });
281
+ }
282
+ }
283
+ }
284
+ return labels;
285
+ }
286
+
287
+ return [];
288
+ }
289
+ }
290
+
291
+ /**
292
+ * SightEngine moderation backend.
293
+ * Calls https://api.sightengine.com/1.0/check.json
294
+ * Models: nudity2, offensive2, gore2, tobacco, recreational_drug, medical, weapon, text-content
295
+ */
296
+ class SightEngineBackend {
297
+ constructor({ apiUser, apiSecret, timeoutMs }) {
298
+ if (!apiUser || !apiSecret) {
299
+ throw new Error("IMAGE_MODERATION_API_KEY (api_user) and IMAGE_MODERATION_API_SECRET are required for SightEngine.");
300
+ }
301
+ this._apiUser = apiUser;
302
+ this._apiSecret = apiSecret;
303
+ this._timeoutMs = timeoutMs || 8000;
304
+ this._endpoint = "https://api.sightengine.com/1.0/check.json";
305
+ }
306
+
307
+ async moderate(buffer, imageType) {
308
+ const controller = new AbortController();
309
+ const timer = setTimeout(() => controller.abort(), this._timeoutMs);
310
+
311
+ try {
312
+ // SightEngine requires multipart/form-data with the image as a file under "media" key
313
+ const form = new FormData();
314
+ form.append("api_user", this._apiUser);
315
+ form.append("api_secret", this._apiSecret);
316
+ form.append("models", "nudity,offensive,gore,wad,weapon");
317
+ form.append("media", new Blob([buffer], { type: "image/webp" }), "image.webp");
318
+
319
+ const res = await fetch(this._endpoint, {
320
+ method: "POST",
321
+ body: form,
322
+ signal: controller.signal,
323
+ });
324
+
325
+ if (!res.ok) {
326
+ const text = await res.text().catch(() => "");
327
+ console.error("[image-moderator] SightEngine HTTP %d: %s", res.status, text.slice(0, 1000));
328
+ return { decision: "needs_review", reason: "provider_error", labels: [], score: 0, provider: "sightengine" };
329
+ }
330
+
331
+ const json = await res.json();
332
+
333
+ if (json.status !== "success") {
334
+ console.error("[image-moderator] SightEngine error:", JSON.stringify(json).slice(0, 300));
335
+ return { decision: "needs_review", reason: "provider_error", labels: [], score: 0, provider: "sightengine" };
336
+ }
337
+
338
+ // Map SightEngine response to our normalized labels
339
+ const labels = this._parseSightEngineResponse(json);
340
+ const classification = classifyResponse(labels, imageType);
341
+
342
+ // DEBUG: log full response for tuning (remove after testing)
343
+ console.log("[image-moderator] SightEngine raw:", JSON.stringify(json).slice(0, 500));
344
+ console.log("[image-moderator] Parsed labels:", JSON.stringify(labels));
345
+ console.log("[image-moderator] Classification:", JSON.stringify(classification));
346
+
347
+ return { ...classification, provider: "sightengine" };
348
+ } catch (err) {
349
+ if (err.name === "AbortError") {
350
+ console.error("[image-moderator] SightEngine timed out after %dms", this._timeoutMs);
351
+ return { decision: "needs_review", reason: "provider_timeout", labels: [], score: 0, provider: "sightengine" };
352
+ }
353
+ console.error("[image-moderator] SightEngine error:", err.message);
354
+ return { decision: "needs_review", reason: "provider_error", labels: [], score: 0, provider: "sightengine" };
355
+ } finally {
356
+ clearTimeout(timer);
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Map SightEngine JSON to normalized labels.
362
+ * SightEngine response shape:
363
+ * {
364
+ * nudity: { sexual_activity: 0.01, sexual_display: 0.02, erotica: 0.01, very_suggestive: 0.03, suggestive: 0.10, ... },
365
+ * offensive: { prob: 0.02, ... },
366
+ * gore: { prob: 0.01 },
367
+ * weapon: 0.01,
368
+ * tobacco: { prob: 0.00 },
369
+ * recreational_drug: { prob: 0.00 },
370
+ * ...
371
+ * }
372
+ */
373
+ _parseSightEngineResponse(json) {
374
+ const labels = [];
375
+
376
+ // Nudity — map SightEngine nudity scores to our categories
377
+ if (json.nudity) {
378
+ const n = json.nudity;
379
+ // v1 model fields: raw, safe, partial
380
+ if (n.raw != null && n.raw > 0.3) labels.push({ category: "explicit_nudity", confidence: n.raw });
381
+ if (n.partial != null && n.partial > 0.3) labels.push({ category: "partial_nudity", confidence: n.partial });
382
+ // v2 model fields (if available)
383
+ if (n.sexual_activity != null) labels.push({ category: "sexual_content", confidence: n.sexual_activity });
384
+ if (n.sexual_display != null) labels.push({ category: "explicit_nudity", confidence: n.sexual_display });
385
+ if (n.erotica != null) labels.push({ category: "pornography", confidence: n.erotica });
386
+ if (n.very_suggestive != null && n.very_suggestive > 0.3) labels.push({ category: "suggestive", confidence: n.very_suggestive });
387
+ if (n.suggestive != null && n.suggestive > 0.3) labels.push({ category: "suggestive", confidence: n.suggestive });
388
+ if (n.mildly_suggestive != null && n.mildly_suggestive > 0.5) labels.push({ category: "partial_nudity", confidence: n.mildly_suggestive });
389
+ }
390
+
391
+ // Offensive content
392
+ if (json.offensive) {
393
+ const prob = typeof json.offensive === "number" ? json.offensive : (json.offensive.prob || 0);
394
+ if (prob > 0.1) labels.push({ category: "offensive_gestures", confidence: prob });
395
+ }
396
+
397
+ // Gore
398
+ if (json.gore) {
399
+ const prob = typeof json.gore === "number" ? json.gore : (json.gore.prob || 0);
400
+ if (prob > 0.1) labels.push({ category: "graphic_gore", confidence: prob });
401
+ }
402
+
403
+ // Weapon
404
+ if (json.weapon != null) {
405
+ const prob = typeof json.weapon === "number" ? json.weapon : (json.weapon.prob || 0);
406
+ if (prob > 0.3) labels.push({ category: "violence", confidence: prob });
407
+ }
408
+
409
+ // Drugs
410
+ if (json.recreational_drug) {
411
+ const prob = typeof json.recreational_drug === "number" ? json.recreational_drug : (json.recreational_drug.prob || 0);
412
+ if (prob > 0.2) labels.push({ category: "drugs", confidence: prob });
413
+ }
414
+
415
+ // Tobacco
416
+ if (json.tobacco) {
417
+ const prob = typeof json.tobacco === "number" ? json.tobacco : (json.tobacco.prob || 0);
418
+ if (prob > 0.3) labels.push({ category: "drugs", confidence: prob });
419
+ }
420
+
421
+ return labels;
422
+ }
423
+ }
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // Factory
427
+ // ---------------------------------------------------------------------------
428
+
429
+ /**
430
+ * Create the appropriate moderation backend based on environment variables.
431
+ *
432
+ * Environment variables consumed:
433
+ * IMAGE_MODERATION_PROVIDER — 'noop' (default in dev) or provider name
434
+ * IMAGE_MODERATION_API_KEY — API key for the external provider
435
+ * IMAGE_MODERATION_API_SECRET — API secret for the external provider
436
+ * IMAGE_MODERATION_ENDPOINT — HTTP(S) URL for the external provider
437
+ * IMAGE_MODERATION_TIMEOUT_MS — request timeout, default 8000
438
+ *
439
+ * @returns {NoopBackend | WarningNoopBackend | ExternalApiBackend}
440
+ */
441
+ function createModerator() {
442
+ const provider = process.env.IMAGE_MODERATION_PROVIDER || "noop";
443
+ const nodeEnv = process.env.NODE_ENV || "development";
444
+
445
+ // --- Production safety gate (no override) ---------------------------------
446
+ if (nodeEnv === "production" && provider === "noop") {
447
+ throw new Error(
448
+ "FATAL: Image moderation provider is \"noop\" in production. " +
449
+ "Set IMAGE_MODERATION_PROVIDER to a real provider. There is no override.",
450
+ );
451
+ }
452
+
453
+ // --- Noop backends --------------------------------------------------------
454
+ if (provider === "noop") {
455
+ if (nodeEnv === "staging") {
456
+ return new WarningNoopBackend();
457
+ }
458
+ return new NoopBackend();
459
+ }
460
+
461
+ // --- SightEngine ----------------------------------------------------------
462
+ if (provider === "sightengine") {
463
+ return new SightEngineBackend({
464
+ apiUser: process.env.IMAGE_MODERATION_API_KEY,
465
+ apiSecret: process.env.IMAGE_MODERATION_API_SECRET,
466
+ timeoutMs: parseInt(process.env.IMAGE_MODERATION_TIMEOUT_MS || "8000", 10),
467
+ });
468
+ }
469
+
470
+ // --- Generic external provider --------------------------------------------
471
+ return new ExternalApiBackend({
472
+ apiKey: process.env.IMAGE_MODERATION_API_KEY,
473
+ apiSecret: process.env.IMAGE_MODERATION_API_SECRET,
474
+ endpoint: process.env.IMAGE_MODERATION_ENDPOINT,
475
+ timeoutMs: parseInt(process.env.IMAGE_MODERATION_TIMEOUT_MS || "8000", 10),
476
+ });
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // Exports
481
+ // ---------------------------------------------------------------------------
482
+ module.exports = {
483
+ createModerator,
484
+ classifyResponse,
485
+ MODERATION_POLICY,
486
+ // Exported for testing / advanced usage
487
+ NoopBackend,
488
+ WarningNoopBackend,
489
+ ExternalApiBackend,
490
+ SightEngineBackend,
491
+ };
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+
3
+ const sharp = require("sharp");
4
+ const fileType = require("file-type");
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Constants
8
+ // ---------------------------------------------------------------------------
9
+ const ALLOWED_MIMES = new Set(["image/jpeg", "image/png", "image/webp"]);
10
+ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB decoded
11
+ const MAX_DIMENSION = 4096;
12
+ const MIN_DIMENSION = 32;
13
+ const MAX_PIXEL_COUNT = 25_000_000; // 25 megapixels — decompression bomb guard
14
+ const OUTPUT_QUALITY = 85;
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Create an Error with a machine-readable `.code` property. */
21
+ function codeErr(message, code) {
22
+ return Object.assign(new Error(message), { code });
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Public API
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Validate magic bytes of a buffer.
31
+ * Returns detected MIME type or throws with a safe error message.
32
+ *
33
+ * @param {Buffer} buffer
34
+ * @returns {Promise<string>} detected MIME (e.g. "image/jpeg")
35
+ */
36
+ async function validateMagicBytes(buffer) {
37
+ const result = await fileType.fromBuffer(buffer);
38
+ if (!result) {
39
+ throw codeErr("Unrecognized file format", "INVALID_FORMAT");
40
+ }
41
+ if (!ALLOWED_MIMES.has(result.mime)) {
42
+ throw codeErr("File type not allowed: " + result.mime, "DISALLOWED_MIME");
43
+ }
44
+ return result.mime;
45
+ }
46
+
47
+ /**
48
+ * Full validation and processing pipeline.
49
+ *
50
+ * 1. Size guard on raw input
51
+ * 2. Magic-byte validation (JPEG / PNG / WebP only)
52
+ * 3. Metadata read + decompression-bomb check (before full decode)
53
+ * 4. Strip ALL metadata (EXIF, GPS, ICC, XMP) — sharp does this by default
54
+ * when re-encoding without calling `.withMetadata()`
55
+ * 5. Auto-orient based on EXIF rotation tag
56
+ * 6. Clamp dimensions to MAX_DIMENSION (preserving aspect ratio)
57
+ * 7. Re-encode to WebP
58
+ *
59
+ * @param {Buffer} buffer - raw image bytes (decoded from base64)
60
+ * @param {string} imageType - e.g. 'avatar', 'project_image', 'banner', 'public_page_image'
61
+ * @returns {Promise<{
62
+ * processed: Buffer,
63
+ * width: number,
64
+ * height: number,
65
+ * detectedMime: string,
66
+ * originalSize: number,
67
+ * processedSize: number
68
+ * }>}
69
+ */
70
+ async function validateAndProcess(buffer, imageType) {
71
+ // ---- 1. Basic input guard ------------------------------------------------
72
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
73
+ throw codeErr("Empty or invalid image data", "EMPTY_INPUT");
74
+ }
75
+ if (buffer.length > MAX_FILE_SIZE) {
76
+ throw codeErr(
77
+ "Image exceeds maximum size of " + (MAX_FILE_SIZE / 1024 / 1024) + "MB",
78
+ "TOO_LARGE",
79
+ );
80
+ }
81
+
82
+ // ---- 2. Magic-byte validation --------------------------------------------
83
+ const detectedMime = await validateMagicBytes(buffer);
84
+
85
+ // ---- 3. Metadata pre-check (before full pixel decode) --------------------
86
+ let metadata;
87
+ try {
88
+ metadata = await sharp(buffer).metadata();
89
+ } catch (_err) {
90
+ throw codeErr("Unable to read image — file may be corrupted", "CORRUPT_IMAGE");
91
+ }
92
+
93
+ const { width: origW, height: origH } = metadata;
94
+ if (!origW || !origH) {
95
+ throw codeErr("Unable to determine image dimensions", "NO_DIMENSIONS");
96
+ }
97
+
98
+ // Decompression bomb check
99
+ if (origW * origH > MAX_PIXEL_COUNT) {
100
+ throw codeErr(
101
+ "Image dimensions too large (max " + MAX_PIXEL_COUNT.toLocaleString() + " pixels)",
102
+ "DECOMPRESSION_BOMB",
103
+ );
104
+ }
105
+
106
+ // Minimum dimension check
107
+ if (origW < MIN_DIMENSION || origH < MIN_DIMENSION) {
108
+ throw codeErr(
109
+ "Image too small (minimum " + MIN_DIMENSION + "x" + MIN_DIMENSION + ")",
110
+ "TOO_SMALL",
111
+ );
112
+ }
113
+
114
+ // ---- 4-7. Process --------------------------------------------------------
115
+ // sharp strips ALL metadata (EXIF, ICC, XMP, GPS) by default when
116
+ // re-encoding — we intentionally do NOT call .withMetadata() so nothing
117
+ // is carried over from the original.
118
+ let pipeline = sharp(buffer).rotate(); // auto-orient via EXIF, then discard it
119
+
120
+ // Clamp oversized images (fit inside MAX_DIMENSION box, never enlarge)
121
+ if (origW > MAX_DIMENSION || origH > MAX_DIMENSION) {
122
+ pipeline = pipeline.resize(MAX_DIMENSION, MAX_DIMENSION, {
123
+ fit: "inside",
124
+ withoutEnlargement: true,
125
+ });
126
+ }
127
+
128
+ // Re-encode to WebP
129
+ let processed;
130
+ try {
131
+ processed = await pipeline
132
+ .webp({ quality: OUTPUT_QUALITY, effort: 4 })
133
+ .toBuffer({ resolveWithObject: true });
134
+ } catch (_err) {
135
+ throw codeErr("Failed to process image", "PROCESS_FAILED");
136
+ }
137
+
138
+ return {
139
+ processed: processed.data,
140
+ width: processed.info.width,
141
+ height: processed.info.height,
142
+ detectedMime,
143
+ originalSize: buffer.length,
144
+ processedSize: processed.data.length,
145
+ };
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Exports
150
+ // ---------------------------------------------------------------------------
151
+ module.exports = {
152
+ validateAndProcess,
153
+ validateMagicBytes,
154
+ ALLOWED_MIMES,
155
+ MAX_FILE_SIZE,
156
+ MAX_DIMENSION,
157
+ MIN_DIMENSION,
158
+ MAX_PIXEL_COUNT,
159
+ OUTPUT_QUALITY,
160
+ };