@jgardner04/ghost-mcp-server 1.13.5 → 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.
@@ -1,204 +1,175 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
3
- import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
4
-
5
- // Mock dotenv
6
- vi.mock('dotenv', () => mockDotenv());
1
+ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2
+ import sharp from 'sharp';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import os from 'os';
7
6
 
8
- // Mock logger
7
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
9
8
  vi.mock('../../utils/logger.js', () => ({
10
9
  createContextLogger: createMockContextLogger(),
11
10
  }));
12
11
 
13
- // Mock sharp with chainable API
14
- const mockMetadata = vi.fn();
15
- const mockResize = vi.fn();
16
- const mockJpeg = vi.fn();
17
- const mockToFile = vi.fn();
18
-
19
- const createMockSharp = () => {
20
- const instance = {
21
- metadata: mockMetadata,
22
- resize: mockResize,
23
- jpeg: mockJpeg,
24
- };
25
-
26
- // Make methods chainable
27
- mockResize.mockReturnValue(instance);
28
- mockJpeg.mockReturnValue(instance);
29
- instance.toFile = mockToFile;
30
-
31
- return instance;
32
- };
33
-
34
- vi.mock('sharp', () => ({
35
- default: vi.fn(() => createMockSharp()),
36
- }));
37
-
38
- // Mock fs
39
- vi.mock('fs', () => ({
40
- default: {
41
- existsSync: vi.fn(),
42
- },
43
- }));
12
+ // Import under test (real sharp, real filesystem)
13
+ const { processImage } = await import('../imageProcessingService.js');
14
+
15
+ const tmpRoot = path.join(os.tmpdir(), `ghost-mcp-img-test-${Date.now()}`);
16
+ const fixtures = {};
17
+
18
+ async function makeRasterFixture(format, width, height = width) {
19
+ const file = path.join(tmpRoot, `in-${format}-${width}.${format === 'jpeg' ? 'jpg' : format}`);
20
+ await sharp({
21
+ create: {
22
+ width,
23
+ height,
24
+ channels: format === 'png' ? 4 : 3,
25
+ background: { r: 10, g: 50, b: 150, alpha: 1 },
26
+ },
27
+ })
28
+ [format]()
29
+ .toFile(file);
30
+ return file;
31
+ }
32
+
33
+ beforeAll(async () => {
34
+ await fs.mkdir(tmpRoot, { recursive: true });
35
+
36
+ fixtures.pngSmall = await makeRasterFixture('png', 800);
37
+ fixtures.pngLarge = await makeRasterFixture('png', 2000);
38
+ fixtures.jpegSmall = await makeRasterFixture('jpeg', 800);
39
+ fixtures.jpegLarge = await makeRasterFixture('jpeg', 2000);
40
+ fixtures.webpSmall = await makeRasterFixture('webp', 800);
41
+ fixtures.webpLarge = await makeRasterFixture('webp', 2000);
42
+
43
+ // SVG: write minimal vector source
44
+ fixtures.svg = path.join(tmpRoot, 'in.svg');
45
+ await fs.writeFile(
46
+ fixtures.svg,
47
+ '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#39f"/></svg>'
48
+ );
49
+
50
+ // GIF: sharp cannot reliably create animated GIFs across versions; use a
51
+ // known-valid 1x1 transparent GIF89a byte sequence for a deterministic fixture.
52
+ fixtures.gif = path.join(tmpRoot, 'in.gif');
53
+ await fs.writeFile(
54
+ fixtures.gif,
55
+ Buffer.from(
56
+ '47494638396101000100800000ffffff00000021f90401000001002c00000000010001000002024401003b',
57
+ 'hex'
58
+ )
59
+ );
60
+
61
+ // ICO: minimal 1x1 ICO header (used only for passthrough byte comparison).
62
+ fixtures.ico = path.join(tmpRoot, 'in.ico');
63
+ await fs.writeFile(
64
+ fixtures.ico,
65
+ Buffer.from(
66
+ '00000100010001010000010020003000000016000000280000000100000002000000010020000000000004000000000000000000000000000000000000000000000000000000000000000000000000',
67
+ 'hex'
68
+ )
69
+ );
70
+ });
44
71
 
45
- // Mock path - use actual implementation but allow spying
46
- vi.mock('path', async () => {
47
- const actual = await vi.importActual('path');
48
- return {
49
- default: actual.default,
50
- ...actual,
51
- };
72
+ afterAll(async () => {
73
+ await fs.rm(tmpRoot, { recursive: true, force: true });
52
74
  });
53
75
 
54
- // Import after mocks are set up
55
- import { processImage } from '../imageProcessingService.js';
56
- import sharp from 'sharp';
57
- import fs from 'fs';
58
-
59
- describe('imageProcessingService', () => {
60
- beforeEach(() => {
61
- vi.clearAllMocks();
62
- // Reset mock implementations
63
- mockMetadata.mockResolvedValue({ width: 800, size: 100000 });
64
- mockToFile.mockResolvedValue();
65
- fs.existsSync.mockReturnValue(true);
76
+ async function metaOf(file) {
77
+ return sharp(file).metadata();
78
+ }
79
+
80
+ describe('processImage — format passthrough', () => {
81
+ it('PNG <= 1200: preserves format and passes through unmodified', async () => {
82
+ const out = await processImage(fixtures.pngSmall, tmpRoot);
83
+ expect(path.extname(out).toLowerCase()).toBe('.png');
84
+ const m = await metaOf(out);
85
+ expect(m.format).toBe('png');
86
+ expect(m.width).toBe(800);
66
87
  });
67
88
 
68
- describe('Input Validation', () => {
69
- it('should accept valid inputPath and outputDir', async () => {
70
- const inputPath = '/tmp/test-image.jpg';
71
- const outputDir = '/tmp/output';
72
-
73
- await processImage(inputPath, outputDir);
74
-
75
- expect(sharp).toHaveBeenCalledWith(inputPath);
76
- expect(mockToFile).toHaveBeenCalled();
77
- });
78
-
79
- it('should reject missing inputPath', async () => {
80
- await expect(processImage(undefined, '/tmp/output')).rejects.toThrow(
81
- 'Invalid processing parameters'
82
- );
83
- expect(sharp).not.toHaveBeenCalled();
84
- });
85
-
86
- it('should reject missing outputDir', async () => {
87
- await expect(processImage('/tmp/test.jpg', undefined)).rejects.toThrow(
88
- 'Invalid processing parameters'
89
- );
90
- expect(sharp).not.toHaveBeenCalled();
91
- });
89
+ it('PNG > 1200: resized but still PNG (no JPEG conversion)', async () => {
90
+ const out = await processImage(fixtures.pngLarge, tmpRoot);
91
+ expect(path.extname(out).toLowerCase()).toBe('.png');
92
+ const m = await metaOf(out);
93
+ expect(m.format).toBe('png');
94
+ expect(m.width).toBe(1200);
92
95
  });
93
96
 
94
- describe('Path Security', () => {
95
- it('should resolve paths correctly', async () => {
96
- const inputPath = './relative/path/image.jpg';
97
- const outputDir = './output';
98
-
99
- await processImage(inputPath, outputDir);
100
-
101
- expect(fs.existsSync).toHaveBeenCalledWith(expect.stringContaining('image.jpg'));
102
- expect(mockToFile).toHaveBeenCalledWith(expect.stringContaining('output'));
103
- });
104
-
105
- it('should throw error when input file does not exist', async () => {
106
- fs.existsSync.mockReturnValue(false);
107
-
108
- await expect(processImage('/tmp/nonexistent.jpg', '/tmp/output')).rejects.toThrow(
109
- 'Input file does not exist'
110
- );
111
- expect(sharp).not.toHaveBeenCalled();
112
- });
97
+ it('JPEG <= 1200: passes through as JPEG', async () => {
98
+ const out = await processImage(fixtures.jpegSmall, tmpRoot);
99
+ expect(path.extname(out).toLowerCase()).toMatch(/\.jpe?g$/);
100
+ const m = await metaOf(out);
101
+ expect(m.format).toBe('jpeg');
102
+ expect(m.width).toBe(800);
113
103
  });
114
104
 
115
- describe('Image Processing', () => {
116
- it('should not resize image when width <= MAX_WIDTH (1200)', async () => {
117
- mockMetadata.mockResolvedValue({ width: 1000, size: 100000 });
118
-
119
- await processImage('/tmp/small-image.jpg', '/tmp/output');
120
-
121
- expect(mockResize).not.toHaveBeenCalled();
122
- expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
123
- expect(mockToFile).toHaveBeenCalled();
124
- });
125
-
126
- it('should resize image when width > MAX_WIDTH (1200)', async () => {
127
- mockMetadata.mockResolvedValue({ width: 2000, size: 200000 });
128
-
129
- await processImage('/tmp/large-image.jpg', '/tmp/output');
130
-
131
- expect(mockResize).toHaveBeenCalledWith({ width: 1200 });
132
- expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
133
- expect(mockToFile).toHaveBeenCalled();
134
- });
135
-
136
- it('should generate output filename with timestamp and processed prefix', async () => {
137
- const inputPath = '/tmp/test-photo.jpg';
138
- const outputDir = '/tmp/output';
139
-
140
- // Mock Date.now to get predictable filename
141
- const mockTimestamp = 1234567890;
142
- vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp);
105
+ it('JPEG > 1200: resized as JPEG', async () => {
106
+ const out = await processImage(fixtures.jpegLarge, tmpRoot);
107
+ const m = await metaOf(out);
108
+ expect(m.format).toBe('jpeg');
109
+ expect(m.width).toBe(1200);
110
+ });
143
111
 
144
- await processImage(inputPath, outputDir);
112
+ it('WEBP <= 1200: passes through as WEBP', async () => {
113
+ const out = await processImage(fixtures.webpSmall, tmpRoot);
114
+ const m = await metaOf(out);
115
+ expect(m.format).toBe('webp');
116
+ expect(m.width).toBe(800);
117
+ });
145
118
 
146
- expect(mockToFile).toHaveBeenCalledWith(
147
- expect.stringMatching(/processed-1234567890-test-photo\.jpg$/)
148
- );
119
+ it('WEBP > 1200: resized as WEBP', async () => {
120
+ const out = await processImage(fixtures.webpLarge, tmpRoot);
121
+ const m = await metaOf(out);
122
+ expect(m.format).toBe('webp');
123
+ expect(m.width).toBe(1200);
124
+ });
149
125
 
150
- vi.restoreAllMocks();
151
- });
126
+ it('SVG: passthrough — output bytes identical to input (never touched by sharp)', async () => {
127
+ const out = await processImage(fixtures.svg, tmpRoot);
128
+ expect(path.extname(out).toLowerCase()).toBe('.svg');
129
+ const [a, b] = await Promise.all([fs.readFile(fixtures.svg), fs.readFile(out)]);
130
+ expect(a.equals(b)).toBe(true);
131
+ });
152
132
 
153
- it('should convert image to JPEG with quality setting of 80', async () => {
154
- await processImage('/tmp/image.png', '/tmp/output');
133
+ it('GIF: passthrough output bytes identical to input (animation-safe)', async () => {
134
+ const out = await processImage(fixtures.gif, tmpRoot);
135
+ expect(path.extname(out).toLowerCase()).toBe('.gif');
136
+ const [a, b] = await Promise.all([fs.readFile(fixtures.gif), fs.readFile(out)]);
137
+ expect(a.equals(b)).toBe(true);
138
+ });
155
139
 
156
- expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
157
- expect(mockToFile).toHaveBeenCalled();
158
- });
140
+ it('ICO: passthrough — output bytes identical to input', async () => {
141
+ const out = await processImage(fixtures.ico, tmpRoot, { purpose: 'icon' });
142
+ expect(path.extname(out).toLowerCase()).toBe('.ico');
143
+ const [a, b] = await Promise.all([fs.readFile(fixtures.ico), fs.readFile(out)]);
144
+ expect(a.equals(b)).toBe(true);
145
+ });
159
146
 
160
- it('should handle images with multiple dots in filename', async () => {
161
- const inputPath = '/tmp/my.test.image.png';
162
- const outputDir = '/tmp/output';
163
- const mockTimestamp = 9999999999;
164
- vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp);
147
+ it('output path is distinct from input path', async () => {
148
+ const out = await processImage(fixtures.pngSmall, tmpRoot);
149
+ expect(out).not.toBe(fixtures.pngSmall);
150
+ });
151
+ });
165
152
 
166
- await processImage(inputPath, outputDir);
153
+ describe('processImage — validation & errors', () => {
154
+ it('rejects missing inputPath', async () => {
155
+ await expect(processImage(undefined, tmpRoot)).rejects.toThrow(/Invalid processing parameters/);
156
+ });
167
157
 
168
- expect(mockToFile).toHaveBeenCalledWith(
169
- expect.stringMatching(/processed-9999999999-my\.test\.image\.jpg$/)
170
- );
158
+ it('rejects missing outputDir', async () => {
159
+ await expect(processImage(fixtures.pngSmall, undefined)).rejects.toThrow(
160
+ /Invalid processing parameters/
161
+ );
162
+ });
171
163
 
172
- vi.restoreAllMocks();
173
- });
164
+ it('throws when input file does not exist', async () => {
165
+ await expect(processImage('/nonexistent/xyz.png', tmpRoot)).rejects.toThrow(
166
+ /Input file does not exist/
167
+ );
174
168
  });
175
169
 
176
- describe('Error Handling', () => {
177
- it('should catch and re-throw sharp processing failures', async () => {
178
- const processingError = new Error('Sharp processing failed');
179
- mockMetadata.mockRejectedValue(processingError);
180
-
181
- await expect(processImage('/tmp/corrupt.jpg', '/tmp/output')).rejects.toThrow(
182
- 'Image processing failed: Sharp processing failed'
183
- );
184
- });
185
-
186
- it('should include original error message in re-thrown error', async () => {
187
- const originalMessage = 'Input buffer contains unsupported image format';
188
- mockToFile.mockRejectedValue(new Error(originalMessage));
189
-
190
- await expect(processImage('/tmp/bad-format.dat', '/tmp/output')).rejects.toThrow(
191
- `Image processing failed: ${originalMessage}`
192
- );
193
- });
194
-
195
- it('should handle errors during JPEG conversion', async () => {
196
- const conversionError = new Error('JPEG conversion failed');
197
- mockToFile.mockRejectedValue(conversionError);
198
-
199
- await expect(processImage('/tmp/image.jpg', '/tmp/output')).rejects.toThrow(
200
- 'Image processing failed: JPEG conversion failed'
201
- );
202
- });
170
+ it('wraps sharp failures with context', async () => {
171
+ const bogus = path.join(tmpRoot, 'bogus.png');
172
+ await fs.writeFile(bogus, 'not an image');
173
+ await expect(processImage(bogus, tmpRoot)).rejects.toThrow(/Image processing failed/);
203
174
  });
204
175
  });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
3
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
4
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
5
+
6
+ vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
7
+ vi.mock('dotenv', () => mockDotenv());
8
+ vi.mock('../../utils/logger.js', () => ({
9
+ createContextLogger: createMockContextLogger(),
10
+ }));
11
+
12
+ // Stub path validation so these tests focus on argument shaping, not FS.
13
+ vi.mock('../validators.js', () => ({
14
+ validators: { validateImagePath: vi.fn().mockResolvedValue(undefined) },
15
+ }));
16
+
17
+ import { uploadImage } from '../images.js';
18
+ import { api } from '../ghostApiClient.js';
19
+ import { ValidationError } from '../../errors/index.js';
20
+
21
+ describe('images.uploadImage (domain module used by MCP server)', () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ it('forwards purpose and ref to the SDK', async () => {
27
+ const expected = { url: 'https://cdn/x.png', ref: 'original.png' };
28
+ api.images.upload.mockResolvedValue(expected);
29
+
30
+ const result = await uploadImage('/tmp/x.png', {
31
+ purpose: 'icon',
32
+ ref: 'original.png',
33
+ });
34
+
35
+ expect(result).toEqual(expected);
36
+ expect(api.images.upload).toHaveBeenCalledWith({
37
+ file: '/tmp/x.png',
38
+ purpose: 'icon',
39
+ ref: 'original.png',
40
+ });
41
+ });
42
+
43
+ it('omits purpose/ref when not provided', async () => {
44
+ api.images.upload.mockResolvedValue({ url: 'https://cdn/x.png' });
45
+ await uploadImage('/tmp/x.png');
46
+ expect(api.images.upload).toHaveBeenCalledWith({ file: '/tmp/x.png' });
47
+ });
48
+
49
+ it('accepts each allowed purpose value', async () => {
50
+ api.images.upload.mockResolvedValue({ url: 'https://cdn/x' });
51
+ for (const purpose of ['image', 'profile_image', 'icon']) {
52
+ await uploadImage('/tmp/x.png', { purpose });
53
+ }
54
+ expect(api.images.upload).toHaveBeenCalledTimes(3);
55
+ });
56
+
57
+ it('rejects unknown purpose', async () => {
58
+ await expect(uploadImage('/tmp/x.png', { purpose: 'banner' })).rejects.toBeInstanceOf(
59
+ ValidationError
60
+ );
61
+ expect(api.images.upload).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it('rejects non-string ref', async () => {
65
+ await expect(uploadImage('/tmp/x.png', { ref: 42 })).rejects.toThrow(/must be a string/);
66
+ });
67
+
68
+ it('rejects ref longer than 200 chars', async () => {
69
+ await expect(uploadImage('/tmp/x.png', { ref: 'x'.repeat(201) })).rejects.toThrow(
70
+ /cannot exceed 200/
71
+ );
72
+ });
73
+
74
+ it('accepts ref of exactly 200 chars', async () => {
75
+ api.images.upload.mockResolvedValue({ url: 'https://cdn/x' });
76
+ await expect(uploadImage('/tmp/x.png', { ref: 'x'.repeat(200) })).resolves.toBeDefined();
77
+ });
78
+ });
@@ -148,24 +148,6 @@ const createPost = async (postData, options = { source: 'html' }) => {
148
148
  return handleApiRequest('posts', 'add', dataWithDefaults, options);
149
149
  };
150
150
 
151
- /**
152
- * Uploads an image to Ghost.
153
- * Requires the image file path.
154
- * @param {string} imagePath - The local path to the image file.
155
- * @returns {Promise<object>} The result from the image upload API call, typically includes the URL of the uploaded image.
156
- */
157
- const uploadImage = async (imagePath) => {
158
- if (!imagePath) {
159
- throw new Error('Image path is required for upload.');
160
- }
161
-
162
- // The Ghost Admin API expects an object with a 'file' property containing the path
163
- const imageData = { file: imagePath };
164
-
165
- // Use the handleApiRequest function for consistency
166
- return handleApiRequest('images', 'upload', imageData);
167
- };
168
-
169
151
  /**
170
152
  * Creates a new tag in Ghost.
171
153
  * @param {object} tagData - Data for the new tag (e.g., { name: 'New Tag', slug: 'new-tag' }).
@@ -209,4 +191,4 @@ const getTags = async (options = {}) => {
209
191
  // Add other content management functions here (createTag, etc.)
210
192
 
211
193
  // Export the API client instance and any service functions
212
- export { api, getSiteInfo, handleApiRequest, createPost, uploadImage, createTag, getTags };
194
+ export { api, getSiteInfo, handleApiRequest, createPost, createTag, getTags };
@@ -1,96 +1,140 @@
1
1
  import sharp from 'sharp';
2
2
  import path from 'path';
3
- import fs from 'fs';
4
- import Joi from 'joi';
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 OUTPUT_QUALITY = 80; // JPEG quality
8
+ const JPEG_QUALITY = 80;
10
9
 
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(),
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 processImage = async (inputPath, outputDir) => {
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
- // 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: 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
- // Ensure paths are safe
38
- const resolvedInputPath = path.resolve(inputPath);
58
+ const resolvedInput = path.resolve(inputPath);
39
59
  const resolvedOutputDir = path.resolve(outputDir);
40
60
 
41
- // Verify input file exists
42
- if (!fs.existsSync(resolvedInputPath)) {
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 filename = path.basename(resolvedInputPath);
47
- const nameWithoutExt = filename.split('.').slice(0, -1).join('.');
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
- logger.info('Processing image', {
55
- inputFile: path.basename(inputPath),
56
- outputDir: path.basename(outputDir),
57
- });
58
- const image = sharp(inputPath);
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
- let processedImage = image;
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
- // 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,
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
- processedImage = processedImage.resize({ width: MAX_WIDTH });
95
+ return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, inputExt);
71
96
  }
72
97
 
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);
98
+ const ext = EXT_BY_FORMAT[format];
99
+ const outputPath = path.join(resolvedOutputDir, `processed-${timestamp}-${baseName}${ext}`);
76
100
 
77
- logger.info('Image processing completed', {
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
- export { processImage };
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
+ }