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