Skip to content

Commit 76891bf

Browse files
committed
fix(ui): resolve library search modal, SSRF, and import issues
- Move library search modal to separate Alpine scope to avoid variable conflicts with homeApp (fixes "undefined variable" errors) - Use delegated click handler instead of inline onclick (CSP compliance) - Fix SSRF false positive for IPv4-mapped IPv6 addresses in isPublicIp() (e.g. ::ffff:9813:862f now correctly decodes to 152.19.134.47) - Fix extract-url response parsing: API returns data directly, not wrapped in a .data property - Wait for Alpine initialization before auto-importing from URL params - Register Globe and Video icons in Lucide icon set - Re-init Lucide icons after search results render
1 parent 377ae45 commit 76891bf

5 files changed

Lines changed: 188 additions & 142 deletions

File tree

src/Modules/Home/Views/index.php

Lines changed: 107 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,7 @@ function renderHomeConfig(?array $lastTextInfo, string $base, int $textCount, in
180180
</div>
181181

182182
<!-- Text cards (single row, horizontal scroll) -->
183-
<div style="display: flex; gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem;"
184-
x-data="librarySearch()">
183+
<div style="display: flex; gap: 0.75rem; overflow-x: auto; padding-bottom: 0.5rem;">
185184
<!-- Current text card -->
186185
<div style="flex-shrink: 0;">
187186
<template x-if="lastText">
@@ -275,10 +274,10 @@ class="box has-background-primary-light has-text-centered"
275274
</a>
276275
</div>
277276

278-
<!-- Search library card -->
277+
<!-- Search library card (plain HTML, opens modal via DOM event) -->
279278
<div style="flex-shrink: 0;">
280279
<div
281-
@click="open = true"
280+
data-action="open-library-search"
282281
class="box has-background-warning-light has-text-centered"
283282
style="width: 180px; min-height: 180px; display: flex;
284283
flex-direction: column; justify-content: center; align-items: center;
@@ -290,108 +289,6 @@ class="box has-background-warning-light has-text-centered"
290289
<p class="mt-3 has-text-weight-semibold">Search Library</p>
291290
</div>
292291
</div>
293-
294-
<!-- Library search modal -->
295-
<div class="modal" :class="{ 'is-active': open }">
296-
<div class="modal-background" @click="close()"></div>
297-
<div class="modal-card" style="max-width: 600px; width: 90vw;">
298-
<header class="modal-card-head">
299-
<p class="modal-card-title">Search Project Gutenberg</p>
300-
<button class="delete" aria-label="close" @click="close()"></button>
301-
</header>
302-
<section class="modal-card-body">
303-
<form @submit.prevent="search()" class="mb-4">
304-
<div class="field has-addons">
305-
<div class="control is-expanded">
306-
<input
307-
x-model="query"
308-
class="input"
309-
type="text"
310-
placeholder="Search by title or author..."
311-
/>
312-
</div>
313-
<div class="control">
314-
<button
315-
type="submit"
316-
class="button is-warning"
317-
:class="{ 'is-loading': loading && !searched }"
318-
:disabled="loading"
319-
>
320-
<span class="icon"><i data-lucide="search"></i></span>
321-
<span>Search</span>
322-
</button>
323-
</div>
324-
</div>
325-
</form>
326-
327-
<!-- Error message -->
328-
<div x-show="error" class="notification is-danger is-light" x-text="error"></div>
329-
330-
<!-- Results count -->
331-
<p
332-
x-show="searched && !error && !loading"
333-
class="has-text-grey is-size-7 mb-2"
334-
>
335-
<span x-text="totalCount"></span> books found
336-
</p>
337-
338-
<!-- Results list -->
339-
<div
340-
x-show="results.length > 0"
341-
style="max-height: 400px; overflow-y: auto;"
342-
>
343-
<template x-for="book in results" :key="book.id">
344-
<div class="box p-3 mb-2" style="cursor: default;">
345-
<div class="is-flex is-justify-content-space-between is-align-items-start">
346-
<div style="flex: 1; min-width: 0;">
347-
<p
348-
class="has-text-weight-semibold is-size-6"
349-
x-text="book.title"
350-
style="overflow: hidden; text-overflow: ellipsis;"
351-
></p>
352-
<p
353-
class="has-text-grey is-size-7"
354-
x-text="formatAuthors(book.authors)"
355-
></p>
356-
<p class="has-text-grey-light is-size-7">
357-
<span x-text="formatDownloads(book.downloadCount)"></span> downloads
358-
</p>
359-
</div>
360-
<button
361-
@click="importBook(book)"
362-
class="button is-primary is-small ml-3"
363-
:class="{ 'is-loading': importing === book.id }"
364-
:disabled="importing !== null"
365-
>
366-
<span class="icon"><i data-lucide="download"></i></span>
367-
<span>Import</span>
368-
</button>
369-
</div>
370-
</div>
371-
</template>
372-
</div>
373-
374-
<!-- Load more -->
375-
<button
376-
x-show="hasMore && results.length > 0"
377-
@click="loadMore()"
378-
class="button is-small is-fullwidth mt-2"
379-
:class="{ 'is-loading': loading }"
380-
:disabled="loading"
381-
>
382-
Load more
383-
</button>
384-
385-
<!-- No results -->
386-
<p
387-
x-show="searched && results.length === 0 && !loading && !error"
388-
class="has-text-grey is-italic"
389-
>
390-
No books found. Try a different search term.
391-
</p>
392-
</section>
393-
</div>
394-
</div>
395292
</div>
396293
</div>
397294
</section>
@@ -607,4 +504,108 @@ class="button is-fullwidth is-light"
607504

608505
</div><!-- End Alpine.js container -->
609506

507+
<!-- Library search modal (separate Alpine scope, outside homeApp) -->
508+
<div x-data="librarySearch" @open-library-search.document="open = true" x-cloak>
509+
<div class="modal" :class="{ 'is-active': open }">
510+
<div class="modal-background" @click="close()"></div>
511+
<div class="modal-card" style="max-width: 600px; width: 90vw;">
512+
<header class="modal-card-head">
513+
<p class="modal-card-title">Search Project Gutenberg</p>
514+
<button class="delete" aria-label="close" @click="close()"></button>
515+
</header>
516+
<section class="modal-card-body">
517+
<form @submit.prevent="search()" class="mb-4">
518+
<div class="field has-addons">
519+
<div class="control is-expanded">
520+
<input
521+
x-model="query"
522+
class="input"
523+
type="text"
524+
placeholder="Search by title or author..."
525+
/>
526+
</div>
527+
<div class="control">
528+
<button
529+
type="submit"
530+
class="button is-warning"
531+
:class="{ 'is-loading': loading && !searched }"
532+
:disabled="loading"
533+
>
534+
<span class="icon"><i data-lucide="search"></i></span>
535+
<span>Search</span>
536+
</button>
537+
</div>
538+
</div>
539+
</form>
540+
541+
<!-- Error message -->
542+
<div x-show="error" class="notification is-danger is-light" x-text="error"></div>
543+
544+
<!-- Results count -->
545+
<p
546+
x-show="searched && !error && !loading"
547+
class="has-text-grey is-size-7 mb-2"
548+
>
549+
<span x-text="totalCount"></span> books found
550+
</p>
551+
552+
<!-- Results list -->
553+
<div
554+
x-show="results.length > 0"
555+
style="max-height: 400px; overflow-y: auto;"
556+
>
557+
<template x-for="book in results" :key="book.id">
558+
<div class="box p-3 mb-2" style="cursor: default;">
559+
<div class="is-flex is-justify-content-space-between is-align-items-start">
560+
<div style="flex: 1; min-width: 0;">
561+
<p
562+
class="has-text-weight-semibold is-size-6"
563+
x-text="book.title"
564+
style="overflow: hidden; text-overflow: ellipsis;"
565+
></p>
566+
<p
567+
class="has-text-grey is-size-7"
568+
x-text="formatAuthors(book.authors)"
569+
></p>
570+
<p class="has-text-grey-light is-size-7">
571+
<span x-text="formatDownloads(book.downloadCount)"></span> downloads
572+
</p>
573+
</div>
574+
<button
575+
@click="importBook(book)"
576+
class="button is-primary is-small ml-3"
577+
:class="{ 'is-loading': importing === book.id }"
578+
:disabled="importing !== null"
579+
>
580+
<span class="icon"><i data-lucide="download"></i></span>
581+
<span>Import</span>
582+
</button>
583+
</div>
584+
</div>
585+
</template>
586+
</div>
587+
588+
<!-- Load more -->
589+
<button
590+
x-show="hasMore && results.length > 0"
591+
@click="loadMore()"
592+
class="button is-small is-fullwidth mt-2"
593+
:class="{ 'is-loading': loading }"
594+
:disabled="loading"
595+
>
596+
Load more
597+
</button>
598+
599+
<!-- No results -->
600+
<p
601+
x-show="searched && results.length === 0 && !loading && !error"
602+
class="has-text-grey is-italic"
603+
>
604+
No books found. Try a different search term.
605+
</p>
606+
</section>
607+
</div>
608+
</div>
609+
</div>
610+
610611
<?php renderHomeConfig($lastTextInfo, $base, $textCount, $currentlang); ?>

src/Shared/Infrastructure/Http/UrlUtilities.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,27 @@ private static function resolveHostToIps(string $host): array
339339
*/
340340
private static function isPublicIp(string $ip): bool
341341
{
342+
// IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are flagged as reserved
343+
// by PHP's FILTER_FLAG_NO_RES_RANGE, but they're just IPv4 addresses
344+
// in IPv6 notation. Extract and validate the IPv4 part instead.
345+
if (str_starts_with($ip, '::ffff:') && substr_count($ip, ':') <= 4) {
346+
$ipv4Part = substr($ip, 7);
347+
// The IPv4 part may be in hex notation (e.g. 9813:862f) or dotted decimal
348+
if (filter_var($ipv4Part, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
349+
return self::isPublicIp($ipv4Part);
350+
}
351+
// Hex-encoded IPv4 (e.g. ::ffff:9813:862f → 152.19.134.47)
352+
$hexParts = explode(':', $ipv4Part);
353+
if (count($hexParts) === 2) {
354+
$hi = (int) hexdec($hexParts[0]);
355+
$lo = (int) hexdec($hexParts[1]);
356+
$decoded = (($hi >> 8) & 0xFF) . '.' . ($hi & 0xFF) . '.' . (($lo >> 8) & 0xFF) . '.' . ($lo & 0xFF);
357+
if (filter_var($decoded, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
358+
return self::isPublicIp($decoded);
359+
}
360+
}
361+
}
362+
342363
// Use PHP's built-in filters to check for private and reserved ranges
343364
// FILTER_FLAG_NO_PRIV_RANGE blocks: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
344365
// FILTER_FLAG_NO_RES_RANGE blocks: 0.0.0.0/8, 169.254.0.0/16, 127.0.0.0/8, 240.0.0.0/4,

src/frontend/js/home/library_search.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import Alpine from 'alpinejs';
12+
import { initIcons } from '@shared/icons/lucide_icons';
1213

1314
interface GutenbergBook {
1415
id: number;
@@ -21,11 +22,10 @@ interface GutenbergBook {
2122
}
2223

2324
interface SearchResponse {
24-
data: {
25-
results: GutenbergBook[];
26-
count: number;
27-
next: boolean;
28-
};
25+
results: GutenbergBook[];
26+
count: number;
27+
next: boolean;
28+
error?: string;
2929
}
3030

3131
interface LibrarySearchData {
@@ -88,16 +88,15 @@ export function librarySearchData(): LibrarySearchData {
8888
const response = await fetch(`/api/v1/texts/library-search?${params}`);
8989
const data: SearchResponse = await response.json();
9090

91-
if (!response.ok) {
92-
this.error =
93-
(data as unknown as { error?: string }).error ||
94-
'Search failed. Please try again.';
91+
if (!response.ok || data.error) {
92+
this.error = data.error || 'Search failed. Please try again.';
9593
return;
9694
}
9795

98-
this.results = data.data.results;
99-
this.totalCount = data.data.count;
100-
this.hasMore = data.data.next;
96+
this.results = data.results;
97+
this.totalCount = data.count;
98+
this.hasMore = data.next;
99+
requestAnimationFrame(() => initIcons());
101100
} catch {
102101
this.error = 'Could not reach the server. Please try again.';
103102
} finally {
@@ -125,9 +124,10 @@ export function librarySearchData(): LibrarySearchData {
125124
const response = await fetch(`/api/v1/texts/library-search?${params}`);
126125
const data: SearchResponse = await response.json();
127126

128-
if (response.ok) {
129-
this.results = this.results.concat(data.data.results);
130-
this.hasMore = data.data.next;
127+
if (response.ok && !data.error) {
128+
this.results = this.results.concat(data.results);
129+
this.hasMore = data.next;
130+
requestAnimationFrame(() => initIcons());
131131
}
132132
} catch {
133133
// Silently fail on load-more
@@ -173,6 +173,14 @@ export function librarySearchData(): LibrarySearchData {
173173
*/
174174
export function initLibrarySearch(): void {
175175
Alpine.data('librarySearch', librarySearchData);
176+
177+
// Delegated click handler for the search card (CSP-safe, no inline JS)
178+
document.addEventListener('click', (e) => {
179+
const target = e.target as HTMLElement;
180+
if (target.closest('[data-action="open-library-search"]')) {
181+
document.dispatchEvent(new CustomEvent('open-library-search'));
182+
}
183+
});
176184
}
177185

178186
// Register immediately (before Alpine.start())

0 commit comments

Comments
 (0)