@jgardner04/ghost-mcp-server 1.0.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/LICENSE +21 -0
- package/README.md +118 -0
- package/package.json +89 -0
- package/src/config/mcp-config.js +131 -0
- package/src/controllers/imageController.js +271 -0
- package/src/controllers/postController.js +46 -0
- package/src/controllers/tagController.js +79 -0
- package/src/errors/index.js +447 -0
- package/src/index.js +110 -0
- package/src/mcp_server.js +509 -0
- package/src/mcp_server_enhanced.js +675 -0
- package/src/mcp_server_improved.js +657 -0
- package/src/middleware/errorMiddleware.js +489 -0
- package/src/resources/ResourceManager.js +666 -0
- package/src/routes/imageRoutes.js +33 -0
- package/src/routes/postRoutes.js +72 -0
- package/src/routes/tagRoutes.js +47 -0
- package/src/services/ghostService.js +221 -0
- package/src/services/ghostServiceImproved.js +489 -0
- package/src/services/imageProcessingService.js +96 -0
- package/src/services/postService.js +174 -0
- package/src/utils/logger.js +153 -0
- package/src/utils/urlValidator.js +169 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Jonathan Gardner
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Ghost MCP Server
|
|
2
|
+
|
|
3
|
+
This project (`ghost-mcp-server`) implements a **Model Context Protocol (MCP) Server** that allows an MCP client (like Cursor or Claude Desktop) to interact with a Ghost CMS instance via defined tools.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 14.0.0 or higher
|
|
8
|
+
- Ghost Admin API URL and Key
|
|
9
|
+
|
|
10
|
+
## Ghost MCP Server Details
|
|
11
|
+
|
|
12
|
+
This server exposes Ghost CMS management functions as MCP tools, allowing an AI client like Cursor or Claude Desktop to manage a Ghost blog.
|
|
13
|
+
|
|
14
|
+
An MCP client can discover these resources and tools by querying the running MCP server (typically listening on port 3001 by default) at its root endpoint (e.g., `http://localhost:3001/`). The server responds with its capabilities according to the Model Context Protocol specification.
|
|
15
|
+
|
|
16
|
+
### Resources Defined
|
|
17
|
+
|
|
18
|
+
- **`ghost/tag`**: Represents a tag in Ghost CMS. Contains `id`, `name`, `slug`, `description`.
|
|
19
|
+
- **`ghost/post`**: Represents a post in Ghost CMS. Contains `id`, `title`, `slug`, `html`, `status`, `feature_image`, `published_at`, `tags` (array of `ghost/tag`), metadata fields, etc.
|
|
20
|
+
|
|
21
|
+
_(Refer to `src/mcp_server.js` for full resource schemas.)_
|
|
22
|
+
|
|
23
|
+
### Tools Defined
|
|
24
|
+
|
|
25
|
+
Below is a guide for using the available MCP tools:
|
|
26
|
+
|
|
27
|
+
1. **`ghost_create_tag`**
|
|
28
|
+
|
|
29
|
+
- **Purpose**: Creates a new tag.
|
|
30
|
+
- **Inputs**:
|
|
31
|
+
- `name` (string, required): The name for the new tag.
|
|
32
|
+
- `description` (string, optional): A description for the tag.
|
|
33
|
+
- `slug` (string, optional): A URL-friendly slug (auto-generated if omitted).
|
|
34
|
+
- **Output**: The created `ghost/tag` resource.
|
|
35
|
+
|
|
36
|
+
2. **`ghost_get_tags`**
|
|
37
|
+
|
|
38
|
+
- **Purpose**: Retrieves existing tags. Can be used to find a tag ID or check if a tag exists before creation.
|
|
39
|
+
- **Inputs**:
|
|
40
|
+
- `name` (string, optional): Filter tags by exact name.
|
|
41
|
+
- **Output**: An array of `ghost/tag` resources matching the filter (or all tags if no name is provided).
|
|
42
|
+
|
|
43
|
+
3. **`ghost_upload_image`**
|
|
44
|
+
|
|
45
|
+
- **Purpose**: Uploads an image to Ghost for use, typically as a post's featured image.
|
|
46
|
+
- **Inputs**:
|
|
47
|
+
- `imageUrl` (string URL, required): A publicly accessible URL of the image to upload.
|
|
48
|
+
- `alt` (string, optional): Alt text for the image (a default is generated if omitted).
|
|
49
|
+
- **Output**: An object containing the final `url` (the Ghost URL for the uploaded image) and the determined `alt` text.
|
|
50
|
+
- **Usage Note**: Call this tool first to get a Ghost image URL before creating a post that needs a featured image.
|
|
51
|
+
|
|
52
|
+
4. **`ghost_create_post`**
|
|
53
|
+
- **Purpose**: Creates a new post.
|
|
54
|
+
- **Inputs**:
|
|
55
|
+
- `title` (string, required): The title of the post.
|
|
56
|
+
- `html` (string, required): The main content of the post in HTML format.
|
|
57
|
+
- `status` (string, optional, default: 'draft'): Set status to 'draft', 'published', or 'scheduled'.
|
|
58
|
+
- `tags` (array of strings, optional): List of tag _names_ to associate. Tags will be looked up or created automatically.
|
|
59
|
+
- `published_at` (string ISO date, optional): Date/time to publish or schedule. Required if status is 'scheduled'.
|
|
60
|
+
- `custom_excerpt` (string, optional): A short summary.
|
|
61
|
+
- `feature_image` (string URL, optional): The URL of the featured image (use the `url` output from `ghost_upload_image`).
|
|
62
|
+
- `feature_image_alt` (string, optional): Alt text for the feature image.
|
|
63
|
+
- `feature_image_caption` (string, optional): Caption for the feature image.
|
|
64
|
+
- `meta_title` (string, optional): Custom SEO title.
|
|
65
|
+
- `meta_description` (string, optional): Custom SEO description.
|
|
66
|
+
- **Output**: The created `ghost/post` resource.
|
|
67
|
+
|
|
68
|
+
## Installation and Running
|
|
69
|
+
|
|
70
|
+
1. **Clone the Repository**:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
git clone <repository_url>
|
|
74
|
+
cd ghost-mcp-server
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
2. **Install Dependencies**:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm install
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
3. **Configure Environment Variables**:
|
|
84
|
+
Create a `.env` file in the project root and add your Ghost Admin API credentials:
|
|
85
|
+
|
|
86
|
+
```dotenv
|
|
87
|
+
# Required:
|
|
88
|
+
GHOST_ADMIN_API_URL=https://your-ghost-site.com
|
|
89
|
+
GHOST_ADMIN_API_KEY=your_admin_api_key
|
|
90
|
+
|
|
91
|
+
# If using 1Password CLI for secrets:
|
|
92
|
+
# You might store the API key in 1Password and use `op run --env-file=.env -- ...`
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- Find your Ghost Admin API URL and Key in your Ghost Admin settings under Integrations -> Custom Integrations.
|
|
96
|
+
|
|
97
|
+
4. **Run the Server**:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm start
|
|
101
|
+
# OR directly:
|
|
102
|
+
# node src/index.js
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
This command will start the MCP server.
|
|
106
|
+
|
|
107
|
+
5. **Development Mode (using nodemon)**:
|
|
108
|
+
For development with automatic restarting:
|
|
109
|
+
```bash
|
|
110
|
+
npm run dev
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Troubleshooting
|
|
114
|
+
|
|
115
|
+
- **401 Unauthorized Error from Ghost:** Check that your `GHOST_ADMIN_API_URL` and `GHOST_ADMIN_API_KEY` in the `.env` file are correct and that the Custom Integration in Ghost is enabled.
|
|
116
|
+
- **MCP Server Connection Issues:** Ensure the MCP server is running (check console logs). Verify the port (`MCP_PORT`, default 3001) is not blocked by a firewall. Check that the client is connecting to the correct address and port.
|
|
117
|
+
- **Tool Execution Errors:** Check the server console logs for detailed error messages from the specific tool implementation (e.g., `ghost_create_post`, `ghost_upload_image`). Common issues include invalid input (check against tool schemas in `src/mcp_server.js` and the README guide), problems downloading from `imageUrl`, image processing failures, or upstream errors from the Ghost API.
|
|
118
|
+
- **Dependency Installation Issues:** Ensure you have a compatible Node.js version installed (see Requirements section). Try removing `node_modules` and `package-lock.json` and running `npm install` again.
|
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jgardner04/ghost-mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
|
|
5
|
+
"author": "Jonathan Gardner",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js",
|
|
10
|
+
"./mcp": "./src/mcp_server_improved.js"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"ghost-mcp-server": "src/index.js",
|
|
14
|
+
"ghost-mcp": "src/mcp_server_improved.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18.0.0"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/jgardner04/Ghost-MCP-Server.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/jgardner04/Ghost-MCP-Server#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/jgardner04/Ghost-MCP-Server/issues"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "node scripts/dev.js",
|
|
34
|
+
"list": "node scripts/dev.js list",
|
|
35
|
+
"generate": "node scripts/dev.js generate",
|
|
36
|
+
"parse-prd": "node scripts/dev.js parse-prd",
|
|
37
|
+
"build": "mkdir -p build && cp -r src/* build/",
|
|
38
|
+
"start": "node src/index.js",
|
|
39
|
+
"start:mcp": "node src/mcp_server_improved.js",
|
|
40
|
+
"start:mcp:stdio": "MCP_TRANSPORT=stdio node src/mcp_server_improved.js",
|
|
41
|
+
"start:mcp:http": "MCP_TRANSPORT=http node src/mcp_server_improved.js",
|
|
42
|
+
"start:mcp:websocket": "MCP_TRANSPORT=websocket node src/mcp_server_improved.js"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.24.0",
|
|
47
|
+
"@tryghost/admin-api": "^1.13.12",
|
|
48
|
+
"axios": "^1.12.1",
|
|
49
|
+
"boxen": "^7.1.1",
|
|
50
|
+
"chalk": "^5.3.0",
|
|
51
|
+
"cli-table3": "^0.6.3",
|
|
52
|
+
"commander": "^11.1.0",
|
|
53
|
+
"dotenv": "^16.5.0",
|
|
54
|
+
"express": "^5.2.1",
|
|
55
|
+
"express-rate-limit": "^8.0.1",
|
|
56
|
+
"express-validator": "^7.3.1",
|
|
57
|
+
"figlet": "^1.7.0",
|
|
58
|
+
"gradient-string": "^2.0.2",
|
|
59
|
+
"helmet": "^8.1.0",
|
|
60
|
+
"joi": "^18.0.1",
|
|
61
|
+
"multer": "^2.0.2",
|
|
62
|
+
"openai": "^4.86.1",
|
|
63
|
+
"ora": "^7.0.1",
|
|
64
|
+
"sanitize-html": "^2.17.0",
|
|
65
|
+
"sharp": "^0.34.1",
|
|
66
|
+
"winston": "^3.17.0"
|
|
67
|
+
},
|
|
68
|
+
"keywords": [
|
|
69
|
+
"ghost",
|
|
70
|
+
"cms",
|
|
71
|
+
"mcp",
|
|
72
|
+
"model-context-protocol",
|
|
73
|
+
"ai",
|
|
74
|
+
"claude",
|
|
75
|
+
"ghost-cms",
|
|
76
|
+
"admin-api",
|
|
77
|
+
"blog",
|
|
78
|
+
"publishing"
|
|
79
|
+
],
|
|
80
|
+
"publishConfig": {
|
|
81
|
+
"access": "public"
|
|
82
|
+
},
|
|
83
|
+
"license": "MIT",
|
|
84
|
+
"devDependencies": {
|
|
85
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
86
|
+
"@semantic-release/git": "^10.0.1",
|
|
87
|
+
"semantic-release": "^25.0.2"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
|
|
3
|
+
dotenv.config();
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MCP Server Configuration
|
|
7
|
+
*
|
|
8
|
+
* Transport Options:
|
|
9
|
+
* - 'stdio': Best for CLI tools and direct process communication
|
|
10
|
+
* - 'http'/'sse': Good for web clients, supports CORS
|
|
11
|
+
* - 'websocket': Best for real-time bidirectional communication
|
|
12
|
+
*/
|
|
13
|
+
export const mcpConfig = {
|
|
14
|
+
// Transport configuration
|
|
15
|
+
transport: {
|
|
16
|
+
type: process.env.MCP_TRANSPORT || 'http', // 'stdio', 'http', 'sse', 'websocket'
|
|
17
|
+
port: parseInt(process.env.MCP_PORT || '3001'),
|
|
18
|
+
|
|
19
|
+
// HTTP/SSE specific options
|
|
20
|
+
cors: process.env.MCP_CORS || '*',
|
|
21
|
+
sseEndpoint: process.env.MCP_SSE_ENDPOINT || '/mcp/sse',
|
|
22
|
+
|
|
23
|
+
// WebSocket specific options
|
|
24
|
+
wsPath: process.env.MCP_WS_PATH || '/',
|
|
25
|
+
wsHeartbeatInterval: parseInt(process.env.MCP_WS_HEARTBEAT || '30000'),
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
// Server metadata
|
|
29
|
+
metadata: {
|
|
30
|
+
name: process.env.MCP_SERVER_NAME || 'Ghost CMS Manager',
|
|
31
|
+
description: process.env.MCP_SERVER_DESC || 'MCP Server to manage a Ghost CMS instance using the Admin API.',
|
|
32
|
+
version: process.env.MCP_SERVER_VERSION || '1.0.0',
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// Error handling
|
|
36
|
+
errorHandling: {
|
|
37
|
+
includeStackTrace: process.env.NODE_ENV === 'development',
|
|
38
|
+
maxRetries: parseInt(process.env.MCP_MAX_RETRIES || '3'),
|
|
39
|
+
retryDelay: parseInt(process.env.MCP_RETRY_DELAY || '1000'),
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// Logging
|
|
43
|
+
logging: {
|
|
44
|
+
level: process.env.MCP_LOG_LEVEL || 'info', // 'debug', 'info', 'warn', 'error'
|
|
45
|
+
format: process.env.MCP_LOG_FORMAT || 'json', // 'json', 'text'
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Security
|
|
49
|
+
security: {
|
|
50
|
+
// Add API key authentication if needed
|
|
51
|
+
apiKey: process.env.MCP_API_KEY,
|
|
52
|
+
allowedOrigins: process.env.MCP_ALLOWED_ORIGINS?.split(',') || ['*'],
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get transport-specific configuration
|
|
58
|
+
*/
|
|
59
|
+
export function getTransportConfig() {
|
|
60
|
+
const { transport } = mcpConfig;
|
|
61
|
+
|
|
62
|
+
switch (transport.type) {
|
|
63
|
+
case 'stdio':
|
|
64
|
+
return {
|
|
65
|
+
type: 'stdio',
|
|
66
|
+
// No additional config needed for stdio
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
case 'http':
|
|
70
|
+
case 'sse':
|
|
71
|
+
return {
|
|
72
|
+
type: 'sse',
|
|
73
|
+
port: transport.port,
|
|
74
|
+
cors: transport.cors,
|
|
75
|
+
endpoint: transport.sseEndpoint,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
case 'websocket':
|
|
79
|
+
return {
|
|
80
|
+
type: 'websocket',
|
|
81
|
+
port: transport.port,
|
|
82
|
+
path: transport.wsPath,
|
|
83
|
+
heartbeatInterval: transport.wsHeartbeatInterval,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
default:
|
|
87
|
+
throw new Error(`Unknown transport type: ${transport.type}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Validate configuration
|
|
93
|
+
*/
|
|
94
|
+
export function validateConfig() {
|
|
95
|
+
const errors = [];
|
|
96
|
+
|
|
97
|
+
// Check if transport configuration exists
|
|
98
|
+
if (!mcpConfig.transport) {
|
|
99
|
+
errors.push('Missing transport configuration');
|
|
100
|
+
} else {
|
|
101
|
+
// Check transport type
|
|
102
|
+
const validTransports = ['stdio', 'http', 'sse', 'websocket'];
|
|
103
|
+
if (!mcpConfig.transport.type || !validTransports.includes(mcpConfig.transport.type)) {
|
|
104
|
+
errors.push(`Invalid transport type: ${mcpConfig.transport.type}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check port for network transports
|
|
108
|
+
if (mcpConfig.transport.type && ['http', 'sse', 'websocket'].includes(mcpConfig.transport.type)) {
|
|
109
|
+
if (!mcpConfig.transport.port || mcpConfig.transport.port < 1 || mcpConfig.transport.port > 65535) {
|
|
110
|
+
errors.push(`Invalid port: ${mcpConfig.transport.port}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check Ghost configuration
|
|
116
|
+
if (!process.env.GHOST_ADMIN_API_URL) {
|
|
117
|
+
errors.push('Missing GHOST_ADMIN_API_URL environment variable');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!process.env.GHOST_ADMIN_API_KEY) {
|
|
121
|
+
errors.push('Missing GHOST_ADMIN_API_KEY environment variable');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (errors.length > 0) {
|
|
125
|
+
throw new Error(`Configuration errors:\n${errors.join('\n')}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default mcpConfig;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import multer from "multer";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os"; // Import the os module
|
|
5
|
+
import Joi from "joi";
|
|
6
|
+
import crypto from "crypto";
|
|
7
|
+
import { createContextLogger } from "../utils/logger.js";
|
|
8
|
+
import { uploadImage as uploadGhostImage } from "../services/ghostService.js"; // Assuming uploadImage is in ghostService
|
|
9
|
+
import { processImage } from "../services/imageProcessingService.js"; // Import the processing service
|
|
10
|
+
|
|
11
|
+
// --- Use OS temporary directory for uploads ---
|
|
12
|
+
const uploadDir = os.tmpdir(); // Use the OS default temp directory
|
|
13
|
+
// We generally don't need to create os.tmpdir(), it should exist
|
|
14
|
+
// if (!fs.existsSync(uploadDir)){
|
|
15
|
+
// fs.mkdirSync(uploadDir);
|
|
16
|
+
// }
|
|
17
|
+
|
|
18
|
+
// Validation schema for uploaded files (excluding size - validated by multer limits)
|
|
19
|
+
const fileValidationSchema = Joi.object({
|
|
20
|
+
originalname: Joi.string().max(255).required(),
|
|
21
|
+
mimetype: Joi.string().pattern(/^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i).required()
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Post-upload validation schema (when file.size is available)
|
|
25
|
+
const uploadedFileValidationSchema = Joi.object({
|
|
26
|
+
originalname: Joi.string().max(255).required(),
|
|
27
|
+
mimetype: Joi.string().pattern(/^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i).required(),
|
|
28
|
+
size: Joi.number().max(10 * 1024 * 1024).required(), // 10MB max
|
|
29
|
+
path: Joi.string().required()
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Safe filename generation
|
|
33
|
+
const generateSafeFilename = (originalName) => {
|
|
34
|
+
const ext = path.extname(originalName);
|
|
35
|
+
// Validate extension against whitelist
|
|
36
|
+
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
|
|
37
|
+
const normalizedExt = ext.toLowerCase();
|
|
38
|
+
|
|
39
|
+
if (!allowedExtensions.includes(normalizedExt)) {
|
|
40
|
+
throw new Error('Invalid file extension');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Generate cryptographically secure random filename
|
|
44
|
+
const randomBytes = crypto.randomBytes(16).toString('hex');
|
|
45
|
+
const timestamp = Date.now();
|
|
46
|
+
return `mcp-upload-${timestamp}-${randomBytes}${normalizedExt}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const storage = multer.diskStorage({
|
|
50
|
+
destination: function (req, file, cb) {
|
|
51
|
+
// Ensure we're using the temp directory, no user input for path
|
|
52
|
+
cb(null, uploadDir);
|
|
53
|
+
},
|
|
54
|
+
filename: function (req, file, cb) {
|
|
55
|
+
try {
|
|
56
|
+
// Generate safe filename that prevents path traversal
|
|
57
|
+
const safeFilename = generateSafeFilename(file.originalname);
|
|
58
|
+
cb(null, safeFilename);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
cb(error);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Enhanced filter for image files with validation
|
|
66
|
+
const imageFileFilter = (req, file, cb) => {
|
|
67
|
+
// Validate file properties (excluding size - not available at this stage)
|
|
68
|
+
const validation = fileValidationSchema.validate({
|
|
69
|
+
originalname: file.originalname,
|
|
70
|
+
mimetype: file.mimetype
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (validation.error) {
|
|
74
|
+
return cb(new Error(`File validation failed: ${validation.error.details[0].message}`), false);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Additional security checks
|
|
78
|
+
const filename = file.originalname;
|
|
79
|
+
|
|
80
|
+
// Check for path traversal attempts
|
|
81
|
+
if (filename.includes('../') || filename.includes('..\\') || path.isAbsolute(filename)) {
|
|
82
|
+
return cb(new Error('Invalid filename: Path traversal detected'), false);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check for null bytes
|
|
86
|
+
if (filename.includes('\0')) {
|
|
87
|
+
return cb(new Error('Invalid filename: Null byte detected'), false);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
cb(null, true);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const upload = multer({
|
|
94
|
+
storage: storage,
|
|
95
|
+
fileFilter: imageFileFilter,
|
|
96
|
+
limits: {
|
|
97
|
+
fileSize: 10 * 1024 * 1024, // 10MB
|
|
98
|
+
files: 1 // Only allow 1 file per request
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extracts a base filename without extension or unique identifiers.
|
|
104
|
+
* Example: 'mcp-upload-1678886400000-123456789.jpg' -> 'image' (if original was image.jpg)
|
|
105
|
+
* Note: This might be simplified depending on how original filename is best accessed.
|
|
106
|
+
* Multer's `file.originalname` is the best source.
|
|
107
|
+
*/
|
|
108
|
+
const getDefaultAltText = (originalName) => {
|
|
109
|
+
try {
|
|
110
|
+
// Use the original filename directly instead of a file path to avoid path traversal
|
|
111
|
+
// Validate the input is a string and not a path
|
|
112
|
+
if (!originalName || typeof originalName !== 'string') {
|
|
113
|
+
return "Uploaded image";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Ensure no path separators are present (defense in depth)
|
|
117
|
+
const sanitizedName = originalName.replace(/[/\\:]/g, '');
|
|
118
|
+
|
|
119
|
+
const originalFilename = sanitizedName
|
|
120
|
+
.split(".")
|
|
121
|
+
.slice(0, -1)
|
|
122
|
+
.join(".");
|
|
123
|
+
|
|
124
|
+
// Attempt to remove common prefixes/suffixes added during upload/processing
|
|
125
|
+
const nameWithoutIds = originalFilename.replace(
|
|
126
|
+
/^(processed-|mcp-upload-)\d+-\d+-?/,
|
|
127
|
+
""
|
|
128
|
+
);
|
|
129
|
+
return nameWithoutIds.replace(/[-_]/g, " ") || "Uploaded image";
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return "Uploaded image"; // Fallback
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Controller to handle image uploads.
|
|
137
|
+
* Processes the image and includes alt text in the response.
|
|
138
|
+
*/
|
|
139
|
+
const handleImageUpload = async (req, res, next) => {
|
|
140
|
+
const logger = createContextLogger('image-controller');
|
|
141
|
+
let originalPath = null;
|
|
142
|
+
let processedPath = null;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
if (!req.file) {
|
|
146
|
+
return res.status(400).json({ message: "No image file uploaded." });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Post-upload validation with complete file information
|
|
150
|
+
const fileValidation = uploadedFileValidationSchema.validate({
|
|
151
|
+
originalname: req.file.originalname,
|
|
152
|
+
mimetype: req.file.mimetype,
|
|
153
|
+
size: req.file.size,
|
|
154
|
+
path: req.file.path
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (fileValidation.error) {
|
|
158
|
+
// Delete the uploaded file since validation failed
|
|
159
|
+
// Validate file path is within upload directory before deletion
|
|
160
|
+
const filePath = req.file.path;
|
|
161
|
+
const resolvedFilePath = path.resolve(filePath);
|
|
162
|
+
const resolvedUploadDir = path.resolve(uploadDir);
|
|
163
|
+
|
|
164
|
+
if (resolvedFilePath.startsWith(resolvedUploadDir)) {
|
|
165
|
+
fs.unlink(filePath, () => {});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return res.status(400).json({
|
|
169
|
+
message: `File validation failed: ${fileValidation.error.details[0].message}`
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Validate the file path is within our temp directory (defense in depth)
|
|
174
|
+
originalPath = req.file.path;
|
|
175
|
+
const resolvedPath = path.resolve(originalPath);
|
|
176
|
+
const resolvedUploadDir = path.resolve(uploadDir);
|
|
177
|
+
|
|
178
|
+
if (!resolvedPath.startsWith(resolvedUploadDir)) {
|
|
179
|
+
logger.error('Security violation: File path outside upload directory', {
|
|
180
|
+
filePath: path.basename(originalPath),
|
|
181
|
+
uploadDir: path.basename(uploadDir)
|
|
182
|
+
});
|
|
183
|
+
throw new Error('Security violation: File path outside of upload directory');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
logger.info('Image received for processing', {
|
|
187
|
+
originalName: req.file.originalname,
|
|
188
|
+
size: req.file.size,
|
|
189
|
+
mimetype: req.file.mimetype,
|
|
190
|
+
tempFile: path.basename(originalPath)
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Process Image (output directory is still the temp dir)
|
|
194
|
+
processedPath = await processImage(originalPath, uploadDir);
|
|
195
|
+
|
|
196
|
+
// --- Handle Alt Text ---
|
|
197
|
+
// Validate and sanitize alt text from the request body
|
|
198
|
+
const altSchema = Joi.string().max(500).allow('').optional();
|
|
199
|
+
const { error, value: sanitizedAlt } = altSchema.validate(req.body.alt);
|
|
200
|
+
|
|
201
|
+
if (error) {
|
|
202
|
+
return res.status(400).json({ message: `Invalid alt text: ${error.details[0].message}` });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const providedAlt = sanitizedAlt;
|
|
206
|
+
// Generate a default alt text from the original filename if none provided
|
|
207
|
+
const defaultAlt = getDefaultAltText(req.file.originalname);
|
|
208
|
+
const altText = providedAlt || defaultAlt;
|
|
209
|
+
logger.debug('Alt text determined', {
|
|
210
|
+
provided: !!providedAlt,
|
|
211
|
+
generated: !providedAlt,
|
|
212
|
+
altText
|
|
213
|
+
});
|
|
214
|
+
// --- End Alt Text Handling ---
|
|
215
|
+
|
|
216
|
+
// Call ghostService to upload the processed image
|
|
217
|
+
const uploadResult = await uploadGhostImage(processedPath);
|
|
218
|
+
logger.info('Image uploaded to Ghost successfully', {
|
|
219
|
+
ghostUrl: uploadResult.url,
|
|
220
|
+
processedFile: path.basename(processedPath)
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Respond with the URL and the determined alt text
|
|
224
|
+
res.status(200).json({ url: uploadResult.url, alt: altText });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
logger.error('Image upload controller error', {
|
|
227
|
+
error: error.message,
|
|
228
|
+
stack: error.stack,
|
|
229
|
+
originalFile: originalPath ? path.basename(originalPath) : null,
|
|
230
|
+
processedFile: processedPath ? path.basename(processedPath) : null
|
|
231
|
+
});
|
|
232
|
+
// If it's a multer error (e.g., file filter), it might need specific handling
|
|
233
|
+
if (error instanceof multer.MulterError) {
|
|
234
|
+
return res.status(400).json({ message: error.message });
|
|
235
|
+
}
|
|
236
|
+
// Pass other errors to the global handler
|
|
237
|
+
next(error);
|
|
238
|
+
} finally {
|
|
239
|
+
// Cleanup: Delete temporary files with path validation
|
|
240
|
+
if (originalPath) {
|
|
241
|
+
const resolvedOriginalPath = path.resolve(originalPath);
|
|
242
|
+
const resolvedUploadDir = path.resolve(uploadDir);
|
|
243
|
+
|
|
244
|
+
if (resolvedOriginalPath.startsWith(resolvedUploadDir)) {
|
|
245
|
+
fs.unlink(originalPath, (err) => {
|
|
246
|
+
if (err)
|
|
247
|
+
logger.warn('Failed to delete original temp file', {
|
|
248
|
+
file: path.basename(originalPath),
|
|
249
|
+
error: err.message
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (processedPath && processedPath !== originalPath) {
|
|
255
|
+
const resolvedProcessedPath = path.resolve(processedPath);
|
|
256
|
+
const resolvedUploadDir = path.resolve(uploadDir);
|
|
257
|
+
|
|
258
|
+
if (resolvedProcessedPath.startsWith(resolvedUploadDir)) {
|
|
259
|
+
fs.unlink(processedPath, (err) => {
|
|
260
|
+
if (err)
|
|
261
|
+
logger.warn('Failed to delete processed temp file', {
|
|
262
|
+
file: path.basename(processedPath),
|
|
263
|
+
error: err.message
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export { upload, handleImageUpload }; // Export upload middleware and controller
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createPostService } from "../services/postService.js";
|
|
2
|
+
import { createContextLogger } from "../utils/logger.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Controller to handle creating a new post.
|
|
6
|
+
* Assumes input has been validated by middleware.
|
|
7
|
+
* Takes request body containing post data (incl. feature image, metadata)
|
|
8
|
+
* and calls the Post service layer.
|
|
9
|
+
*/
|
|
10
|
+
const createPost = async (req, res, next) => {
|
|
11
|
+
const logger = createContextLogger('post-controller');
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
// Input is already validated by express-validator middleware
|
|
15
|
+
// The body now includes potential feature_image and metadata fields
|
|
16
|
+
const postInput = req.body;
|
|
17
|
+
|
|
18
|
+
logger.info('Creating post via service layer', {
|
|
19
|
+
title: postInput.title,
|
|
20
|
+
status: postInput.status,
|
|
21
|
+
hasFeatureImage: !!postInput.feature_image,
|
|
22
|
+
tagCount: postInput.tags?.length || 0
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Call the service layer function
|
|
26
|
+
const newPost = await createPostService(postInput);
|
|
27
|
+
|
|
28
|
+
logger.info('Post created successfully', {
|
|
29
|
+
postId: newPost.id,
|
|
30
|
+
title: newPost.title,
|
|
31
|
+
status: newPost.status
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
res.status(201).json(newPost);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
logger.error('Post creation failed', {
|
|
37
|
+
error: error.message,
|
|
38
|
+
stack: error.stack,
|
|
39
|
+
title: req.body?.title
|
|
40
|
+
});
|
|
41
|
+
// Pass error to the Express error handler
|
|
42
|
+
next(error);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export { createPost };
|