@jgardner04/ghost-mcp-server 1.13.4 → 1.14.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/README.md +68 -0
- package/package.json +7 -3
- package/src/__tests__/helpers/testUtils.js +15 -1
- package/src/__tests__/mcp_server.test.js +152 -1
- package/src/__tests__/mcp_server_pages.test.js +23 -6
- package/src/controllers/__tests__/imageController.test.js +2 -2
- package/src/controllers/imageController.js +11 -10
- package/src/mcp_server.js +647 -1203
- package/src/routes/__tests__/imageRoutes.test.js +2 -2
- package/src/schemas/__tests__/common.test.js +3 -3
- package/src/schemas/__tests__/pageSchemas.test.js +11 -2
- package/src/schemas/common.js +3 -2
- package/src/schemas/pageSchemas.js +1 -1
- package/src/schemas/postSchemas.js +1 -1
- package/src/services/__tests__/createResourceService.test.js +468 -0
- package/src/services/__tests__/ghostService.test.js +0 -19
- package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
- package/src/services/__tests__/imageProcessingService.test.js +148 -177
- package/src/services/__tests__/images.test.js +78 -0
- package/src/services/createResourceService.js +138 -0
- package/src/services/ghostApiClient.js +240 -0
- package/src/services/ghostService.js +1 -19
- package/src/services/ghostServiceImproved.js +76 -915
- package/src/services/imageProcessingService.js +100 -56
- package/src/services/images.js +54 -0
- package/src/services/members.js +127 -0
- package/src/services/newsletters.js +63 -0
- package/src/services/pageService.js +2 -2
- package/src/services/pages.js +116 -0
- package/src/services/posts.js +116 -0
- package/src/services/tags.js +118 -0
- package/src/services/tiers.js +72 -0
- package/src/services/validators.js +218 -0
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- package/src/utils/imageInputResolver.js +127 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ValidationError } from '../errors/index.js';
|
|
2
|
+
import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
|
|
3
|
+
import { handleApiRequest, readResource } from './ghostApiClient.js';
|
|
4
|
+
import { createResourceService } from './createResourceService.js';
|
|
5
|
+
import { validators } from './validators.js';
|
|
6
|
+
|
|
7
|
+
const service = createResourceService({
|
|
8
|
+
resource: 'posts',
|
|
9
|
+
label: 'Post',
|
|
10
|
+
createDefaults: { status: 'draft' },
|
|
11
|
+
createOptions: { source: 'html' },
|
|
12
|
+
listDefaults: { limit: 15, include: 'tags,authors' },
|
|
13
|
+
validateCreate: (data) => validators.validatePostData(data),
|
|
14
|
+
validateUpdate: async (id, updateData) => {
|
|
15
|
+
if (updateData.status || updateData.published_at) {
|
|
16
|
+
let validationData = updateData;
|
|
17
|
+
if (!updateData.status && updateData.published_at) {
|
|
18
|
+
const existing = await readResource('posts', id, 'Post');
|
|
19
|
+
validationData = { ...updateData, status: existing.status };
|
|
20
|
+
}
|
|
21
|
+
validators.validateScheduledStatus(validationData, 'Post');
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a new post in Ghost CMS.
|
|
28
|
+
* @param {Object} postData - The post data
|
|
29
|
+
* @param {string} postData.title - Post title (required)
|
|
30
|
+
* @param {string} [postData.html] - HTML content (required if mobiledoc not provided)
|
|
31
|
+
* @param {string} [postData.mobiledoc] - Mobiledoc content (required if html not provided)
|
|
32
|
+
* @param {string} [postData.status='draft'] - Post status ('draft', 'published', 'scheduled')
|
|
33
|
+
* @param {string} [postData.published_at] - ISO 8601 date for scheduled posts
|
|
34
|
+
* @param {Object} [options={ source: 'html' }] - API request options
|
|
35
|
+
* @returns {Promise<Object>} The created post object
|
|
36
|
+
* @throws {ValidationError} If validation fails or Ghost returns a 422
|
|
37
|
+
* @throws {GhostAPIError} If the API request fails
|
|
38
|
+
*/
|
|
39
|
+
export const createPost = service.create;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Updates an existing post with optimistic concurrency control.
|
|
43
|
+
* Validates scheduling fields when status or published_at is being changed.
|
|
44
|
+
* @param {string} postId - The post ID to update
|
|
45
|
+
* @param {Object} updateData - Fields to update on the post
|
|
46
|
+
* @param {Object} [options={}] - API request options
|
|
47
|
+
* @returns {Promise<Object>} The updated post object
|
|
48
|
+
* @throws {ValidationError} If the post ID is missing or scheduling validation fails
|
|
49
|
+
* @throws {NotFoundError} If the post is not found
|
|
50
|
+
* @throws {GhostAPIError} If the API request fails
|
|
51
|
+
*/
|
|
52
|
+
export const updatePost = service.update;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deletes a post by ID.
|
|
56
|
+
* @param {string} postId - The post ID to delete
|
|
57
|
+
* @returns {Promise<Object>} Deletion confirmation
|
|
58
|
+
* @throws {ValidationError} If the post ID is missing
|
|
59
|
+
* @throws {NotFoundError} If the post is not found
|
|
60
|
+
* @throws {GhostAPIError} If the API request fails
|
|
61
|
+
*/
|
|
62
|
+
export const deletePost = service.remove;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Retrieves a single post by ID.
|
|
66
|
+
* @param {string} postId - The post ID to retrieve
|
|
67
|
+
* @param {Object} [options={}] - API request options (e.g., includes)
|
|
68
|
+
* @returns {Promise<Object>} The post object
|
|
69
|
+
* @throws {ValidationError} If the post ID is missing
|
|
70
|
+
* @throws {NotFoundError} If the post is not found
|
|
71
|
+
* @throws {GhostAPIError} If the API request fails
|
|
72
|
+
*/
|
|
73
|
+
export const getPost = service.getOne;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Lists posts with optional filtering and pagination.
|
|
77
|
+
* @param {Object} [options={}] - Query options
|
|
78
|
+
* @param {number} [options.limit=15] - Number of posts to return
|
|
79
|
+
* @param {string} [options.include='tags,authors'] - Related resources to include
|
|
80
|
+
* @param {string} [options.filter] - NQL filter string
|
|
81
|
+
* @param {string} [options.order] - Order string (e.g., 'published_at desc')
|
|
82
|
+
* @returns {Promise<Array>} Array of post objects
|
|
83
|
+
* @throws {GhostAPIError} If the API request fails
|
|
84
|
+
*/
|
|
85
|
+
export const getPosts = service.getList;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Searches posts by title using Ghost NQL fuzzy matching.
|
|
89
|
+
* @param {string} query - Search query string (required, non-empty)
|
|
90
|
+
* @param {Object} [options={}] - Additional search options
|
|
91
|
+
* @param {string} [options.status] - Filter by status ('draft', 'published', 'scheduled', or 'all')
|
|
92
|
+
* @param {number} [options.limit=15] - Maximum number of results
|
|
93
|
+
* @returns {Promise<Array>} Array of matching post objects
|
|
94
|
+
* @throws {ValidationError} If the query is empty
|
|
95
|
+
* @throws {GhostAPIError} If the API request fails
|
|
96
|
+
*/
|
|
97
|
+
export async function searchPosts(query, options = {}) {
|
|
98
|
+
if (!query || query.trim().length === 0) {
|
|
99
|
+
throw new ValidationError('Search query is required');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sanitizedQuery = sanitizeNqlValue(query);
|
|
103
|
+
|
|
104
|
+
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
105
|
+
if (options.status && options.status !== 'all') {
|
|
106
|
+
filterParts.push(`status:${options.status}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const searchOptions = {
|
|
110
|
+
limit: options.limit || 15,
|
|
111
|
+
include: 'tags,authors',
|
|
112
|
+
filter: filterParts.join('+'),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return handleApiRequest('posts', 'browse', {}, searchOptions);
|
|
116
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { GhostAPIError, ValidationError, NotFoundError } from '../errors/index.js';
|
|
2
|
+
import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
|
|
3
|
+
import { handleApiRequest, readResource } from './ghostApiClient.js';
|
|
4
|
+
import { createResourceService } from './createResourceService.js';
|
|
5
|
+
import { validators } from './validators.js';
|
|
6
|
+
|
|
7
|
+
const service = createResourceService({
|
|
8
|
+
resource: 'tags',
|
|
9
|
+
label: 'Tag',
|
|
10
|
+
listDefaults: { limit: 15 },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new tag in Ghost CMS. Auto-generates a slug from the name if not provided.
|
|
15
|
+
* If a tag with the same name already exists, returns the existing tag instead of failing.
|
|
16
|
+
* @param {Object} tagData - The tag data
|
|
17
|
+
* @param {string} tagData.name - Tag name (required)
|
|
18
|
+
* @param {string} [tagData.slug] - Tag slug (auto-generated from name if omitted)
|
|
19
|
+
* @param {string} [tagData.description] - Tag description
|
|
20
|
+
* @returns {Promise<Object>} The created (or existing) tag object
|
|
21
|
+
* @throws {ValidationError} If validation fails or Ghost returns a 422 (non-duplicate)
|
|
22
|
+
* @throws {GhostAPIError} If the API request fails
|
|
23
|
+
*/
|
|
24
|
+
export async function createTag(tagData) {
|
|
25
|
+
validators.validateTagData(tagData);
|
|
26
|
+
|
|
27
|
+
const dataToCreate = tagData.slug
|
|
28
|
+
? { ...tagData }
|
|
29
|
+
: {
|
|
30
|
+
...tagData,
|
|
31
|
+
slug: tagData.name
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
34
|
+
.replace(/^-+|-+$/g, ''),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
return await handleApiRequest('tags', 'add', dataToCreate);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
41
|
+
if (error.originalError.includes('already exists')) {
|
|
42
|
+
const existingTags = await getTags({ filter: `name:'${sanitizeNqlValue(tagData.name)}'` });
|
|
43
|
+
if (existingTags.length > 0) {
|
|
44
|
+
return existingTags[0];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new ValidationError('Tag creation failed', [
|
|
48
|
+
{ field: 'tag', message: error.originalError },
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lists tags with optional filtering and pagination.
|
|
57
|
+
* @param {Object} [options={}] - Query options
|
|
58
|
+
* @param {number} [options.limit=15] - Number of tags to return
|
|
59
|
+
* @param {string} [options.filter] - NQL filter string
|
|
60
|
+
* @param {string} [options.order] - Order string
|
|
61
|
+
* @returns {Promise<Array>} Array of tag objects (empty array if none found)
|
|
62
|
+
* @throws {GhostAPIError} If the API request fails
|
|
63
|
+
*/
|
|
64
|
+
export const getTags = service.getList;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Retrieves a single tag by ID.
|
|
68
|
+
* @param {string} tagId - The tag ID to retrieve
|
|
69
|
+
* @param {Object} [options={}] - API request options
|
|
70
|
+
* @returns {Promise<Object>} The tag object
|
|
71
|
+
* @throws {ValidationError} If the tag ID is missing
|
|
72
|
+
* @throws {NotFoundError} If the tag is not found
|
|
73
|
+
* @throws {GhostAPIError} If the API request fails
|
|
74
|
+
*/
|
|
75
|
+
export const getTag = service.getOne;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Updates an existing tag. Validates update data and checks that the tag exists first.
|
|
79
|
+
* Tags use direct edit (not OCC) since they don't have concurrent editing concerns.
|
|
80
|
+
* @param {string} tagId - The tag ID to update
|
|
81
|
+
* @param {Object} updateData - Fields to update on the tag
|
|
82
|
+
* @param {string} [updateData.name] - Updated tag name
|
|
83
|
+
* @param {string} [updateData.slug] - Updated tag slug
|
|
84
|
+
* @param {string} [updateData.description] - Updated tag description
|
|
85
|
+
* @returns {Promise<Object>} The updated tag object
|
|
86
|
+
* @throws {ValidationError} If the tag ID is missing or update data is invalid
|
|
87
|
+
* @throws {NotFoundError} If the tag is not found
|
|
88
|
+
* @throws {GhostAPIError} If the API request fails
|
|
89
|
+
*/
|
|
90
|
+
export async function updateTag(tagId, updateData) {
|
|
91
|
+
validators.requireId(tagId, 'Tag');
|
|
92
|
+
validators.validateTagUpdateData(updateData);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await readResource('tags', tagId, 'Tag');
|
|
96
|
+
return await handleApiRequest('tags', 'edit', { id: tagId, ...updateData });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof NotFoundError) {
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
102
|
+
throw new ValidationError('Tag update failed', [
|
|
103
|
+
{ field: 'tag', message: error.originalError },
|
|
104
|
+
]);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Deletes a tag by ID.
|
|
112
|
+
* @param {string} tagId - The tag ID to delete
|
|
113
|
+
* @returns {Promise<Object>} Deletion confirmation
|
|
114
|
+
* @throws {ValidationError} If the tag ID is missing
|
|
115
|
+
* @throws {NotFoundError} If the tag is not found
|
|
116
|
+
* @throws {GhostAPIError} If the API request fails
|
|
117
|
+
*/
|
|
118
|
+
export const deleteTag = service.remove;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createResourceService } from './createResourceService.js';
|
|
2
|
+
import {
|
|
3
|
+
validateTierData,
|
|
4
|
+
validateTierUpdateData,
|
|
5
|
+
validateTierQueryOptions,
|
|
6
|
+
} from './tierService.js';
|
|
7
|
+
|
|
8
|
+
const service = createResourceService({
|
|
9
|
+
resource: 'tiers',
|
|
10
|
+
label: 'Tier',
|
|
11
|
+
listDefaults: { limit: 15 },
|
|
12
|
+
validateCreate: (data) => validateTierData(data),
|
|
13
|
+
validateUpdate: (_id, data) => validateTierUpdateData(data),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a new tier (membership level)
|
|
18
|
+
* @param {Object} tierData - Tier data
|
|
19
|
+
* @param {Object} [options={}] - Options for the API request
|
|
20
|
+
* @returns {Promise<Object>} Created tier
|
|
21
|
+
* @throws {ValidationError} If validation fails or Ghost returns a 422
|
|
22
|
+
* @throws {GhostAPIError} If the API request fails
|
|
23
|
+
*/
|
|
24
|
+
export const createTier = service.create;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Update an existing tier with optimistic concurrency control.
|
|
28
|
+
* @param {string} id - Tier ID
|
|
29
|
+
* @param {Object} updateData - Tier update data
|
|
30
|
+
* @param {Object} [options={}] - Options for the API request
|
|
31
|
+
* @returns {Promise<Object>} Updated tier
|
|
32
|
+
* @throws {ValidationError} If the tier ID is missing or update data is invalid
|
|
33
|
+
* @throws {NotFoundError} If the tier is not found
|
|
34
|
+
* @throws {GhostAPIError} If the API request fails
|
|
35
|
+
*/
|
|
36
|
+
export const updateTier = service.update;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delete a tier by ID.
|
|
40
|
+
* @param {string} id - Tier ID
|
|
41
|
+
* @returns {Promise<Object>} Deletion result
|
|
42
|
+
* @throws {ValidationError} If the tier ID is missing
|
|
43
|
+
* @throws {NotFoundError} If the tier is not found
|
|
44
|
+
* @throws {GhostAPIError} If the API request fails
|
|
45
|
+
*/
|
|
46
|
+
export const deleteTier = service.remove;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get all tiers with optional filtering
|
|
50
|
+
* @param {Object} [options={}] - Query options
|
|
51
|
+
* @param {number} [options.limit] - Number of tiers to return (1-100, default 15)
|
|
52
|
+
* @param {number} [options.page] - Page number
|
|
53
|
+
* @param {string} [options.filter] - NQL filter string (e.g., "type:paid", "type:free")
|
|
54
|
+
* @param {string} [options.order] - Order string
|
|
55
|
+
* @param {string} [options.include] - Include string
|
|
56
|
+
* @returns {Promise<Array>} Array of tiers
|
|
57
|
+
* @throws {GhostAPIError} If the API request fails
|
|
58
|
+
*/
|
|
59
|
+
export async function getTiers(options = {}) {
|
|
60
|
+
validateTierQueryOptions(options);
|
|
61
|
+
return service.getList(options);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get a single tier by ID.
|
|
66
|
+
* @param {string} id - Tier ID
|
|
67
|
+
* @returns {Promise<Object>} Tier object
|
|
68
|
+
* @throws {ValidationError} If the tier ID is missing
|
|
69
|
+
* @throws {NotFoundError} If the tier is not found
|
|
70
|
+
* @throws {GhostAPIError} If the API request fails
|
|
71
|
+
*/
|
|
72
|
+
export const getTier = service.getOne;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { ValidationError, NotFoundError } from '../errors/index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Input validation helpers
|
|
6
|
+
*/
|
|
7
|
+
export const validators = {
|
|
8
|
+
/**
|
|
9
|
+
* Validates that an ID is a non-empty string. Used by CRUD helpers to enforce
|
|
10
|
+
* consistent ID validation across all resource types.
|
|
11
|
+
* @param {string} id - The resource ID to validate
|
|
12
|
+
* @param {string} entityName - Human-readable resource name for error messages (e.g., 'Post', 'Tag')
|
|
13
|
+
* @throws {ValidationError} If the ID is falsy, not a string, or empty/whitespace
|
|
14
|
+
*/
|
|
15
|
+
requireId(id, entityName) {
|
|
16
|
+
if (!id || typeof id !== 'string' || id.trim().length === 0) {
|
|
17
|
+
throw new ValidationError(`${entityName} ID is required`);
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validates scheduling fields for posts and pages. Ensures published_at is present
|
|
23
|
+
* when status is 'scheduled' and that the date is valid and in the future.
|
|
24
|
+
* @param {Object} data - The resource data containing status and/or published_at
|
|
25
|
+
* @param {string} [data.status] - Resource status ('draft', 'published', 'scheduled')
|
|
26
|
+
* @param {string} [data.published_at] - ISO 8601 date string for scheduling
|
|
27
|
+
* @param {string} [resourceLabel='Resource'] - Human-readable label for error messages
|
|
28
|
+
* @throws {ValidationError} If scheduling validation fails
|
|
29
|
+
*/
|
|
30
|
+
validateScheduledStatus(data, resourceLabel = 'Resource') {
|
|
31
|
+
const errors = [];
|
|
32
|
+
|
|
33
|
+
if (data.status === 'scheduled' && !data.published_at) {
|
|
34
|
+
errors.push({
|
|
35
|
+
field: 'published_at',
|
|
36
|
+
message: 'published_at is required when status is scheduled',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (data.published_at) {
|
|
41
|
+
const publishDate = new Date(data.published_at);
|
|
42
|
+
if (isNaN(publishDate.getTime())) {
|
|
43
|
+
errors.push({ field: 'published_at', message: 'Invalid date format' });
|
|
44
|
+
} else if (data.status === 'scheduled' && publishDate <= new Date()) {
|
|
45
|
+
errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (errors.length > 0) {
|
|
50
|
+
throw new ValidationError(`${resourceLabel} validation failed`, errors);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validates post creation data. Requires a title and either html or mobiledoc content.
|
|
56
|
+
* Also validates status values and delegates to validateScheduledStatus for scheduling.
|
|
57
|
+
* @param {Object} postData - The post data to validate
|
|
58
|
+
* @param {string} postData.title - Post title (required, non-empty)
|
|
59
|
+
* @param {string} [postData.html] - HTML content (required if mobiledoc not provided)
|
|
60
|
+
* @param {string} [postData.mobiledoc] - Mobiledoc content (required if html not provided)
|
|
61
|
+
* @param {string} [postData.status] - Post status ('draft', 'published', 'scheduled')
|
|
62
|
+
* @param {string} [postData.published_at] - ISO 8601 date for scheduled posts
|
|
63
|
+
* @throws {ValidationError} If validation fails
|
|
64
|
+
*/
|
|
65
|
+
validatePostData(postData) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
|
|
68
|
+
if (!postData.title || postData.title.trim().length === 0) {
|
|
69
|
+
errors.push({ field: 'title', message: 'Title is required' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!postData.html && !postData.mobiledoc) {
|
|
73
|
+
errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (postData.status && !['draft', 'published', 'scheduled'].includes(postData.status)) {
|
|
77
|
+
errors.push({
|
|
78
|
+
field: 'status',
|
|
79
|
+
message: 'Invalid status. Must be draft, published, or scheduled',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (errors.length > 0) {
|
|
84
|
+
throw new ValidationError('Post validation failed', errors);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.validateScheduledStatus(postData, 'Post');
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validates tag creation data. Requires a non-empty name and validates slug format.
|
|
92
|
+
* @param {Object} tagData - The tag data to validate
|
|
93
|
+
* @param {string} tagData.name - Tag name (required, non-empty)
|
|
94
|
+
* @param {string} [tagData.slug] - Tag slug (lowercase letters, numbers, hyphens only)
|
|
95
|
+
* @throws {ValidationError} If validation fails
|
|
96
|
+
*/
|
|
97
|
+
validateTagData(tagData) {
|
|
98
|
+
const errors = [];
|
|
99
|
+
|
|
100
|
+
if (!tagData.name || tagData.name.trim().length === 0) {
|
|
101
|
+
errors.push({ field: 'name', message: 'Tag name is required' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (tagData.slug && !/^[a-z0-9-]+$/.test(tagData.slug)) {
|
|
105
|
+
errors.push({
|
|
106
|
+
field: 'slug',
|
|
107
|
+
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (errors.length > 0) {
|
|
112
|
+
throw new ValidationError('Tag validation failed', errors);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validates tag update data. Name is optional but cannot be empty if provided.
|
|
118
|
+
* Validates slug format if provided.
|
|
119
|
+
* @param {Object} updateData - The tag update data to validate
|
|
120
|
+
* @param {string} [updateData.name] - Tag name (cannot be empty if provided)
|
|
121
|
+
* @param {string} [updateData.slug] - Tag slug (lowercase letters, numbers, hyphens only)
|
|
122
|
+
* @throws {ValidationError} If validation fails
|
|
123
|
+
*/
|
|
124
|
+
validateTagUpdateData(updateData) {
|
|
125
|
+
const errors = [];
|
|
126
|
+
|
|
127
|
+
// Name is optional in updates, but if provided, it cannot be empty
|
|
128
|
+
if (updateData.name !== undefined && updateData.name.trim().length === 0) {
|
|
129
|
+
errors.push({ field: 'name', message: 'Tag name cannot be empty' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validate slug format if provided
|
|
133
|
+
if (updateData.slug && !/^[a-z0-9-]+$/.test(updateData.slug)) {
|
|
134
|
+
errors.push({
|
|
135
|
+
field: 'slug',
|
|
136
|
+
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (errors.length > 0) {
|
|
141
|
+
throw new ValidationError('Tag update validation failed', errors);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Validates that an image path is a non-empty string and that the file exists on disk.
|
|
147
|
+
* @param {string} imagePath - Absolute path to the image file
|
|
148
|
+
* @returns {Promise<void>}
|
|
149
|
+
* @throws {ValidationError} If imagePath is falsy or not a string
|
|
150
|
+
* @throws {NotFoundError} If the file does not exist at the given path
|
|
151
|
+
*/
|
|
152
|
+
async validateImagePath(imagePath) {
|
|
153
|
+
if (!imagePath || typeof imagePath !== 'string') {
|
|
154
|
+
throw new ValidationError('Image path is required and must be a string');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check if file exists
|
|
158
|
+
try {
|
|
159
|
+
await fs.access(imagePath);
|
|
160
|
+
} catch {
|
|
161
|
+
throw new NotFoundError('Image file', imagePath);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Validates page creation data. Requires a title and either html or mobiledoc content.
|
|
167
|
+
* Also validates status values and delegates to validateScheduledStatus for scheduling.
|
|
168
|
+
* @param {Object} pageData - The page data to validate
|
|
169
|
+
* @param {string} pageData.title - Page title (required, non-empty)
|
|
170
|
+
* @param {string} [pageData.html] - HTML content (required if mobiledoc not provided)
|
|
171
|
+
* @param {string} [pageData.mobiledoc] - Mobiledoc content (required if html not provided)
|
|
172
|
+
* @param {string} [pageData.status] - Page status ('draft', 'published', 'scheduled')
|
|
173
|
+
* @param {string} [pageData.published_at] - ISO 8601 date for scheduled pages
|
|
174
|
+
* @throws {ValidationError} If validation fails
|
|
175
|
+
*/
|
|
176
|
+
validatePageData(pageData) {
|
|
177
|
+
const errors = [];
|
|
178
|
+
|
|
179
|
+
if (!pageData.title || pageData.title.trim().length === 0) {
|
|
180
|
+
errors.push({ field: 'title', message: 'Title is required' });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!pageData.html && !pageData.mobiledoc) {
|
|
184
|
+
errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (pageData.status && !['draft', 'published', 'scheduled'].includes(pageData.status)) {
|
|
188
|
+
errors.push({
|
|
189
|
+
field: 'status',
|
|
190
|
+
message: 'Invalid status. Must be draft, published, or scheduled',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (errors.length > 0) {
|
|
195
|
+
throw new ValidationError('Page validation failed', errors);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.validateScheduledStatus(pageData, 'Page');
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Validates newsletter creation data. Requires a non-empty name.
|
|
203
|
+
* @param {Object} newsletterData - The newsletter data to validate
|
|
204
|
+
* @param {string} newsletterData.name - Newsletter name (required, non-empty)
|
|
205
|
+
* @throws {ValidationError} If validation fails
|
|
206
|
+
*/
|
|
207
|
+
validateNewsletterData(newsletterData) {
|
|
208
|
+
const errors = [];
|
|
209
|
+
|
|
210
|
+
if (!newsletterData.name || newsletterData.name.trim().length === 0) {
|
|
211
|
+
errors.push({ field: 'name', message: 'Newsletter name is required' });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (errors.length > 0) {
|
|
215
|
+
throw new ValidationError('Newsletter validation failed', errors);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
resolveLocalImagePath,
|
|
8
|
+
decodeBase64ToTempFile,
|
|
9
|
+
MAX_BASE64_BYTES,
|
|
10
|
+
} from '../imageInputResolver.js';
|
|
11
|
+
|
|
12
|
+
const tmpRoot = path.join(os.tmpdir(), `img-resolver-${Date.now()}`);
|
|
13
|
+
const allowedRoot = path.join(tmpRoot, 'allowed');
|
|
14
|
+
const outside = path.join(tmpRoot, 'outside');
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
await fs.mkdir(allowedRoot, { recursive: true });
|
|
18
|
+
await fs.mkdir(outside, { recursive: true });
|
|
19
|
+
await fs.writeFile(path.join(allowedRoot, 'ok.png'), 'fake-png');
|
|
20
|
+
await fs.writeFile(path.join(outside, 'secret.txt'), 'top secret');
|
|
21
|
+
await fs.symlink(path.join(outside, 'secret.txt'), path.join(allowedRoot, 'escape.txt'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(async () => {
|
|
25
|
+
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('resolveLocalImagePath', () => {
|
|
29
|
+
const origRoot = process.env.GHOST_MCP_IMAGE_ROOT;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
process.env.GHOST_MCP_IMAGE_ROOT = allowedRoot;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
if (origRoot === undefined) delete process.env.GHOST_MCP_IMAGE_ROOT;
|
|
37
|
+
else process.env.GHOST_MCP_IMAGE_ROOT = origRoot;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('accepts a file inside the configured root', async () => {
|
|
41
|
+
const resolved = await resolveLocalImagePath(path.join(allowedRoot, 'ok.png'));
|
|
42
|
+
// Returns the realpath, which on macOS canonicalizes /var -> /private/var.
|
|
43
|
+
const canonical = await fs.realpath(path.join(allowedRoot, 'ok.png'));
|
|
44
|
+
expect(resolved).toBe(canonical);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects when GHOST_MCP_IMAGE_ROOT is unset (local-path mode disabled by default)', async () => {
|
|
48
|
+
delete process.env.GHOST_MCP_IMAGE_ROOT;
|
|
49
|
+
await expect(resolveLocalImagePath('/anywhere.png')).rejects.toThrow(
|
|
50
|
+
/GHOST_MCP_IMAGE_ROOT is not set/
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects a path outside the configured root', async () => {
|
|
55
|
+
await expect(resolveLocalImagePath(path.join(outside, 'secret.txt'))).rejects.toThrow(
|
|
56
|
+
/outside the allowed root/
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('rejects a relative traversal attempt', async () => {
|
|
61
|
+
await expect(resolveLocalImagePath(`${allowedRoot}/../outside/secret.txt`)).rejects.toThrow(
|
|
62
|
+
/outside the allowed root/
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rejects a symlink that escapes the allowed root', async () => {
|
|
67
|
+
await expect(resolveLocalImagePath(path.join(allowedRoot, 'escape.txt'))).rejects.toThrow(
|
|
68
|
+
/symlink escapes/
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects a non-existent file', async () => {
|
|
73
|
+
await expect(resolveLocalImagePath(path.join(allowedRoot, 'nope.png'))).rejects.toThrow(
|
|
74
|
+
/does not exist/
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('decodeBase64ToTempFile', () => {
|
|
80
|
+
it('decodes a plain base64 string to a temp file', async () => {
|
|
81
|
+
const payload = Buffer.from('hello png');
|
|
82
|
+
const out = await decodeBase64ToTempFile(payload.toString('base64'), 'image/png');
|
|
83
|
+
try {
|
|
84
|
+
expect(path.extname(out)).toBe('.png');
|
|
85
|
+
const written = await fs.readFile(out);
|
|
86
|
+
expect(written.equals(payload)).toBe(true);
|
|
87
|
+
} finally {
|
|
88
|
+
await fs.unlink(out).catch(() => {});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('accepts a full data: URI and ignores the prefix', async () => {
|
|
93
|
+
const payload = Buffer.from('svg-bytes');
|
|
94
|
+
const dataUri = `data:image/svg+xml;base64,${payload.toString('base64')}`;
|
|
95
|
+
const out = await decodeBase64ToTempFile(dataUri, 'image/svg+xml');
|
|
96
|
+
try {
|
|
97
|
+
expect(path.extname(out)).toBe('.svg');
|
|
98
|
+
const written = await fs.readFile(out);
|
|
99
|
+
expect(written.equals(payload)).toBe(true);
|
|
100
|
+
} finally {
|
|
101
|
+
await fs.unlink(out).catch(() => {});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects a payload larger than the 5MB cap', async () => {
|
|
106
|
+
const big = Buffer.alloc(MAX_BASE64_BYTES + 1, 0x41);
|
|
107
|
+
await expect(decodeBase64ToTempFile(big.toString('base64'), 'image/png')).rejects.toThrow(
|
|
108
|
+
/exceeds the 5MB/
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('rejects an unsupported mimeType', async () => {
|
|
113
|
+
const payload = Buffer.from('x');
|
|
114
|
+
await expect(
|
|
115
|
+
decodeBase64ToTempFile(payload.toString('base64'), 'application/zip')
|
|
116
|
+
).rejects.toThrow(/Unsupported mimeType/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects invalid base64 input', async () => {
|
|
120
|
+
await expect(decodeBase64ToTempFile('not base64 at all!!!', 'image/png')).rejects.toThrow(
|
|
121
|
+
/Invalid base64/
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('maps image/jpeg to .jpg extension', async () => {
|
|
126
|
+
const payload = Buffer.from([0xff, 0xd8, 0xff]);
|
|
127
|
+
const out = await decodeBase64ToTempFile(payload.toString('base64'), 'image/jpeg');
|
|
128
|
+
try {
|
|
129
|
+
expect(path.extname(out)).toBe('.jpg');
|
|
130
|
+
} finally {
|
|
131
|
+
await fs.unlink(out).catch(() => {});
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|