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,398 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const { PutObjectCommand } = require("@aws-sdk/client-s3");
5
+ const { validateAndProcess } = require("./image-processor");
6
+ const store = require("./data-store");
7
+
8
+ // Strict image types: needs_review is treated as rejected (no quarantine, no R2 upload)
9
+ const STRICT_TYPES = new Set(["avatar", "project_image", "banner", "public_page_image"]);
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // State
13
+ // ---------------------------------------------------------------------------
14
+ let _moderator = null;
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Generate a ULID-ish key: timestamp base36 + random suffix. */
21
+ function _generateKey(prefix, userId) {
22
+ const id = Date.now().toString(36) + Math.random().toString(36).substr(2, 8);
23
+ return `images/${prefix}/${userId}/${id}.webp`;
24
+ }
25
+
26
+ /** Upload a buffer to R2 (Cloudflare / S3-compatible). */
27
+ async function _uploadToR2(r2Client, bucket, key, buffer) {
28
+ await r2Client.send(new PutObjectCommand({
29
+ Bucket: bucket,
30
+ Key: key,
31
+ Body: buffer,
32
+ ContentType: "image/webp",
33
+ }));
34
+ }
35
+
36
+ /**
37
+ * Decode a data URL into a raw Buffer.
38
+ *
39
+ * Accepts: data:image/png;base64,iVBOR...
40
+ * Returns: { buffer, declaredMime }
41
+ * Throws: on invalid format
42
+ */
43
+ function _decodeDataUrl(dataUrl) {
44
+ if (typeof dataUrl !== "string" || !dataUrl.startsWith("data:")) {
45
+ return null;
46
+ }
47
+ const commaIdx = dataUrl.indexOf(",");
48
+ if (commaIdx === -1) return null;
49
+
50
+ // Extract declared MIME from the header portion
51
+ const header = dataUrl.slice(0, commaIdx); // e.g. "data:image/png;base64"
52
+ const mimeMatch = header.match(/^data:([^;,]+)/);
53
+ const declaredMime = mimeMatch ? mimeMatch[1] : null;
54
+
55
+ const base64 = dataUrl.slice(commaIdx + 1);
56
+ if (!base64) return null;
57
+
58
+ const buffer = Buffer.from(base64, "base64");
59
+ if (buffer.length === 0) return null;
60
+
61
+ return { buffer, declaredMime };
62
+ }
63
+
64
+ /**
65
+ * Check whether the user is currently blocked from uploading.
66
+ *
67
+ * Lockout ladder (fail-closed):
68
+ * 1. Persisted lock — admin lock, or auto-lock written after previous rejection
69
+ * - "indefinite" → blocked permanently
70
+ * - "auto_1h:<ISO>" / "auto_24h:<ISO>" → blocked until timestamp
71
+ * - plain ISO timestamp → admin-set expiry
72
+ * 2. Fallback count-based derivation (covers race / missing persist)
73
+ * - >= 20 rejections in 30 days → indefinite
74
+ * - >= 10 rejections in 24 h → 24-hour cooldown
75
+ * - >= 5 rejections in 24 h → 1-hour cooldown (from last rejection)
76
+ *
77
+ * @returns {{ locked: boolean, reason?: string }}
78
+ */
79
+ function _checkAbuseLockout(userId) {
80
+ // --- 1. Persisted lock (admin or auto) ---
81
+ const lockVal = store.getUserUploadLock(userId);
82
+ if (lockVal) {
83
+ if (lockVal === "indefinite") {
84
+ return { locked: true, reason: "lock_indefinite" };
85
+ }
86
+ // auto_1h:<ISO> or auto_24h:<ISO>
87
+ const autoMatch = lockVal.match(/^auto_\w+:(.+)$/);
88
+ if (autoMatch) {
89
+ const expiresAt = new Date(autoMatch[1]);
90
+ if (!isNaN(expiresAt.getTime()) && expiresAt.getTime() > Date.now()) {
91
+ return { locked: true, reason: "lock_active:" + lockVal };
92
+ }
93
+ // Expired auto-lock — clear and continue
94
+ store.clearUserUploadLock(userId);
95
+ } else {
96
+ // Plain ISO timestamp (admin-set expiry)
97
+ const expiresAt = new Date(lockVal);
98
+ if (!isNaN(expiresAt.getTime()) && expiresAt.getTime() > Date.now()) {
99
+ return { locked: true, reason: "admin_lock_until:" + lockVal };
100
+ }
101
+ // Expired admin lock — clear
102
+ store.clearUserUploadLock(userId);
103
+ }
104
+ }
105
+
106
+ // --- 2. Count-based fallback (covers window where lock wasn't persisted) ---
107
+ const count30d = store.getUserRejectionCount(userId, 720); // 30 days
108
+ if (count30d >= 20) {
109
+ return { locked: true, reason: "auto_lock_indefinite:30d_rejections=" + count30d };
110
+ }
111
+
112
+ const count24h = store.getUserRejectionCount(userId, 24);
113
+ if (count24h >= 10) {
114
+ return { locked: true, reason: "auto_lock_24h:24h_rejections=" + count24h };
115
+ }
116
+
117
+ if (count24h >= 5) {
118
+ const count1h = store.getUserRejectionCount(userId, 1);
119
+ if (count1h > 0) {
120
+ return { locked: true, reason: "auto_lock_1h:24h_rejections=" + count24h };
121
+ }
122
+ }
123
+
124
+ return { locked: false };
125
+ }
126
+
127
+ /**
128
+ * After a rejection, check thresholds and persist an auto-lock if warranted.
129
+ * Also logs a lock_user moderation action for audit trail.
130
+ *
131
+ * @param {string} userId
132
+ * @param {string} imageUploadId — the just-rejected upload (for audit linkage)
133
+ */
134
+ function _escalateAfterRejection(userId, imageUploadId) {
135
+ const count30d = store.getUserRejectionCount(userId, 720);
136
+ const count24h = store.getUserRejectionCount(userId, 24);
137
+
138
+ let lockValue = null;
139
+ let lockReason = null;
140
+
141
+ if (count30d >= 20) {
142
+ lockValue = "indefinite";
143
+ lockReason = "auto_lock:30d_rejections=" + count30d;
144
+ } else if (count24h >= 10) {
145
+ const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
146
+ lockValue = "auto_24h:" + expires;
147
+ lockReason = "auto_lock:24h_rejections=" + count24h;
148
+ } else if (count24h >= 5) {
149
+ const expires = new Date(Date.now() + 60 * 60 * 1000).toISOString();
150
+ lockValue = "auto_1h:" + expires;
151
+ lockReason = "auto_lock:24h_rejections=" + count24h;
152
+ }
153
+
154
+ if (lockValue) {
155
+ store.setUserUploadLock(userId, lockValue);
156
+ store.createModerationAction({
157
+ imageUploadId,
158
+ actorUserId: "system",
159
+ action: "lock_user",
160
+ previousState: "rejected",
161
+ newState: lockValue,
162
+ note: lockReason,
163
+ });
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Public API
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Initialize the service. Must be called once at startup.
173
+ * @param {{ moderator: object }} opts
174
+ */
175
+ function init({ moderator }) {
176
+ _moderator = moderator;
177
+ }
178
+
179
+ /**
180
+ * Process an image upload through the full pipeline.
181
+ *
182
+ * Steps: lockout check → decode → validate → process → DB record →
183
+ * moderate → (reject|quarantine|approve) → R2 upload → done.
184
+ *
185
+ * Returns a structured result — never throws for business-logic failures.
186
+ *
187
+ * @param {object} params
188
+ * @param {string} params.dataUrl - base64 data URL ("data:image/...;base64,...")
189
+ * @param {string} params.userId
190
+ * @param {string|null} params.projectId
191
+ * @param {string} params.imageType - 'avatar','project_image','banner','public_page_image'
192
+ * @param {object} params.r2Client - S3Client instance (injected by API server)
193
+ * @param {string} params.r2Bucket - bucket name
194
+ * @returns {Promise<{ success: boolean, imageUploadId?: string, error?: string, errorCode?: string }>}
195
+ */
196
+ async function processUpload({ dataUrl, userId, projectId, imageType, r2Client, r2Bucket }) {
197
+ try {
198
+ // ── 1. Check abuse lockout ───────────────────────────────────────
199
+ const lockout = _checkAbuseLockout(userId);
200
+ if (lockout.locked) {
201
+ return {
202
+ success: false,
203
+ error: "Upload temporarily disabled: " + lockout.reason,
204
+ errorCode: "UPLOAD_LOCKED",
205
+ };
206
+ }
207
+
208
+ // ── 2. Decode base64 data URL ────────────────────────────────────
209
+ const decoded = _decodeDataUrl(dataUrl);
210
+ if (!decoded) {
211
+ return {
212
+ success: false,
213
+ error: "Invalid or empty data URL",
214
+ errorCode: "INVALID_DATA_URL",
215
+ };
216
+ }
217
+ const { buffer: rawBuffer, declaredMime } = decoded;
218
+
219
+ // ── 3-4. Validate magic bytes, size, format + process ────────────
220
+ let result;
221
+ try {
222
+ result = await validateAndProcess(rawBuffer, imageType);
223
+ } catch (err) {
224
+ // Distinguish validation from processing errors
225
+ const processCodes = new Set(["PROCESS_FAILED", "CORRUPT_IMAGE"]);
226
+ const errorCode = processCodes.has(err.code) ? "PROCESS_FAILED" : "VALIDATION_FAILED";
227
+ return {
228
+ success: false,
229
+ error: err.message || "Image validation failed",
230
+ errorCode,
231
+ };
232
+ }
233
+
234
+ const { processed, width, height, detectedMime, originalSize, processedSize } = result;
235
+
236
+ // ── 4b. Compute content hash for abuse fingerprinting ────────────
237
+ const contentHash = crypto.createHash("sha256").update(processed).digest("hex");
238
+
239
+ // ── 5. Create image_uploads record (state: pending) ──────────────
240
+ const uploadRecord = store.createImageUpload({
241
+ userId,
242
+ projectId: projectId || null,
243
+ imageType,
244
+ originalMime: declaredMime || detectedMime,
245
+ originalSize,
246
+ detectedMime,
247
+ processedSize,
248
+ width,
249
+ height,
250
+ contentHash,
251
+ });
252
+ const imageUploadId = uploadRecord.id;
253
+
254
+ // ── 6. Moderate synchronously ────────────────────────────────────
255
+ if (!_moderator) {
256
+ store.updateImageUploadState(imageUploadId, "storage_failed");
257
+ return {
258
+ success: false,
259
+ error: "Moderation service not initialized",
260
+ errorCode: "INTERNAL_ERROR",
261
+ };
262
+ }
263
+
264
+ let modResult;
265
+ try {
266
+ modResult = await _moderator.moderate(processed, imageType);
267
+ } catch (err) {
268
+ // Moderation backend threw unexpectedly — fail safe to needs_review
269
+ console.error("[image-upload-service] Moderation threw:", err.message);
270
+ modResult = {
271
+ decision: "needs_review",
272
+ reason: "moderation_exception",
273
+ labels: [],
274
+ score: 0,
275
+ provider: "unknown",
276
+ };
277
+ }
278
+
279
+ const { decision, reason, labels, score, provider } = modResult;
280
+
281
+ // ── Strict binary decision: for strict types, needs_review → rejected ──
282
+ const isStrict = STRICT_TYPES.has(imageType);
283
+ let effectiveDecision = decision;
284
+ if (isStrict && effectiveDecision === "needs_review") {
285
+ effectiveDecision = "rejected";
286
+ }
287
+
288
+ // Map effective decision to DB moderation_state
289
+ const stateMap = {
290
+ approved: "approved",
291
+ rejected: "rejected",
292
+ needs_review: "needs_review",
293
+ };
294
+ const moderationState = stateMap[effectiveDecision] || "needs_review";
295
+
296
+ // Update the record with moderation outcome
297
+ store.updateImageUploadModeration(imageUploadId, {
298
+ state: moderationState,
299
+ reason: reason || null,
300
+ labels: Array.isArray(labels) ? labels.join(",") : (labels || null),
301
+ provider: provider || null,
302
+ score: score != null ? score : null,
303
+ });
304
+
305
+ // Log moderation action for ALL outcomes
306
+ const actionMap = {
307
+ approved: "auto_approve",
308
+ rejected: "auto_reject",
309
+ needs_review: "auto_needs_review",
310
+ };
311
+ // Log the original decision as the action, but the effective state as newState
312
+ store.createModerationAction({
313
+ imageUploadId,
314
+ actorUserId: "system",
315
+ action: actionMap[decision] || "auto_needs_review",
316
+ previousState: "pending",
317
+ newState: moderationState,
318
+ note: (isStrict && decision === "needs_review")
319
+ ? (reason || "") + " [strict_type: needs_review→rejected]"
320
+ : (reason || null),
321
+ });
322
+
323
+ // ── 6a. REJECTED (including strict needs_review→rejected) → stop, no R2 upload ──
324
+ if (effectiveDecision === "rejected") {
325
+ // Check if rejection thresholds are now crossed → persist auto-lock
326
+ _escalateAfterRejection(userId, imageUploadId);
327
+
328
+ return {
329
+ success: false,
330
+ imageUploadId,
331
+ error: "Image rejected by content moderation",
332
+ errorCode: "MODERATION_REJECTED",
333
+ };
334
+ }
335
+
336
+ // ── 6b. NEEDS_REVIEW → quarantine (only for non-strict types, future use) ──
337
+ if (!isStrict && effectiveDecision === "needs_review") {
338
+ const quarantineKey = _generateKey("quarantine", userId);
339
+ try {
340
+ await _uploadToR2(r2Client, r2Bucket, quarantineKey, processed);
341
+ store.updateImageUploadR2Key(imageUploadId, quarantineKey);
342
+ } catch (err) {
343
+ console.error("[image-upload-service] Quarantine R2 upload failed:", err.message);
344
+ store.updateImageUploadState(imageUploadId, "storage_failed");
345
+ return {
346
+ success: false,
347
+ imageUploadId,
348
+ error: "Failed to store image for review",
349
+ errorCode: "STORAGE_FAILED",
350
+ };
351
+ }
352
+
353
+ return {
354
+ success: false,
355
+ imageUploadId,
356
+ error: "Image is under review",
357
+ errorCode: "MODERATION_REVIEW",
358
+ };
359
+ }
360
+
361
+ // ── 7. APPROVED → upload to approved/ R2 prefix ──────────────────
362
+ const approvedKey = _generateKey("approved", userId);
363
+ try {
364
+ await _uploadToR2(r2Client, r2Bucket, approvedKey, processed);
365
+ } catch (err) {
366
+ console.error("[image-upload-service] R2 upload failed:", err.message);
367
+ store.updateImageUploadState(imageUploadId, "storage_failed");
368
+ return {
369
+ success: false,
370
+ imageUploadId,
371
+ error: "Image storage failed — please try again",
372
+ errorCode: "STORAGE_FAILED",
373
+ };
374
+ }
375
+
376
+ // ── 8. Update record with R2 key ─────────────────────────────────
377
+ store.updateImageUploadR2Key(imageUploadId, approvedKey);
378
+
379
+ // ── 9. Success ───────────────────────────────────────────────────
380
+ return {
381
+ success: true,
382
+ imageUploadId,
383
+ };
384
+ } catch (err) {
385
+ // Catch-all for truly unexpected errors
386
+ console.error("[image-upload-service] Unexpected error:", err);
387
+ return {
388
+ success: false,
389
+ error: "Internal error processing upload",
390
+ errorCode: "INTERNAL_ERROR",
391
+ };
392
+ }
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Exports
397
+ // ---------------------------------------------------------------------------
398
+ module.exports = { init, processUpload };