suparank 1.2.5 → 1.2.7
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/bin/suparank.js +85 -536
- package/credentials.example.json +36 -18
- package/mcp-client/config.js +37 -0
- package/mcp-client/handlers/action.js +33 -0
- package/mcp-client/handlers/backend.js +43 -0
- package/mcp-client/handlers/index.js +9 -0
- package/mcp-client/handlers/orchestrator.js +850 -0
- package/mcp-client/index.js +33 -0
- package/mcp-client/publishers/ghost.js +105 -0
- package/mcp-client/publishers/image.js +306 -0
- package/mcp-client/publishers/index.js +20 -0
- package/mcp-client/publishers/webhook.js +76 -0
- package/mcp-client/publishers/wordpress.js +220 -0
- package/mcp-client/server.js +220 -0
- package/mcp-client/services/api.js +101 -0
- package/mcp-client/services/credentials.js +149 -0
- package/mcp-client/services/index.js +11 -0
- package/mcp-client/services/project.js +40 -0
- package/mcp-client/services/session-state.js +201 -0
- package/mcp-client/services/stats.js +50 -0
- package/mcp-client/tools/definitions.js +679 -0
- package/mcp-client/tools/discovery.js +132 -0
- package/mcp-client/tools/index.js +22 -0
- package/mcp-client/utils/content.js +126 -0
- package/mcp-client/utils/formatting.js +71 -0
- package/mcp-client/utils/index.js +10 -0
- package/mcp-client/utils/logging.js +38 -0
- package/mcp-client/utils/paths.js +134 -0
- package/mcp-client/workflow/index.js +10 -0
- package/mcp-client/workflow/planner.js +513 -0
- package/package.json +8 -19
- package/mcp-client.js +0 -3693
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suparank MCP - Tool Discovery
|
|
3
|
+
*
|
|
4
|
+
* Functions for discovering and filtering available tools
|
|
5
|
+
* based on configuration and credentials
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TOOLS, ACTION_TOOLS, ORCHESTRATOR_TOOLS, VISIBLE_TOOLS } from './definitions.js'
|
|
9
|
+
import { hasCredential } from '../services/credentials.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get tools to show in ListToolsRequestSchema
|
|
13
|
+
* Only returns visible tools, with action tools marked as disabled if no credentials
|
|
14
|
+
* @returns {Array} Array of tool definitions for MCP clients
|
|
15
|
+
*/
|
|
16
|
+
export function getAvailableTools() {
|
|
17
|
+
const tools = []
|
|
18
|
+
|
|
19
|
+
// Add visible TOOLS (keyword_research only from main tools)
|
|
20
|
+
for (const tool of TOOLS) {
|
|
21
|
+
if (VISIBLE_TOOLS.includes(tool.name)) {
|
|
22
|
+
tools.push({
|
|
23
|
+
name: tool.name,
|
|
24
|
+
description: tool.description,
|
|
25
|
+
inputSchema: tool.inputSchema
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Add visible orchestrator tools
|
|
31
|
+
for (const tool of ORCHESTRATOR_TOOLS) {
|
|
32
|
+
if (VISIBLE_TOOLS.includes(tool.name)) {
|
|
33
|
+
tools.push({
|
|
34
|
+
name: tool.name,
|
|
35
|
+
description: tool.description,
|
|
36
|
+
inputSchema: tool.inputSchema
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Add visible action tools (only if credentials are configured)
|
|
42
|
+
for (const tool of ACTION_TOOLS) {
|
|
43
|
+
if (VISIBLE_TOOLS.includes(tool.name)) {
|
|
44
|
+
if (hasCredential(tool.requiresCredential)) {
|
|
45
|
+
tools.push({
|
|
46
|
+
name: tool.name,
|
|
47
|
+
description: tool.description,
|
|
48
|
+
inputSchema: tool.inputSchema
|
|
49
|
+
})
|
|
50
|
+
} else {
|
|
51
|
+
// Add disabled version with note
|
|
52
|
+
tools.push({
|
|
53
|
+
name: tool.name,
|
|
54
|
+
description: `[DISABLED - requires ${tool.requiresCredential} credentials] ${tool.description}`,
|
|
55
|
+
inputSchema: tool.inputSchema
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return tools
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get ALL tools (visible + hidden) for tool execution
|
|
66
|
+
* This is used by CallToolRequestSchema to find tools by name
|
|
67
|
+
* @returns {Array} Array of all tool definitions
|
|
68
|
+
*/
|
|
69
|
+
export function getAllTools() {
|
|
70
|
+
const tools = [...TOOLS]
|
|
71
|
+
|
|
72
|
+
// Add all orchestrator tools
|
|
73
|
+
for (const tool of ORCHESTRATOR_TOOLS) {
|
|
74
|
+
tools.push({
|
|
75
|
+
name: tool.name,
|
|
76
|
+
description: tool.description,
|
|
77
|
+
inputSchema: tool.inputSchema
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Add all action tools
|
|
82
|
+
for (const tool of ACTION_TOOLS) {
|
|
83
|
+
tools.push({
|
|
84
|
+
name: tool.name,
|
|
85
|
+
description: tool.description,
|
|
86
|
+
inputSchema: tool.inputSchema,
|
|
87
|
+
requiresCredential: tool.requiresCredential
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return tools
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Find a tool by name across all tool arrays
|
|
96
|
+
* @param {string} name - Tool name to find
|
|
97
|
+
* @returns {object|null} Tool definition or null
|
|
98
|
+
*/
|
|
99
|
+
export function findTool(name) {
|
|
100
|
+
// Check backend tools
|
|
101
|
+
const backendTool = TOOLS.find(t => t.name === name)
|
|
102
|
+
if (backendTool) return { ...backendTool, type: 'backend' }
|
|
103
|
+
|
|
104
|
+
// Check orchestrator tools
|
|
105
|
+
const orchestratorTool = ORCHESTRATOR_TOOLS.find(t => t.name === name)
|
|
106
|
+
if (orchestratorTool) return { ...orchestratorTool, type: 'orchestrator' }
|
|
107
|
+
|
|
108
|
+
// Check action tools
|
|
109
|
+
const actionTool = ACTION_TOOLS.find(t => t.name === name)
|
|
110
|
+
if (actionTool) return { ...actionTool, type: 'action' }
|
|
111
|
+
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if a tool name exists
|
|
117
|
+
* @param {string} name - Tool name to check
|
|
118
|
+
* @returns {boolean} Whether the tool exists
|
|
119
|
+
*/
|
|
120
|
+
export function toolExists(name) {
|
|
121
|
+
return findTool(name) !== null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get the tool type (backend, orchestrator, action)
|
|
126
|
+
* @param {string} name - Tool name
|
|
127
|
+
* @returns {string|null} Tool type or null
|
|
128
|
+
*/
|
|
129
|
+
export function getToolType(name) {
|
|
130
|
+
const tool = findTool(name)
|
|
131
|
+
return tool ? tool.type : null
|
|
132
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suparank MCP - Tools Module
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all tool definitions and discovery functions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Tool definitions
|
|
8
|
+
export {
|
|
9
|
+
TOOLS,
|
|
10
|
+
ACTION_TOOLS,
|
|
11
|
+
ORCHESTRATOR_TOOLS,
|
|
12
|
+
VISIBLE_TOOLS
|
|
13
|
+
} from './definitions.js'
|
|
14
|
+
|
|
15
|
+
// Tool discovery functions
|
|
16
|
+
export {
|
|
17
|
+
getAvailableTools,
|
|
18
|
+
getAllTools,
|
|
19
|
+
findTool,
|
|
20
|
+
toolExists,
|
|
21
|
+
getToolType
|
|
22
|
+
} from './discovery.js'
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suparank MCP - Content Utilities
|
|
3
|
+
*
|
|
4
|
+
* Content saving and folder management utilities
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'fs'
|
|
8
|
+
import * as path from 'path'
|
|
9
|
+
import { log, progress } from './logging.js'
|
|
10
|
+
import {
|
|
11
|
+
ensureContentDir,
|
|
12
|
+
getContentFolderSafe,
|
|
13
|
+
atomicWriteSync,
|
|
14
|
+
slugify
|
|
15
|
+
} from './paths.js'
|
|
16
|
+
import { sessionState } from '../services/session-state.js'
|
|
17
|
+
import { projectSlug } from '../config.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Save current article content to a folder on disk
|
|
21
|
+
* Creates a folder with article.md, metadata.json, and optional workflow.json
|
|
22
|
+
* @returns {string|null} Folder path on success, null on failure
|
|
23
|
+
*/
|
|
24
|
+
export function saveContentToFolder() {
|
|
25
|
+
if (!sessionState.title || !sessionState.article) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
ensureContentDir()
|
|
31
|
+
|
|
32
|
+
// Create folder name: YYYY-MM-DD-slug (slugify removes dangerous characters)
|
|
33
|
+
const date = new Date().toISOString().split('T')[0]
|
|
34
|
+
const slug = slugify(sessionState.title)
|
|
35
|
+
const folderName = `${date}-${slug}`
|
|
36
|
+
|
|
37
|
+
// Use safe path function to prevent any path traversal
|
|
38
|
+
const folderPath = getContentFolderSafe(folderName)
|
|
39
|
+
|
|
40
|
+
// Create folder if doesn't exist
|
|
41
|
+
if (!fs.existsSync(folderPath)) {
|
|
42
|
+
fs.mkdirSync(folderPath, { recursive: true })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Save markdown article
|
|
46
|
+
atomicWriteSync(
|
|
47
|
+
path.join(folderPath, 'article.md'),
|
|
48
|
+
sessionState.article
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// Save metadata
|
|
52
|
+
const metadata = {
|
|
53
|
+
title: sessionState.title,
|
|
54
|
+
keywords: sessionState.keywords || [],
|
|
55
|
+
metaDescription: sessionState.metaDescription || '',
|
|
56
|
+
metaTitle: sessionState.metaTitle || sessionState.title,
|
|
57
|
+
imageUrl: sessionState.imageUrl,
|
|
58
|
+
inlineImages: sessionState.inlineImages || [],
|
|
59
|
+
wordCount: sessionState.article.split(/\s+/).length,
|
|
60
|
+
createdAt: new Date().toISOString(),
|
|
61
|
+
projectSlug: projectSlug
|
|
62
|
+
}
|
|
63
|
+
atomicWriteSync(
|
|
64
|
+
path.join(folderPath, 'metadata.json'),
|
|
65
|
+
JSON.stringify(metadata, null, 2)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
// Save workflow state for resuming
|
|
69
|
+
if (sessionState.currentWorkflow) {
|
|
70
|
+
atomicWriteSync(
|
|
71
|
+
path.join(folderPath, 'workflow.json'),
|
|
72
|
+
JSON.stringify({
|
|
73
|
+
workflow: sessionState.currentWorkflow,
|
|
74
|
+
stepResults: sessionState.stepResults,
|
|
75
|
+
savedAt: new Date().toISOString()
|
|
76
|
+
}, null, 2)
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Store folder path in session
|
|
81
|
+
sessionState.contentFolder = folderPath
|
|
82
|
+
|
|
83
|
+
progress('Content', `Saved to folder: ${folderPath}`)
|
|
84
|
+
return folderPath
|
|
85
|
+
} catch (error) {
|
|
86
|
+
log(`Warning: Failed to save content to folder: ${error.message}`)
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract image prompts from article content
|
|
93
|
+
* Looks for [IMAGE: description] placeholders
|
|
94
|
+
* @param {string} content - Article content
|
|
95
|
+
* @returns {string[]} Array of image prompt descriptions
|
|
96
|
+
*/
|
|
97
|
+
export function extractImagePromptsFromArticle(content) {
|
|
98
|
+
const prompts = []
|
|
99
|
+
const regex = /\[IMAGE:\s*([^\]]+)\]/gi
|
|
100
|
+
let match
|
|
101
|
+
|
|
102
|
+
while ((match = regex.exec(content)) !== null) {
|
|
103
|
+
prompts.push(match[1].trim())
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return prompts
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Inject image URLs into article content
|
|
111
|
+
* Replaces [IMAGE: ...] placeholders with markdown images
|
|
112
|
+
* @param {string} content - Article content with placeholders
|
|
113
|
+
* @param {string[]} imageUrls - Array of image URLs to inject
|
|
114
|
+
* @returns {string} Content with images injected
|
|
115
|
+
*/
|
|
116
|
+
export function injectImagesIntoContent(content, imageUrls) {
|
|
117
|
+
let imageIndex = 0
|
|
118
|
+
return content.replace(/\[IMAGE:\s*([^\]]+)\]/gi, (match, description) => {
|
|
119
|
+
if (imageIndex < imageUrls.length) {
|
|
120
|
+
const imgUrl = imageUrls[imageIndex]
|
|
121
|
+
imageIndex++
|
|
122
|
+
return ``
|
|
123
|
+
}
|
|
124
|
+
return match // Keep placeholder if no image available
|
|
125
|
+
})
|
|
126
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suparank MCP - Formatting Utilities
|
|
3
|
+
*
|
|
4
|
+
* Content formatting and conversion utilities
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { marked } from 'marked'
|
|
8
|
+
import { log } from './logging.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert markdown to HTML using marked library
|
|
12
|
+
* Configured for WordPress/Ghost CMS compatibility
|
|
13
|
+
* @param {string} markdown - Markdown content
|
|
14
|
+
* @returns {string} HTML content
|
|
15
|
+
*/
|
|
16
|
+
export function markdownToHtml(markdown) {
|
|
17
|
+
// Configure marked for CMS compatibility
|
|
18
|
+
marked.setOptions({
|
|
19
|
+
gfm: true, // GitHub Flavored Markdown
|
|
20
|
+
breaks: true, // Convert line breaks to <br>
|
|
21
|
+
pedantic: false,
|
|
22
|
+
silent: true // Don't throw on errors
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
return marked.parse(markdown)
|
|
27
|
+
} catch (error) {
|
|
28
|
+
log(`Markdown conversion error: ${error.message}`)
|
|
29
|
+
// Fallback: return markdown wrapped in <p> tags
|
|
30
|
+
return `<p>${markdown.replace(/\n\n+/g, '</p><p>')}</p>`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert aspect ratio string to fal.ai image size
|
|
36
|
+
* @param {string} ratio - Aspect ratio (e.g., '16:9', '1:1')
|
|
37
|
+
* @returns {string} fal.ai size identifier
|
|
38
|
+
*/
|
|
39
|
+
export function aspectRatioToSize(ratio) {
|
|
40
|
+
const sizes = {
|
|
41
|
+
'1:1': 'square',
|
|
42
|
+
'16:9': 'landscape_16_9',
|
|
43
|
+
'9:16': 'portrait_16_9',
|
|
44
|
+
'4:3': 'landscape_4_3',
|
|
45
|
+
'3:4': 'portrait_4_3'
|
|
46
|
+
}
|
|
47
|
+
return sizes[ratio] || 'landscape_16_9'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Truncate text to a maximum length
|
|
52
|
+
* @param {string} text - Text to truncate
|
|
53
|
+
* @param {number} maxLength - Maximum length
|
|
54
|
+
* @returns {string} Truncated text
|
|
55
|
+
*/
|
|
56
|
+
export function truncate(text, maxLength = 100) {
|
|
57
|
+
if (!text || text.length <= maxLength) return text
|
|
58
|
+
return text.substring(0, maxLength) + '...'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sanitize a string for use as a slug/filename
|
|
63
|
+
* @param {string} str - String to sanitize
|
|
64
|
+
* @returns {string} Sanitized string
|
|
65
|
+
*/
|
|
66
|
+
export function slugify(str) {
|
|
67
|
+
return str
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
70
|
+
.replace(/^-|-$/g, '')
|
|
71
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suparank MCP - Logging Utilities
|
|
3
|
+
*
|
|
4
|
+
* Standardized logging to stderr (stdout is for MCP protocol)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Log message to stderr with [suparank] prefix
|
|
9
|
+
* @param {...any} args - Arguments to log
|
|
10
|
+
*/
|
|
11
|
+
export function log(...args) {
|
|
12
|
+
console.error('[suparank]', ...args)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Structured progress logging for user visibility
|
|
17
|
+
* @param {string} step - Current step name
|
|
18
|
+
* @param {string} message - Progress message
|
|
19
|
+
*/
|
|
20
|
+
export function progress(step, message) {
|
|
21
|
+
console.error(`[suparank] ${step}: ${message}`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Log warning message
|
|
26
|
+
* @param {...any} args - Arguments to log
|
|
27
|
+
*/
|
|
28
|
+
export function warn(...args) {
|
|
29
|
+
console.error('[suparank] WARNING:', ...args)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Log error message
|
|
34
|
+
* @param {...any} args - Arguments to log
|
|
35
|
+
*/
|
|
36
|
+
export function error(...args) {
|
|
37
|
+
console.error('[suparank] ERROR:', ...args)
|
|
38
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suparank MCP - Path Utilities
|
|
3
|
+
*
|
|
4
|
+
* Safe path handling and directory management
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from 'fs'
|
|
8
|
+
import * as path from 'path'
|
|
9
|
+
import * as os from 'os'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Get the Suparank configuration directory
|
|
13
|
+
* @returns {string} Path to ~/.suparank
|
|
14
|
+
*/
|
|
15
|
+
export function getSuparankDir() {
|
|
16
|
+
return path.join(os.homedir(), '.suparank')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get the content storage directory
|
|
21
|
+
* @returns {string} Path to ~/.suparank/content
|
|
22
|
+
*/
|
|
23
|
+
export function getContentDir() {
|
|
24
|
+
return path.join(getSuparankDir(), 'content')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get session file path
|
|
29
|
+
* @returns {string} Path to ~/.suparank/session.json
|
|
30
|
+
*/
|
|
31
|
+
export function getSessionFilePath() {
|
|
32
|
+
return path.join(getSuparankDir(), 'session.json')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get credentials file path
|
|
37
|
+
* @returns {string} Path to ~/.suparank/credentials.json
|
|
38
|
+
*/
|
|
39
|
+
export function getCredentialsFilePath() {
|
|
40
|
+
return path.join(getSuparankDir(), 'credentials.json')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get stats file path
|
|
45
|
+
* @returns {string} Path to ~/.suparank/stats.json
|
|
46
|
+
*/
|
|
47
|
+
export function getStatsFilePath() {
|
|
48
|
+
return path.join(getSuparankDir(), 'stats.json')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Ensure the Suparank directory exists
|
|
53
|
+
*/
|
|
54
|
+
export function ensureSuparankDir() {
|
|
55
|
+
const dir = getSuparankDir()
|
|
56
|
+
if (!fs.existsSync(dir)) {
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ensure the content directory exists
|
|
63
|
+
*/
|
|
64
|
+
export function ensureContentDir() {
|
|
65
|
+
const dir = getContentDir()
|
|
66
|
+
if (!fs.existsSync(dir)) {
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sanitize and validate a path to prevent traversal attacks
|
|
73
|
+
* @param {string} userPath - User-provided path segment
|
|
74
|
+
* @param {string} allowedBase - Base directory that paths must stay within
|
|
75
|
+
* @returns {string} Resolved safe path
|
|
76
|
+
* @throws {Error} If path would escape the allowed base
|
|
77
|
+
*/
|
|
78
|
+
export function sanitizePath(userPath, allowedBase) {
|
|
79
|
+
// Remove any null bytes (common attack vector)
|
|
80
|
+
const cleanPath = userPath.replace(/\0/g, '')
|
|
81
|
+
|
|
82
|
+
// Resolve to absolute path
|
|
83
|
+
const resolved = path.resolve(allowedBase, cleanPath)
|
|
84
|
+
|
|
85
|
+
// Ensure the resolved path starts with the allowed base
|
|
86
|
+
const normalizedBase = path.normalize(allowedBase + path.sep)
|
|
87
|
+
const normalizedResolved = path.normalize(resolved + path.sep)
|
|
88
|
+
|
|
89
|
+
if (!normalizedResolved.startsWith(normalizedBase)) {
|
|
90
|
+
throw new Error(`Path traversal detected: "${userPath}" would escape allowed directory`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return resolved
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get content folder path safely
|
|
98
|
+
* @param {string} folderName - Folder name (article ID or title slug)
|
|
99
|
+
* @returns {string} Safe path to content folder
|
|
100
|
+
*/
|
|
101
|
+
export function getContentFolderSafe(folderName) {
|
|
102
|
+
const contentDir = getContentDir()
|
|
103
|
+
return sanitizePath(folderName, contentDir)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Generate a slug from title for folder naming
|
|
108
|
+
* @param {string} text - Text to slugify
|
|
109
|
+
* @returns {string} URL-safe slug
|
|
110
|
+
*/
|
|
111
|
+
export function slugify(text) {
|
|
112
|
+
return text
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
115
|
+
.replace(/^-|-$/g, '')
|
|
116
|
+
.substring(0, 50)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Atomic file write - prevents corruption on concurrent writes
|
|
121
|
+
* @param {string} filePath - Target file path
|
|
122
|
+
* @param {string} data - Data to write
|
|
123
|
+
*/
|
|
124
|
+
export function atomicWriteSync(filePath, data) {
|
|
125
|
+
const tmpFile = filePath + '.tmp.' + process.pid
|
|
126
|
+
try {
|
|
127
|
+
fs.writeFileSync(tmpFile, data)
|
|
128
|
+
fs.renameSync(tmpFile, filePath) // Atomic on POSIX
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// Clean up temp file if rename failed
|
|
131
|
+
try { fs.unlinkSync(tmpFile) } catch (e) { /* ignore */ }
|
|
132
|
+
throw error
|
|
133
|
+
}
|
|
134
|
+
}
|