@jgardner04/ghost-mcp-server 1.0.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/LICENSE +21 -0
- package/README.md +118 -0
- package/package.json +89 -0
- package/src/config/mcp-config.js +131 -0
- package/src/controllers/imageController.js +271 -0
- package/src/controllers/postController.js +46 -0
- package/src/controllers/tagController.js +79 -0
- package/src/errors/index.js +447 -0
- package/src/index.js +110 -0
- package/src/mcp_server.js +509 -0
- package/src/mcp_server_enhanced.js +675 -0
- package/src/mcp_server_improved.js +657 -0
- package/src/middleware/errorMiddleware.js +489 -0
- package/src/resources/ResourceManager.js +666 -0
- package/src/routes/imageRoutes.js +33 -0
- package/src/routes/postRoutes.js +72 -0
- package/src/routes/tagRoutes.js +47 -0
- package/src/services/ghostService.js +221 -0
- package/src/services/ghostServiceImproved.js +489 -0
- package/src/services/imageProcessingService.js +96 -0
- package/src/services/postService.js +174 -0
- package/src/utils/logger.js +153 -0
- package/src/utils/urlValidator.js +169 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced Resource Management System for MCP Server
|
|
3
|
+
* Provides caching, pagination, filtering, and subscription capabilities
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Resource } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { NotFoundError, ValidationError, ErrorHandler } from "../errors/index.js";
|
|
8
|
+
import EventEmitter from 'events';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* LRU Cache implementation for resource caching
|
|
12
|
+
*/
|
|
13
|
+
class LRUCache {
|
|
14
|
+
constructor(maxSize = 100, ttl = 300000) { // 5 minutes default TTL
|
|
15
|
+
this.maxSize = maxSize;
|
|
16
|
+
this.ttl = ttl;
|
|
17
|
+
this.cache = new Map();
|
|
18
|
+
this.accessOrder = [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get(key) {
|
|
22
|
+
const item = this.cache.get(key);
|
|
23
|
+
|
|
24
|
+
if (!item) return null;
|
|
25
|
+
|
|
26
|
+
// Check if expired
|
|
27
|
+
if (Date.now() > item.expiry) {
|
|
28
|
+
this.cache.delete(key);
|
|
29
|
+
this.accessOrder = this.accessOrder.filter(k => k !== key);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Update access order
|
|
34
|
+
this.accessOrder = this.accessOrder.filter(k => k !== key);
|
|
35
|
+
this.accessOrder.push(key);
|
|
36
|
+
|
|
37
|
+
return item.value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
set(key, value, customTTL = null) {
|
|
41
|
+
// Remove oldest items if at capacity
|
|
42
|
+
while (this.cache.size >= this.maxSize && this.accessOrder.length > 0) {
|
|
43
|
+
const oldestKey = this.accessOrder.shift();
|
|
44
|
+
this.cache.delete(oldestKey);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ttl = customTTL || this.ttl;
|
|
48
|
+
this.cache.set(key, {
|
|
49
|
+
value,
|
|
50
|
+
expiry: Date.now() + ttl
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.accessOrder.push(key);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
invalidate(pattern = null) {
|
|
57
|
+
if (!pattern) {
|
|
58
|
+
this.cache.clear();
|
|
59
|
+
this.accessOrder = [];
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Invalidate entries matching pattern
|
|
64
|
+
const regex = new RegExp(pattern);
|
|
65
|
+
for (const [key] of this.cache) {
|
|
66
|
+
if (regex.test(key)) {
|
|
67
|
+
this.cache.delete(key);
|
|
68
|
+
this.accessOrder = this.accessOrder.filter(k => k !== key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getStats() {
|
|
74
|
+
return {
|
|
75
|
+
size: this.cache.size,
|
|
76
|
+
maxSize: this.maxSize,
|
|
77
|
+
ttl: this.ttl,
|
|
78
|
+
keys: Array.from(this.cache.keys())
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* URI Parser for resource URIs
|
|
85
|
+
*/
|
|
86
|
+
class ResourceURIParser {
|
|
87
|
+
static parse(uri) {
|
|
88
|
+
// Support various URI formats:
|
|
89
|
+
// - ghost/post/123
|
|
90
|
+
// - ghost/post/slug:my-post-slug
|
|
91
|
+
// - ghost/post/uuid:550e8400-e29b-41d4-a716-446655440000
|
|
92
|
+
// - ghost/posts?status=published&limit=10&page=2
|
|
93
|
+
// - ghost/tag/technology
|
|
94
|
+
|
|
95
|
+
const url = new URL(uri, 'resource://');
|
|
96
|
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
97
|
+
|
|
98
|
+
if (pathParts.length < 2) {
|
|
99
|
+
throw new ValidationError('Invalid resource URI format');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const [namespace, resourceType, ...identifierParts] = pathParts;
|
|
103
|
+
const identifier = identifierParts.join('/');
|
|
104
|
+
|
|
105
|
+
// Parse query parameters
|
|
106
|
+
const query = {};
|
|
107
|
+
for (const [key, value] of url.searchParams) {
|
|
108
|
+
query[key] = value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Parse identifier type
|
|
112
|
+
let identifierType = 'id';
|
|
113
|
+
let identifierValue = identifier;
|
|
114
|
+
|
|
115
|
+
if (identifier && identifier.includes(':')) {
|
|
116
|
+
const [type, ...valueParts] = identifier.split(':');
|
|
117
|
+
identifierType = type;
|
|
118
|
+
identifierValue = valueParts.join(':');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
namespace,
|
|
123
|
+
resourceType,
|
|
124
|
+
identifier: identifierValue,
|
|
125
|
+
identifierType,
|
|
126
|
+
query,
|
|
127
|
+
isCollection: !identifier || Object.keys(query).length > 0
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
static build(parts) {
|
|
132
|
+
const { namespace, resourceType, identifier, query = {} } = parts;
|
|
133
|
+
|
|
134
|
+
let uri = `${namespace}/${resourceType}`;
|
|
135
|
+
|
|
136
|
+
if (identifier) {
|
|
137
|
+
uri += `/${identifier}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const queryString = new URLSearchParams(query).toString();
|
|
141
|
+
if (queryString) {
|
|
142
|
+
uri += `?${queryString}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return uri;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resource Fetcher with advanced capabilities
|
|
151
|
+
*/
|
|
152
|
+
class ResourceFetcher {
|
|
153
|
+
constructor(ghostService, cache) {
|
|
154
|
+
this.ghostService = ghostService;
|
|
155
|
+
this.cache = cache;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async fetchPost(parsedURI) {
|
|
159
|
+
const { identifier, identifierType, query, isCollection } = parsedURI;
|
|
160
|
+
|
|
161
|
+
if (isCollection) {
|
|
162
|
+
return await this.fetchPosts(query);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cacheKey = `post:${identifierType}:${identifier}`;
|
|
166
|
+
|
|
167
|
+
// Check cache
|
|
168
|
+
const cached = this.cache.get(cacheKey);
|
|
169
|
+
if (cached) {
|
|
170
|
+
console.log(`Cache hit for ${cacheKey}`);
|
|
171
|
+
return cached;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Fetch from Ghost
|
|
175
|
+
let post;
|
|
176
|
+
|
|
177
|
+
switch (identifierType) {
|
|
178
|
+
case 'id':
|
|
179
|
+
post = await this.ghostService.getPost(identifier, { include: 'tags,authors' });
|
|
180
|
+
break;
|
|
181
|
+
|
|
182
|
+
case 'slug':
|
|
183
|
+
const posts = await this.ghostService.getPosts({
|
|
184
|
+
filter: `slug:${identifier}`,
|
|
185
|
+
include: 'tags,authors',
|
|
186
|
+
limit: 1
|
|
187
|
+
});
|
|
188
|
+
post = posts[0];
|
|
189
|
+
break;
|
|
190
|
+
|
|
191
|
+
case 'uuid':
|
|
192
|
+
const postsByUuid = await this.ghostService.getPosts({
|
|
193
|
+
filter: `uuid:${identifier}`,
|
|
194
|
+
include: 'tags,authors',
|
|
195
|
+
limit: 1
|
|
196
|
+
});
|
|
197
|
+
post = postsByUuid[0];
|
|
198
|
+
break;
|
|
199
|
+
|
|
200
|
+
default:
|
|
201
|
+
throw new ValidationError(`Unknown identifier type: ${identifierType}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!post) {
|
|
205
|
+
throw new NotFoundError('Post', identifier);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Cache the result
|
|
209
|
+
this.cache.set(cacheKey, post);
|
|
210
|
+
|
|
211
|
+
return post;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async fetchPosts(query = {}) {
|
|
215
|
+
// Build cache key from query
|
|
216
|
+
const cacheKey = `posts:${JSON.stringify(query)}`;
|
|
217
|
+
|
|
218
|
+
// Check cache
|
|
219
|
+
const cached = this.cache.get(cacheKey);
|
|
220
|
+
if (cached) {
|
|
221
|
+
console.log(`Cache hit for posts query`);
|
|
222
|
+
return cached;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Parse query parameters
|
|
226
|
+
const options = {
|
|
227
|
+
limit: parseInt(query.limit) || 15,
|
|
228
|
+
page: parseInt(query.page) || 1,
|
|
229
|
+
include: query.include || 'tags,authors',
|
|
230
|
+
filter: query.filter,
|
|
231
|
+
order: query.order || 'published_at desc'
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Add status filter if provided
|
|
235
|
+
if (query.status) {
|
|
236
|
+
options.filter = options.filter
|
|
237
|
+
? `${options.filter}+status:${query.status}`
|
|
238
|
+
: `status:${query.status}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Fetch from Ghost
|
|
242
|
+
const result = await this.ghostService.getPosts(options);
|
|
243
|
+
|
|
244
|
+
// Format response with pagination metadata
|
|
245
|
+
const response = {
|
|
246
|
+
data: result,
|
|
247
|
+
meta: {
|
|
248
|
+
pagination: {
|
|
249
|
+
page: options.page,
|
|
250
|
+
limit: options.limit,
|
|
251
|
+
pages: Math.ceil(result.meta?.pagination?.total / options.limit) || 1,
|
|
252
|
+
total: result.meta?.pagination?.total || result.length,
|
|
253
|
+
next: result.meta?.pagination?.next || null,
|
|
254
|
+
prev: result.meta?.pagination?.prev || null
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Cache with shorter TTL for collections
|
|
260
|
+
this.cache.set(cacheKey, response, 60000); // 1 minute for collections
|
|
261
|
+
|
|
262
|
+
return response;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async fetchTag(parsedURI) {
|
|
266
|
+
const { identifier, identifierType, query, isCollection } = parsedURI;
|
|
267
|
+
|
|
268
|
+
if (isCollection) {
|
|
269
|
+
return await this.fetchTags(query);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const cacheKey = `tag:${identifierType}:${identifier}`;
|
|
273
|
+
|
|
274
|
+
// Check cache
|
|
275
|
+
const cached = this.cache.get(cacheKey);
|
|
276
|
+
if (cached) {
|
|
277
|
+
console.log(`Cache hit for ${cacheKey}`);
|
|
278
|
+
return cached;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Fetch from Ghost
|
|
282
|
+
let tag;
|
|
283
|
+
|
|
284
|
+
switch (identifierType) {
|
|
285
|
+
case 'id':
|
|
286
|
+
tag = await this.ghostService.getTag(identifier);
|
|
287
|
+
break;
|
|
288
|
+
|
|
289
|
+
case 'slug':
|
|
290
|
+
const tags = await this.ghostService.getTags();
|
|
291
|
+
tag = tags.find(t => t.slug === identifier);
|
|
292
|
+
break;
|
|
293
|
+
|
|
294
|
+
case 'name':
|
|
295
|
+
const tagsByName = await this.ghostService.getTags(identifier);
|
|
296
|
+
tag = tagsByName[0];
|
|
297
|
+
break;
|
|
298
|
+
|
|
299
|
+
default:
|
|
300
|
+
// Assume it's a slug if no type specified
|
|
301
|
+
const tagsBySlug = await this.ghostService.getTags();
|
|
302
|
+
tag = tagsBySlug.find(t => t.slug === identifier || t.id === identifier);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!tag) {
|
|
306
|
+
throw new NotFoundError('Tag', identifier);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Cache the result
|
|
310
|
+
this.cache.set(cacheKey, tag);
|
|
311
|
+
|
|
312
|
+
return tag;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async fetchTags(query = {}) {
|
|
316
|
+
const cacheKey = `tags:${JSON.stringify(query)}`;
|
|
317
|
+
|
|
318
|
+
// Check cache
|
|
319
|
+
const cached = this.cache.get(cacheKey);
|
|
320
|
+
if (cached) {
|
|
321
|
+
console.log(`Cache hit for tags query`);
|
|
322
|
+
return cached;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Fetch from Ghost
|
|
326
|
+
const tags = await this.ghostService.getTags(query.name);
|
|
327
|
+
|
|
328
|
+
// Apply client-side filtering if needed
|
|
329
|
+
let filteredTags = tags;
|
|
330
|
+
|
|
331
|
+
if (query.filter) {
|
|
332
|
+
// Simple filtering implementation
|
|
333
|
+
const filters = query.filter.split('+');
|
|
334
|
+
filteredTags = tags.filter(tag => {
|
|
335
|
+
return filters.every(filter => {
|
|
336
|
+
const [field, value] = filter.split(':');
|
|
337
|
+
return tag[field]?.toString().toLowerCase().includes(value.toLowerCase());
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Apply pagination
|
|
343
|
+
const limit = parseInt(query.limit) || 50;
|
|
344
|
+
const page = parseInt(query.page) || 1;
|
|
345
|
+
const start = (page - 1) * limit;
|
|
346
|
+
const paginatedTags = filteredTags.slice(start, start + limit);
|
|
347
|
+
|
|
348
|
+
const response = {
|
|
349
|
+
data: paginatedTags,
|
|
350
|
+
meta: {
|
|
351
|
+
pagination: {
|
|
352
|
+
page,
|
|
353
|
+
limit,
|
|
354
|
+
pages: Math.ceil(filteredTags.length / limit),
|
|
355
|
+
total: filteredTags.length
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Cache with shorter TTL for collections
|
|
361
|
+
this.cache.set(cacheKey, response, 60000);
|
|
362
|
+
|
|
363
|
+
return response;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Resource Subscription Manager
|
|
369
|
+
*/
|
|
370
|
+
class ResourceSubscriptionManager extends EventEmitter {
|
|
371
|
+
constructor(resourceFetcher = null) {
|
|
372
|
+
super();
|
|
373
|
+
this.subscriptions = new Map();
|
|
374
|
+
this.pollingIntervals = new Map();
|
|
375
|
+
this.resourceFetcher = resourceFetcher;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
subscribe(uri, callback, options = {}) {
|
|
379
|
+
const subscriptionId = `sub_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
380
|
+
const pollingInterval = options.pollingInterval || 30000; // 30 seconds default
|
|
381
|
+
|
|
382
|
+
const subscription = {
|
|
383
|
+
id: subscriptionId,
|
|
384
|
+
uri,
|
|
385
|
+
callback,
|
|
386
|
+
lastValue: null,
|
|
387
|
+
options
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
this.subscriptions.set(subscriptionId, subscription);
|
|
391
|
+
|
|
392
|
+
// Start polling if requested
|
|
393
|
+
if (options.enablePolling) {
|
|
394
|
+
this.startPolling(subscriptionId, pollingInterval);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
console.log(`Created subscription ${subscriptionId} for ${uri}`);
|
|
398
|
+
|
|
399
|
+
return subscriptionId;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
unsubscribe(subscriptionId) {
|
|
403
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
404
|
+
|
|
405
|
+
if (!subscription) {
|
|
406
|
+
throw new NotFoundError('Subscription', subscriptionId);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Stop polling if active
|
|
410
|
+
this.stopPolling(subscriptionId);
|
|
411
|
+
|
|
412
|
+
// Remove subscription
|
|
413
|
+
this.subscriptions.delete(subscriptionId);
|
|
414
|
+
|
|
415
|
+
console.log(`Removed subscription ${subscriptionId}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
startPolling(subscriptionId, interval) {
|
|
419
|
+
const subscription = this.subscriptions.get(subscriptionId);
|
|
420
|
+
|
|
421
|
+
if (!subscription) return;
|
|
422
|
+
|
|
423
|
+
const pollFunc = async () => {
|
|
424
|
+
try {
|
|
425
|
+
const currentValue = await this.fetchResource(subscription.uri);
|
|
426
|
+
|
|
427
|
+
// Check if value changed
|
|
428
|
+
if (JSON.stringify(currentValue) !== JSON.stringify(subscription.lastValue)) {
|
|
429
|
+
subscription.lastValue = currentValue;
|
|
430
|
+
subscription.callback({
|
|
431
|
+
type: 'update',
|
|
432
|
+
uri: subscription.uri,
|
|
433
|
+
data: currentValue,
|
|
434
|
+
timestamp: new Date().toISOString()
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
} catch (error) {
|
|
438
|
+
subscription.callback({
|
|
439
|
+
type: 'error',
|
|
440
|
+
uri: subscription.uri,
|
|
441
|
+
error: error.message,
|
|
442
|
+
timestamp: new Date().toISOString()
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// Initial fetch
|
|
448
|
+
pollFunc();
|
|
449
|
+
|
|
450
|
+
// Set up interval
|
|
451
|
+
const intervalId = setInterval(pollFunc, interval);
|
|
452
|
+
this.pollingIntervals.set(subscriptionId, intervalId);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
stopPolling(subscriptionId) {
|
|
456
|
+
const intervalId = this.pollingIntervals.get(subscriptionId);
|
|
457
|
+
|
|
458
|
+
if (intervalId) {
|
|
459
|
+
clearInterval(intervalId);
|
|
460
|
+
this.pollingIntervals.delete(subscriptionId);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async fetchResource(uri) {
|
|
465
|
+
// Use the injected resource fetcher if available
|
|
466
|
+
if (this.resourceFetcher && typeof this.resourceFetcher === 'function') {
|
|
467
|
+
return await this.resourceFetcher(uri);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Fallback error if no fetcher is configured
|
|
471
|
+
throw new Error('Resource fetcher not configured for subscription manager');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
notifySubscribers(uri, data, eventType = 'update') {
|
|
475
|
+
for (const [, subscription] of this.subscriptions) {
|
|
476
|
+
if (this.matchesSubscription(subscription.uri, uri)) {
|
|
477
|
+
subscription.callback({
|
|
478
|
+
type: eventType,
|
|
479
|
+
uri,
|
|
480
|
+
data,
|
|
481
|
+
timestamp: new Date().toISOString()
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
matchesSubscription(subscriptionURI, eventURI) {
|
|
488
|
+
// Simple matching - could be enhanced with wildcards
|
|
489
|
+
return subscriptionURI === eventURI ||
|
|
490
|
+
subscriptionURI.startsWith(eventURI) ||
|
|
491
|
+
eventURI.startsWith(subscriptionURI);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Main Resource Manager
|
|
497
|
+
*/
|
|
498
|
+
export class ResourceManager {
|
|
499
|
+
constructor(ghostService) {
|
|
500
|
+
this.ghostService = ghostService;
|
|
501
|
+
this.cache = new LRUCache(100, 300000); // 100 items, 5 min TTL
|
|
502
|
+
this.fetcher = new ResourceFetcher(ghostService, this.cache);
|
|
503
|
+
// Pass a bound fetchResource method to the subscription manager
|
|
504
|
+
this.subscriptionManager = new ResourceSubscriptionManager(
|
|
505
|
+
(uri) => this.fetchResource(uri)
|
|
506
|
+
);
|
|
507
|
+
this.resources = new Map();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Register a resource with enhanced fetching
|
|
512
|
+
*/
|
|
513
|
+
registerResource(name, schema, options = {}) {
|
|
514
|
+
const resource = new Resource({
|
|
515
|
+
name,
|
|
516
|
+
description: options.description,
|
|
517
|
+
schema,
|
|
518
|
+
fetch: async (uri) => this.fetchResource(uri)
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
this.resources.set(name, {
|
|
522
|
+
resource,
|
|
523
|
+
options
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
return resource;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Main resource fetching method
|
|
531
|
+
*/
|
|
532
|
+
async fetchResource(uri) {
|
|
533
|
+
try {
|
|
534
|
+
const parsed = ResourceURIParser.parse(uri);
|
|
535
|
+
|
|
536
|
+
console.log('Fetching resource:', { uri: uri.substring(0, 100), parsed });
|
|
537
|
+
|
|
538
|
+
// Route to appropriate fetcher
|
|
539
|
+
switch (parsed.resourceType) {
|
|
540
|
+
case 'post':
|
|
541
|
+
case 'posts':
|
|
542
|
+
return await this.fetcher.fetchPost(parsed);
|
|
543
|
+
|
|
544
|
+
case 'tag':
|
|
545
|
+
case 'tags':
|
|
546
|
+
return await this.fetcher.fetchTag(parsed);
|
|
547
|
+
|
|
548
|
+
default:
|
|
549
|
+
throw new ValidationError(`Unknown resource type: ${parsed.resourceType}`);
|
|
550
|
+
}
|
|
551
|
+
} catch (error) {
|
|
552
|
+
console.error('Error fetching resource:', { uri: uri.substring(0, 100), error: error.message });
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* List all available resources
|
|
559
|
+
*/
|
|
560
|
+
listResources(filter = {}) {
|
|
561
|
+
const resources = [];
|
|
562
|
+
|
|
563
|
+
for (const [name, { resource, options }] of this.resources) {
|
|
564
|
+
// Apply filter if provided
|
|
565
|
+
if (filter.namespace && !name.startsWith(filter.namespace)) {
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
resources.push({
|
|
570
|
+
uri: name,
|
|
571
|
+
name: resource.name,
|
|
572
|
+
description: resource.description,
|
|
573
|
+
...options
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return resources;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Subscribe to resource changes
|
|
582
|
+
*/
|
|
583
|
+
subscribe(uri, callback, options = {}) {
|
|
584
|
+
return this.subscriptionManager.subscribe(uri, callback, options);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Unsubscribe from resource changes
|
|
589
|
+
*/
|
|
590
|
+
unsubscribe(subscriptionId) {
|
|
591
|
+
return this.subscriptionManager.unsubscribe(subscriptionId);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Invalidate cache
|
|
596
|
+
*/
|
|
597
|
+
invalidateCache(pattern = null) {
|
|
598
|
+
this.cache.invalidate(pattern);
|
|
599
|
+
console.log(`Cache invalidated${pattern ? ` for pattern: ${pattern}` : ''}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Notify about resource changes (for webhooks)
|
|
604
|
+
*/
|
|
605
|
+
notifyChange(uri, data, eventType = 'update') {
|
|
606
|
+
// Invalidate cache for this resource
|
|
607
|
+
this.cache.invalidate(uri);
|
|
608
|
+
|
|
609
|
+
// Notify subscribers
|
|
610
|
+
this.subscriptionManager.notifySubscribers(uri, data, eventType);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Get cache statistics
|
|
615
|
+
*/
|
|
616
|
+
getCacheStats() {
|
|
617
|
+
return this.cache.getStats();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Batch fetch multiple resources
|
|
622
|
+
*/
|
|
623
|
+
async batchFetch(uris) {
|
|
624
|
+
const results = {};
|
|
625
|
+
const errors = {};
|
|
626
|
+
|
|
627
|
+
await Promise.all(
|
|
628
|
+
uris.map(async (uri) => {
|
|
629
|
+
try {
|
|
630
|
+
results[uri] = await this.fetchResource(uri);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
errors[uri] = {
|
|
633
|
+
message: error.message,
|
|
634
|
+
code: error.code
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
})
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
return { results, errors };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Prefetch resources for warming cache
|
|
645
|
+
*/
|
|
646
|
+
async prefetch(patterns) {
|
|
647
|
+
const prefetched = [];
|
|
648
|
+
|
|
649
|
+
for (const pattern of patterns) {
|
|
650
|
+
try {
|
|
651
|
+
const result = await this.fetchResource(pattern);
|
|
652
|
+
prefetched.push({ pattern, status: 'success' });
|
|
653
|
+
} catch (error) {
|
|
654
|
+
prefetched.push({
|
|
655
|
+
pattern,
|
|
656
|
+
status: 'error',
|
|
657
|
+
error: error.message
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return prefetched;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export default ResourceManager;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import rateLimit from "express-rate-limit";
|
|
3
|
+
import { upload, handleImageUpload } from "../controllers/imageController.js";
|
|
4
|
+
|
|
5
|
+
const router = express.Router();
|
|
6
|
+
|
|
7
|
+
// Create rate limiter for image uploads using express-rate-limit
|
|
8
|
+
// More restrictive than general API: 10 uploads per minute
|
|
9
|
+
const imageUploadRateLimiter = rateLimit({
|
|
10
|
+
windowMs: 60 * 1000, // 1 minute
|
|
11
|
+
max: 10, // Limit each IP to 10 requests per windowMs
|
|
12
|
+
message: "Too many image upload attempts from this IP, please try again after a minute",
|
|
13
|
+
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
|
14
|
+
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
|
15
|
+
handler: (req, res) => {
|
|
16
|
+
res.status(429).json({
|
|
17
|
+
error: "Too many requests",
|
|
18
|
+
message: "You have exceeded the 10 image uploads per minute limit. Please try again later.",
|
|
19
|
+
retryAfter: 60
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Define the route for uploading an image
|
|
25
|
+
// POST /api/images
|
|
26
|
+
// The `upload.single('image')` middleware handles the file upload.
|
|
27
|
+
// 'image' should match the field name in the form-data request.
|
|
28
|
+
// Added rate limiting to prevent abuse of file system operations
|
|
29
|
+
router.post("/", imageUploadRateLimiter, upload.single("image"), handleImageUpload);
|
|
30
|
+
|
|
31
|
+
// Add other image-related routes here later if needed
|
|
32
|
+
|
|
33
|
+
export default router;
|