@jgardner04/ghost-mcp-server 1.13.2 → 1.13.3
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 +5 -13
- package/src/__tests__/mcp_server.test.js +204 -117
- package/src/__tests__/mcp_server_pages.test.js +32 -18
- package/src/config/mcp-config.js +1 -1
- package/src/controllers/__tests__/tagController.test.js +12 -8
- package/src/controllers/tagController.js +2 -2
- package/src/errors/__tests__/index.test.js +3 -3
- package/src/errors/index.js +1 -1
- package/src/index.js +1 -1
- package/src/mcp_server.js +35 -31
- package/src/schemas/__tests__/postSchemas.test.js +19 -0
- package/src/schemas/__tests__/tagSchemas.test.js +1 -1
- package/src/schemas/common.js +2 -2
- package/src/schemas/memberSchemas.js +20 -8
- package/src/schemas/newsletterSchemas.js +10 -10
- package/src/schemas/pageSchemas.js +16 -11
- package/src/schemas/postSchemas.js +22 -15
- package/src/schemas/tagSchemas.js +12 -7
- package/src/schemas/tierSchemas.js +17 -8
- package/src/services/__tests__/ghostServiceImproved.members.test.js +7 -2
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +10 -6
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +4 -4
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +4 -4
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +2 -2
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +9 -14
- package/src/services/__tests__/memberService.test.js +0 -28
- package/src/services/__tests__/tierService.test.js +0 -28
- package/src/services/ghostServiceImproved.js +69 -217
- package/src/services/imageProcessingService.js +1 -1
- package/src/services/memberService.js +0 -13
- package/src/services/tierService.js +0 -13
- package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
- package/src/utils/nqlSanitizer.js +11 -0
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
retryWithBackoff,
|
|
12
12
|
} from '../errors/index.js';
|
|
13
13
|
import { createContextLogger } from '../utils/logger.js';
|
|
14
|
+
import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
|
|
14
15
|
|
|
15
16
|
dotenv.config();
|
|
16
17
|
|
|
@@ -257,6 +258,53 @@ const validators = {
|
|
|
257
258
|
},
|
|
258
259
|
};
|
|
259
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Shared CRUD helper: read a single resource by ID with 404→NotFoundError handling
|
|
263
|
+
*/
|
|
264
|
+
async function readResource(resource, id, label, options = {}) {
|
|
265
|
+
if (!id) throw new ValidationError(`${label} ID is required`);
|
|
266
|
+
try {
|
|
267
|
+
return await handleApiRequest(resource, 'read', { id }, options);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
270
|
+
throw new NotFoundError(label, id);
|
|
271
|
+
}
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Shared CRUD helper: update a resource with optimistic concurrency control (OCC)
|
|
278
|
+
* Reads the current version, merges updated_at, then edits.
|
|
279
|
+
*/
|
|
280
|
+
async function updateWithOCC(resource, id, updateData, options = {}, label = resource) {
|
|
281
|
+
const existing = await readResource(resource, id, label);
|
|
282
|
+
const editData = { ...updateData, updated_at: existing.updated_at };
|
|
283
|
+
try {
|
|
284
|
+
return await handleApiRequest(resource, 'edit', { id, ...editData }, options);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
287
|
+
throw new NotFoundError(label, id);
|
|
288
|
+
}
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Shared CRUD helper: delete a resource by ID with 404→NotFoundError handling
|
|
295
|
+
*/
|
|
296
|
+
async function deleteResource(resource, id, label) {
|
|
297
|
+
if (!id) throw new ValidationError(`${label} ID is required for deletion`);
|
|
298
|
+
try {
|
|
299
|
+
return await handleApiRequest(resource, 'delete', { id });
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
302
|
+
throw new NotFoundError(label, id);
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
260
308
|
/**
|
|
261
309
|
* Service functions with enhanced error handling
|
|
262
310
|
*/
|
|
@@ -305,53 +353,15 @@ export async function updatePost(postId, updateData, options = {}) {
|
|
|
305
353
|
validators.validateScheduledStatus(updateData, 'Post');
|
|
306
354
|
}
|
|
307
355
|
|
|
308
|
-
|
|
309
|
-
try {
|
|
310
|
-
const existingPost = await handleApiRequest('posts', 'read', { id: postId });
|
|
311
|
-
|
|
312
|
-
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
313
|
-
const editData = {
|
|
314
|
-
...updateData,
|
|
315
|
-
updated_at: existingPost.updated_at,
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
return await handleApiRequest('posts', 'edit', editData, { id: postId, ...options });
|
|
319
|
-
} catch (error) {
|
|
320
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
321
|
-
throw new NotFoundError('Post', postId);
|
|
322
|
-
}
|
|
323
|
-
throw error;
|
|
324
|
-
}
|
|
356
|
+
return updateWithOCC('posts', postId, updateData, options, 'Post');
|
|
325
357
|
}
|
|
326
358
|
|
|
327
359
|
export async function deletePost(postId) {
|
|
328
|
-
|
|
329
|
-
throw new ValidationError('Post ID is required for deletion');
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
try {
|
|
333
|
-
return await handleApiRequest('posts', 'delete', { id: postId });
|
|
334
|
-
} catch (error) {
|
|
335
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
336
|
-
throw new NotFoundError('Post', postId);
|
|
337
|
-
}
|
|
338
|
-
throw error;
|
|
339
|
-
}
|
|
360
|
+
return deleteResource('posts', postId, 'Post');
|
|
340
361
|
}
|
|
341
362
|
|
|
342
363
|
export async function getPost(postId, options = {}) {
|
|
343
|
-
|
|
344
|
-
throw new ValidationError('Post ID is required');
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
try {
|
|
348
|
-
return await handleApiRequest('posts', 'read', { id: postId }, options);
|
|
349
|
-
} catch (error) {
|
|
350
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
351
|
-
throw new NotFoundError('Post', postId);
|
|
352
|
-
}
|
|
353
|
-
throw error;
|
|
354
|
-
}
|
|
364
|
+
return readResource('posts', postId, 'Post', options);
|
|
355
365
|
}
|
|
356
366
|
|
|
357
367
|
export async function getPosts(options = {}) {
|
|
@@ -376,7 +386,7 @@ export async function searchPosts(query, options = {}) {
|
|
|
376
386
|
}
|
|
377
387
|
|
|
378
388
|
// Sanitize query - escape special NQL characters to prevent injection
|
|
379
|
-
const sanitizedQuery = query
|
|
389
|
+
const sanitizedQuery = sanitizeNqlValue(query);
|
|
380
390
|
|
|
381
391
|
// Build filter with fuzzy title match using Ghost NQL
|
|
382
392
|
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
@@ -441,53 +451,15 @@ export async function updatePage(pageId, updateData, options = {}) {
|
|
|
441
451
|
validators.validateScheduledStatus(updateData, 'Page');
|
|
442
452
|
}
|
|
443
453
|
|
|
444
|
-
|
|
445
|
-
// Get existing page to retrieve updated_at for conflict resolution
|
|
446
|
-
const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
|
|
447
|
-
|
|
448
|
-
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
449
|
-
const editData = {
|
|
450
|
-
...updateData,
|
|
451
|
-
updated_at: existingPage.updated_at,
|
|
452
|
-
};
|
|
453
|
-
|
|
454
|
-
return await handleApiRequest('pages', 'edit', editData, { id: pageId, ...options });
|
|
455
|
-
} catch (error) {
|
|
456
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
457
|
-
throw new NotFoundError('Page', pageId);
|
|
458
|
-
}
|
|
459
|
-
throw error;
|
|
460
|
-
}
|
|
454
|
+
return updateWithOCC('pages', pageId, updateData, options, 'Page');
|
|
461
455
|
}
|
|
462
456
|
|
|
463
457
|
export async function deletePage(pageId) {
|
|
464
|
-
|
|
465
|
-
throw new ValidationError('Page ID is required for delete');
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
try {
|
|
469
|
-
return await handleApiRequest('pages', 'delete', { id: pageId });
|
|
470
|
-
} catch (error) {
|
|
471
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
472
|
-
throw new NotFoundError('Page', pageId);
|
|
473
|
-
}
|
|
474
|
-
throw error;
|
|
475
|
-
}
|
|
458
|
+
return deleteResource('pages', pageId, 'Page');
|
|
476
459
|
}
|
|
477
460
|
|
|
478
461
|
export async function getPage(pageId, options = {}) {
|
|
479
|
-
|
|
480
|
-
throw new ValidationError('Page ID is required');
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
try {
|
|
484
|
-
return await handleApiRequest('pages', 'read', { id: pageId }, options);
|
|
485
|
-
} catch (error) {
|
|
486
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
487
|
-
throw new NotFoundError('Page', pageId);
|
|
488
|
-
}
|
|
489
|
-
throw error;
|
|
490
|
-
}
|
|
462
|
+
return readResource('pages', pageId, 'Page', options);
|
|
491
463
|
}
|
|
492
464
|
|
|
493
465
|
export async function getPages(options = {}) {
|
|
@@ -512,7 +484,7 @@ export async function searchPages(query, options = {}) {
|
|
|
512
484
|
}
|
|
513
485
|
|
|
514
486
|
// Sanitize query - escape special NQL characters to prevent injection
|
|
515
|
-
const sanitizedQuery = query
|
|
487
|
+
const sanitizedQuery = sanitizeNqlValue(query);
|
|
516
488
|
|
|
517
489
|
// Build filter with fuzzy title match using Ghost NQL
|
|
518
490
|
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
@@ -603,18 +575,7 @@ export async function getTags(options = {}) {
|
|
|
603
575
|
}
|
|
604
576
|
|
|
605
577
|
export async function getTag(tagId, options = {}) {
|
|
606
|
-
|
|
607
|
-
throw new ValidationError('Tag ID is required');
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
try {
|
|
611
|
-
return await handleApiRequest('tags', 'read', { id: tagId }, options);
|
|
612
|
-
} catch (error) {
|
|
613
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
614
|
-
throw new NotFoundError('Tag', tagId);
|
|
615
|
-
}
|
|
616
|
-
throw error;
|
|
617
|
-
}
|
|
578
|
+
return readResource('tags', tagId, 'Tag', options);
|
|
618
579
|
}
|
|
619
580
|
|
|
620
581
|
export async function updateTag(tagId, updateData) {
|
|
@@ -622,14 +583,11 @@ export async function updateTag(tagId, updateData) {
|
|
|
622
583
|
throw new ValidationError('Tag ID is required for update');
|
|
623
584
|
}
|
|
624
585
|
|
|
625
|
-
validators.validateTagUpdateData(updateData);
|
|
586
|
+
validators.validateTagUpdateData(updateData);
|
|
626
587
|
|
|
627
588
|
try {
|
|
628
|
-
|
|
629
|
-
await
|
|
630
|
-
|
|
631
|
-
// Send only changed fields (tags don't use updated_at for OCC)
|
|
632
|
-
return await handleApiRequest('tags', 'edit', { ...updateData }, { id: tagId });
|
|
589
|
+
await readResource('tags', tagId, 'Tag');
|
|
590
|
+
return await handleApiRequest('tags', 'edit', { id: tagId, ...updateData });
|
|
633
591
|
} catch (error) {
|
|
634
592
|
if (error instanceof NotFoundError) {
|
|
635
593
|
throw error;
|
|
@@ -644,18 +602,7 @@ export async function updateTag(tagId, updateData) {
|
|
|
644
602
|
}
|
|
645
603
|
|
|
646
604
|
export async function deleteTag(tagId) {
|
|
647
|
-
|
|
648
|
-
throw new ValidationError('Tag ID is required for deletion');
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
try {
|
|
652
|
-
return await handleApiRequest('tags', 'delete', { id: tagId });
|
|
653
|
-
} catch (error) {
|
|
654
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
655
|
-
throw new NotFoundError('Tag', tagId);
|
|
656
|
-
}
|
|
657
|
-
throw error;
|
|
658
|
-
}
|
|
605
|
+
return deleteResource('tags', tagId, 'Tag');
|
|
659
606
|
}
|
|
660
607
|
|
|
661
608
|
/**
|
|
@@ -713,23 +660,7 @@ export async function updateMember(memberId, updateData, options = {}) {
|
|
|
713
660
|
throw new ValidationError('Member ID is required for update');
|
|
714
661
|
}
|
|
715
662
|
|
|
716
|
-
|
|
717
|
-
// Get existing member to retrieve updated_at for conflict resolution
|
|
718
|
-
const existingMember = await handleApiRequest('members', 'read', { id: memberId });
|
|
719
|
-
|
|
720
|
-
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
721
|
-
const editData = {
|
|
722
|
-
...updateData,
|
|
723
|
-
updated_at: existingMember.updated_at,
|
|
724
|
-
};
|
|
725
|
-
|
|
726
|
-
return await handleApiRequest('members', 'edit', editData, { id: memberId, ...options });
|
|
727
|
-
} catch (error) {
|
|
728
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
729
|
-
throw new NotFoundError('Member', memberId);
|
|
730
|
-
}
|
|
731
|
-
throw error;
|
|
732
|
-
}
|
|
663
|
+
return updateWithOCC('members', memberId, updateData, options, 'Member');
|
|
733
664
|
}
|
|
734
665
|
|
|
735
666
|
/**
|
|
@@ -741,18 +672,7 @@ export async function updateMember(memberId, updateData, options = {}) {
|
|
|
741
672
|
* @throws {GhostAPIError} If the API request fails
|
|
742
673
|
*/
|
|
743
674
|
export async function deleteMember(memberId) {
|
|
744
|
-
|
|
745
|
-
throw new ValidationError('Member ID is required for deletion');
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
try {
|
|
749
|
-
return await handleApiRequest('members', 'delete', { id: memberId });
|
|
750
|
-
} catch (error) {
|
|
751
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
752
|
-
throw new NotFoundError('Member', memberId);
|
|
753
|
-
}
|
|
754
|
-
throw error;
|
|
755
|
-
}
|
|
675
|
+
return deleteResource('members', memberId, 'Member');
|
|
756
676
|
}
|
|
757
677
|
|
|
758
678
|
/**
|
|
@@ -795,7 +715,6 @@ export async function getMembers(options = {}) {
|
|
|
795
715
|
*/
|
|
796
716
|
export async function getMember(params) {
|
|
797
717
|
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
798
|
-
const { sanitizeNqlValue } = await import('./memberService.js');
|
|
799
718
|
const { id, email } = params;
|
|
800
719
|
|
|
801
720
|
try {
|
|
@@ -837,7 +756,6 @@ export async function getMember(params) {
|
|
|
837
756
|
*/
|
|
838
757
|
export async function searchMembers(query, options = {}) {
|
|
839
758
|
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
840
|
-
const { sanitizeNqlValue } = await import('./memberService.js');
|
|
841
759
|
const sanitizedQuery = sanitizeNqlValue(query.trim());
|
|
842
760
|
|
|
843
761
|
const limit = options.limit || 15;
|
|
@@ -875,18 +793,7 @@ export async function getNewsletters(options = {}) {
|
|
|
875
793
|
}
|
|
876
794
|
|
|
877
795
|
export async function getNewsletter(newsletterId) {
|
|
878
|
-
|
|
879
|
-
throw new ValidationError('Newsletter ID is required');
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
try {
|
|
883
|
-
return await handleApiRequest('newsletters', 'read', { id: newsletterId });
|
|
884
|
-
} catch (error) {
|
|
885
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
886
|
-
throw new NotFoundError('Newsletter', newsletterId);
|
|
887
|
-
}
|
|
888
|
-
throw error;
|
|
889
|
-
}
|
|
796
|
+
return readResource('newsletters', newsletterId, 'Newsletter');
|
|
890
797
|
}
|
|
891
798
|
|
|
892
799
|
export async function createNewsletter(newsletterData) {
|
|
@@ -911,22 +818,8 @@ export async function updateNewsletter(newsletterId, updateData) {
|
|
|
911
818
|
}
|
|
912
819
|
|
|
913
820
|
try {
|
|
914
|
-
|
|
915
|
-
const existingNewsletter = await handleApiRequest('newsletters', 'read', {
|
|
916
|
-
id: newsletterId,
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
920
|
-
const editData = {
|
|
921
|
-
...updateData,
|
|
922
|
-
updated_at: existingNewsletter.updated_at,
|
|
923
|
-
};
|
|
924
|
-
|
|
925
|
-
return await handleApiRequest('newsletters', 'edit', editData, { id: newsletterId });
|
|
821
|
+
return await updateWithOCC('newsletters', newsletterId, updateData, {}, 'Newsletter');
|
|
926
822
|
} catch (error) {
|
|
927
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
928
|
-
throw new NotFoundError('Newsletter', newsletterId);
|
|
929
|
-
}
|
|
930
823
|
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
931
824
|
throw new ValidationError('Newsletter update failed', [
|
|
932
825
|
{ field: 'newsletter', message: error.originalError },
|
|
@@ -937,18 +830,7 @@ export async function updateNewsletter(newsletterId, updateData) {
|
|
|
937
830
|
}
|
|
938
831
|
|
|
939
832
|
export async function deleteNewsletter(newsletterId) {
|
|
940
|
-
|
|
941
|
-
throw new ValidationError('Newsletter ID is required for deletion');
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
try {
|
|
945
|
-
return await handleApiRequest('newsletters', 'delete', newsletterId);
|
|
946
|
-
} catch (error) {
|
|
947
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
948
|
-
throw new NotFoundError('Newsletter', newsletterId);
|
|
949
|
-
}
|
|
950
|
-
throw error;
|
|
951
|
-
}
|
|
833
|
+
return deleteResource('newsletters', newsletterId, 'Newsletter');
|
|
952
834
|
}
|
|
953
835
|
|
|
954
836
|
/**
|
|
@@ -988,23 +870,7 @@ export async function updateTier(id, updateData, options = {}) {
|
|
|
988
870
|
const { validateTierUpdateData } = await import('./tierService.js');
|
|
989
871
|
validateTierUpdateData(updateData);
|
|
990
872
|
|
|
991
|
-
|
|
992
|
-
// Get existing tier to retrieve updated_at for conflict resolution
|
|
993
|
-
const existingTier = await handleApiRequest('tiers', 'read', { id }, { id });
|
|
994
|
-
|
|
995
|
-
// Send only changed fields + updated_at for OCC (optimistic concurrency control)
|
|
996
|
-
const editData = {
|
|
997
|
-
...updateData,
|
|
998
|
-
updated_at: existingTier.updated_at,
|
|
999
|
-
};
|
|
1000
|
-
|
|
1001
|
-
return await handleApiRequest('tiers', 'edit', editData, { id, ...options });
|
|
1002
|
-
} catch (error) {
|
|
1003
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1004
|
-
throw new NotFoundError('Tier', id);
|
|
1005
|
-
}
|
|
1006
|
-
throw error;
|
|
1007
|
-
}
|
|
873
|
+
return updateWithOCC('tiers', id, updateData, options, 'Tier');
|
|
1008
874
|
}
|
|
1009
875
|
|
|
1010
876
|
/**
|
|
@@ -1017,14 +883,7 @@ export async function deleteTier(id) {
|
|
|
1017
883
|
throw new ValidationError('Tier ID is required for deletion');
|
|
1018
884
|
}
|
|
1019
885
|
|
|
1020
|
-
|
|
1021
|
-
return await handleApiRequest('tiers', 'delete', { id });
|
|
1022
|
-
} catch (error) {
|
|
1023
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1024
|
-
throw new NotFoundError('Tier', id);
|
|
1025
|
-
}
|
|
1026
|
-
throw error;
|
|
1027
|
-
}
|
|
886
|
+
return deleteResource('tiers', id, 'Tier');
|
|
1028
887
|
}
|
|
1029
888
|
|
|
1030
889
|
/**
|
|
@@ -1065,14 +924,7 @@ export async function getTier(id) {
|
|
|
1065
924
|
throw new ValidationError('Tier ID is required and must be a non-empty string');
|
|
1066
925
|
}
|
|
1067
926
|
|
|
1068
|
-
|
|
1069
|
-
return await handleApiRequest('tiers', 'read', { id }, { id });
|
|
1070
|
-
} catch (error) {
|
|
1071
|
-
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
1072
|
-
throw new NotFoundError('Tier', id);
|
|
1073
|
-
}
|
|
1074
|
-
throw error;
|
|
1075
|
-
}
|
|
927
|
+
return readResource('tiers', id, 'Tier');
|
|
1076
928
|
}
|
|
1077
929
|
|
|
1078
930
|
/**
|
|
@@ -89,7 +89,7 @@ const processImage = async (inputPath, outputDir) => {
|
|
|
89
89
|
});
|
|
90
90
|
// If processing fails, maybe fall back to using the original?
|
|
91
91
|
// Or throw the error to fail the upload.
|
|
92
|
-
throw new Error('Image processing failed: ' + error.message);
|
|
92
|
+
throw new Error('Image processing failed: ' + error.message, { cause: error });
|
|
93
93
|
}
|
|
94
94
|
};
|
|
95
95
|
|
|
@@ -204,18 +204,6 @@ export function validateMemberUpdateData(updateData) {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
/**
|
|
208
|
-
* Sanitizes a value for use in NQL filters to prevent injection
|
|
209
|
-
* Escapes backslashes, single quotes, and double quotes
|
|
210
|
-
* @param {string} value - The value to sanitize
|
|
211
|
-
* @returns {string} The sanitized value
|
|
212
|
-
*/
|
|
213
|
-
export function sanitizeNqlValue(value) {
|
|
214
|
-
if (!value) return value;
|
|
215
|
-
// Escape backslashes first, then quotes
|
|
216
|
-
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
207
|
/**
|
|
220
208
|
* Validates query options for member browsing
|
|
221
209
|
* @param {Object} options - The query options to validate
|
|
@@ -388,5 +376,4 @@ export default {
|
|
|
388
376
|
validateMemberLookup,
|
|
389
377
|
validateSearchQuery,
|
|
390
378
|
validateSearchOptions,
|
|
391
|
-
sanitizeNqlValue,
|
|
392
379
|
};
|
|
@@ -284,21 +284,8 @@ export function validateTierQueryOptions(options) {
|
|
|
284
284
|
}
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
/**
|
|
288
|
-
* Sanitizes a value for use in NQL filters to prevent injection
|
|
289
|
-
* Escapes backslashes, single quotes, and double quotes
|
|
290
|
-
* @param {string} value - The value to sanitize
|
|
291
|
-
* @returns {string} The sanitized value
|
|
292
|
-
*/
|
|
293
|
-
export function sanitizeNqlValue(value) {
|
|
294
|
-
if (!value) return value;
|
|
295
|
-
// Escape backslashes first, then quotes
|
|
296
|
-
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
297
|
-
}
|
|
298
|
-
|
|
299
287
|
export default {
|
|
300
288
|
validateTierData,
|
|
301
289
|
validateTierUpdateData,
|
|
302
290
|
validateTierQueryOptions,
|
|
303
|
-
sanitizeNqlValue,
|
|
304
291
|
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sanitizeNqlValue } from '../nqlSanitizer.js';
|
|
3
|
+
|
|
4
|
+
describe('sanitizeNqlValue', () => {
|
|
5
|
+
it('should escape backslashes', () => {
|
|
6
|
+
expect(sanitizeNqlValue('hello\\world')).toBe('hello\\\\world');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('should escape single quotes', () => {
|
|
10
|
+
expect(sanitizeNqlValue("it's")).toBe("it\\'s");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should escape double quotes', () => {
|
|
14
|
+
expect(sanitizeNqlValue('say "hello"')).toBe('say \\"hello\\"');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should escape all three special characters combined', () => {
|
|
18
|
+
expect(sanitizeNqlValue('a\\b\'c"d')).toBe('a\\\\b\\\'c\\"d');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should return null as-is', () => {
|
|
22
|
+
expect(sanitizeNqlValue(null)).toBe(null);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should return undefined as-is', () => {
|
|
26
|
+
expect(sanitizeNqlValue(undefined)).toBe(undefined);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return empty string as-is', () => {
|
|
30
|
+
expect(sanitizeNqlValue('')).toBe('');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should pass through normal strings without special characters', () => {
|
|
34
|
+
expect(sanitizeNqlValue('simple-value')).toBe('simple-value');
|
|
35
|
+
expect(sanitizeNqlValue('test@example.com')).toBe('test@example.com');
|
|
36
|
+
expect(sanitizeNqlValue('hello world 123')).toBe('hello world 123');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitizes a value for use in NQL (Ghost's filter query language) to prevent injection.
|
|
3
|
+
* Escapes backslashes, single quotes, and double quotes.
|
|
4
|
+
* @param {string} value - The value to sanitize
|
|
5
|
+
* @returns {string} The sanitized value
|
|
6
|
+
*/
|
|
7
|
+
export function sanitizeNqlValue(value) {
|
|
8
|
+
if (!value) return value;
|
|
9
|
+
// Escape backslashes first, then quotes
|
|
10
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
11
|
+
}
|