@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.
@@ -0,0 +1,174 @@
1
+ import sanitizeHtml from "sanitize-html";
2
+ import Joi from "joi";
3
+ import { createContextLogger } from "../utils/logger.js";
4
+ import {
5
+ createPost as createGhostPost,
6
+ getTags as getGhostTags,
7
+ createTag as createGhostTag,
8
+ // Import other necessary functions from ghostService later
9
+ } from "./ghostService.js"; // Note the relative path
10
+
11
+ /**
12
+ * Helper to generate a simple meta description from HTML content.
13
+ * Uses sanitize-html to safely strip HTML tags and truncates.
14
+ * @param {string} htmlContent - The HTML content of the post.
15
+ * @param {number} maxLength - The maximum length of the description.
16
+ * @returns {string} A plain text truncated description.
17
+ */
18
+ const generateSimpleMetaDescription = (htmlContent, maxLength = 500) => {
19
+ if (!htmlContent) return "";
20
+
21
+ // Use sanitize-html to safely remove all HTML tags
22
+ // This prevents ReDoS attacks and properly handles malformed HTML
23
+ const textContent = sanitizeHtml(htmlContent, {
24
+ allowedTags: [], // Remove all HTML tags
25
+ allowedAttributes: {},
26
+ textFilter: function(text) {
27
+ return text.replace(/\s\s+/g, ' ').trim();
28
+ }
29
+ });
30
+
31
+ // Truncate and add ellipsis if needed
32
+ return textContent.length > maxLength
33
+ ? textContent.substring(0, maxLength - 3) + "..."
34
+ : textContent;
35
+ };
36
+
37
+ /**
38
+ * Service layer function to handle the business logic of creating a post.
39
+ * Transforms input data, handles/resolves tags, includes feature image and metadata.
40
+ * @param {object} postInput - Data received from the controller.
41
+ * @returns {Promise<object>} The created post object from the Ghost API.
42
+ */
43
+ // Validation schema for post input
44
+ const postInputSchema = Joi.object({
45
+ title: Joi.string().max(255).required(),
46
+ html: Joi.string().required(),
47
+ custom_excerpt: Joi.string().max(500).optional(),
48
+ status: Joi.string().valid('draft', 'published', 'scheduled').optional(),
49
+ published_at: Joi.string().isoDate().optional(),
50
+ tags: Joi.array().items(Joi.string().max(50)).max(10).optional(),
51
+ feature_image: Joi.string().uri().optional(),
52
+ feature_image_alt: Joi.string().max(255).optional(),
53
+ feature_image_caption: Joi.string().max(500).optional(),
54
+ meta_title: Joi.string().max(70).optional(),
55
+ meta_description: Joi.string().max(160).optional()
56
+ });
57
+
58
+ const createPostService = async (postInput) => {
59
+ const logger = createContextLogger('post-service');
60
+
61
+ // Validate input to prevent format string vulnerabilities
62
+ const { error, value: validatedInput } = postInputSchema.validate(postInput);
63
+ if (error) {
64
+ logger.error('Post input validation failed', {
65
+ error: error.details[0].message,
66
+ inputKeys: Object.keys(postInput)
67
+ });
68
+ throw new Error(`Invalid post input: ${error.details[0].message}`);
69
+ }
70
+
71
+ const {
72
+ title,
73
+ html,
74
+ custom_excerpt,
75
+ status,
76
+ published_at,
77
+ tags, // Expecting array of strings (tag names) here now
78
+ feature_image,
79
+ feature_image_alt,
80
+ feature_image_caption,
81
+ meta_title,
82
+ meta_description,
83
+ } = validatedInput;
84
+
85
+ // --- Resolve Tag Names to Tag Objects (ID/Slug/Name) ---
86
+ let resolvedTags = [];
87
+ if (tags && Array.isArray(tags) && tags.length > 0) {
88
+ logger.info('Resolving provided tag names', { tagCount: tags.length, tags });
89
+ resolvedTags = await Promise.all(
90
+ tags.map(async (tagName) => {
91
+ if (typeof tagName !== "string" || !tagName.trim()) {
92
+ logger.warn('Skipping invalid tag name', { tagName, type: typeof tagName });
93
+ return null; // Skip invalid entries
94
+ }
95
+ tagName = tagName.trim();
96
+
97
+ try {
98
+ // Check if tag exists by name
99
+ const existingTags = await getGhostTags(tagName);
100
+ if (existingTags && existingTags.length > 0) {
101
+ logger.debug('Found existing tag', {
102
+ tagName,
103
+ tagId: existingTags[0].id
104
+ });
105
+ // Use the existing tag (Ghost usually accepts name, slug, or id)
106
+ return { name: tagName }; // Or { id: existingTags[0].id } or { slug: existingTags[0].slug }
107
+ } else {
108
+ // Tag doesn't exist, create it
109
+ logger.info('Creating new tag', { tagName });
110
+ const newTag = await createGhostTag({ name: tagName });
111
+ logger.info('Created new tag successfully', {
112
+ tagName,
113
+ tagId: newTag.id
114
+ });
115
+ // Use the new tag
116
+ return { name: tagName }; // Or { id: newTag.id }
117
+ }
118
+ } catch (tagError) {
119
+ logger.error('Error processing tag', {
120
+ tagName,
121
+ error: tagError.message
122
+ });
123
+ return null; // Skip tags that cause errors during processing
124
+ }
125
+ })
126
+ );
127
+ // Filter out any nulls from skipped/errored tags
128
+ resolvedTags = resolvedTags.filter((tag) => tag !== null);
129
+ logger.debug('Resolved tags for API', {
130
+ resolvedTagCount: resolvedTags.length,
131
+ resolvedTags
132
+ });
133
+ }
134
+ // --- End Tag Resolution ---
135
+
136
+ // --- Metadata Defaults/Generation ---
137
+ const finalMetaTitle = meta_title || title; // Default meta_title to title
138
+ const finalMetaDescription =
139
+ meta_description || custom_excerpt || generateSimpleMetaDescription(html); // Default meta_description
140
+ // Ensure description does not exceed limit even after defaulting
141
+ const truncatedMetaDescription =
142
+ finalMetaDescription.length > 500
143
+ ? finalMetaDescription.substring(0, 497) + "..."
144
+ : finalMetaDescription;
145
+ // --- End Metadata Defaults ---
146
+
147
+ // Prepare data for the Ghost API, including feature image fields
148
+ const postDataForApi = {
149
+ title,
150
+ html,
151
+ custom_excerpt,
152
+ status: status || "draft", // Default to draft
153
+ published_at,
154
+ tags: resolvedTags,
155
+ feature_image,
156
+ feature_image_alt,
157
+ feature_image_caption,
158
+ meta_title: finalMetaTitle, // Use final value
159
+ meta_description: truncatedMetaDescription, // Use final, truncated value
160
+ // Add metadata fields here if needed in the future
161
+ };
162
+
163
+ logger.info('Creating Ghost post', {
164
+ title: postDataForApi.title,
165
+ status: postDataForApi.status,
166
+ tagCount: postDataForApi.tags?.length || 0,
167
+ hasFeatureImage: !!postDataForApi.feature_image
168
+ });
169
+ // Call the lower-level ghostService function
170
+ const newPost = await createGhostPost(postDataForApi);
171
+ return newPost;
172
+ };
173
+
174
+ export { createPostService };
@@ -0,0 +1,153 @@
1
+ import winston from 'winston';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+
8
+ // Get log level from environment or default to 'info'
9
+ const logLevel = process.env.LOG_LEVEL || 'info';
10
+ const isDevelopment = process.env.NODE_ENV === 'development';
11
+
12
+ // Define custom log format
13
+ const logFormat = winston.format.combine(
14
+ winston.format.timestamp({
15
+ format: 'YYYY-MM-DD HH:mm:ss'
16
+ }),
17
+ winston.format.errors({ stack: true }),
18
+ winston.format.json(),
19
+ winston.format.metadata({ fillExcept: ['message', 'level', 'timestamp'] })
20
+ );
21
+
22
+ // Development format (more readable)
23
+ const devFormat = winston.format.combine(
24
+ winston.format.timestamp({
25
+ format: 'HH:mm:ss'
26
+ }),
27
+ winston.format.colorize(),
28
+ winston.format.printf(({ level, message, timestamp, metadata }) => {
29
+ let metaString = '';
30
+ if (metadata && Object.keys(metadata).length > 0) {
31
+ metaString = ` ${JSON.stringify(metadata)}`;
32
+ }
33
+ return `${timestamp} [${level}] ${message}${metaString}`;
34
+ })
35
+ );
36
+
37
+ // Create the logger instance
38
+ const logger = winston.createLogger({
39
+ level: logLevel,
40
+ format: isDevelopment ? devFormat : logFormat,
41
+ defaultMeta: {
42
+ service: 'ghost-mcp-server',
43
+ pid: process.pid
44
+ },
45
+ transports: [
46
+ // Console output
47
+ new winston.transports.Console({
48
+ level: isDevelopment ? 'debug' : 'info'
49
+ })
50
+ ],
51
+ // Handle uncaught exceptions
52
+ exceptionHandlers: [
53
+ new winston.transports.Console()
54
+ ],
55
+ // Handle unhandled promise rejections
56
+ rejectionHandlers: [
57
+ new winston.transports.Console()
58
+ ]
59
+ });
60
+
61
+ // Add file logging in production
62
+ if (!isDevelopment) {
63
+ const logDir = path.join(__dirname, '../../logs');
64
+
65
+ // Create logs directory if it doesn't exist
66
+ import('fs').then(fs => {
67
+ if (!fs.existsSync(logDir)) {
68
+ fs.mkdirSync(logDir, { recursive: true });
69
+ }
70
+ });
71
+
72
+ // Add file transports for production
73
+ logger.add(new winston.transports.File({
74
+ filename: path.join(logDir, 'error.log'),
75
+ level: 'error',
76
+ maxsize: 10 * 1024 * 1024, // 10MB
77
+ maxFiles: 5,
78
+ tailable: true
79
+ }));
80
+
81
+ logger.add(new winston.transports.File({
82
+ filename: path.join(logDir, 'combined.log'),
83
+ maxsize: 10 * 1024 * 1024, // 10MB
84
+ maxFiles: 10,
85
+ tailable: true
86
+ }));
87
+ }
88
+
89
+ // Create helper functions for common logging patterns
90
+ const createContextLogger = (context) => {
91
+ return {
92
+ debug: (message, meta = {}) => logger.debug(message, { ...meta, context }),
93
+ info: (message, meta = {}) => logger.info(message, { ...meta, context }),
94
+ warn: (message, meta = {}) => logger.warn(message, { ...meta, context }),
95
+ error: (message, meta = {}) => logger.error(message, { ...meta, context }),
96
+
97
+ // Convenience methods for common patterns
98
+ apiRequest: (method, url, meta = {}) =>
99
+ logger.info(`${method} ${url}`, { ...meta, context, type: 'api_request' }),
100
+
101
+ apiResponse: (method, url, status, meta = {}) =>
102
+ logger.info(`${method} ${url} -> ${status}`, { ...meta, context, type: 'api_response' }),
103
+
104
+ apiError: (method, url, error, meta = {}) =>
105
+ logger.error(`${method} ${url} failed`, {
106
+ ...meta,
107
+ context,
108
+ type: 'api_error',
109
+ error: error.message,
110
+ stack: error.stack
111
+ }),
112
+
113
+ toolExecution: (toolName, input, meta = {}) =>
114
+ logger.info(`Executing tool: ${toolName}`, {
115
+ ...meta,
116
+ context,
117
+ type: 'tool_execution',
118
+ tool: toolName,
119
+ inputKeys: Object.keys(input || {})
120
+ }),
121
+
122
+ toolSuccess: (toolName, result, meta = {}) =>
123
+ logger.info(`Tool ${toolName} completed successfully`, {
124
+ ...meta,
125
+ context,
126
+ type: 'tool_success',
127
+ tool: toolName,
128
+ resultType: typeof result
129
+ }),
130
+
131
+ toolError: (toolName, error, meta = {}) =>
132
+ logger.error(`Tool ${toolName} failed`, {
133
+ ...meta,
134
+ context,
135
+ type: 'tool_error',
136
+ tool: toolName,
137
+ error: error.message,
138
+ stack: error.stack
139
+ }),
140
+
141
+ fileOperation: (operation, filePath, meta = {}) =>
142
+ logger.debug(`File operation: ${operation}`, {
143
+ ...meta,
144
+ context,
145
+ type: 'file_operation',
146
+ operation,
147
+ file: path.basename(filePath)
148
+ })
149
+ };
150
+ };
151
+
152
+ export default logger;
153
+ export { createContextLogger };
@@ -0,0 +1,169 @@
1
+ import Joi from 'joi';
2
+ import { URL } from 'url';
3
+
4
+ // Configure allowed domains for image downloads
5
+ const ALLOWED_DOMAINS = [
6
+ // Common CDNs and image hosting services
7
+ 'imgur.com',
8
+ 'i.imgur.com',
9
+ 'github.com',
10
+ 'githubusercontent.com',
11
+ 'unsplash.com',
12
+ 'images.unsplash.com',
13
+ 'cloudinary.com',
14
+ 'res.cloudinary.com',
15
+ 'amazonaws.com',
16
+ 's3.amazonaws.com',
17
+ 'googleusercontent.com',
18
+ 'gravatar.com',
19
+ 'secure.gravatar.com',
20
+ 'wp.com',
21
+ 'wordpress.com',
22
+ 'flickr.com',
23
+ 'staticflickr.com',
24
+ 'dropbox.com',
25
+ 'dl.dropboxusercontent.com',
26
+ 'pexels.com',
27
+ 'images.pexels.com',
28
+ 'pixabay.com',
29
+ 'cdn.pixabay.com'
30
+ ];
31
+
32
+ // Private/internal IP ranges to block
33
+ const BLOCKED_IP_PATTERNS = [
34
+ /^127\./, // 127.0.0.0/8 - localhost
35
+ /^10\./, // 10.0.0.0/8 - private
36
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 - private
37
+ /^192\.168\./, // 192.168.0.0/16 - private
38
+ /^169\.254\./, // 169.254.0.0/16 - link local
39
+ /^0\./, // 0.0.0.0/8
40
+ /^224\./, // 224.0.0.0/4 - multicast
41
+ /^240\./, // 240.0.0.0/4 - reserved
42
+ /^255\.255\.255\.255$/, // broadcast
43
+ /^::1$/, // IPv6 localhost
44
+ /^::/, // IPv6 unspecified
45
+ /^fc00:/, // IPv6 unique local
46
+ /^fe80:/, // IPv6 link local
47
+ /^ff00:/ // IPv6 multicast
48
+ ];
49
+
50
+ /**
51
+ * Validates if a hostname/IP is safe for external requests
52
+ * @param {string} hostname - The hostname or IP to validate
53
+ * @returns {boolean} True if safe, false if blocked
54
+ */
55
+ const isSafeHost = (hostname) => {
56
+ // Check if hostname is an IP address
57
+ const ipv4Pattern = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
58
+ const ipv6Pattern = /^[0-9a-f:]+$/i;
59
+
60
+ if (ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname)) {
61
+ // Check against blocked IP patterns
62
+ return !BLOCKED_IP_PATTERNS.some(pattern => pattern.test(hostname));
63
+ }
64
+
65
+ // For domain names, check against allowlist
66
+ const normalizedHost = hostname.toLowerCase();
67
+ return ALLOWED_DOMAINS.some(allowed =>
68
+ normalizedHost === allowed || normalizedHost.endsWith('.' + allowed)
69
+ );
70
+ };
71
+
72
+ /**
73
+ * Validates and sanitizes a URL for safe external requests
74
+ * @param {string} url - The URL to validate
75
+ * @returns {object} Validation result with isValid boolean and sanitizedUrl
76
+ */
77
+ const validateImageUrl = (url) => {
78
+ try {
79
+ // Basic URL validation with Joi
80
+ const urlSchema = Joi.string().uri({
81
+ scheme: ['http', 'https'],
82
+ allowRelative: false
83
+ }).required();
84
+
85
+ const validation = urlSchema.validate(url);
86
+ if (validation.error) {
87
+ return {
88
+ isValid: false,
89
+ error: `Invalid URL format: ${validation.error.details[0].message}`
90
+ };
91
+ }
92
+
93
+ // Parse URL for additional security checks
94
+ const parsedUrl = new URL(url);
95
+
96
+ // Only allow HTTP/HTTPS
97
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
98
+ return {
99
+ isValid: false,
100
+ error: 'Only HTTP and HTTPS protocols are allowed'
101
+ };
102
+ }
103
+
104
+ // Check if host is safe (not internal/private)
105
+ if (!isSafeHost(parsedUrl.hostname)) {
106
+ return {
107
+ isValid: false,
108
+ error: `Requests to ${parsedUrl.hostname} are not allowed for security reasons`
109
+ };
110
+ }
111
+
112
+ // Prevent requests to non-standard ports (common in SSRF attacks)
113
+ const port = parsedUrl.port;
114
+ if (port && !['80', '443', '8080', '8443'].includes(port)) {
115
+ return {
116
+ isValid: false,
117
+ error: `Requests to non-standard port ${port} are not allowed`
118
+ };
119
+ }
120
+
121
+ // Additional checks for suspicious patterns
122
+ if (parsedUrl.hostname.includes('localhost') ||
123
+ parsedUrl.hostname === '0.0.0.0' ||
124
+ parsedUrl.hostname.startsWith('192.168.') ||
125
+ parsedUrl.hostname.startsWith('10.') ||
126
+ parsedUrl.hostname.includes('.local')) {
127
+ return {
128
+ isValid: false,
129
+ error: 'Requests to local/private addresses are not allowed'
130
+ };
131
+ }
132
+
133
+ return {
134
+ isValid: true,
135
+ sanitizedUrl: parsedUrl.href
136
+ };
137
+
138
+ } catch (error) {
139
+ return {
140
+ isValid: false,
141
+ error: `URL parsing failed: ${error.message}`
142
+ };
143
+ }
144
+ };
145
+
146
+ /**
147
+ * Configures axios with security settings for external requests
148
+ * @param {string} url - The validated URL to request
149
+ * @returns {object} Axios configuration with security settings
150
+ */
151
+ const createSecureAxiosConfig = (url) => {
152
+ return {
153
+ url,
154
+ responseType: 'stream',
155
+ timeout: 10000, // 10 second timeout
156
+ maxRedirects: 3, // Limit redirects
157
+ maxContentLength: 50 * 1024 * 1024, // 50MB max response
158
+ validateStatus: (status) => status >= 200 && status < 300, // Only accept 2xx
159
+ headers: {
160
+ 'User-Agent': 'Ghost-MCP-Server/1.0'
161
+ }
162
+ };
163
+ };
164
+
165
+ export {
166
+ validateImageUrl,
167
+ createSecureAxiosConfig,
168
+ ALLOWED_DOMAINS
169
+ };