@jgardner04/ghost-mcp-server 1.0.0 → 1.1.1
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 +21 -8
- 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 +217 -582
- 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 +118 -0
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import GhostAdminAPI from
|
|
1
|
+
import GhostAdminAPI from '@tryghost/admin-api';
|
|
2
2
|
import sanitizeHtml from 'sanitize-html';
|
|
3
|
-
import dotenv from
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
4
|
import { promises as fs } from 'fs';
|
|
5
5
|
import {
|
|
6
6
|
GhostAPIError,
|
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
NotFoundError,
|
|
10
10
|
ErrorHandler,
|
|
11
11
|
CircuitBreaker,
|
|
12
|
-
retryWithBackoff
|
|
13
|
-
} from
|
|
12
|
+
retryWithBackoff,
|
|
13
|
+
} from '../errors/index.js';
|
|
14
14
|
|
|
15
15
|
dotenv.config();
|
|
16
16
|
|
|
@@ -19,10 +19,8 @@ const { GHOST_ADMIN_API_URL, GHOST_ADMIN_API_KEY } = process.env;
|
|
|
19
19
|
// Validate configuration at startup
|
|
20
20
|
if (!GHOST_ADMIN_API_URL || !GHOST_ADMIN_API_KEY) {
|
|
21
21
|
throw new ConfigurationError(
|
|
22
|
-
|
|
23
|
-
[
|
|
24
|
-
key => !process.env[key]
|
|
25
|
-
)
|
|
22
|
+
'Ghost Admin API configuration is incomplete',
|
|
23
|
+
['GHOST_ADMIN_API_URL', 'GHOST_ADMIN_API_KEY'].filter((key) => !process.env[key])
|
|
26
24
|
);
|
|
27
25
|
}
|
|
28
26
|
|
|
@@ -30,31 +28,23 @@ if (!GHOST_ADMIN_API_URL || !GHOST_ADMIN_API_KEY) {
|
|
|
30
28
|
const api = new GhostAdminAPI({
|
|
31
29
|
url: GHOST_ADMIN_API_URL,
|
|
32
30
|
key: GHOST_ADMIN_API_KEY,
|
|
33
|
-
version:
|
|
31
|
+
version: 'v5.0',
|
|
34
32
|
});
|
|
35
33
|
|
|
36
34
|
// Circuit breaker for Ghost API
|
|
37
35
|
const ghostCircuitBreaker = new CircuitBreaker({
|
|
38
36
|
failureThreshold: 5,
|
|
39
37
|
resetTimeout: 60000, // 1 minute
|
|
40
|
-
monitoringPeriod: 10000 // 10 seconds
|
|
38
|
+
monitoringPeriod: 10000, // 10 seconds
|
|
41
39
|
});
|
|
42
40
|
|
|
43
41
|
/**
|
|
44
42
|
* Enhanced handler for Ghost Admin API requests with proper error handling
|
|
45
43
|
*/
|
|
46
|
-
const handleApiRequest = async (
|
|
47
|
-
resource,
|
|
48
|
-
action,
|
|
49
|
-
data = {},
|
|
50
|
-
options = {},
|
|
51
|
-
config = {}
|
|
52
|
-
) => {
|
|
44
|
+
const handleApiRequest = async (resource, action, data = {}, options = {}, config = {}) => {
|
|
53
45
|
// Validate inputs
|
|
54
|
-
if (!api[resource] || typeof api[resource][action] !==
|
|
55
|
-
throw new ValidationError(
|
|
56
|
-
`Invalid Ghost API resource or action: ${resource}.${action}`
|
|
57
|
-
);
|
|
46
|
+
if (!api[resource] || typeof api[resource][action] !== 'function') {
|
|
47
|
+
throw new ValidationError(`Invalid Ghost API resource or action: ${resource}.${action}`);
|
|
58
48
|
}
|
|
59
49
|
|
|
60
50
|
const operation = `${resource}.${action}`;
|
|
@@ -65,23 +55,23 @@ const handleApiRequest = async (
|
|
|
65
55
|
const executeRequest = async () => {
|
|
66
56
|
try {
|
|
67
57
|
console.log(`Executing Ghost API request: ${operation}`);
|
|
68
|
-
|
|
58
|
+
|
|
69
59
|
let result;
|
|
70
|
-
|
|
60
|
+
|
|
71
61
|
// Handle different action signatures
|
|
72
62
|
switch (action) {
|
|
73
|
-
case
|
|
74
|
-
case
|
|
63
|
+
case 'add':
|
|
64
|
+
case 'edit':
|
|
75
65
|
result = await api[resource][action](data, options);
|
|
76
66
|
break;
|
|
77
|
-
case
|
|
67
|
+
case 'upload':
|
|
78
68
|
result = await api[resource][action](data);
|
|
79
69
|
break;
|
|
80
|
-
case
|
|
81
|
-
case
|
|
70
|
+
case 'browse':
|
|
71
|
+
case 'read':
|
|
82
72
|
result = await api[resource][action](options, data);
|
|
83
73
|
break;
|
|
84
|
-
case
|
|
74
|
+
case 'delete':
|
|
85
75
|
result = await api[resource][action](data.id || data, options);
|
|
86
76
|
break;
|
|
87
77
|
default:
|
|
@@ -90,7 +80,6 @@ const handleApiRequest = async (
|
|
|
90
80
|
|
|
91
81
|
console.log(`Successfully executed Ghost API request: ${operation}`);
|
|
92
82
|
return result;
|
|
93
|
-
|
|
94
83
|
} catch (error) {
|
|
95
84
|
// Transform Ghost API errors into our error types
|
|
96
85
|
throw ErrorHandler.fromGhostError(error, operation);
|
|
@@ -106,15 +95,15 @@ const handleApiRequest = async (
|
|
|
106
95
|
try {
|
|
107
96
|
return await retryWithBackoff(wrappedExecute, {
|
|
108
97
|
maxAttempts: maxRetries,
|
|
109
|
-
onRetry: (attempt,
|
|
98
|
+
onRetry: (attempt, _error) => {
|
|
110
99
|
console.log(`Retrying ${operation} (attempt ${attempt}/${maxRetries})`);
|
|
111
|
-
|
|
100
|
+
|
|
112
101
|
// Log circuit breaker state if relevant
|
|
113
102
|
if (useCircuitBreaker) {
|
|
114
103
|
const state = ghostCircuitBreaker.getState();
|
|
115
104
|
console.log(`Circuit breaker state:`, state);
|
|
116
105
|
}
|
|
117
|
-
}
|
|
106
|
+
},
|
|
118
107
|
});
|
|
119
108
|
} catch (error) {
|
|
120
109
|
console.error(`Failed to execute ${operation} after ${maxRetries} attempts:`, error.message);
|
|
@@ -128,23 +117,29 @@ const handleApiRequest = async (
|
|
|
128
117
|
const validators = {
|
|
129
118
|
validatePostData(postData) {
|
|
130
119
|
const errors = [];
|
|
131
|
-
|
|
120
|
+
|
|
132
121
|
if (!postData.title || postData.title.trim().length === 0) {
|
|
133
122
|
errors.push({ field: 'title', message: 'Title is required' });
|
|
134
123
|
}
|
|
135
|
-
|
|
124
|
+
|
|
136
125
|
if (!postData.html && !postData.mobiledoc) {
|
|
137
126
|
errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
|
|
138
127
|
}
|
|
139
|
-
|
|
128
|
+
|
|
140
129
|
if (postData.status && !['draft', 'published', 'scheduled'].includes(postData.status)) {
|
|
141
|
-
errors.push({
|
|
130
|
+
errors.push({
|
|
131
|
+
field: 'status',
|
|
132
|
+
message: 'Invalid status. Must be draft, published, or scheduled',
|
|
133
|
+
});
|
|
142
134
|
}
|
|
143
|
-
|
|
135
|
+
|
|
144
136
|
if (postData.status === 'scheduled' && !postData.published_at) {
|
|
145
|
-
errors.push({
|
|
137
|
+
errors.push({
|
|
138
|
+
field: 'published_at',
|
|
139
|
+
message: 'published_at is required when status is scheduled',
|
|
140
|
+
});
|
|
146
141
|
}
|
|
147
|
-
|
|
142
|
+
|
|
148
143
|
if (postData.published_at) {
|
|
149
144
|
const publishDate = new Date(postData.published_at);
|
|
150
145
|
if (isNaN(publishDate.getTime())) {
|
|
@@ -153,7 +148,7 @@ const validators = {
|
|
|
153
148
|
errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
|
|
154
149
|
}
|
|
155
150
|
}
|
|
156
|
-
|
|
151
|
+
|
|
157
152
|
if (errors.length > 0) {
|
|
158
153
|
throw new ValidationError('Post validation failed', errors);
|
|
159
154
|
}
|
|
@@ -161,15 +156,18 @@ const validators = {
|
|
|
161
156
|
|
|
162
157
|
validateTagData(tagData) {
|
|
163
158
|
const errors = [];
|
|
164
|
-
|
|
159
|
+
|
|
165
160
|
if (!tagData.name || tagData.name.trim().length === 0) {
|
|
166
161
|
errors.push({ field: 'name', message: 'Tag name is required' });
|
|
167
162
|
}
|
|
168
|
-
|
|
169
|
-
if (tagData.slug && !/^[a-z0-9
|
|
170
|
-
errors.push({
|
|
163
|
+
|
|
164
|
+
if (tagData.slug && !/^[a-z0-9-]+$/.test(tagData.slug)) {
|
|
165
|
+
errors.push({
|
|
166
|
+
field: 'slug',
|
|
167
|
+
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
|
168
|
+
});
|
|
171
169
|
}
|
|
172
|
-
|
|
170
|
+
|
|
173
171
|
if (errors.length > 0) {
|
|
174
172
|
throw new ValidationError('Tag validation failed', errors);
|
|
175
173
|
}
|
|
@@ -179,14 +177,14 @@ const validators = {
|
|
|
179
177
|
if (!imagePath || typeof imagePath !== 'string') {
|
|
180
178
|
throw new ValidationError('Image path is required and must be a string');
|
|
181
179
|
}
|
|
182
|
-
|
|
180
|
+
|
|
183
181
|
// Check if file exists
|
|
184
182
|
try {
|
|
185
183
|
await fs.access(imagePath);
|
|
186
184
|
} catch {
|
|
187
185
|
throw new NotFoundError('Image file', imagePath);
|
|
188
186
|
}
|
|
189
|
-
}
|
|
187
|
+
},
|
|
190
188
|
};
|
|
191
189
|
|
|
192
190
|
/**
|
|
@@ -195,20 +193,20 @@ const validators = {
|
|
|
195
193
|
|
|
196
194
|
export async function getSiteInfo() {
|
|
197
195
|
try {
|
|
198
|
-
return await handleApiRequest(
|
|
196
|
+
return await handleApiRequest('site', 'read');
|
|
199
197
|
} catch (error) {
|
|
200
|
-
console.error(
|
|
198
|
+
console.error('Failed to get site info:', error);
|
|
201
199
|
throw error;
|
|
202
200
|
}
|
|
203
201
|
}
|
|
204
202
|
|
|
205
|
-
export async function createPost(postData, options = { source:
|
|
203
|
+
export async function createPost(postData, options = { source: 'html' }) {
|
|
206
204
|
// Validate input
|
|
207
205
|
validators.validatePostData(postData);
|
|
208
|
-
|
|
206
|
+
|
|
209
207
|
// Add defaults
|
|
210
208
|
const dataWithDefaults = {
|
|
211
|
-
status:
|
|
209
|
+
status: 'draft',
|
|
212
210
|
...postData,
|
|
213
211
|
};
|
|
214
212
|
|
|
@@ -217,28 +215,51 @@ export async function createPost(postData, options = { source: "html" }) {
|
|
|
217
215
|
// Use proper HTML sanitization library to prevent XSS
|
|
218
216
|
dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
|
|
219
217
|
allowedTags: [
|
|
220
|
-
'h1',
|
|
221
|
-
'
|
|
218
|
+
'h1',
|
|
219
|
+
'h2',
|
|
220
|
+
'h3',
|
|
221
|
+
'h4',
|
|
222
|
+
'h5',
|
|
223
|
+
'h6',
|
|
224
|
+
'blockquote',
|
|
225
|
+
'p',
|
|
226
|
+
'a',
|
|
227
|
+
'ul',
|
|
228
|
+
'ol',
|
|
229
|
+
'nl',
|
|
230
|
+
'li',
|
|
231
|
+
'b',
|
|
232
|
+
'i',
|
|
233
|
+
'strong',
|
|
234
|
+
'em',
|
|
235
|
+
'strike',
|
|
236
|
+
'code',
|
|
237
|
+
'hr',
|
|
238
|
+
'br',
|
|
239
|
+
'div',
|
|
240
|
+
'span',
|
|
241
|
+
'img',
|
|
242
|
+
'pre',
|
|
222
243
|
],
|
|
223
244
|
allowedAttributes: {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
'*': ['class', 'id']
|
|
245
|
+
a: ['href', 'title'],
|
|
246
|
+
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
247
|
+
'*': ['class', 'id'],
|
|
227
248
|
},
|
|
228
249
|
allowedSchemes: ['http', 'https', 'mailto'],
|
|
229
250
|
allowedSchemesByTag: {
|
|
230
|
-
img: ['http', 'https', 'data']
|
|
231
|
-
}
|
|
251
|
+
img: ['http', 'https', 'data'],
|
|
252
|
+
},
|
|
232
253
|
});
|
|
233
254
|
}
|
|
234
255
|
|
|
235
256
|
try {
|
|
236
|
-
return await handleApiRequest(
|
|
257
|
+
return await handleApiRequest('posts', 'add', dataWithDefaults, options);
|
|
237
258
|
} catch (error) {
|
|
238
259
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
239
260
|
// Transform Ghost validation errors into our format
|
|
240
261
|
throw new ValidationError('Post creation failed due to validation errors', [
|
|
241
|
-
{ field: 'post', message: error.originalError }
|
|
262
|
+
{ field: 'post', message: error.originalError },
|
|
242
263
|
]);
|
|
243
264
|
}
|
|
244
265
|
throw error;
|
|
@@ -249,19 +270,19 @@ export async function updatePost(postId, updateData, options = {}) {
|
|
|
249
270
|
if (!postId) {
|
|
250
271
|
throw new ValidationError('Post ID is required for update');
|
|
251
272
|
}
|
|
252
|
-
|
|
273
|
+
|
|
253
274
|
// Get the current post first to ensure it exists
|
|
254
275
|
try {
|
|
255
|
-
const existingPost = await handleApiRequest(
|
|
256
|
-
|
|
276
|
+
const existingPost = await handleApiRequest('posts', 'read', { id: postId });
|
|
277
|
+
|
|
257
278
|
// Merge with existing data
|
|
258
279
|
const mergedData = {
|
|
259
280
|
...existingPost,
|
|
260
281
|
...updateData,
|
|
261
|
-
updated_at: existingPost.updated_at // Required for Ghost API
|
|
282
|
+
updated_at: existingPost.updated_at, // Required for Ghost API
|
|
262
283
|
};
|
|
263
|
-
|
|
264
|
-
return await handleApiRequest(
|
|
284
|
+
|
|
285
|
+
return await handleApiRequest('posts', 'edit', mergedData, { id: postId, ...options });
|
|
265
286
|
} catch (error) {
|
|
266
287
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
267
288
|
throw new NotFoundError('Post', postId);
|
|
@@ -274,9 +295,9 @@ export async function deletePost(postId) {
|
|
|
274
295
|
if (!postId) {
|
|
275
296
|
throw new ValidationError('Post ID is required for deletion');
|
|
276
297
|
}
|
|
277
|
-
|
|
298
|
+
|
|
278
299
|
try {
|
|
279
|
-
return await handleApiRequest(
|
|
300
|
+
return await handleApiRequest('posts', 'delete', { id: postId });
|
|
280
301
|
} catch (error) {
|
|
281
302
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
282
303
|
throw new NotFoundError('Post', postId);
|
|
@@ -289,9 +310,9 @@ export async function getPost(postId, options = {}) {
|
|
|
289
310
|
if (!postId) {
|
|
290
311
|
throw new ValidationError('Post ID is required');
|
|
291
312
|
}
|
|
292
|
-
|
|
313
|
+
|
|
293
314
|
try {
|
|
294
|
-
return await handleApiRequest(
|
|
315
|
+
return await handleApiRequest('posts', 'read', { id: postId }, options);
|
|
295
316
|
} catch (error) {
|
|
296
317
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
297
318
|
throw new NotFoundError('Post', postId);
|
|
@@ -304,13 +325,13 @@ export async function getPosts(options = {}) {
|
|
|
304
325
|
const defaultOptions = {
|
|
305
326
|
limit: 15,
|
|
306
327
|
include: 'tags,authors',
|
|
307
|
-
...options
|
|
328
|
+
...options,
|
|
308
329
|
};
|
|
309
|
-
|
|
330
|
+
|
|
310
331
|
try {
|
|
311
|
-
return await handleApiRequest(
|
|
332
|
+
return await handleApiRequest('posts', 'browse', {}, defaultOptions);
|
|
312
333
|
} catch (error) {
|
|
313
|
-
console.error(
|
|
334
|
+
console.error('Failed to get posts:', error);
|
|
314
335
|
throw error;
|
|
315
336
|
}
|
|
316
337
|
}
|
|
@@ -318,11 +339,11 @@ export async function getPosts(options = {}) {
|
|
|
318
339
|
export async function uploadImage(imagePath) {
|
|
319
340
|
// Validate input
|
|
320
341
|
await validators.validateImagePath(imagePath);
|
|
321
|
-
|
|
342
|
+
|
|
322
343
|
const imageData = { file: imagePath };
|
|
323
|
-
|
|
344
|
+
|
|
324
345
|
try {
|
|
325
|
-
return await handleApiRequest(
|
|
346
|
+
return await handleApiRequest('images', 'upload', imageData);
|
|
326
347
|
} catch (error) {
|
|
327
348
|
if (error instanceof GhostAPIError) {
|
|
328
349
|
throw new ValidationError(`Image upload failed: ${error.originalError}`);
|
|
@@ -334,7 +355,7 @@ export async function uploadImage(imagePath) {
|
|
|
334
355
|
export async function createTag(tagData) {
|
|
335
356
|
// Validate input
|
|
336
357
|
validators.validateTagData(tagData);
|
|
337
|
-
|
|
358
|
+
|
|
338
359
|
// Auto-generate slug if not provided
|
|
339
360
|
if (!tagData.slug) {
|
|
340
361
|
tagData.slug = tagData.name
|
|
@@ -342,9 +363,9 @@ export async function createTag(tagData) {
|
|
|
342
363
|
.replace(/[^a-z0-9]+/g, '-')
|
|
343
364
|
.replace(/^-+|-+$/g, '');
|
|
344
365
|
}
|
|
345
|
-
|
|
366
|
+
|
|
346
367
|
try {
|
|
347
|
-
return await handleApiRequest(
|
|
368
|
+
return await handleApiRequest('tags', 'add', tagData);
|
|
348
369
|
} catch (error) {
|
|
349
370
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
350
371
|
// Check if it's a duplicate tag error
|
|
@@ -356,7 +377,7 @@ export async function createTag(tagData) {
|
|
|
356
377
|
}
|
|
357
378
|
}
|
|
358
379
|
throw new ValidationError('Tag creation failed', [
|
|
359
|
-
{ field: 'tag', message: error.originalError }
|
|
380
|
+
{ field: 'tag', message: error.originalError },
|
|
360
381
|
]);
|
|
361
382
|
}
|
|
362
383
|
throw error;
|
|
@@ -365,15 +386,15 @@ export async function createTag(tagData) {
|
|
|
365
386
|
|
|
366
387
|
export async function getTags(name) {
|
|
367
388
|
const options = {
|
|
368
|
-
limit:
|
|
389
|
+
limit: 'all',
|
|
369
390
|
...(name && { filter: `name:'${name}'` }),
|
|
370
391
|
};
|
|
371
|
-
|
|
392
|
+
|
|
372
393
|
try {
|
|
373
|
-
const tags = await handleApiRequest(
|
|
394
|
+
const tags = await handleApiRequest('tags', 'browse', {}, options);
|
|
374
395
|
return tags || [];
|
|
375
396
|
} catch (error) {
|
|
376
|
-
console.error(
|
|
397
|
+
console.error('Failed to get tags:', error);
|
|
377
398
|
throw error;
|
|
378
399
|
}
|
|
379
400
|
}
|
|
@@ -382,9 +403,9 @@ export async function getTag(tagId) {
|
|
|
382
403
|
if (!tagId) {
|
|
383
404
|
throw new ValidationError('Tag ID is required');
|
|
384
405
|
}
|
|
385
|
-
|
|
406
|
+
|
|
386
407
|
try {
|
|
387
|
-
return await handleApiRequest(
|
|
408
|
+
return await handleApiRequest('tags', 'read', { id: tagId });
|
|
388
409
|
} catch (error) {
|
|
389
410
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
390
411
|
throw new NotFoundError('Tag', tagId);
|
|
@@ -397,24 +418,24 @@ export async function updateTag(tagId, updateData) {
|
|
|
397
418
|
if (!tagId) {
|
|
398
419
|
throw new ValidationError('Tag ID is required for update');
|
|
399
420
|
}
|
|
400
|
-
|
|
421
|
+
|
|
401
422
|
validators.validateTagData({ name: 'dummy', ...updateData }); // Validate update data
|
|
402
|
-
|
|
423
|
+
|
|
403
424
|
try {
|
|
404
425
|
const existingTag = await getTag(tagId);
|
|
405
426
|
const mergedData = {
|
|
406
427
|
...existingTag,
|
|
407
|
-
...updateData
|
|
428
|
+
...updateData,
|
|
408
429
|
};
|
|
409
|
-
|
|
410
|
-
return await handleApiRequest(
|
|
430
|
+
|
|
431
|
+
return await handleApiRequest('tags', 'edit', mergedData, { id: tagId });
|
|
411
432
|
} catch (error) {
|
|
412
433
|
if (error instanceof NotFoundError) {
|
|
413
434
|
throw error;
|
|
414
435
|
}
|
|
415
436
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
416
437
|
throw new ValidationError('Tag update failed', [
|
|
417
|
-
{ field: 'tag', message: error.originalError }
|
|
438
|
+
{ field: 'tag', message: error.originalError },
|
|
418
439
|
]);
|
|
419
440
|
}
|
|
420
441
|
throw error;
|
|
@@ -425,9 +446,9 @@ export async function deleteTag(tagId) {
|
|
|
425
446
|
if (!tagId) {
|
|
426
447
|
throw new ValidationError('Tag ID is required for deletion');
|
|
427
448
|
}
|
|
428
|
-
|
|
449
|
+
|
|
429
450
|
try {
|
|
430
|
-
return await handleApiRequest(
|
|
451
|
+
return await handleApiRequest('tags', 'delete', { id: tagId });
|
|
431
452
|
} catch (error) {
|
|
432
453
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
433
454
|
throw new NotFoundError('Tag', tagId);
|
|
@@ -443,34 +464,29 @@ export async function checkHealth() {
|
|
|
443
464
|
try {
|
|
444
465
|
const site = await getSiteInfo();
|
|
445
466
|
const circuitState = ghostCircuitBreaker.getState();
|
|
446
|
-
|
|
467
|
+
|
|
447
468
|
return {
|
|
448
469
|
status: 'healthy',
|
|
449
470
|
site: {
|
|
450
471
|
title: site.title,
|
|
451
472
|
version: site.version,
|
|
452
|
-
url: site.url
|
|
473
|
+
url: site.url,
|
|
453
474
|
},
|
|
454
475
|
circuitBreaker: circuitState,
|
|
455
|
-
timestamp: new Date().toISOString()
|
|
476
|
+
timestamp: new Date().toISOString(),
|
|
456
477
|
};
|
|
457
478
|
} catch (error) {
|
|
458
479
|
return {
|
|
459
480
|
status: 'unhealthy',
|
|
460
481
|
error: error.message,
|
|
461
482
|
circuitBreaker: ghostCircuitBreaker.getState(),
|
|
462
|
-
timestamp: new Date().toISOString()
|
|
483
|
+
timestamp: new Date().toISOString(),
|
|
463
484
|
};
|
|
464
485
|
}
|
|
465
486
|
}
|
|
466
487
|
|
|
467
488
|
// Export everything including the API client for backward compatibility
|
|
468
|
-
export {
|
|
469
|
-
api,
|
|
470
|
-
handleApiRequest,
|
|
471
|
-
ghostCircuitBreaker,
|
|
472
|
-
validators
|
|
473
|
-
};
|
|
489
|
+
export { api, handleApiRequest, ghostCircuitBreaker, validators };
|
|
474
490
|
|
|
475
491
|
export default {
|
|
476
492
|
getSiteInfo,
|
|
@@ -485,5 +501,5 @@ export default {
|
|
|
485
501
|
getTag,
|
|
486
502
|
updateTag,
|
|
487
503
|
deleteTag,
|
|
488
|
-
checkHealth
|
|
489
|
-
};
|
|
504
|
+
checkHealth,
|
|
505
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import sharp from
|
|
2
|
-
import path from
|
|
3
|
-
import fs from
|
|
4
|
-
import Joi from
|
|
5
|
-
import { createContextLogger } from
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import Joi from 'joi';
|
|
5
|
+
import { createContextLogger } from '../utils/logger.js';
|
|
6
6
|
|
|
7
7
|
// Define processing parameters (e.g., max width)
|
|
8
8
|
const MAX_WIDTH = 1200;
|
|
@@ -17,32 +17,32 @@ const OUTPUT_QUALITY = 80; // JPEG quality
|
|
|
17
17
|
// Validation schema for processing parameters
|
|
18
18
|
const processImageSchema = Joi.object({
|
|
19
19
|
inputPath: Joi.string().required(),
|
|
20
|
-
outputDir: Joi.string().required()
|
|
20
|
+
outputDir: Joi.string().required(),
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
const processImage = async (inputPath, outputDir) => {
|
|
24
24
|
const logger = createContextLogger('image-processing');
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
// Validate inputs to prevent path injection
|
|
27
27
|
const { error } = processImageSchema.validate({ inputPath, outputDir });
|
|
28
28
|
if (error) {
|
|
29
29
|
logger.error('Invalid processing parameters', {
|
|
30
30
|
error: error.details[0].message,
|
|
31
31
|
inputPath: path.basename(inputPath),
|
|
32
|
-
outputDir: path.basename(outputDir)
|
|
32
|
+
outputDir: path.basename(outputDir),
|
|
33
33
|
});
|
|
34
34
|
throw new Error('Invalid processing parameters');
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
|
|
37
37
|
// Ensure paths are safe
|
|
38
38
|
const resolvedInputPath = path.resolve(inputPath);
|
|
39
39
|
const resolvedOutputDir = path.resolve(outputDir);
|
|
40
|
-
|
|
40
|
+
|
|
41
41
|
// Verify input file exists
|
|
42
42
|
if (!fs.existsSync(resolvedInputPath)) {
|
|
43
43
|
throw new Error('Input file does not exist');
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
|
|
46
46
|
const filename = path.basename(resolvedInputPath);
|
|
47
47
|
const nameWithoutExt = filename.split('.').slice(0, -1).join('.');
|
|
48
48
|
// Use timestamp for unique output filename
|
|
@@ -53,7 +53,7 @@ const processImage = async (inputPath, outputDir) => {
|
|
|
53
53
|
try {
|
|
54
54
|
logger.info('Processing image', {
|
|
55
55
|
inputFile: path.basename(inputPath),
|
|
56
|
-
outputDir: path.basename(outputDir)
|
|
56
|
+
outputDir: path.basename(outputDir),
|
|
57
57
|
});
|
|
58
58
|
const image = sharp(inputPath);
|
|
59
59
|
const metadata = await image.metadata();
|
|
@@ -65,7 +65,7 @@ const processImage = async (inputPath, outputDir) => {
|
|
|
65
65
|
logger.info('Resizing image', {
|
|
66
66
|
originalWidth: metadata.width,
|
|
67
67
|
targetWidth: MAX_WIDTH,
|
|
68
|
-
inputFile: path.basename(inputPath)
|
|
68
|
+
inputFile: path.basename(inputPath),
|
|
69
69
|
});
|
|
70
70
|
processedImage = processedImage.resize({ width: MAX_WIDTH });
|
|
71
71
|
}
|
|
@@ -78,14 +78,14 @@ const processImage = async (inputPath, outputDir) => {
|
|
|
78
78
|
inputFile: path.basename(inputPath),
|
|
79
79
|
outputFile: path.basename(outputPath),
|
|
80
80
|
originalSize: metadata.size,
|
|
81
|
-
quality: OUTPUT_QUALITY
|
|
81
|
+
quality: OUTPUT_QUALITY,
|
|
82
82
|
});
|
|
83
83
|
return outputPath;
|
|
84
84
|
} catch (error) {
|
|
85
85
|
logger.error('Image processing failed', {
|
|
86
86
|
inputFile: path.basename(inputPath),
|
|
87
87
|
error: error.message,
|
|
88
|
-
stack: error.stack
|
|
88
|
+
stack: error.stack,
|
|
89
89
|
});
|
|
90
90
|
// If processing fails, maybe fall back to using the original?
|
|
91
91
|
// Or throw the error to fail the upload.
|