Skip to content

Commit ad85ab7

Browse files
authored
Harden backend (#139)
1 parent 2ed6637 commit ad85ab7

25 files changed

+1210
-180
lines changed

docker-compose.yml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ services:
1414
NOSTR_AMB_RELAY_URL: ${NOSTR_AMB_RELAY_URL:-ws://amb-relay:3334}
1515
ENABLED_ADAPTERS: ${ENABLED_ADAPTERS:-nostr-amb-relay}
1616
RPI_VIRTUELL_API_URL: ${RPI_VIRTUELL_API_URL:-}
17-
IMGPROXY_BASE_URL: ${IMGPROXY_BASE_URL}
18-
IMGPROXY_KEY: ${IMGPROXY_KEY:-}
19-
IMGPROXY_SALT: ${IMGPROXY_SALT:-}
17+
ASSET_SIGNING_KEY: ${ASSET_SIGNING_KEY}
18+
ASSET_SIGNING_TTL_SECONDS: ${ASSET_SIGNING_TTL_SECONDS:-3600}
19+
THROTTLE_LIMIT: ${THROTTLE_LIMIT:-60}
20+
# IMGPROXY_BASE_URL: ${IMGPROXY_BASE_URL}
21+
# IMGPROXY_KEY: ${IMGPROXY_KEY:-}
22+
# IMGPROXY_SALT: ${IMGPROXY_SALT:-}
2023
ports:
2124
- "3000:3000"
2225
- "5173:5173"
@@ -34,8 +37,8 @@ services:
3437
- typesense_data:/data
3538
environment:
3639
TYPESENSE_DATA_DIR: /data
37-
# IMPORTANT: Change TS_APIKEY for production deployments
38-
TYPESENSE_API_KEY: ${TS_APIKEY:-xyz}
40+
# WARNING: Default key is for local development only. Set TS_APIKEY for any shared/production deployment.
41+
TYPESENSE_API_KEY: ${TS_APIKEY:-development-only-key-change-me}
3942
restart: unless-stopped
4043

4144
amb-relay:
@@ -52,7 +55,7 @@ services:
5255
PUBKEY: ${AMB_RELAY_PUBKEY:-}
5356
DESCRIPTION: ${AMB_RELAY_DESCRIPTION:-Local AMB Relay}
5457
ICON: ${AMB_RELAY_ICON:-}
55-
TS_APIKEY: ${TS_APIKEY:-xyz}
58+
TS_APIKEY: ${TS_APIKEY:-development-only-key-change-me}
5659
TS_HOST: http://typesense:8108
5760
TS_COLLECTION: ${TS_COLLECTION:-amb_events}
5861
restart: unless-stopped

packages/oer-adapter-arasaac/src/arasaac.adapter.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ describe('ArasaacAdapter', () => {
120120
expect(result.items.map((item) => item.id)).toEqual(['arasaac-2', 'arasaac-3']);
121121
});
122122

123+
it('caps parsed results at 2000 items', async () => {
124+
const pictograms = Array.from({ length: 2500 }, (_, i) =>
125+
makePictogram(i),
126+
);
127+
vi.stubGlobal('fetch', mockFetchResponse(pictograms));
128+
129+
const result = await adapter.search(makeQuery({ page: 1, pageSize: 10 }));
130+
131+
expect(result.total).toBe(2000);
132+
});
133+
123134
it('passes AbortSignal to fetch', async () => {
124135
const mockFetch = mockFetchResponse([makePictogram(1)]);
125136
vi.stubGlobal('fetch', mockFetch);

packages/oer-adapter-arasaac/src/arasaac.adapter.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const IMAGE_BASE_URL = 'https://static.arasaac.org/pictograms';
2222
/** Default language for searches when not specified in query */
2323
const DEFAULT_LANGUAGE = 'en';
2424

25+
/** Maximum number of items to parse from API response to prevent memory exhaustion */
26+
const MAX_RESULTS = 2000;
27+
2528
/**
2629
* ARASAAC adapter for searching AAC pictograms.
2730
*
@@ -75,7 +78,8 @@ export class ArasaacAdapter implements SourceAdapter {
7578
}
7679

7780
const rawData: unknown = await response.json();
78-
const pictograms = parseArasaacSearchResponse(rawData);
81+
const allPictograms = parseArasaacSearchResponse(rawData);
82+
const pictograms = allPictograms.slice(0, MAX_RESULTS);
7983

8084
// Apply pagination to the results
8185
const total = pictograms.length;

packages/oer-adapter-nostr-amb-relay/src/nostr-amb-relay.adapter.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const DEFAULT_TIMEOUT_MS = 10000;
2626
/** Maximum number of relay URLs allowed to prevent resource exhaustion */
2727
const MAX_RELAY_COUNT = 10;
2828

29+
/** Maximum number of events to collect per relay to prevent memory exhaustion */
30+
const MAX_EVENTS = 500;
31+
2932
/** @see https://w3id.org/kim/hcrt/scheme */
3033
const HCRT = {
3134
IMAGE: 'https://w3id.org/kim/hcrt/image',
@@ -270,7 +273,9 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
270273

271274
sub = relay.subscribe([filter], {
272275
onevent: (event: Event) => {
273-
events.push(event);
276+
if (events.length < MAX_EVENTS) {
277+
events.push(event);
278+
}
274279
},
275280
oneose: () => {
276281
clearTimeout(timeoutId);

packages/oer-adapter-nostr-amb-relay/test/nostr-amb-relay.adapter.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,50 @@ describe('NostrAmbRelayAdapter keyword sanitization', () => {
230230
});
231231
});
232232

233+
describe('NostrAmbRelayAdapter event collection cap', () => {
234+
beforeEach(() => {
235+
jest.clearAllMocks();
236+
});
237+
238+
it('should cap collected events at 500 per relay', async () => {
239+
const eventCount = 600;
240+
const events = Array.from({ length: eventCount }, (_, i) =>
241+
makeEvent(`event-${i}`),
242+
);
243+
244+
const mockRelay = {
245+
subscribe: jest.fn(
246+
(
247+
_filters: unknown[],
248+
params: {
249+
onevent?: (e: unknown) => void;
250+
oneose?: () => void;
251+
},
252+
) => {
253+
queueMicrotask(() => {
254+
for (const event of events) {
255+
params.onevent?.(event);
256+
}
257+
params.oneose?.();
258+
});
259+
return { close: jest.fn() };
260+
},
261+
),
262+
close: jest.fn(),
263+
};
264+
265+
(Relay.connect as jest.Mock).mockResolvedValue(mockRelay);
266+
267+
const adapter = createAdapter([RELAY_URL]);
268+
const result = await adapter.search({
269+
...baseQuery,
270+
pageSize: 20,
271+
});
272+
273+
expect(result.total).toBe(500);
274+
});
275+
});
276+
233277
describe('NostrAmbRelayAdapter constructor validation', () => {
234278
it('should throw when relayUrls is empty', () => {
235279
expect(() => createAdapter([])).toThrow(

packages/oer-finder-plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@edufeed-org/oer-finder-plugin",
3-
"version": "0.2.3",
3+
"version": "0.2.4",
44
"description": "Web Components plugin for OER Proxy",
55
"author": "B310 Digital GmbH",
66
"license": "MIT",

packages/oer-finder-plugin/src/oer-search/OerSearch.spec.ts

Lines changed: 188 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@ function createOerItem(name: string): OerItem {
2828
};
2929
}
3030

31-
function createSearchResult(items: OerItem[], page: number, total: number): SearchResult {
32-
const pageSize = 20;
31+
function createSearchResult(
32+
items: OerItem[],
33+
page: number,
34+
total: number,
35+
pageSize = 20,
36+
): SearchResult {
3337
return {
3438
data: items,
3539
meta: {
@@ -41,6 +45,10 @@ function createSearchResult(items: OerItem[], page: number, total: number): Sear
4145
};
4246
}
4347

48+
function createOerItems(prefix: string, count: number): OerItem[] {
49+
return Array.from({ length: count }, (_, i) => createOerItem(`${prefix}-${i + 1}`));
50+
}
51+
4452
function createMockClient(
4553
searchFn?: (params: SearchParams) => Promise<SearchResult>,
4654
availableSources?: { id: string; label: string; checked?: boolean }[],
@@ -144,6 +152,81 @@ describe('OerSearch', () => {
144152
expect(loadingFired).toBe(true);
145153
});
146154

155+
it('caps multi-source results at pageSize', async () => {
156+
const pageSize = 10;
157+
const sourceAItems = createOerItems('SourceA', pageSize);
158+
const sourceBItems = createOerItems('SourceB', pageSize);
159+
160+
const mockClient = createMockClient(
161+
(params: SearchParams) => {
162+
if (params.source === 'source-a') {
163+
return Promise.resolve(createSearchResult(sourceAItems, 1, 40, pageSize));
164+
}
165+
return Promise.resolve(createSearchResult(sourceBItems, 1, 40, pageSize));
166+
},
167+
[
168+
{ id: 'source-a', label: 'Source A' },
169+
{ id: 'source-b', label: 'Source B' },
170+
],
171+
);
172+
173+
createSpy.mockReturnValue(mockClient);
174+
175+
search = document.createElement('oer-search') as OerSearchElement;
176+
search.language = 'en';
177+
search.setAttribute('page-size', String(pageSize));
178+
document.body.appendChild(search);
179+
await new Promise((resolve) => setTimeout(resolve, 0));
180+
181+
const resultPromise = awaitSearchResult(search);
182+
triggerSearch(search, 'test');
183+
184+
const result = await resultPromise;
185+
// 2 sources each return 10 items = 20 total, but should be capped at pageSize (10)
186+
expect(result.data).toHaveLength(pageSize);
187+
});
188+
189+
it('interleaves multi-source results in round-robin order within pageSize', async () => {
190+
const pageSize = 6;
191+
const sourceAItems = createOerItems('A', pageSize);
192+
const sourceBItems = createOerItems('B', pageSize);
193+
194+
const mockClient = createMockClient(
195+
(params: SearchParams) => {
196+
if (params.source === 'source-a') {
197+
return Promise.resolve(createSearchResult(sourceAItems, 1, 40, pageSize));
198+
}
199+
return Promise.resolve(createSearchResult(sourceBItems, 1, 40, pageSize));
200+
},
201+
[
202+
{ id: 'source-a', label: 'Source A' },
203+
{ id: 'source-b', label: 'Source B' },
204+
],
205+
);
206+
207+
createSpy.mockReturnValue(mockClient);
208+
209+
search = document.createElement('oer-search') as OerSearchElement;
210+
search.language = 'en';
211+
search.setAttribute('page-size', String(pageSize));
212+
document.body.appendChild(search);
213+
await new Promise((resolve) => setTimeout(resolve, 0));
214+
215+
const resultPromise = awaitSearchResult(search);
216+
triggerSearch(search, 'test');
217+
218+
const result = await resultPromise;
219+
// Round-robin: A-1, B-1, A-2, B-2, A-3, B-3
220+
expect(result.data.map((d) => d.amb.name)).toEqual([
221+
'A-1',
222+
'B-1',
223+
'A-2',
224+
'B-2',
225+
'A-3',
226+
'B-3',
227+
]);
228+
});
229+
147230
it('dispatches search-results with empty data when source fails', async () => {
148231
const mockClient = createMockClient(() => Promise.reject(new Error('Network failure')));
149232

@@ -187,6 +270,95 @@ describe('OerSearch', () => {
187270
expect(secondResult.data).toEqual([...page1Items, ...page2Items]);
188271
});
189272

273+
it('caps multi-source load-more results at pageSize', async () => {
274+
const pageSize = 6;
275+
276+
const mockClient = createMockClient(
277+
(params: SearchParams) => {
278+
const sourcePrefix = params.source === 'source-a' ? 'A' : 'B';
279+
const items = createOerItems(`${sourcePrefix}-p${params.page}`, pageSize);
280+
return Promise.resolve(createSearchResult(items, params.page ?? 1, 60, pageSize));
281+
},
282+
[
283+
{ id: 'source-a', label: 'Source A' },
284+
{ id: 'source-b', label: 'Source B' },
285+
],
286+
);
287+
288+
createSpy.mockReturnValue(mockClient);
289+
290+
search = document.createElement('oer-search') as OerSearchElement;
291+
search.language = 'en';
292+
search.setAttribute('page-size', String(pageSize));
293+
document.body.appendChild(search);
294+
await new Promise((resolve) => setTimeout(resolve, 0));
295+
296+
// Initial search
297+
const firstResultPromise = awaitSearchResult(search);
298+
triggerSearch(search, 'test');
299+
const firstResult = await firstResultPromise;
300+
301+
// First search should be capped at pageSize
302+
expect(firstResult.data).toHaveLength(pageSize);
303+
304+
// Load more — should drain overflow buffer, no new fetch
305+
const secondResultPromise = awaitSearchResult(search);
306+
search.dispatchEvent(new CustomEvent('load-more', { bubbles: true, composed: true }));
307+
const secondResult = await secondResultPromise;
308+
309+
// After load-more: pageSize (initial) + pageSize (from buffer) = 12 total
310+
expect(secondResult.data).toHaveLength(pageSize * 2);
311+
});
312+
313+
it('drains overflow buffer before fetching new pages', async () => {
314+
const pageSize = 6;
315+
const searchCalls: SearchParams[] = [];
316+
317+
const mockClient = createMockClient(
318+
(params: SearchParams) => {
319+
searchCalls.push({ ...params });
320+
const sourcePrefix = params.source === 'source-a' ? 'A' : 'B';
321+
const items = createOerItems(`${sourcePrefix}-p${params.page}`, pageSize);
322+
return Promise.resolve(createSearchResult(items, params.page ?? 1, 60, pageSize));
323+
},
324+
[
325+
{ id: 'source-a', label: 'Source A' },
326+
{ id: 'source-b', label: 'Source B' },
327+
],
328+
);
329+
330+
createSpy.mockReturnValue(mockClient);
331+
332+
search = document.createElement('oer-search') as OerSearchElement;
333+
search.language = 'en';
334+
search.setAttribute('page-size', String(pageSize));
335+
document.body.appendChild(search);
336+
await new Promise((resolve) => setTimeout(resolve, 0));
337+
338+
// Initial search: 2 sources × 6 items = 12 fetched, 6 shown, 6 buffered
339+
const firstResultPromise = awaitSearchResult(search);
340+
triggerSearch(search, 'test');
341+
await firstResultPromise;
342+
343+
// Record call count after initial search
344+
const callsAfterSearch = searchCalls.length;
345+
346+
// First load-more should drain buffer — no new fetch
347+
const secondResultPromise = awaitSearchResult(search);
348+
search.dispatchEvent(new CustomEvent('load-more', { bubbles: true, composed: true }));
349+
await secondResultPromise;
350+
351+
expect(searchCalls.length).toBe(callsAfterSearch);
352+
353+
// Second load-more: buffer is empty, should trigger fetch (page 2)
354+
const thirdResultPromise = awaitSearchResult(search);
355+
search.dispatchEvent(new CustomEvent('load-more', { bubbles: true, composed: true }));
356+
await thirdResultPromise;
357+
358+
const page2Calls = searchCalls.slice(callsAfterSearch);
359+
expect(page2Calls.every((c) => c.page === 2)).toBe(true);
360+
});
361+
190362
it('sends correct page number to the client on load-more', async () => {
191363
const searchCalls: SearchParams[] = [];
192364
const mockClient = createMockClient((params: SearchParams) => {
@@ -737,7 +909,20 @@ describe('OerSearch', () => {
737909
});
738910

739911
describe('lockedType', () => {
740-
it('sends the locked type in search params and hides type dropdown', async () => {
912+
it('hides the type dropdown when locked-type is set', async () => {
913+
createSpy.mockReturnValue(createMockClient());
914+
915+
search = document.createElement('oer-search') as OerSearchElement;
916+
search.language = 'en';
917+
search.setAttribute('locked-type', 'image');
918+
document.body.appendChild(search);
919+
await new Promise((resolve) => setTimeout(resolve, 0));
920+
921+
const typeSelect = search.shadowRoot?.querySelector('#type');
922+
expect(typeSelect).toBeNull();
923+
});
924+
925+
it('sends the locked type in search params', async () => {
741926
const capturedParams: SearchParams[] = [];
742927
const mockClient = createMockClient((params: SearchParams) => {
743928
capturedParams.push({ ...params });
@@ -752,10 +937,6 @@ describe('OerSearch', () => {
752937
document.body.appendChild(search);
753938
await new Promise((resolve) => setTimeout(resolve, 0));
754939

755-
// Type dropdown should be hidden
756-
const typeSelect = search.shadowRoot?.querySelector('#type');
757-
expect(typeSelect).toBeNull();
758-
759940
const resultPromise = awaitSearchResult(search);
760941
triggerSearch(search, 'test');
761942
await resultPromise;

0 commit comments

Comments
 (0)