@jgardner04/ghost-mcp-server 1.13.5 → 1.14.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/package.json +1 -1
- package/src/__tests__/mcp_server.test.js +83 -0
- package/src/controllers/__tests__/imageController.test.js +2 -2
- package/src/controllers/imageController.js +11 -10
- package/src/mcp_server.js +270 -84
- 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__/ghostService.test.js +0 -19
- package/src/services/__tests__/imageProcessingService.test.js +148 -177
- package/src/services/__tests__/images.test.js +78 -0
- package/src/services/ghostService.js +1 -19
- package/src/services/imageProcessingService.js +100 -56
- package/src/services/images.js +34 -7
- package/src/services/pageService.js +2 -2
- package/src/utils/__tests__/formatErrorResponse.test.js +158 -0
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- package/src/utils/__tests__/sanitizeErrorPayload.test.js +130 -0
- package/src/utils/__tests__/validation.test.js +13 -7
- package/src/utils/formatErrorResponse.js +63 -0
- package/src/utils/imageInputResolver.js +127 -0
- package/src/utils/sanitizeErrorPayload.js +67 -0
- package/src/utils/validation.js +2 -4
|
@@ -1,96 +1,140 @@
|
|
|
1
1
|
import sharp from 'sharp';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import fsp from 'fs/promises';
|
|
4
|
+
import { z } from 'zod';
|
|
5
5
|
import { createContextLogger } from '../utils/logger.js';
|
|
6
6
|
|
|
7
|
-
// Define processing parameters (e.g., max width)
|
|
8
7
|
const MAX_WIDTH = 1200;
|
|
9
|
-
const
|
|
8
|
+
const JPEG_QUALITY = 80;
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
// Formats we re-encode via sharp when the image is oversized.
|
|
11
|
+
// Everything else is passed through untouched to preserve fidelity
|
|
12
|
+
// (SVG vectors, GIF animation, ICO, exotic formats).
|
|
13
|
+
const RESIZABLE_FORMATS = new Set(['jpeg', 'png', 'webp']);
|
|
14
|
+
|
|
15
|
+
const EXT_BY_FORMAT = {
|
|
16
|
+
jpeg: '.jpg',
|
|
17
|
+
png: '.png',
|
|
18
|
+
webp: '.webp',
|
|
19
|
+
gif: '.gif',
|
|
20
|
+
svg: '.svg',
|
|
21
|
+
// ICO has no sharp `format` name; detected by extension.
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const processImageParamsSchema = z.object({
|
|
25
|
+
inputPath: z.string().min(1),
|
|
26
|
+
outputDir: z.string().min(1),
|
|
21
27
|
});
|
|
22
28
|
|
|
23
|
-
const
|
|
29
|
+
const processImageOptsSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
purpose: z.enum(['image', 'profile_image', 'icon']).optional(),
|
|
32
|
+
})
|
|
33
|
+
.optional();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Process an image for upload to Ghost.
|
|
37
|
+
*
|
|
38
|
+
* Preserves the original format. Resizes (preserving format) only for
|
|
39
|
+
* raster formats sharp can safely re-encode — JPEG, PNG, WEBP — when
|
|
40
|
+
* wider than MAX_WIDTH. SVG, GIF, ICO, and unrecognized formats are
|
|
41
|
+
* copied byte-for-byte so vectors, animation frames, and icon metadata
|
|
42
|
+
* survive the round trip.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} inputPath - Absolute path to the source image.
|
|
45
|
+
* @param {string} outputDir - Directory to write the processed file into.
|
|
46
|
+
* @param {{ purpose?: 'image'|'profile_image'|'icon' }} [opts]
|
|
47
|
+
* @returns {Promise<string>} Absolute path to the processed (or copied) file.
|
|
48
|
+
*/
|
|
49
|
+
export async function processImage(inputPath, outputDir, opts = {}) {
|
|
24
50
|
const logger = createContextLogger('image-processing');
|
|
25
51
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (error) {
|
|
29
|
-
logger.error('Invalid processing parameters', {
|
|
30
|
-
error: error.details[0].message,
|
|
31
|
-
inputPath: inputPath ? path.basename(inputPath) : 'undefined',
|
|
32
|
-
outputDir: outputDir ? path.basename(outputDir) : 'undefined',
|
|
33
|
-
});
|
|
52
|
+
const params = processImageParamsSchema.safeParse({ inputPath, outputDir });
|
|
53
|
+
if (!params.success) {
|
|
34
54
|
throw new Error('Invalid processing parameters');
|
|
35
55
|
}
|
|
56
|
+
processImageOptsSchema.parse(opts);
|
|
36
57
|
|
|
37
|
-
|
|
38
|
-
const resolvedInputPath = path.resolve(inputPath);
|
|
58
|
+
const resolvedInput = path.resolve(inputPath);
|
|
39
59
|
const resolvedOutputDir = path.resolve(outputDir);
|
|
40
60
|
|
|
41
|
-
|
|
42
|
-
|
|
61
|
+
try {
|
|
62
|
+
await fsp.access(resolvedInput);
|
|
63
|
+
} catch {
|
|
43
64
|
throw new Error('Input file does not exist');
|
|
44
65
|
}
|
|
45
66
|
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
// Use timestamp for unique output filename
|
|
67
|
+
const inputExt = path.extname(resolvedInput).toLowerCase();
|
|
68
|
+
const baseName = path.basename(resolvedInput, path.extname(resolvedInput));
|
|
49
69
|
const timestamp = Date.now();
|
|
50
|
-
const outputFilename = `processed-${timestamp}-${nameWithoutExt}.jpg`;
|
|
51
|
-
const outputPath = path.join(resolvedOutputDir, outputFilename);
|
|
52
70
|
|
|
53
71
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
// Non-raster passthrough: SVG and ICO are never handed to sharp.
|
|
73
|
+
// (sharp can read SVG but would rasterize it, destroying the vector.)
|
|
74
|
+
if (inputExt === '.svg' || inputExt === '.ico') {
|
|
75
|
+
return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, inputExt);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const image = sharp(resolvedInput);
|
|
59
79
|
const metadata = await image.metadata();
|
|
80
|
+
const format = metadata.format; // 'jpeg' | 'png' | 'webp' | 'gif' | 'svg' | ...
|
|
60
81
|
|
|
61
|
-
|
|
82
|
+
// GIF: never re-encode — sharp would drop animation frames.
|
|
83
|
+
// SVG (if it slipped through by extension mismatch): also passthrough.
|
|
84
|
+
if (format === 'gif' || format === 'svg') {
|
|
85
|
+
const ext = EXT_BY_FORMAT[format] || inputExt || '';
|
|
86
|
+
return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, ext);
|
|
87
|
+
}
|
|
62
88
|
|
|
63
|
-
//
|
|
64
|
-
if (
|
|
65
|
-
logger.info('
|
|
66
|
-
|
|
67
|
-
targetWidth: MAX_WIDTH,
|
|
89
|
+
// Unknown / unsupported format: safest is passthrough.
|
|
90
|
+
if (!RESIZABLE_FORMATS.has(format)) {
|
|
91
|
+
logger.info('Unknown format, passing through', {
|
|
92
|
+
format,
|
|
68
93
|
inputFile: path.basename(inputPath),
|
|
69
94
|
});
|
|
70
|
-
|
|
95
|
+
return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, inputExt);
|
|
71
96
|
}
|
|
72
97
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
await processedImage.jpeg({ quality: OUTPUT_QUALITY }).toFile(outputPath);
|
|
98
|
+
const ext = EXT_BY_FORMAT[format];
|
|
99
|
+
const outputPath = path.join(resolvedOutputDir, `processed-${timestamp}-${baseName}${ext}`);
|
|
76
100
|
|
|
77
|
-
|
|
101
|
+
// Passthrough when no resize is needed — avoids generation loss.
|
|
102
|
+
if (!metadata.width || metadata.width <= MAX_WIDTH) {
|
|
103
|
+
await fsp.copyFile(resolvedInput, outputPath);
|
|
104
|
+
logger.info('Image within size limits, passthrough copy', {
|
|
105
|
+
format,
|
|
106
|
+
width: metadata.width,
|
|
107
|
+
inputFile: path.basename(inputPath),
|
|
108
|
+
});
|
|
109
|
+
return outputPath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
logger.info('Resizing image', {
|
|
113
|
+
format,
|
|
114
|
+
originalWidth: metadata.width,
|
|
115
|
+
targetWidth: MAX_WIDTH,
|
|
78
116
|
inputFile: path.basename(inputPath),
|
|
79
|
-
outputFile: path.basename(outputPath),
|
|
80
|
-
originalSize: metadata.size,
|
|
81
|
-
quality: OUTPUT_QUALITY,
|
|
82
117
|
});
|
|
118
|
+
|
|
119
|
+
// sharp picks the encoder from outputPath extension (EXT_BY_FORMAT),
|
|
120
|
+
// so only JPEG needs the explicit call here to set quality. PNG/WEBP
|
|
121
|
+
// would be redundant.
|
|
122
|
+
let pipeline = image.resize({ width: MAX_WIDTH });
|
|
123
|
+
if (format === 'jpeg') pipeline = pipeline.jpeg({ quality: JPEG_QUALITY });
|
|
124
|
+
|
|
125
|
+
await pipeline.toFile(outputPath);
|
|
83
126
|
return outputPath;
|
|
84
127
|
} catch (error) {
|
|
85
128
|
logger.error('Image processing failed', {
|
|
86
129
|
inputFile: path.basename(inputPath),
|
|
87
130
|
error: error.message,
|
|
88
|
-
stack: error.stack,
|
|
89
131
|
});
|
|
90
|
-
// If processing fails, maybe fall back to using the original?
|
|
91
|
-
// Or throw the error to fail the upload.
|
|
92
132
|
throw new Error('Image processing failed: ' + error.message, { cause: error });
|
|
93
133
|
}
|
|
94
|
-
}
|
|
134
|
+
}
|
|
95
135
|
|
|
96
|
-
|
|
136
|
+
async function passthrough(inputPath, outputDir, baseName, timestamp, ext) {
|
|
137
|
+
const outputPath = path.join(outputDir, `processed-${timestamp}-${baseName}${ext}`);
|
|
138
|
+
await fsp.copyFile(inputPath, outputPath);
|
|
139
|
+
return outputPath;
|
|
140
|
+
}
|
package/src/services/images.js
CHANGED
|
@@ -2,19 +2,46 @@ import { GhostAPIError, ValidationError } from '../errors/index.js';
|
|
|
2
2
|
import { handleApiRequest } from './ghostApiClient.js';
|
|
3
3
|
import { validators } from './validators.js';
|
|
4
4
|
|
|
5
|
+
const ALLOWED_PURPOSES = new Set(['image', 'profile_image', 'icon']);
|
|
6
|
+
const REF_MAX_LENGTH = 200;
|
|
7
|
+
|
|
5
8
|
/**
|
|
6
9
|
* Uploads an image to Ghost CMS from a local file path.
|
|
7
|
-
*
|
|
8
|
-
* @
|
|
9
|
-
* @
|
|
10
|
-
* @
|
|
11
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* @param {string} imagePath - Absolute path to the image file.
|
|
12
|
+
* @param {object} [opts]
|
|
13
|
+
* @param {'image'|'profile_image'|'icon'} [opts.purpose] - Intended use.
|
|
14
|
+
* Ghost applies format/size validation per purpose (icon/profile_image
|
|
15
|
+
* must be square; icon also accepts ICO).
|
|
16
|
+
* @param {string} [opts.ref] - Caller-supplied identifier (e.g. original
|
|
17
|
+
* filename). Ghost echoes it back in the response. Max 200 chars.
|
|
18
|
+
* @returns {Promise<Object>} The uploaded image object with URL (and ref,
|
|
19
|
+
* if provided and the SDK forwards it).
|
|
20
|
+
* @throws {ValidationError} If inputs are invalid or upload fails.
|
|
21
|
+
* @throws {NotFoundError} If the file does not exist.
|
|
22
|
+
* @throws {GhostAPIError} If the API request fails.
|
|
12
23
|
*/
|
|
13
|
-
export async function uploadImage(imagePath) {
|
|
14
|
-
// Validate input
|
|
24
|
+
export async function uploadImage(imagePath, opts = {}) {
|
|
15
25
|
await validators.validateImagePath(imagePath);
|
|
16
26
|
|
|
27
|
+
const { purpose, ref } = opts;
|
|
28
|
+
if (purpose !== undefined && !ALLOWED_PURPOSES.has(purpose)) {
|
|
29
|
+
throw new ValidationError(
|
|
30
|
+
`Invalid purpose "${purpose}". Must be one of: ${[...ALLOWED_PURPOSES].join(', ')}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (ref !== undefined) {
|
|
34
|
+
if (typeof ref !== 'string') {
|
|
35
|
+
throw new ValidationError('ref must be a string');
|
|
36
|
+
}
|
|
37
|
+
if (ref.length > REF_MAX_LENGTH) {
|
|
38
|
+
throw new ValidationError(`ref cannot exceed ${REF_MAX_LENGTH} characters`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
17
42
|
const imageData = { file: imagePath };
|
|
43
|
+
if (purpose !== undefined) imageData.purpose = purpose;
|
|
44
|
+
if (ref !== undefined) imageData.ref = ref;
|
|
18
45
|
|
|
19
46
|
try {
|
|
20
47
|
return await handleApiRequest('images', 'upload', imageData);
|
|
@@ -41,8 +41,8 @@ const pageInputSchema = Joi.object({
|
|
|
41
41
|
published_at: Joi.string().isoDate().optional(),
|
|
42
42
|
// NO tags field - pages don't support tags
|
|
43
43
|
feature_image: Joi.string().uri().optional(),
|
|
44
|
-
feature_image_alt: Joi.string().max(
|
|
45
|
-
feature_image_caption: Joi.string().max(
|
|
44
|
+
feature_image_alt: Joi.string().max(191).optional(),
|
|
45
|
+
feature_image_caption: Joi.string().max(5000).optional(),
|
|
46
46
|
meta_title: Joi.string().max(70).optional(),
|
|
47
47
|
meta_description: Joi.string().max(160).optional(),
|
|
48
48
|
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { formatErrorResponse } from '../formatErrorResponse.js';
|
|
3
|
+
import { GhostAPIError, ValidationError, NotFoundError } from '../../errors/index.js';
|
|
4
|
+
|
|
5
|
+
function parseJsonBlock(text) {
|
|
6
|
+
const match = text.match(/```json\n([\s\S]+?)\n```/);
|
|
7
|
+
expect(match, `no JSON block in: ${text}`).toBeTruthy();
|
|
8
|
+
return JSON.parse(match[1]);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('formatErrorResponse', () => {
|
|
12
|
+
const originalEnv = process.env.GHOST_ADMIN_API_KEY;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
process.env.GHOST_ADMIN_API_KEY = '';
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
if (originalEnv === undefined) delete process.env.GHOST_ADMIN_API_KEY;
|
|
20
|
+
else process.env.GHOST_ADMIN_API_KEY = originalEnv;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns consistent envelope with error key for generic Error (no ghost key)', () => {
|
|
24
|
+
const response = formatErrorResponse(new Error('boom'), 'ghost_get_posts');
|
|
25
|
+
expect(response.isError).toBe(true);
|
|
26
|
+
expect(response.content[0].type).toBe('text');
|
|
27
|
+
const envelope = parseJsonBlock(response.content[0].text);
|
|
28
|
+
expect(envelope.error).toBeDefined();
|
|
29
|
+
expect(envelope.error.message).toBe('boom');
|
|
30
|
+
expect(envelope).not.toHaveProperty('ghost');
|
|
31
|
+
expect(response.content[0].text).toContain('Error in ghost_get_posts: boom');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('includes gated ghost sub-object for GhostAPIError', () => {
|
|
35
|
+
const err = new GhostAPIError('posts.edit', 'Title is required', 422);
|
|
36
|
+
const response = formatErrorResponse(err, 'ghost_update_post');
|
|
37
|
+
const envelope = parseJsonBlock(response.content[0].text);
|
|
38
|
+
expect(envelope.error.name).toBe('GhostAPIError');
|
|
39
|
+
expect(envelope.error.code).toBe('GHOST_VALIDATION_ERROR');
|
|
40
|
+
expect(envelope.ghost).toBeDefined();
|
|
41
|
+
expect(envelope.ghost.operation).toBe('posts.edit');
|
|
42
|
+
expect(envelope.ghost.statusCode).toBe(422);
|
|
43
|
+
expect(envelope.ghost.originalMessage).toBe('Title is required');
|
|
44
|
+
expect(response.content[0].text).toContain('422');
|
|
45
|
+
expect(response.content[0].text).toContain('posts.edit');
|
|
46
|
+
expect(response.content[0].text).toContain('Title is required');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('uses raw ghostStatusCode (not remapped statusCode) in ghost envelope', () => {
|
|
50
|
+
const err = new GhostAPIError('posts.edit', 'bad', 422);
|
|
51
|
+
// GhostAPIError remaps 422 -> 400 for .statusCode; ghost.statusCode must be 422
|
|
52
|
+
expect(err.statusCode).toBe(400);
|
|
53
|
+
expect(err.ghostStatusCode).toBe(422);
|
|
54
|
+
const envelope = parseJsonBlock(formatErrorResponse(err, 'ghost_update_post').content[0].text);
|
|
55
|
+
expect(envelope.ghost.statusCode).toBe(422);
|
|
56
|
+
expect(envelope.error.statusCode).toBe(400);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not leak GHOST_ADMIN_API_KEY in surfaced response', () => {
|
|
60
|
+
process.env.GHOST_ADMIN_API_KEY = 'plaintext-admin-key-xyz';
|
|
61
|
+
const err = new GhostAPIError(
|
|
62
|
+
'posts.edit',
|
|
63
|
+
'Ghost complained: token plaintext-admin-key-xyz invalid',
|
|
64
|
+
401
|
|
65
|
+
);
|
|
66
|
+
const response = formatErrorResponse(err, 'ghost_update_post');
|
|
67
|
+
expect(response.content[0].text).not.toContain('plaintext-admin-key-xyz');
|
|
68
|
+
expect(response.content[0].text).toContain('[REDACTED]');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not leak Ghost-shaped admin key pattern in originalMessage', () => {
|
|
72
|
+
const fakeKey = `${'1'.repeat(24)}:${'2'.repeat(64)}`;
|
|
73
|
+
const err = new GhostAPIError('posts.edit', `failed with ${fakeKey}`, 401);
|
|
74
|
+
const response = formatErrorResponse(err, 'ghost_update_post');
|
|
75
|
+
expect(response.content[0].text).not.toContain(fakeKey);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('produces envelope for ValidationError (no ghost key)', () => {
|
|
79
|
+
const err = new ValidationError('Validation failed', [
|
|
80
|
+
{ field: 'title', message: 'required', type: 'invalid_type' },
|
|
81
|
+
]);
|
|
82
|
+
const response = formatErrorResponse(err, 'ghost_update_post');
|
|
83
|
+
const envelope = parseJsonBlock(response.content[0].text);
|
|
84
|
+
expect(envelope.error.code).toBe('VALIDATION_ERROR');
|
|
85
|
+
expect(envelope).not.toHaveProperty('ghost');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('produces envelope for NotFoundError (no ghost key)', () => {
|
|
89
|
+
const err = new NotFoundError('Post', 'abc');
|
|
90
|
+
const response = formatErrorResponse(err, 'ghost_get_post');
|
|
91
|
+
const envelope = parseJsonBlock(response.content[0].text);
|
|
92
|
+
expect(envelope.error.code).toBe('NOT_FOUND');
|
|
93
|
+
expect(envelope).not.toHaveProperty('ghost');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('extra context', () => {
|
|
97
|
+
it('includes and sanitizes extra context when provided', () => {
|
|
98
|
+
const extra = {
|
|
99
|
+
orphanedImage: { url: 'https://cdn.example/img.jpg?key=LEAK_ME_XYZ', ref: 'r1' },
|
|
100
|
+
};
|
|
101
|
+
const response = formatErrorResponse(new Error('boom'), 'ghost_set_feature_image', extra);
|
|
102
|
+
const envelope = parseJsonBlock(response.content[0].text);
|
|
103
|
+
expect(envelope.extra).toBeDefined();
|
|
104
|
+
expect(envelope.extra.orphanedImage.url).toContain('key=[REDACTED]');
|
|
105
|
+
expect(response.content[0].text).not.toContain('LEAK_ME_XYZ');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('omits extra key entirely when arg is not provided', () => {
|
|
109
|
+
const envelope = parseJsonBlock(
|
|
110
|
+
formatErrorResponse(new Error('boom'), 'ghost_update_post').content[0].text
|
|
111
|
+
);
|
|
112
|
+
expect(envelope).not.toHaveProperty('extra');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('omits extra key when arg is empty object (no empty-object leak)', () => {
|
|
116
|
+
const envelope = parseJsonBlock(
|
|
117
|
+
formatErrorResponse(new Error('boom'), 'ghost_update_post', {}).content[0].text
|
|
118
|
+
);
|
|
119
|
+
expect(envelope).not.toHaveProperty('extra');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('combines error, ghost, and extra when all apply', () => {
|
|
123
|
+
const err = new GhostAPIError('posts.edit', 'bad', 422);
|
|
124
|
+
const envelope = parseJsonBlock(
|
|
125
|
+
formatErrorResponse(err, 'ghost_set_feature_image', { orphanedImage: { url: 'x' } })
|
|
126
|
+
.content[0].text
|
|
127
|
+
);
|
|
128
|
+
expect(envelope.error).toBeDefined();
|
|
129
|
+
expect(envelope.ghost).toBeDefined();
|
|
130
|
+
expect(envelope.extra).toBeDefined();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('ZodError coercion', () => {
|
|
135
|
+
it('coerces ZodError-shaped input to VALIDATION_ERROR / 400 envelope', () => {
|
|
136
|
+
const zodLike = Object.assign(new Error('zod'), {
|
|
137
|
+
name: 'ZodError',
|
|
138
|
+
issues: [{ path: ['purpose'], message: 'Invalid enum value', code: 'invalid_enum_value' }],
|
|
139
|
+
});
|
|
140
|
+
const envelope = parseJsonBlock(
|
|
141
|
+
formatErrorResponse(zodLike, 'ghost_upload_image').content[0].text
|
|
142
|
+
);
|
|
143
|
+
expect(envelope.error.code).toBe('VALIDATION_ERROR');
|
|
144
|
+
expect(envelope.error.statusCode).toBe(400);
|
|
145
|
+
expect(envelope.error.errors).toEqual([
|
|
146
|
+
{ field: 'purpose', message: 'Invalid enum value', type: 'invalid_enum_value' },
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('does not coerce a non-ZodError error that happens to have an issues field', () => {
|
|
151
|
+
const notZod = Object.assign(new Error('unrelated'), { issues: [] });
|
|
152
|
+
const envelope = parseJsonBlock(
|
|
153
|
+
formatErrorResponse(notZod, 'ghost_update_post').content[0].text
|
|
154
|
+
);
|
|
155
|
+
expect(envelope.error.code).toBe('UNKNOWN');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -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
|
+
});
|