taskover-mcp 1.1.0 → 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 +6 -53
- package/auth-gate.js +253 -0
- package/cloud-adapter.js +38 -15
- 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 -1433
- package/migrate-json-to-sqlite.js +256 -0
- package/package.json +29 -21
- 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,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 };
|