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.
- package/auth-flow.js +228 -0
- package/auth-gate.js +253 -0
- package/cloud-adapter.js +73 -26
- package/credential-store.js +93 -0
- package/crypto.js +386 -0
- package/data-store.js +9352 -0
- package/data-store.json-backup.js +1264 -0
- package/db.js +2292 -0
- package/image-moderator.js +491 -0
- package/image-processor.js +160 -0
- package/image-upload-service.js +398 -0
- package/index.js +2294 -2068
- package/migrate-json-to-sqlite.js +256 -0
- package/package.json +29 -16
- package/publish/auth-flow.js +275 -0
- package/publish/cloud-adapter.js +246 -0
- package/publish/credential-store.js +93 -0
- package/publish/index.js +1433 -0
- package/publish/package.json +21 -0
- package/publish/tool-map.js +1146 -0
- package/scripts/build-publish.sh +95 -0
- package/scripts/test-auth-failure.js +68 -0
- package/scripts/test-success.js +232 -0
- package/scripts/test-validation.js +105 -0
- package/tool-map.js +58 -0
- /package/{README.md → publish/README.md} +0 -0
|
@@ -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
|
+
};
|