suparank 1.2.4 → 1.2.5
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/mcp-client.js +121 -9
- package/package.json +1 -1
package/mcp-client.js
CHANGED
|
@@ -35,6 +35,17 @@ const projectSlug = process.argv[2]
|
|
|
35
35
|
const apiKey = process.argv[3]
|
|
36
36
|
const apiUrl = process.env.SUPARANK_API_URL || 'https://api.suparank.io'
|
|
37
37
|
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// EXTERNAL API ENDPOINTS - Configurable via environment variables
|
|
40
|
+
// ============================================================================
|
|
41
|
+
const API_ENDPOINTS = {
|
|
42
|
+
// Image generation providers
|
|
43
|
+
fal: process.env.FAL_API_URL || 'https://fal.run/fal-ai/nano-banana-pro',
|
|
44
|
+
gemini: process.env.GEMINI_API_URL || 'https://generativelanguage.googleapis.com/v1beta/models',
|
|
45
|
+
wiro: process.env.WIRO_API_URL || 'https://api.wiro.ai/v1',
|
|
46
|
+
wiroTaskDetail: process.env.WIRO_TASK_URL || 'https://api.wiro.ai/v1/Task/Detail'
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
if (!projectSlug) {
|
|
39
50
|
console.error('Error: Project slug is required')
|
|
40
51
|
console.error('Usage: node mcp-client.js <project-slug> <api-key>')
|
|
@@ -160,7 +171,9 @@ function loadStats() {
|
|
|
160
171
|
if (fs.existsSync(file)) {
|
|
161
172
|
return JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
162
173
|
}
|
|
163
|
-
} catch (e) {
|
|
174
|
+
} catch (e) {
|
|
175
|
+
log(`Warning: Could not load stats: ${e.message}`)
|
|
176
|
+
}
|
|
164
177
|
return { tool_calls: 0, images_generated: 0, articles_created: 0, words_written: 0 }
|
|
165
178
|
}
|
|
166
179
|
|
|
@@ -196,6 +209,46 @@ function slugify(text) {
|
|
|
196
209
|
.substring(0, 50)
|
|
197
210
|
}
|
|
198
211
|
|
|
212
|
+
// ============================================================================
|
|
213
|
+
// PATH SANITIZATION - Prevents path traversal attacks
|
|
214
|
+
// ============================================================================
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Sanitize and validate a path to prevent traversal attacks
|
|
218
|
+
* @param {string} userPath - User-provided path segment
|
|
219
|
+
* @param {string} allowedBase - Base directory that paths must stay within
|
|
220
|
+
* @returns {string} - Resolved safe path
|
|
221
|
+
* @throws {Error} - If path would escape the allowed base
|
|
222
|
+
*/
|
|
223
|
+
function sanitizePath(userPath, allowedBase) {
|
|
224
|
+
// Remove any null bytes (common attack vector)
|
|
225
|
+
const cleanPath = userPath.replace(/\0/g, '')
|
|
226
|
+
|
|
227
|
+
// Resolve to absolute path
|
|
228
|
+
const resolved = path.resolve(allowedBase, cleanPath)
|
|
229
|
+
|
|
230
|
+
// Ensure the resolved path starts with the allowed base
|
|
231
|
+
// Adding path.sep ensures we don't match partial directory names
|
|
232
|
+
const normalizedBase = path.normalize(allowedBase + path.sep)
|
|
233
|
+
const normalizedResolved = path.normalize(resolved + path.sep)
|
|
234
|
+
|
|
235
|
+
if (!normalizedResolved.startsWith(normalizedBase)) {
|
|
236
|
+
throw new Error(`Path traversal detected: "${userPath}" would escape allowed directory`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return resolved
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get content folder path safely
|
|
244
|
+
* @param {string} folderName - Folder name (article ID or title slug)
|
|
245
|
+
* @returns {string} - Safe path to content folder
|
|
246
|
+
*/
|
|
247
|
+
function getContentFolderSafe(folderName) {
|
|
248
|
+
const contentDir = path.join(getSuparankDir(), 'content')
|
|
249
|
+
return sanitizePath(folderName, contentDir)
|
|
250
|
+
}
|
|
251
|
+
|
|
199
252
|
/**
|
|
200
253
|
* Atomic file write - prevents corruption on concurrent writes
|
|
201
254
|
*/
|
|
@@ -356,9 +409,56 @@ function loadSession() {
|
|
|
356
409
|
return false
|
|
357
410
|
}
|
|
358
411
|
|
|
412
|
+
// ============================================================================
|
|
413
|
+
// SESSION MUTEX - Prevents race conditions on concurrent session writes
|
|
414
|
+
// ============================================================================
|
|
415
|
+
|
|
416
|
+
let sessionLock = false
|
|
417
|
+
const sessionLockQueue = []
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Acquire session lock for safe concurrent access
|
|
421
|
+
* @returns {Promise<void>}
|
|
422
|
+
*/
|
|
423
|
+
async function acquireSessionLock() {
|
|
424
|
+
if (!sessionLock) {
|
|
425
|
+
sessionLock = true
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
return new Promise(resolve => {
|
|
429
|
+
sessionLockQueue.push(resolve)
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Release session lock, allowing next queued operation
|
|
435
|
+
*/
|
|
436
|
+
function releaseSessionLock() {
|
|
437
|
+
if (sessionLockQueue.length > 0) {
|
|
438
|
+
const next = sessionLockQueue.shift()
|
|
439
|
+
next()
|
|
440
|
+
} else {
|
|
441
|
+
sessionLock = false
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Safe session save with mutex - use this for concurrent operations
|
|
447
|
+
* @returns {Promise<void>}
|
|
448
|
+
*/
|
|
449
|
+
async function saveSessionSafe() {
|
|
450
|
+
await acquireSessionLock()
|
|
451
|
+
try {
|
|
452
|
+
saveSession()
|
|
453
|
+
} finally {
|
|
454
|
+
releaseSessionLock()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
359
458
|
/**
|
|
360
459
|
* Save session state to file (persists across MCP restarts)
|
|
361
460
|
* Uses atomic write to prevent corruption
|
|
461
|
+
* NOTE: For concurrent operations, use saveSessionSafe() instead
|
|
362
462
|
*/
|
|
363
463
|
function saveSession() {
|
|
364
464
|
try {
|
|
@@ -457,11 +557,13 @@ function saveContentToFolder() {
|
|
|
457
557
|
try {
|
|
458
558
|
ensureContentDir()
|
|
459
559
|
|
|
460
|
-
// Create folder name: YYYY-MM-DD-slug
|
|
560
|
+
// Create folder name: YYYY-MM-DD-slug (slugify removes dangerous characters)
|
|
461
561
|
const date = new Date().toISOString().split('T')[0]
|
|
462
562
|
const slug = slugify(sessionState.title)
|
|
463
563
|
const folderName = `${date}-${slug}`
|
|
464
|
-
|
|
564
|
+
|
|
565
|
+
// Use safe path function to prevent any path traversal
|
|
566
|
+
const folderPath = getContentFolderSafe(folderName)
|
|
465
567
|
|
|
466
568
|
// Create folder if doesn't exist
|
|
467
569
|
if (!fs.existsSync(folderPath)) {
|
|
@@ -2558,8 +2660,18 @@ Once loaded, you can run optimization tools:
|
|
|
2558
2660
|
}
|
|
2559
2661
|
}
|
|
2560
2662
|
|
|
2561
|
-
|
|
2562
|
-
|
|
2663
|
+
// Sanitize folder_name to prevent path traversal attacks
|
|
2664
|
+
let folderPath
|
|
2665
|
+
try {
|
|
2666
|
+
folderPath = getContentFolderSafe(folder_name)
|
|
2667
|
+
} catch (error) {
|
|
2668
|
+
return {
|
|
2669
|
+
content: [{
|
|
2670
|
+
type: 'text',
|
|
2671
|
+
text: `❌ Invalid folder name: ${error.message}`
|
|
2672
|
+
}]
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2563
2675
|
|
|
2564
2676
|
if (!fs.existsSync(folderPath)) {
|
|
2565
2677
|
return {
|
|
@@ -2705,7 +2817,7 @@ async function executeImageGeneration(args) {
|
|
|
2705
2817
|
switch (provider) {
|
|
2706
2818
|
case 'fal': {
|
|
2707
2819
|
// fal.ai Nano Banana Pro (gemini-3-pro-image)
|
|
2708
|
-
const response = await fetchWithRetry(
|
|
2820
|
+
const response = await fetchWithRetry(API_ENDPOINTS.fal, {
|
|
2709
2821
|
method: 'POST',
|
|
2710
2822
|
headers: {
|
|
2711
2823
|
'Authorization': `Key ${config.api_key}`,
|
|
@@ -2766,7 +2878,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
|
|
|
2766
2878
|
// Google Gemini 3 Pro Image (Nano Banana Pro) - generateContent API
|
|
2767
2879
|
const model = config.model || 'gemini-3-pro-image-preview'
|
|
2768
2880
|
const response = await fetch(
|
|
2769
|
-
|
|
2881
|
+
`${API_ENDPOINTS.gemini}/${model}:generateContent`,
|
|
2770
2882
|
{
|
|
2771
2883
|
method: 'POST',
|
|
2772
2884
|
headers: {
|
|
@@ -2837,7 +2949,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
|
|
|
2837
2949
|
|
|
2838
2950
|
// Submit task
|
|
2839
2951
|
log(`Submitting wiro.ai task for model: ${model}`)
|
|
2840
|
-
const submitResponse = await fetch(
|
|
2952
|
+
const submitResponse = await fetch(`${API_ENDPOINTS.wiro}/Run/${model}`, {
|
|
2841
2953
|
method: 'POST',
|
|
2842
2954
|
headers: {
|
|
2843
2955
|
'Content-Type': 'application/json',
|
|
@@ -2880,7 +2992,7 @@ ${imageNumber < totalImages ? `\n**Next:** Generate ${totalImages - imageNumber}
|
|
|
2880
2992
|
.update(pollSignatureData)
|
|
2881
2993
|
.digest('hex')
|
|
2882
2994
|
|
|
2883
|
-
const pollResponse = await fetch(
|
|
2995
|
+
const pollResponse = await fetch(API_ENDPOINTS.wiroTaskDetail, {
|
|
2884
2996
|
method: 'POST',
|
|
2885
2997
|
headers: {
|
|
2886
2998
|
'Content-Type': 'application/json',
|
package/package.json
CHANGED