@jgardner04/ghost-mcp-server 1.4.0 → 1.6.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.
@@ -185,6 +185,57 @@ const validators = {
185
185
  throw new NotFoundError('Image file', imagePath);
186
186
  }
187
187
  },
188
+
189
+ validatePageData(pageData) {
190
+ const errors = [];
191
+
192
+ if (!pageData.title || pageData.title.trim().length === 0) {
193
+ errors.push({ field: 'title', message: 'Title is required' });
194
+ }
195
+
196
+ if (!pageData.html && !pageData.mobiledoc) {
197
+ errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
198
+ }
199
+
200
+ if (pageData.status && !['draft', 'published', 'scheduled'].includes(pageData.status)) {
201
+ errors.push({
202
+ field: 'status',
203
+ message: 'Invalid status. Must be draft, published, or scheduled',
204
+ });
205
+ }
206
+
207
+ if (pageData.status === 'scheduled' && !pageData.published_at) {
208
+ errors.push({
209
+ field: 'published_at',
210
+ message: 'published_at is required when status is scheduled',
211
+ });
212
+ }
213
+
214
+ if (pageData.published_at) {
215
+ const publishDate = new Date(pageData.published_at);
216
+ if (isNaN(publishDate.getTime())) {
217
+ errors.push({ field: 'published_at', message: 'Invalid date format' });
218
+ } else if (pageData.status === 'scheduled' && publishDate <= new Date()) {
219
+ errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
220
+ }
221
+ }
222
+
223
+ if (errors.length > 0) {
224
+ throw new ValidationError('Page validation failed', errors);
225
+ }
226
+ },
227
+
228
+ validateNewsletterData(newsletterData) {
229
+ const errors = [];
230
+
231
+ if (!newsletterData.name || newsletterData.name.trim().length === 0) {
232
+ errors.push({ field: 'name', message: 'Newsletter name is required' });
233
+ }
234
+
235
+ if (errors.length > 0) {
236
+ throw new ValidationError('Newsletter validation failed', errors);
237
+ }
238
+ },
188
239
  };
189
240
 
190
241
  /**
@@ -367,6 +418,218 @@ export async function searchPosts(query, options = {}) {
367
418
  }
368
419
  }
369
420
 
421
+ /**
422
+ * Page CRUD Operations
423
+ * Pages are similar to posts but do NOT support tags
424
+ */
425
+
426
+ export async function createPage(pageData, options = { source: 'html' }) {
427
+ // Validate input
428
+ validators.validatePageData(pageData);
429
+
430
+ // Add defaults
431
+ const dataWithDefaults = {
432
+ status: 'draft',
433
+ ...pageData,
434
+ };
435
+
436
+ // Sanitize HTML content if provided (use same sanitization as posts)
437
+ if (dataWithDefaults.html) {
438
+ dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
439
+ allowedTags: [
440
+ 'h1',
441
+ 'h2',
442
+ 'h3',
443
+ 'h4',
444
+ 'h5',
445
+ 'h6',
446
+ 'blockquote',
447
+ 'p',
448
+ 'a',
449
+ 'ul',
450
+ 'ol',
451
+ 'nl',
452
+ 'li',
453
+ 'b',
454
+ 'i',
455
+ 'strong',
456
+ 'em',
457
+ 'strike',
458
+ 'code',
459
+ 'hr',
460
+ 'br',
461
+ 'div',
462
+ 'span',
463
+ 'img',
464
+ 'pre',
465
+ ],
466
+ allowedAttributes: {
467
+ a: ['href', 'title'],
468
+ img: ['src', 'alt', 'title', 'width', 'height'],
469
+ '*': ['class', 'id'],
470
+ },
471
+ allowedSchemes: ['http', 'https', 'mailto'],
472
+ allowedSchemesByTag: {
473
+ img: ['http', 'https', 'data'],
474
+ },
475
+ });
476
+ }
477
+
478
+ try {
479
+ return await handleApiRequest('pages', 'add', dataWithDefaults, options);
480
+ } catch (error) {
481
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
482
+ throw new ValidationError('Page creation failed due to validation errors', [
483
+ { field: 'page', message: error.originalError },
484
+ ]);
485
+ }
486
+ throw error;
487
+ }
488
+ }
489
+
490
+ export async function updatePage(pageId, updateData, options = {}) {
491
+ if (!pageId) {
492
+ throw new ValidationError('Page ID is required for update');
493
+ }
494
+
495
+ // Sanitize HTML if being updated
496
+ if (updateData.html) {
497
+ updateData.html = sanitizeHtml(updateData.html, {
498
+ allowedTags: [
499
+ 'h1',
500
+ 'h2',
501
+ 'h3',
502
+ 'h4',
503
+ 'h5',
504
+ 'h6',
505
+ 'blockquote',
506
+ 'p',
507
+ 'a',
508
+ 'ul',
509
+ 'ol',
510
+ 'nl',
511
+ 'li',
512
+ 'b',
513
+ 'i',
514
+ 'strong',
515
+ 'em',
516
+ 'strike',
517
+ 'code',
518
+ 'hr',
519
+ 'br',
520
+ 'div',
521
+ 'span',
522
+ 'img',
523
+ 'pre',
524
+ ],
525
+ allowedAttributes: {
526
+ a: ['href', 'title'],
527
+ img: ['src', 'alt', 'title', 'width', 'height'],
528
+ '*': ['class', 'id'],
529
+ },
530
+ allowedSchemes: ['http', 'https', 'mailto'],
531
+ allowedSchemesByTag: {
532
+ img: ['http', 'https', 'data'],
533
+ },
534
+ });
535
+ }
536
+
537
+ try {
538
+ // Get existing page to retrieve updated_at for conflict resolution
539
+ const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
540
+
541
+ // Merge existing data with updates, preserving updated_at
542
+ const mergedData = {
543
+ ...existingPage,
544
+ ...updateData,
545
+ updated_at: existingPage.updated_at,
546
+ };
547
+
548
+ return await handleApiRequest('pages', 'edit', mergedData, { id: pageId, ...options });
549
+ } catch (error) {
550
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
551
+ throw new NotFoundError('Page', pageId);
552
+ }
553
+ throw error;
554
+ }
555
+ }
556
+
557
+ export async function deletePage(pageId) {
558
+ if (!pageId) {
559
+ throw new ValidationError('Page ID is required for delete');
560
+ }
561
+
562
+ try {
563
+ return await handleApiRequest('pages', 'delete', { id: pageId });
564
+ } catch (error) {
565
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
566
+ throw new NotFoundError('Page', pageId);
567
+ }
568
+ throw error;
569
+ }
570
+ }
571
+
572
+ export async function getPage(pageId, options = {}) {
573
+ if (!pageId) {
574
+ throw new ValidationError('Page ID is required');
575
+ }
576
+
577
+ try {
578
+ return await handleApiRequest('pages', 'read', { id: pageId }, options);
579
+ } catch (error) {
580
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
581
+ throw new NotFoundError('Page', pageId);
582
+ }
583
+ throw error;
584
+ }
585
+ }
586
+
587
+ export async function getPages(options = {}) {
588
+ const defaultOptions = {
589
+ limit: 15,
590
+ include: 'authors',
591
+ ...options,
592
+ };
593
+
594
+ try {
595
+ return await handleApiRequest('pages', 'browse', {}, defaultOptions);
596
+ } catch (error) {
597
+ console.error('Failed to get pages:', error);
598
+ throw error;
599
+ }
600
+ }
601
+
602
+ export async function searchPages(query, options = {}) {
603
+ // Validate query
604
+ if (!query || query.trim().length === 0) {
605
+ throw new ValidationError('Search query is required');
606
+ }
607
+
608
+ // Sanitize query - escape special NQL characters to prevent injection
609
+ const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
610
+
611
+ // Build filter with fuzzy title match using Ghost NQL
612
+ const filterParts = [`title:~'${sanitizedQuery}'`];
613
+
614
+ // Add status filter if provided and not 'all'
615
+ if (options.status && options.status !== 'all') {
616
+ filterParts.push(`status:${options.status}`);
617
+ }
618
+
619
+ const searchOptions = {
620
+ limit: options.limit || 15,
621
+ include: 'authors',
622
+ filter: filterParts.join('+'),
623
+ };
624
+
625
+ try {
626
+ return await handleApiRequest('pages', 'browse', {}, searchOptions);
627
+ } catch (error) {
628
+ console.error('Failed to search pages:', error);
629
+ throw error;
630
+ }
631
+ }
632
+
370
633
  export async function uploadImage(imagePath) {
371
634
  // Validate input
372
635
  await validators.validateImagePath(imagePath);
@@ -488,6 +751,103 @@ export async function deleteTag(tagId) {
488
751
  }
489
752
  }
490
753
 
754
+ /**
755
+ * Newsletter CRUD Operations
756
+ */
757
+
758
+ export async function getNewsletters(options = {}) {
759
+ const defaultOptions = {
760
+ limit: 'all',
761
+ ...options,
762
+ };
763
+
764
+ try {
765
+ const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions);
766
+ return newsletters || [];
767
+ } catch (error) {
768
+ console.error('Failed to get newsletters:', error);
769
+ throw error;
770
+ }
771
+ }
772
+
773
+ export async function getNewsletter(newsletterId) {
774
+ if (!newsletterId) {
775
+ throw new ValidationError('Newsletter ID is required');
776
+ }
777
+
778
+ try {
779
+ return await handleApiRequest('newsletters', 'read', { id: newsletterId });
780
+ } catch (error) {
781
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
782
+ throw new NotFoundError('Newsletter', newsletterId);
783
+ }
784
+ throw error;
785
+ }
786
+ }
787
+
788
+ export async function createNewsletter(newsletterData) {
789
+ // Validate input
790
+ validators.validateNewsletterData(newsletterData);
791
+
792
+ try {
793
+ return await handleApiRequest('newsletters', 'add', newsletterData);
794
+ } catch (error) {
795
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
796
+ throw new ValidationError('Newsletter creation failed', [
797
+ { field: 'newsletter', message: error.originalError },
798
+ ]);
799
+ }
800
+ throw error;
801
+ }
802
+ }
803
+
804
+ export async function updateNewsletter(newsletterId, updateData) {
805
+ if (!newsletterId) {
806
+ throw new ValidationError('Newsletter ID is required for update');
807
+ }
808
+
809
+ try {
810
+ // Get existing newsletter to retrieve updated_at for conflict resolution
811
+ const existingNewsletter = await handleApiRequest('newsletters', 'read', {
812
+ id: newsletterId,
813
+ });
814
+
815
+ // Merge existing data with updates, preserving updated_at
816
+ const mergedData = {
817
+ ...existingNewsletter,
818
+ ...updateData,
819
+ updated_at: existingNewsletter.updated_at,
820
+ };
821
+
822
+ return await handleApiRequest('newsletters', 'edit', mergedData, { id: newsletterId });
823
+ } catch (error) {
824
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
825
+ throw new NotFoundError('Newsletter', newsletterId);
826
+ }
827
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
828
+ throw new ValidationError('Newsletter update failed', [
829
+ { field: 'newsletter', message: error.originalError },
830
+ ]);
831
+ }
832
+ throw error;
833
+ }
834
+ }
835
+
836
+ export async function deleteNewsletter(newsletterId) {
837
+ if (!newsletterId) {
838
+ throw new ValidationError('Newsletter ID is required for deletion');
839
+ }
840
+
841
+ try {
842
+ return await handleApiRequest('newsletters', 'delete', newsletterId);
843
+ } catch (error) {
844
+ if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
845
+ throw new NotFoundError('Newsletter', newsletterId);
846
+ }
847
+ throw error;
848
+ }
849
+ }
850
+
491
851
  /**
492
852
  * Health check for Ghost API connection
493
853
  */
@@ -527,11 +887,22 @@ export default {
527
887
  getPost,
528
888
  getPosts,
529
889
  searchPosts,
890
+ createPage,
891
+ updatePage,
892
+ deletePage,
893
+ getPage,
894
+ getPages,
895
+ searchPages,
530
896
  uploadImage,
531
897
  createTag,
532
898
  getTags,
533
899
  getTag,
534
900
  updateTag,
535
901
  deleteTag,
902
+ getNewsletters,
903
+ getNewsletter,
904
+ createNewsletter,
905
+ updateNewsletter,
906
+ deleteNewsletter,
536
907
  checkHealth,
537
908
  };
@@ -0,0 +1,47 @@
1
+ import Joi from 'joi';
2
+ import { createContextLogger } from '../utils/logger.js';
3
+ import { createNewsletter as createGhostNewsletter } from './ghostServiceImproved.js';
4
+
5
+ /**
6
+ * Validation schema for newsletter input
7
+ */
8
+ const newsletterInputSchema = Joi.object({
9
+ name: Joi.string().required(),
10
+ description: Joi.string().optional(),
11
+ sender_name: Joi.string().optional(),
12
+ sender_email: Joi.string().email().optional(),
13
+ sender_reply_to: Joi.string().valid('newsletter', 'support').optional(),
14
+ subscribe_on_signup: Joi.boolean().strict().optional(),
15
+ show_header_icon: Joi.boolean().strict().optional(),
16
+ show_header_title: Joi.boolean().strict().optional(),
17
+ });
18
+
19
+ /**
20
+ * Service layer function to handle the business logic of creating a newsletter.
21
+ * Validates input and creates a newsletter in Ghost CMS.
22
+ * @param {object} newsletterInput - Data received from the controller.
23
+ * @returns {Promise<object>} The created newsletter object from the Ghost API.
24
+ */
25
+ const createNewsletterService = async (newsletterInput) => {
26
+ const logger = createContextLogger('newsletter-service');
27
+
28
+ // Validate input
29
+ const { error, value: validatedInput } = newsletterInputSchema.validate(newsletterInput);
30
+ if (error) {
31
+ logger.error('Newsletter input validation failed', {
32
+ error: error.details[0].message,
33
+ inputKeys: Object.keys(newsletterInput),
34
+ });
35
+ throw new Error(`Invalid newsletter input: ${error.details[0].message}`);
36
+ }
37
+
38
+ logger.info('Creating Ghost newsletter', {
39
+ name: validatedInput.name,
40
+ hasSenderEmail: !!validatedInput.sender_email,
41
+ });
42
+
43
+ const newNewsletter = await createGhostNewsletter(validatedInput);
44
+ return newNewsletter;
45
+ };
46
+
47
+ export { createNewsletterService };
@@ -0,0 +1,121 @@
1
+ import sanitizeHtml from 'sanitize-html';
2
+ import Joi from 'joi';
3
+ import { createContextLogger } from '../utils/logger.js';
4
+ import { createPage as createGhostPage } from './ghostServiceImproved.js';
5
+
6
+ /**
7
+ * Helper to generate a simple meta description from HTML content.
8
+ * Uses sanitize-html to safely strip HTML tags and truncates.
9
+ * @param {string} htmlContent - The HTML content of the page.
10
+ * @param {number} maxLength - The maximum length of the description.
11
+ * @returns {string} A plain text truncated description.
12
+ */
13
+ const generateSimpleMetaDescription = (htmlContent, maxLength = 500) => {
14
+ if (!htmlContent) return '';
15
+
16
+ // Use sanitize-html to safely remove all HTML tags
17
+ // This prevents ReDoS attacks and properly handles malformed HTML
18
+ const textContent = sanitizeHtml(htmlContent, {
19
+ allowedTags: [], // Remove all HTML tags
20
+ allowedAttributes: {},
21
+ textFilter: function (text) {
22
+ return text.replace(/\s\s+/g, ' ').trim();
23
+ },
24
+ });
25
+
26
+ // Truncate and add ellipsis if needed
27
+ return textContent.length > maxLength
28
+ ? textContent.substring(0, maxLength - 3) + '...'
29
+ : textContent;
30
+ };
31
+
32
+ /**
33
+ * Validation schema for page input
34
+ * Pages are similar to posts but do NOT support tags
35
+ */
36
+ const pageInputSchema = Joi.object({
37
+ title: Joi.string().max(255).required(),
38
+ html: Joi.string().required(),
39
+ custom_excerpt: Joi.string().max(500).optional(),
40
+ status: Joi.string().valid('draft', 'published', 'scheduled').optional(),
41
+ published_at: Joi.string().isoDate().optional(),
42
+ // NO tags field - pages don't support tags
43
+ feature_image: Joi.string().uri().optional(),
44
+ feature_image_alt: Joi.string().max(255).optional(),
45
+ feature_image_caption: Joi.string().max(500).optional(),
46
+ meta_title: Joi.string().max(70).optional(),
47
+ meta_description: Joi.string().max(160).optional(),
48
+ });
49
+
50
+ /**
51
+ * Service layer function to handle the business logic of creating a page.
52
+ * Transforms input data, generates metadata defaults.
53
+ * Note: Pages do NOT support tags (unlike posts).
54
+ * @param {object} pageInput - Data received from the controller.
55
+ * @returns {Promise<object>} The created page object from the Ghost API.
56
+ */
57
+ const createPageService = async (pageInput) => {
58
+ const logger = createContextLogger('page-service');
59
+
60
+ // Validate input to prevent format string vulnerabilities
61
+ const { error, value: validatedInput } = pageInputSchema.validate(pageInput);
62
+ if (error) {
63
+ logger.error('Page input validation failed', {
64
+ error: error.details[0].message,
65
+ inputKeys: Object.keys(pageInput),
66
+ });
67
+ throw new Error(`Invalid page input: ${error.details[0].message}`);
68
+ }
69
+
70
+ const {
71
+ title,
72
+ html,
73
+ custom_excerpt,
74
+ status,
75
+ published_at,
76
+ // NO tags destructuring - pages don't support tags
77
+ feature_image,
78
+ feature_image_alt,
79
+ feature_image_caption,
80
+ meta_title,
81
+ meta_description,
82
+ } = validatedInput;
83
+
84
+ // NO tag resolution section (removed from postService)
85
+ // Pages do not support tags in Ghost CMS
86
+
87
+ // Metadata defaults
88
+ const finalMetaTitle = meta_title || title;
89
+ const finalMetaDescription =
90
+ meta_description || custom_excerpt || generateSimpleMetaDescription(html);
91
+ const truncatedMetaDescription =
92
+ finalMetaDescription.length > 500
93
+ ? finalMetaDescription.substring(0, 497) + '...'
94
+ : finalMetaDescription;
95
+
96
+ // Prepare data for Ghost API
97
+ const pageDataForApi = {
98
+ title,
99
+ html,
100
+ custom_excerpt,
101
+ status: status || 'draft',
102
+ published_at,
103
+ // NO tags field
104
+ feature_image,
105
+ feature_image_alt,
106
+ feature_image_caption,
107
+ meta_title: finalMetaTitle,
108
+ meta_description: truncatedMetaDescription,
109
+ };
110
+
111
+ logger.info('Creating Ghost page', {
112
+ title: pageDataForApi.title,
113
+ status: pageDataForApi.status,
114
+ hasFeatureImage: !!pageDataForApi.feature_image,
115
+ });
116
+
117
+ const newPage = await createGhostPage(pageDataForApi);
118
+ return newPage;
119
+ };
120
+
121
+ export { createPageService };