@jgardner04/ghost-mcp-server 1.1.0 → 1.1.2
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/README.md +0 -3
- package/package.json +20 -2
- package/src/config/mcp-config.js +31 -22
- package/src/controllers/imageController.js +62 -62
- package/src/controllers/postController.js +8 -8
- package/src/controllers/tagController.js +17 -20
- package/src/errors/index.js +49 -44
- package/src/index.js +56 -50
- package/src/mcp_server.js +151 -178
- package/src/mcp_server_enhanced.js +265 -259
- package/src/mcp_server_improved.js +104 -71
- package/src/middleware/errorMiddleware.js +69 -70
- package/src/resources/ResourceManager.js +143 -134
- package/src/routes/imageRoutes.js +9 -9
- package/src/routes/postRoutes.js +22 -28
- package/src/routes/tagRoutes.js +12 -14
- package/src/services/__tests__/ghostService.test.js +20 -18
- package/src/services/ghostService.js +34 -46
- package/src/services/ghostServiceImproved.js +125 -109
- package/src/services/imageProcessingService.js +15 -15
- package/src/services/postService.js +22 -22
- package/src/utils/logger.js +50 -50
- package/src/utils/urlValidator.js +37 -38
package/README.md
CHANGED
|
@@ -25,7 +25,6 @@ _(Refer to `src/mcp_server.js` for full resource schemas.)_
|
|
|
25
25
|
Below is a guide for using the available MCP tools:
|
|
26
26
|
|
|
27
27
|
1. **`ghost_create_tag`**
|
|
28
|
-
|
|
29
28
|
- **Purpose**: Creates a new tag.
|
|
30
29
|
- **Inputs**:
|
|
31
30
|
- `name` (string, required): The name for the new tag.
|
|
@@ -34,14 +33,12 @@ Below is a guide for using the available MCP tools:
|
|
|
34
33
|
- **Output**: The created `ghost/tag` resource.
|
|
35
34
|
|
|
36
35
|
2. **`ghost_get_tags`**
|
|
37
|
-
|
|
38
36
|
- **Purpose**: Retrieves existing tags. Can be used to find a tag ID or check if a tag exists before creation.
|
|
39
37
|
- **Inputs**:
|
|
40
38
|
- `name` (string, optional): Filter tags by exact name.
|
|
41
39
|
- **Output**: An array of `ghost/tag` resources matching the filter (or all tags if no name is provided).
|
|
42
40
|
|
|
43
41
|
3. **`ghost_upload_image`**
|
|
44
|
-
|
|
45
42
|
- **Purpose**: Uploads an image to Ghost for use, typically as a post's featured image.
|
|
46
43
|
- **Inputs**:
|
|
47
44
|
- `imageUrl` (string URL, required): A publicly accessible URL of the image to upload.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgardner04/ghost-mcp-server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for interacting with Ghost CMS via the Admin API",
|
|
5
5
|
"author": "Jonathan Gardner",
|
|
6
6
|
"type": "module",
|
|
@@ -42,7 +42,12 @@
|
|
|
42
42
|
"start:mcp:websocket": "MCP_TRANSPORT=websocket node src/mcp_server_improved.js",
|
|
43
43
|
"test": "vitest run",
|
|
44
44
|
"test:watch": "vitest watch",
|
|
45
|
-
"test:coverage": "vitest run --coverage"
|
|
45
|
+
"test:coverage": "vitest run --coverage",
|
|
46
|
+
"lint": "eslint .",
|
|
47
|
+
"lint:fix": "eslint . --fix",
|
|
48
|
+
"format": "prettier --write \"**/*.{js,json,md}\"",
|
|
49
|
+
"format:check": "prettier --check \"**/*.{js,json,md}\"",
|
|
50
|
+
"prepare": "husky"
|
|
46
51
|
},
|
|
47
52
|
"dependencies": {
|
|
48
53
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
@@ -85,8 +90,21 @@
|
|
|
85
90
|
"access": "public"
|
|
86
91
|
},
|
|
87
92
|
"license": "MIT",
|
|
93
|
+
"lint-staged": {
|
|
94
|
+
"*.js": [
|
|
95
|
+
"eslint --fix",
|
|
96
|
+
"prettier --write"
|
|
97
|
+
]
|
|
98
|
+
},
|
|
88
99
|
"devDependencies": {
|
|
100
|
+
"@eslint/js": "^9.39.1",
|
|
89
101
|
"@vitest/coverage-v8": "^4.0.15",
|
|
102
|
+
"eslint": "^9.39.1",
|
|
103
|
+
"eslint-config-prettier": "^10.1.8",
|
|
104
|
+
"eslint-plugin-prettier": "^5.5.4",
|
|
105
|
+
"husky": "^9.1.7",
|
|
106
|
+
"lint-staged": "^16.2.7",
|
|
107
|
+
"prettier": "^3.7.4",
|
|
90
108
|
"semantic-release": "^25.0.2",
|
|
91
109
|
"vitest": "^4.0.15"
|
|
92
110
|
}
|
package/src/config/mcp-config.js
CHANGED
|
@@ -4,7 +4,7 @@ dotenv.config();
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* MCP Server Configuration
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
8
|
* Transport Options:
|
|
9
9
|
* - 'stdio': Best for CLI tools and direct process communication
|
|
10
10
|
* - 'http'/'sse': Good for web clients, supports CORS
|
|
@@ -15,42 +15,44 @@ export const mcpConfig = {
|
|
|
15
15
|
transport: {
|
|
16
16
|
type: process.env.MCP_TRANSPORT || 'http', // 'stdio', 'http', 'sse', 'websocket'
|
|
17
17
|
port: parseInt(process.env.MCP_PORT || '3001'),
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
// HTTP/SSE specific options
|
|
20
20
|
cors: process.env.MCP_CORS || '*',
|
|
21
21
|
sseEndpoint: process.env.MCP_SSE_ENDPOINT || '/mcp/sse',
|
|
22
|
-
|
|
22
|
+
|
|
23
23
|
// WebSocket specific options
|
|
24
24
|
wsPath: process.env.MCP_WS_PATH || '/',
|
|
25
25
|
wsHeartbeatInterval: parseInt(process.env.MCP_WS_HEARTBEAT || '30000'),
|
|
26
26
|
},
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
// Server metadata
|
|
29
29
|
metadata: {
|
|
30
30
|
name: process.env.MCP_SERVER_NAME || 'Ghost CMS Manager',
|
|
31
|
-
description:
|
|
31
|
+
description:
|
|
32
|
+
process.env.MCP_SERVER_DESC ||
|
|
33
|
+
'MCP Server to manage a Ghost CMS instance using the Admin API.',
|
|
32
34
|
version: process.env.MCP_SERVER_VERSION || '1.0.0',
|
|
33
35
|
},
|
|
34
|
-
|
|
36
|
+
|
|
35
37
|
// Error handling
|
|
36
38
|
errorHandling: {
|
|
37
39
|
includeStackTrace: process.env.NODE_ENV === 'development',
|
|
38
40
|
maxRetries: parseInt(process.env.MCP_MAX_RETRIES || '3'),
|
|
39
41
|
retryDelay: parseInt(process.env.MCP_RETRY_DELAY || '1000'),
|
|
40
42
|
},
|
|
41
|
-
|
|
43
|
+
|
|
42
44
|
// Logging
|
|
43
45
|
logging: {
|
|
44
46
|
level: process.env.MCP_LOG_LEVEL || 'info', // 'debug', 'info', 'warn', 'error'
|
|
45
47
|
format: process.env.MCP_LOG_FORMAT || 'json', // 'json', 'text'
|
|
46
48
|
},
|
|
47
|
-
|
|
49
|
+
|
|
48
50
|
// Security
|
|
49
51
|
security: {
|
|
50
52
|
// Add API key authentication if needed
|
|
51
53
|
apiKey: process.env.MCP_API_KEY,
|
|
52
54
|
allowedOrigins: process.env.MCP_ALLOWED_ORIGINS?.split(',') || ['*'],
|
|
53
|
-
}
|
|
55
|
+
},
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -58,14 +60,14 @@ export const mcpConfig = {
|
|
|
58
60
|
*/
|
|
59
61
|
export function getTransportConfig() {
|
|
60
62
|
const { transport } = mcpConfig;
|
|
61
|
-
|
|
63
|
+
|
|
62
64
|
switch (transport.type) {
|
|
63
65
|
case 'stdio':
|
|
64
66
|
return {
|
|
65
67
|
type: 'stdio',
|
|
66
68
|
// No additional config needed for stdio
|
|
67
69
|
};
|
|
68
|
-
|
|
70
|
+
|
|
69
71
|
case 'http':
|
|
70
72
|
case 'sse':
|
|
71
73
|
return {
|
|
@@ -74,7 +76,7 @@ export function getTransportConfig() {
|
|
|
74
76
|
cors: transport.cors,
|
|
75
77
|
endpoint: transport.sseEndpoint,
|
|
76
78
|
};
|
|
77
|
-
|
|
79
|
+
|
|
78
80
|
case 'websocket':
|
|
79
81
|
return {
|
|
80
82
|
type: 'websocket',
|
|
@@ -82,7 +84,7 @@ export function getTransportConfig() {
|
|
|
82
84
|
path: transport.wsPath,
|
|
83
85
|
heartbeatInterval: transport.wsHeartbeatInterval,
|
|
84
86
|
};
|
|
85
|
-
|
|
87
|
+
|
|
86
88
|
default:
|
|
87
89
|
throw new Error(`Unknown transport type: ${transport.type}`);
|
|
88
90
|
}
|
|
@@ -93,7 +95,7 @@ export function getTransportConfig() {
|
|
|
93
95
|
*/
|
|
94
96
|
export function validateConfig() {
|
|
95
97
|
const errors = [];
|
|
96
|
-
|
|
98
|
+
|
|
97
99
|
// Check if transport configuration exists
|
|
98
100
|
if (!mcpConfig.transport) {
|
|
99
101
|
errors.push('Missing transport configuration');
|
|
@@ -103,29 +105,36 @@ export function validateConfig() {
|
|
|
103
105
|
if (!mcpConfig.transport.type || !validTransports.includes(mcpConfig.transport.type)) {
|
|
104
106
|
errors.push(`Invalid transport type: ${mcpConfig.transport.type}`);
|
|
105
107
|
}
|
|
106
|
-
|
|
108
|
+
|
|
107
109
|
// Check port for network transports
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
+
if (
|
|
111
|
+
mcpConfig.transport.type &&
|
|
112
|
+
['http', 'sse', 'websocket'].includes(mcpConfig.transport.type)
|
|
113
|
+
) {
|
|
114
|
+
if (
|
|
115
|
+
!mcpConfig.transport.port ||
|
|
116
|
+
mcpConfig.transport.port < 1 ||
|
|
117
|
+
mcpConfig.transport.port > 65535
|
|
118
|
+
) {
|
|
110
119
|
errors.push(`Invalid port: ${mcpConfig.transport.port}`);
|
|
111
120
|
}
|
|
112
121
|
}
|
|
113
122
|
}
|
|
114
|
-
|
|
123
|
+
|
|
115
124
|
// Check Ghost configuration
|
|
116
125
|
if (!process.env.GHOST_ADMIN_API_URL) {
|
|
117
126
|
errors.push('Missing GHOST_ADMIN_API_URL environment variable');
|
|
118
127
|
}
|
|
119
|
-
|
|
128
|
+
|
|
120
129
|
if (!process.env.GHOST_ADMIN_API_KEY) {
|
|
121
130
|
errors.push('Missing GHOST_ADMIN_API_KEY environment variable');
|
|
122
131
|
}
|
|
123
|
-
|
|
132
|
+
|
|
124
133
|
if (errors.length > 0) {
|
|
125
134
|
throw new Error(`Configuration errors:\n${errors.join('\n')}`);
|
|
126
135
|
}
|
|
127
|
-
|
|
136
|
+
|
|
128
137
|
return true;
|
|
129
138
|
}
|
|
130
139
|
|
|
131
|
-
export default mcpConfig;
|
|
140
|
+
export default mcpConfig;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import multer from
|
|
2
|
-
import path from
|
|
3
|
-
import fs from
|
|
4
|
-
import os from
|
|
5
|
-
import Joi from
|
|
6
|
-
import crypto from
|
|
7
|
-
import { createContextLogger } from
|
|
8
|
-
import { uploadImage as uploadGhostImage } from
|
|
9
|
-
import { processImage } from
|
|
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
10
|
|
|
11
11
|
// --- Use OS temporary directory for uploads ---
|
|
12
12
|
const uploadDir = os.tmpdir(); // Use the OS default temp directory
|
|
@@ -18,15 +18,21 @@ const uploadDir = os.tmpdir(); // Use the OS default temp directory
|
|
|
18
18
|
// Validation schema for uploaded files (excluding size - validated by multer limits)
|
|
19
19
|
const fileValidationSchema = Joi.object({
|
|
20
20
|
originalname: Joi.string().max(255).required(),
|
|
21
|
-
mimetype: Joi.string()
|
|
21
|
+
mimetype: Joi.string()
|
|
22
|
+
.pattern(/^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i)
|
|
23
|
+
.required(),
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
// Post-upload validation schema (when file.size is available)
|
|
25
27
|
const uploadedFileValidationSchema = Joi.object({
|
|
26
28
|
originalname: Joi.string().max(255).required(),
|
|
27
|
-
mimetype: Joi.string()
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
mimetype: Joi.string()
|
|
30
|
+
.pattern(/^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i)
|
|
31
|
+
.required(),
|
|
32
|
+
size: Joi.number()
|
|
33
|
+
.max(10 * 1024 * 1024)
|
|
34
|
+
.required(), // 10MB max
|
|
35
|
+
path: Joi.string().required(),
|
|
30
36
|
});
|
|
31
37
|
|
|
32
38
|
// Safe filename generation
|
|
@@ -35,11 +41,11 @@ const generateSafeFilename = (originalName) => {
|
|
|
35
41
|
// Validate extension against whitelist
|
|
36
42
|
const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
|
|
37
43
|
const normalizedExt = ext.toLowerCase();
|
|
38
|
-
|
|
44
|
+
|
|
39
45
|
if (!allowedExtensions.includes(normalizedExt)) {
|
|
40
46
|
throw new Error('Invalid file extension');
|
|
41
47
|
}
|
|
42
|
-
|
|
48
|
+
|
|
43
49
|
// Generate cryptographically secure random filename
|
|
44
50
|
const randomBytes = crypto.randomBytes(16).toString('hex');
|
|
45
51
|
const timestamp = Date.now();
|
|
@@ -67,36 +73,36 @@ const imageFileFilter = (req, file, cb) => {
|
|
|
67
73
|
// Validate file properties (excluding size - not available at this stage)
|
|
68
74
|
const validation = fileValidationSchema.validate({
|
|
69
75
|
originalname: file.originalname,
|
|
70
|
-
mimetype: file.mimetype
|
|
76
|
+
mimetype: file.mimetype,
|
|
71
77
|
});
|
|
72
|
-
|
|
78
|
+
|
|
73
79
|
if (validation.error) {
|
|
74
80
|
return cb(new Error(`File validation failed: ${validation.error.details[0].message}`), false);
|
|
75
81
|
}
|
|
76
|
-
|
|
82
|
+
|
|
77
83
|
// Additional security checks
|
|
78
84
|
const filename = file.originalname;
|
|
79
|
-
|
|
85
|
+
|
|
80
86
|
// Check for path traversal attempts
|
|
81
87
|
if (filename.includes('../') || filename.includes('..\\') || path.isAbsolute(filename)) {
|
|
82
88
|
return cb(new Error('Invalid filename: Path traversal detected'), false);
|
|
83
89
|
}
|
|
84
|
-
|
|
90
|
+
|
|
85
91
|
// Check for null bytes
|
|
86
92
|
if (filename.includes('\0')) {
|
|
87
93
|
return cb(new Error('Invalid filename: Null byte detected'), false);
|
|
88
94
|
}
|
|
89
|
-
|
|
95
|
+
|
|
90
96
|
cb(null, true);
|
|
91
97
|
};
|
|
92
98
|
|
|
93
|
-
const upload = multer({
|
|
94
|
-
storage: storage,
|
|
99
|
+
const upload = multer({
|
|
100
|
+
storage: storage,
|
|
95
101
|
fileFilter: imageFileFilter,
|
|
96
102
|
limits: {
|
|
97
103
|
fileSize: 10 * 1024 * 1024, // 10MB
|
|
98
|
-
files: 1 // Only allow 1 file per request
|
|
99
|
-
}
|
|
104
|
+
files: 1, // Only allow 1 file per request
|
|
105
|
+
},
|
|
100
106
|
});
|
|
101
107
|
|
|
102
108
|
/**
|
|
@@ -110,25 +116,19 @@ const getDefaultAltText = (originalName) => {
|
|
|
110
116
|
// Use the original filename directly instead of a file path to avoid path traversal
|
|
111
117
|
// Validate the input is a string and not a path
|
|
112
118
|
if (!originalName || typeof originalName !== 'string') {
|
|
113
|
-
return
|
|
119
|
+
return 'Uploaded image';
|
|
114
120
|
}
|
|
115
|
-
|
|
121
|
+
|
|
116
122
|
// Ensure no path separators are present (defense in depth)
|
|
117
123
|
const sanitizedName = originalName.replace(/[/\\:]/g, '');
|
|
118
|
-
|
|
119
|
-
const originalFilename = sanitizedName
|
|
120
|
-
|
|
121
|
-
.slice(0, -1)
|
|
122
|
-
.join(".");
|
|
123
|
-
|
|
124
|
+
|
|
125
|
+
const originalFilename = sanitizedName.split('.').slice(0, -1).join('.');
|
|
126
|
+
|
|
124
127
|
// Attempt to remove common prefixes/suffixes added during upload/processing
|
|
125
|
-
const nameWithoutIds = originalFilename.replace(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return nameWithoutIds.replace(/[-_]/g, " ") || "Uploaded image";
|
|
130
|
-
} catch (e) {
|
|
131
|
-
return "Uploaded image"; // Fallback
|
|
128
|
+
const nameWithoutIds = originalFilename.replace(/^(processed-|mcp-upload-)\d+-\d+-?/, '');
|
|
129
|
+
return nameWithoutIds.replace(/[-_]/g, ' ') || 'Uploaded image';
|
|
130
|
+
} catch (_e) {
|
|
131
|
+
return 'Uploaded image'; // Fallback
|
|
132
132
|
}
|
|
133
133
|
};
|
|
134
134
|
|
|
@@ -143,51 +143,51 @@ const handleImageUpload = async (req, res, next) => {
|
|
|
143
143
|
|
|
144
144
|
try {
|
|
145
145
|
if (!req.file) {
|
|
146
|
-
return res.status(400).json({ message:
|
|
146
|
+
return res.status(400).json({ message: 'No image file uploaded.' });
|
|
147
147
|
}
|
|
148
|
-
|
|
148
|
+
|
|
149
149
|
// Post-upload validation with complete file information
|
|
150
150
|
const fileValidation = uploadedFileValidationSchema.validate({
|
|
151
151
|
originalname: req.file.originalname,
|
|
152
152
|
mimetype: req.file.mimetype,
|
|
153
153
|
size: req.file.size,
|
|
154
|
-
path: req.file.path
|
|
154
|
+
path: req.file.path,
|
|
155
155
|
});
|
|
156
|
-
|
|
156
|
+
|
|
157
157
|
if (fileValidation.error) {
|
|
158
158
|
// Delete the uploaded file since validation failed
|
|
159
159
|
// Validate file path is within upload directory before deletion
|
|
160
160
|
const filePath = req.file.path;
|
|
161
161
|
const resolvedFilePath = path.resolve(filePath);
|
|
162
162
|
const resolvedUploadDir = path.resolve(uploadDir);
|
|
163
|
-
|
|
163
|
+
|
|
164
164
|
if (resolvedFilePath.startsWith(resolvedUploadDir)) {
|
|
165
165
|
fs.unlink(filePath, () => {});
|
|
166
166
|
}
|
|
167
|
-
|
|
168
|
-
return res.status(400).json({
|
|
169
|
-
message: `File validation failed: ${fileValidation.error.details[0].message}
|
|
167
|
+
|
|
168
|
+
return res.status(400).json({
|
|
169
|
+
message: `File validation failed: ${fileValidation.error.details[0].message}`,
|
|
170
170
|
});
|
|
171
171
|
}
|
|
172
|
-
|
|
172
|
+
|
|
173
173
|
// Validate the file path is within our temp directory (defense in depth)
|
|
174
174
|
originalPath = req.file.path;
|
|
175
175
|
const resolvedPath = path.resolve(originalPath);
|
|
176
176
|
const resolvedUploadDir = path.resolve(uploadDir);
|
|
177
|
-
|
|
177
|
+
|
|
178
178
|
if (!resolvedPath.startsWith(resolvedUploadDir)) {
|
|
179
179
|
logger.error('Security violation: File path outside upload directory', {
|
|
180
180
|
filePath: path.basename(originalPath),
|
|
181
|
-
uploadDir: path.basename(uploadDir)
|
|
181
|
+
uploadDir: path.basename(uploadDir),
|
|
182
182
|
});
|
|
183
183
|
throw new Error('Security violation: File path outside of upload directory');
|
|
184
184
|
}
|
|
185
|
-
|
|
185
|
+
|
|
186
186
|
logger.info('Image received for processing', {
|
|
187
187
|
originalName: req.file.originalname,
|
|
188
188
|
size: req.file.size,
|
|
189
189
|
mimetype: req.file.mimetype,
|
|
190
|
-
tempFile: path.basename(originalPath)
|
|
190
|
+
tempFile: path.basename(originalPath),
|
|
191
191
|
});
|
|
192
192
|
|
|
193
193
|
// Process Image (output directory is still the temp dir)
|
|
@@ -197,11 +197,11 @@ const handleImageUpload = async (req, res, next) => {
|
|
|
197
197
|
// Validate and sanitize alt text from the request body
|
|
198
198
|
const altSchema = Joi.string().max(500).allow('').optional();
|
|
199
199
|
const { error, value: sanitizedAlt } = altSchema.validate(req.body.alt);
|
|
200
|
-
|
|
200
|
+
|
|
201
201
|
if (error) {
|
|
202
202
|
return res.status(400).json({ message: `Invalid alt text: ${error.details[0].message}` });
|
|
203
203
|
}
|
|
204
|
-
|
|
204
|
+
|
|
205
205
|
const providedAlt = sanitizedAlt;
|
|
206
206
|
// Generate a default alt text from the original filename if none provided
|
|
207
207
|
const defaultAlt = getDefaultAltText(req.file.originalname);
|
|
@@ -209,7 +209,7 @@ const handleImageUpload = async (req, res, next) => {
|
|
|
209
209
|
logger.debug('Alt text determined', {
|
|
210
210
|
provided: !!providedAlt,
|
|
211
211
|
generated: !providedAlt,
|
|
212
|
-
altText
|
|
212
|
+
altText,
|
|
213
213
|
});
|
|
214
214
|
// --- End Alt Text Handling ---
|
|
215
215
|
|
|
@@ -217,7 +217,7 @@ const handleImageUpload = async (req, res, next) => {
|
|
|
217
217
|
const uploadResult = await uploadGhostImage(processedPath);
|
|
218
218
|
logger.info('Image uploaded to Ghost successfully', {
|
|
219
219
|
ghostUrl: uploadResult.url,
|
|
220
|
-
processedFile: path.basename(processedPath)
|
|
220
|
+
processedFile: path.basename(processedPath),
|
|
221
221
|
});
|
|
222
222
|
|
|
223
223
|
// Respond with the URL and the determined alt text
|
|
@@ -227,7 +227,7 @@ const handleImageUpload = async (req, res, next) => {
|
|
|
227
227
|
error: error.message,
|
|
228
228
|
stack: error.stack,
|
|
229
229
|
originalFile: originalPath ? path.basename(originalPath) : null,
|
|
230
|
-
processedFile: processedPath ? path.basename(processedPath) : null
|
|
230
|
+
processedFile: processedPath ? path.basename(processedPath) : null,
|
|
231
231
|
});
|
|
232
232
|
// If it's a multer error (e.g., file filter), it might need specific handling
|
|
233
233
|
if (error instanceof multer.MulterError) {
|
|
@@ -240,13 +240,13 @@ const handleImageUpload = async (req, res, next) => {
|
|
|
240
240
|
if (originalPath) {
|
|
241
241
|
const resolvedOriginalPath = path.resolve(originalPath);
|
|
242
242
|
const resolvedUploadDir = path.resolve(uploadDir);
|
|
243
|
-
|
|
243
|
+
|
|
244
244
|
if (resolvedOriginalPath.startsWith(resolvedUploadDir)) {
|
|
245
245
|
fs.unlink(originalPath, (err) => {
|
|
246
246
|
if (err)
|
|
247
247
|
logger.warn('Failed to delete original temp file', {
|
|
248
248
|
file: path.basename(originalPath),
|
|
249
|
-
error: err.message
|
|
249
|
+
error: err.message,
|
|
250
250
|
});
|
|
251
251
|
});
|
|
252
252
|
}
|
|
@@ -254,13 +254,13 @@ const handleImageUpload = async (req, res, next) => {
|
|
|
254
254
|
if (processedPath && processedPath !== originalPath) {
|
|
255
255
|
const resolvedProcessedPath = path.resolve(processedPath);
|
|
256
256
|
const resolvedUploadDir = path.resolve(uploadDir);
|
|
257
|
-
|
|
257
|
+
|
|
258
258
|
if (resolvedProcessedPath.startsWith(resolvedUploadDir)) {
|
|
259
259
|
fs.unlink(processedPath, (err) => {
|
|
260
260
|
if (err)
|
|
261
261
|
logger.warn('Failed to delete processed temp file', {
|
|
262
262
|
file: path.basename(processedPath),
|
|
263
|
-
error: err.message
|
|
263
|
+
error: err.message,
|
|
264
264
|
});
|
|
265
265
|
});
|
|
266
266
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createPostService } from
|
|
2
|
-
import { createContextLogger } from
|
|
1
|
+
import { createPostService } from '../services/postService.js';
|
|
2
|
+
import { createContextLogger } from '../utils/logger.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Controller to handle creating a new post.
|
|
@@ -9,7 +9,7 @@ import { createContextLogger } from "../utils/logger.js";
|
|
|
9
9
|
*/
|
|
10
10
|
const createPost = async (req, res, next) => {
|
|
11
11
|
const logger = createContextLogger('post-controller');
|
|
12
|
-
|
|
12
|
+
|
|
13
13
|
try {
|
|
14
14
|
// Input is already validated by express-validator middleware
|
|
15
15
|
// The body now includes potential feature_image and metadata fields
|
|
@@ -19,16 +19,16 @@ const createPost = async (req, res, next) => {
|
|
|
19
19
|
title: postInput.title,
|
|
20
20
|
status: postInput.status,
|
|
21
21
|
hasFeatureImage: !!postInput.feature_image,
|
|
22
|
-
tagCount: postInput.tags?.length || 0
|
|
22
|
+
tagCount: postInput.tags?.length || 0,
|
|
23
23
|
});
|
|
24
|
-
|
|
24
|
+
|
|
25
25
|
// Call the service layer function
|
|
26
26
|
const newPost = await createPostService(postInput);
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
logger.info('Post created successfully', {
|
|
29
29
|
postId: newPost.id,
|
|
30
30
|
title: newPost.title,
|
|
31
|
-
status: newPost.status
|
|
31
|
+
status: newPost.status,
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
res.status(201).json(newPost);
|
|
@@ -36,7 +36,7 @@ const createPost = async (req, res, next) => {
|
|
|
36
36
|
logger.error('Post creation failed', {
|
|
37
37
|
error: error.message,
|
|
38
38
|
stack: error.stack,
|
|
39
|
-
title: req.body?.title
|
|
39
|
+
title: req.body?.title,
|
|
40
40
|
});
|
|
41
41
|
// Pass error to the Express error handler
|
|
42
42
|
next(error);
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
createTag as createGhostTag,
|
|
4
|
-
} from "../services/ghostService.js";
|
|
5
|
-
import { createContextLogger } from "../utils/logger.js";
|
|
1
|
+
import { getTags as getGhostTags, createTag as createGhostTag } from '../services/ghostService.js';
|
|
2
|
+
import { createContextLogger } from '../utils/logger.js';
|
|
6
3
|
|
|
7
4
|
/**
|
|
8
5
|
* Controller to handle fetching tags.
|
|
@@ -10,26 +7,26 @@ import { createContextLogger } from "../utils/logger.js";
|
|
|
10
7
|
*/
|
|
11
8
|
const getTags = async (req, res, next) => {
|
|
12
9
|
const logger = createContextLogger('tag-controller');
|
|
13
|
-
|
|
10
|
+
|
|
14
11
|
try {
|
|
15
12
|
const { name } = req.query; // Get name from query params like /api/tags?name=some-tag
|
|
16
13
|
logger.info('Fetching tags', {
|
|
17
14
|
filtered: !!name,
|
|
18
|
-
filterName: name
|
|
15
|
+
filterName: name,
|
|
19
16
|
});
|
|
20
|
-
|
|
17
|
+
|
|
21
18
|
const tags = await getGhostTags(name);
|
|
22
|
-
|
|
19
|
+
|
|
23
20
|
logger.info('Tags retrieved successfully', {
|
|
24
21
|
count: tags.length,
|
|
25
|
-
filtered: !!name
|
|
22
|
+
filtered: !!name,
|
|
26
23
|
});
|
|
27
|
-
|
|
24
|
+
|
|
28
25
|
res.status(200).json(tags);
|
|
29
26
|
} catch (error) {
|
|
30
27
|
logger.error('Get tags failed', {
|
|
31
28
|
error: error.message,
|
|
32
|
-
filterName: req.query?.name
|
|
29
|
+
filterName: req.query?.name,
|
|
33
30
|
});
|
|
34
31
|
next(error);
|
|
35
32
|
}
|
|
@@ -40,35 +37,35 @@ const getTags = async (req, res, next) => {
|
|
|
40
37
|
*/
|
|
41
38
|
const createTag = async (req, res, next) => {
|
|
42
39
|
const logger = createContextLogger('tag-controller');
|
|
43
|
-
|
|
40
|
+
|
|
44
41
|
try {
|
|
45
42
|
// Basic validation (more could be added via express-validator)
|
|
46
43
|
const { name, description, slug, ...otherData } = req.body;
|
|
47
44
|
if (!name) {
|
|
48
45
|
logger.warn('Tag creation attempted without name');
|
|
49
|
-
return res.status(400).json({ message:
|
|
46
|
+
return res.status(400).json({ message: 'Tag name is required.' });
|
|
50
47
|
}
|
|
51
48
|
const tagData = { name, description, slug, ...otherData };
|
|
52
49
|
|
|
53
50
|
logger.info('Creating tag', {
|
|
54
51
|
name,
|
|
55
52
|
hasDescription: !!description,
|
|
56
|
-
hasSlug: !!slug
|
|
53
|
+
hasSlug: !!slug,
|
|
57
54
|
});
|
|
58
|
-
|
|
55
|
+
|
|
59
56
|
const newTag = await createGhostTag(tagData);
|
|
60
|
-
|
|
57
|
+
|
|
61
58
|
logger.info('Tag created successfully', {
|
|
62
59
|
tagId: newTag.id,
|
|
63
60
|
name: newTag.name,
|
|
64
|
-
slug: newTag.slug
|
|
61
|
+
slug: newTag.slug,
|
|
65
62
|
});
|
|
66
|
-
|
|
63
|
+
|
|
67
64
|
res.status(201).json(newTag);
|
|
68
65
|
} catch (error) {
|
|
69
66
|
logger.error('Tag creation failed', {
|
|
70
67
|
error: error.message,
|
|
71
|
-
tagName: req.body?.name
|
|
68
|
+
tagName: req.body?.name,
|
|
72
69
|
});
|
|
73
70
|
next(error);
|
|
74
71
|
}
|