From 59d68869dccbe1efb2cff15c04e11e02bc31bd11 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 6 Nov 2026 14:54:35 -0500 Subject: [PATCH 1/3] feat(routes): serve the browser page at a configurable path ICHAVA_BROWSER_PATH (browser.path) moves the browser page and its icon pages, e.g. to /app, under the same route names. The API and the other pages stay under the prefix. The old /{prefix}/icons URLs answer 301 with their query kept, so ?icon= deep links survive. Unset, nothing changes. Also quote the wildcard CORS origin in .env.example, which the commit hook requires of a touched file. --- .env.example | 5 +- README.md | 2 +- config/icon-browser.php | 14 +++- docs/architecture.md | 2 + docs/configuration.md | 1 + docs/environment.md | 1 + docs/getting-started.md | 2 +- phpunit.xml.dist | 3 + resources/js/core/browserPath.test.ts | 37 +++++++++ resources/js/core/browserPath.ts | 11 +++ resources/js/pages/Browser/Show.tsx | 3 +- resources/js/pages/Collections/Show.tsx | 3 +- resources/js/pages/Favorites/Index.tsx | 3 +- resources/js/pages/History/Index.tsx | 3 +- resources/js/types.ts | 6 ++ resources/views/app.blade.php | 6 ++ routes/inertia.php | 29 +++++-- routes/web.php | 5 +- .../Web/MovedBrowserRedirectController.php | 28 +++++++ src/Http/Middleware/HandleInertiaRequests.php | 3 + src/Http/Middleware/ValidateIchavaRoute.php | 6 +- src/Support/Helpers.php | 46 ++++++++++ tests/Feature/BrowserPathTest.php | 61 ++++++++++++++ tests/MovedBrowser/BrowserPathTest.php | 83 +++++++++++++++++++ tests/MovedBrowserTestCase.php | 27 ++++++ tests/Pest.php | 6 ++ 26 files changed, 377 insertions(+), 19 deletions(-) create mode 100644 resources/js/core/browserPath.test.ts create mode 100644 resources/js/core/browserPath.ts create mode 100644 src/Http/Controllers/Web/MovedBrowserRedirectController.php create mode 100644 tests/Feature/BrowserPathTest.php create mode 100644 tests/MovedBrowser/BrowserPathTest.php create mode 100644 tests/MovedBrowserTestCase.php diff --git a/.env.example b/.env.example index eaab0ce..eac2d7f 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ ICHAVA_VITE_DEV=true # Lives in core's config (`ichava.prefix`) — set ICHAVA_BROWSER_PREFIX in core # .env or here; both read the same env var. ICHAVA_BROWSER_PREFIX=ichava +# Serve the browser page elsewhere, e.g. `app` for /app. The API stays under the +# prefix, and /{prefix}/icons redirects (301) to the new URL. Unset: /{prefix}/icons. +# ICHAVA_BROWSER_PATH= ICHAVA_BROWSER_PER_PAGE=60 ICHAVA_BROWSER_CACHE=3600 @@ -43,7 +46,7 @@ ICHAVA_DOMAINS= # ---------------------------------------------------------------------------- ICHAVA_API_PRETTY_PRINT=true ICHAVA_API_CORS_ENABLED=true -ICHAVA_API_CORS_ORIGINS=* +ICHAVA_API_CORS_ORIGINS="*" # ---------------------------------------------------------------------------- # HTTP request size guard (bytes) diff --git a/README.md b/README.md index dba9581..f3a0f5e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ php artisan ichava::ichava-core.database seed --package=ichava/icon-sets-tabler ### Usage -Open the browser at `/{prefix}/icons` (`/ichava/icons` by default). To expose the JSON API as well, turn it on: +Open the browser at `/{prefix}/icons` (`/ichava/icons` by default, or wherever `ICHAVA_BROWSER_PATH` puts it). To expose the JSON API as well, turn it on: ```dotenv ICHAVA_API_ENABLED=true diff --git a/config/icon-browser.php b/config/icon-browser.php index 4804647..8ab80bf 100644 --- a/config/icon-browser.php +++ b/config/icon-browser.php @@ -41,11 +41,19 @@ |-------------------------------------------------------------------------- | Browser UI |-------------------------------------------------------------------------- - | Mounted at /{prefix}/icons by default ({prefix} is the value below). - | The same prefix is used by core's API routes (/{prefix}/api/v1/...) so - | the SPA and the REST API share a unified URL space. + | Mounted at /{prefix}/icons by default ({prefix} is core's + | ICHAVA_BROWSER_PREFIX). The same prefix is used by the API routes + | (/{prefix}/api/v1/...) so the SPA and the REST API share a URL space. + | + | `path` moves the browser page (and its /{path}/{id} icon pages) to a URL + | of the host's choosing, e.g. ICHAVA_BROWSER_PATH=app serves it at /app. + | The API and the other pages stay under the prefix, and /{prefix}/icons + | (with its query) and /{prefix}/icons/{id} answer with a permanent + | redirect to the new URLs, so old links keep working. Unset, empty or not + | a plain path: /{prefix}/icons, as before. */ 'browser' => [ + 'path' => env('ICHAVA_BROWSER_PATH'), 'per_page' => env('ICHAVA_BROWSER_PER_PAGE', IchavaConstants::BROWSER_PER_PAGE), 'cache_duration' => env('ICHAVA_BROWSER_CACHE', IchavaConstants::DEFAULT_CACHE_TTL), // Whether the browser shows its theme control (`features.themeToggle`). diff --git a/docs/architecture.md b/docs/architecture.md index ee00335..b3514e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,6 +81,8 @@ The third case is the one worth stating plainly: in a stateless host the API is The `/{prefix}/icons` path comes from `ICHAVA_BROWSER_PREFIX` in **core's** configuration, and it is shared between core's API and the browser pages. Changing it in one place moves both, which is why it lives upstream of the package that serves the routes. +The browser page alone can move off the prefix with this package's `browser.path` (`ICHAVA_BROWSER_PATH`), for a host that wants it at a short URL such as `/app`. It is this package's setting, not core's, because only the page moves: the API stays at `/{prefix}/api/v1/...`, and the old page URLs redirect permanently to the new ones, so links already shared keep working. + ## See also - [Installation](installation.md) diff --git a/docs/configuration.md b/docs/configuration.md index 804b76e..febac40 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -142,6 +142,7 @@ The `ichava` Inertia prop and `GET {prefix}/api/v1/bootstrap` carry three docume ## Notes - The route prefix (`/{prefix}/icons`, `/{prefix}/api/v1/...`) is set by `ICHAVA_BROWSER_PREFIX` and lives in **core's** config (`config('ichava.ichava-core.prefix')`). Both packages read it; icon-browser reads it through `Helpers::routePrefix()`. +- `browser.path` (`ICHAVA_BROWSER_PATH`) moves only the browser page and its icon pages, e.g. to `/app`; read through `Helpers::browserPath()`. The route names stay `ichava.inertia.browser` and `ichava.inertia.icons.show`, the page gets a `` to the new URL, the Inertia pages link icons through the shared `ichava.browserPath`, and the old `/{prefix}/icons` URLs answer 301 with their query kept (the browser's `?icon=` deep links live there). `/{prefix}` redirects to the new path. A value that is not a plain path is ignored. If your host sends a CSP, the moved page is the same page, so nothing in it changes; if you prerender or cache pages by URL, add the new path. - `rate_limiting.enabled = false` (default) makes per-route limits no-ops. The `api_floor` global throttle still applies regardless. - With `rate_limiting.enabled = true`, each REST route carries the limit it declares (for example 60 a minute for `icons/filters`, 10 for the cache writes), counted per route and per caller. `rate_limiting.api` can only **lower** that figure for every API route; it never raises it. The figure is read per request, so changing it needs no route cache clear, but `enabled` is read when routes load. - `api.cors.allowed_origins` takes one origin, a list, or the comma-separated string `ICHAVA_API_CORS_ORIGINS` holds. With one origin it is sent as is; with several, `Access-Control-Allow-Origin` echoes the request's `Origin` when it is listed (and the response varies on `Origin`), and is omitted otherwise. No credentials header is ever sent. diff --git a/docs/environment.md b/docs/environment.md index 7404dc9..71d488f 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -19,6 +19,7 @@ Copy what you need from [`.env.example`](https://github.com/ichava/icon-browser/ | Key | Default | Effect | Config | |---|---|---|---| | `ICHAVA_BROWSER_PREFIX` | `ichava` | URL prefix segment. Browser mounts at `/{prefix}/icons` and core's API at `/{prefix}/api/v1/...`. Lives in core's config (`ichava.prefix`); listed here because env-var ownership is conventional | `ichava.prefix` *(in core)* | +| `ICHAVA_BROWSER_PATH` | *(none)* | Serve the browser page somewhere else, e.g. `app` for `/app`, with its icon pages at `/{path}/{id}`. The REST API and the other pages stay under the prefix, and `/{prefix}/icons` and `/{prefix}/icons/{id}` answer 301 to the new URLs with their query kept. Unset, empty, not a plain path, the bare prefix or under `{prefix}/api`: `/{prefix}/icons` | `ichava.icon-browser.browser.path` | | `ICHAVA_BROWSER_PER_PAGE` | `60` | Default page size in the icon-browser grid | `ichava.icon-browser.browser.per_page` | | `ICHAVA_BROWSER_CACHE` | `3600` | Discovery cache TTL (seconds) | `ichava.icon-browser.browser.cache_duration` | | `ICHAVA_BROWSER_THEME_TOGGLE` | `true` | Show the theme control (`ui.themeToggle`, `features.themeToggle`) | `ichava.icon-browser.browser.theme_toggle` | diff --git a/docs/getting-started.md b/docs/getting-started.md index 2d450bd..c47f8cc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -12,7 +12,7 @@ With the config and assets published, the SPA is at: http://example.com/ichava/icons ``` -The `ichava` segment is the **prefix**, and it comes from `ICHAVA_BROWSER_PREFIX` in core's configuration rather than this package's — it is shared between core's API and this SPA, so changing it moves both. See [Configuration](configuration.md). +The `ichava` segment is the **prefix**, and it comes from `ICHAVA_BROWSER_PREFIX` in core's configuration rather than this package's — it is shared between core's API and this SPA, so changing it moves both. To serve the page somewhere else, such as `/app`, set `ICHAVA_BROWSER_PATH=app`; the old URL then redirects there. See [Configuration](configuration.md). ## Nothing appears until a pack is seeded diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ebef801..2e42db5 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -22,6 +22,9 @@ tests/Gated + + tests/MovedBrowser + tests/Prefixed diff --git a/resources/js/core/browserPath.test.ts b/resources/js/core/browserPath.test.ts new file mode 100644 index 0000000..19eb0de --- /dev/null +++ b/resources/js/core/browserPath.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import { iconPageBase } from './browserPath'; + +describe('iconPageBase', () => { + it('follows the path the server shares', () => { + expect(iconPageBase({ prefix: 'ichava', browserPath: '/app' })).toBe('/app'); + expect(iconPageBase({ prefix: 'ichava', browserPath: '/tools/icons/' })).toBe('/tools/icons'); + }); + + it('falls back to the prefix for a page rendered by an older server', () => { + expect(iconPageBase({ prefix: 'custom' })).toBe('/custom/icons'); + expect(iconPageBase({ prefix: 'custom', browserPath: '' })).toBe('/custom/icons'); + }); +}); + +describe('the pages', () => { + // Every page that links an icon page builds the base here, never from the prefix. + const pages = join(__dirname, '..', 'pages'); + const files = (function walk(dir: string): string[] { + return readdirSync(dir).flatMap((f) => { + const p = join(dir, f); + return statSync(p).isDirectory() ? walk(p) : p.endsWith('.tsx') ? [p] : []; + }); + })(pages); + + it('read the pages they inspect', () => { + expect(files.length).toBeGreaterThanOrEqual(8); + }); + + it('do not build icon URLs from the prefix', () => { + const offenders = files.filter((f) => /prefix\}\/icons/.test(readFileSync(f, 'utf8'))); + expect(offenders).toEqual([]); + }); +}); diff --git a/resources/js/core/browserPath.ts b/resources/js/core/browserPath.ts new file mode 100644 index 0000000..79af1c8 --- /dev/null +++ b/resources/js/core/browserPath.ts @@ -0,0 +1,11 @@ +/** + * The base of an icon page's URL: `${iconPageBase(ichava)}/${id}`. + * + * The server shares it as `ichava.browserPath` (`/{prefix}/icons`, or wherever the host's + * `ICHAVA_BROWSER_PATH` moved the browser). The pages used to build `/${prefix}/icons` + * themselves, which a moved browser turns into a redirect on every click. + */ +export function iconPageBase(ichava: { prefix: string; browserPath?: string }): string { + const path = ichava.browserPath?.replace(/\/+$/, ''); + return path ? path : `/${ichava.prefix}/icons`; +} diff --git a/resources/js/pages/Browser/Show.tsx b/resources/js/pages/Browser/Show.tsx index 08d917b..dac6551 100644 --- a/resources/js/pages/Browser/Show.tsx +++ b/resources/js/pages/Browser/Show.tsx @@ -6,6 +6,7 @@ import { FlashBanner } from '@/components/FlashBanner'; import { normalizeRawIcon } from '@/core/propsToCatalog'; import { snippets, SNIPPET_TABS, toIcon, type RawIcon, type SnippetFormat } from '@/lib/browser'; import type { SharedProps } from '@/types'; +import { iconPageBase } from '@/core/browserPath'; interface BrowserShowProps extends SharedProps { icon: RawIcon | null; @@ -36,7 +37,7 @@ export default function BrowserShow() { ); } - const showBase = `/${props.ichava.prefix}/icons`; + const showBase = iconPageBase(props.ichava); // The forked UI's `useCopy` also raised a toast through the browser store. This page mounts // no browser, and the button's own "Copied!" state was always the feedback here. diff --git a/resources/js/pages/Collections/Show.tsx b/resources/js/pages/Collections/Show.tsx index 6087f65..f29a61b 100644 --- a/resources/js/pages/Collections/Show.tsx +++ b/resources/js/pages/Collections/Show.tsx @@ -5,6 +5,7 @@ import { FlashBanner } from '@/components/FlashBanner'; import { normalizeRawIcon } from '@/core/propsToCatalog'; import { toIcon, type RawIcon } from '@/lib/browser'; import type { SharedProps } from '@/types'; +import { iconPageBase } from '@/core/browserPath'; interface CollectionDetail { id: string; @@ -24,7 +25,7 @@ export default function CollectionsShow() { () => (collection?.icons ?? []).map((r) => toIcon(normalizeRawIcon(r))), [collection], ); - const showBase = `/${props.ichava.prefix}/icons`; + const showBase = iconPageBase(props.ichava); if (!collection) { return ( diff --git a/resources/js/pages/Favorites/Index.tsx b/resources/js/pages/Favorites/Index.tsx index 206fb25..7e69a5a 100644 --- a/resources/js/pages/Favorites/Index.tsx +++ b/resources/js/pages/Favorites/Index.tsx @@ -5,6 +5,7 @@ import { FlashBanner } from '@/components/FlashBanner'; import { normalizeRawIcon } from '@/core/propsToCatalog'; import { toIcon, type RawIcon } from '@/lib/browser'; import type { SharedProps } from '@/types'; +import { iconPageBase } from '@/core/browserPath'; interface FavoritesIndexProps extends SharedProps { ids: number[]; @@ -16,7 +17,7 @@ export default function FavoritesIndex() { const { props } = usePage(); const icons = useMemo(() => props.icons.map((r) => toIcon(normalizeRawIcon(r))), [props.icons]); - const showBase = `/${props.ichava.prefix}/icons`; + const showBase = iconPageBase(props.ichava); return (
diff --git a/resources/js/pages/History/Index.tsx b/resources/js/pages/History/Index.tsx index 5567699..a516806 100644 --- a/resources/js/pages/History/Index.tsx +++ b/resources/js/pages/History/Index.tsx @@ -5,6 +5,7 @@ import { FlashBanner } from '@/components/FlashBanner'; import { normalizeRawIcon, type ServerIcon } from '@/core/propsToCatalog'; import { toIcon, type Icon } from '@/lib/browser'; import type { SharedProps } from '@/types'; +import { iconPageBase } from '@/core/browserPath'; interface HistoryRow { icon_id: number; @@ -21,7 +22,7 @@ interface HistoryIndexProps extends SharedProps { export default function HistoryIndex() { const { props } = usePage(); - const showBase = `/${props.ichava.prefix}/icons`; + const showBase = iconPageBase(props.ichava); const rows = useMemo( () => diff --git a/resources/js/types.ts b/resources/js/types.ts index 2d5e3d2..82b7f01 100644 --- a/resources/js/types.ts +++ b/resources/js/types.ts @@ -59,6 +59,12 @@ export interface IchavaShared { * The server sends null for a route a disabled feature left unmounted. Typed as strings * until the browser reads `features` first (react-icon-browser's manifest-driven modules). */ + /** + * The browser page's root-relative path (`/ichava/icons`, or wherever the host's + * `ICHAVA_BROWSER_PATH` moved it). Icon pages are `${browserPath}/${id}`. Optional, for a + * page rendered by an older server. + */ + browserPath?: string; routes: { browser: string; stats: string; diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 869d355..29fc9d5 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -14,6 +14,12 @@ {{ config('app.name', 'Ichava') }} + @if(request()->routeIs('ichava.inertia.browser', 'ichava.inertia.icons.show')) + {{-- The browser and its icon pages at the configured path (`browser.path`), without the + filter query, so a moved browser's old URLs and every filtered view name one page. --}} + + @endif + @php use Simtabi\Laranail\Ichava\Support\Helpers; $viteHost = config('ichava.icon-browser.vite.host', 'localhost'); diff --git a/routes/inertia.php b/routes/inertia.php index 10497d1..77fd696 100644 --- a/routes/inertia.php +++ b/routes/inertia.php @@ -14,6 +14,7 @@ use Simtabi\Laranail\Ichava\IconBrowser\Http\Controllers\Web\CollectionController; use Simtabi\Laranail\Ichava\IconBrowser\Http\Controllers\Web\CommandHistoryController; use Simtabi\Laranail\Ichava\IconBrowser\Http\Controllers\Web\InertiaBrowserController; +use Simtabi\Laranail\Ichava\IconBrowser\Http\Controllers\Web\MovedBrowserRedirectController; /* |-------------------------------------------------------------------------- @@ -33,14 +34,32 @@ | */ -Route::group(Helpers::routeGroup(Helpers::routePrefix(), ['ichava-icon-browser.inertia'], 'ichava.inertia.'), function () { - - // Browser: filtered icon listing + detail + stats dashboard. - Route::get('/icons', [InertiaBrowserController::class, 'index']) +// Browser: the filtered icon listing and its icon pages, at `browser.path` +// (ICHAVA_BROWSER_PATH), which is `{prefix}/icons` unless the host moved it. +// Same names and middleware either way. +Route::group(Helpers::routeGroup(Helpers::browserPath(), ['ichava-icon-browser.inertia'], 'ichava.inertia.'), function () { + Route::get('/', [InertiaBrowserController::class, 'index']) ->name('browser'); - Route::get('/icons/{id}', [InertiaBrowserController::class, 'show']) + Route::get('/{id}', [InertiaBrowserController::class, 'show']) ->name('icons.show') ->where('id', '[0-9]+'); +}); + +// A moved browser keeps its old URLs: `{prefix}/icons` and `{prefix}/icons/{id}` +// answer 301 with the query kept (`?icon=` deep links live in it). +if (Helpers::browserMoved()) { + Route::group(Helpers::routeGroup(Helpers::routePrefix(), ['ichava-icon-browser.web'], 'ichava.inertia.moved.'), function () { + Route::get('/icons', MovedBrowserRedirectController::class) + ->name('browser'); + Route::get('/icons/{id}', MovedBrowserRedirectController::class) + ->name('icons.show') + ->where('id', '[0-9]+'); + }); +} + +Route::group(Helpers::routeGroup(Helpers::routePrefix(), ['ichava-icon-browser.inertia'], 'ichava.inertia.'), function () { + + // Stats dashboard. Route::get('/stats', [InertiaBrowserController::class, 'stats']) ->name('stats'); diff --git a/routes/web.php b/routes/web.php index dfa68d2..f18ae3f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -25,8 +25,9 @@ // pages do: with ICHAVA_INERTIA_ENABLED=false, /{prefix} redirected to a 404. if (filter_var(config('ichava.icon-browser.inertia.enabled', true), FILTER_VALIDATE_BOOL)) { Route::group(Helpers::routeGroup(Helpers::routePrefix(), ['ichava-icon-browser.web'], 'ichava.'), function () { - // Redirect bare /{prefix} to the canonical (Inertia) browser URL. - Route::redirect('/', '/' . Helpers::routePrefix() . '/icons') + // Redirect bare /{prefix} to the canonical (Inertia) browser URL, wherever + // `browser.path` put it. + Route::redirect('/', '/' . Helpers::browserPath()) ->name('home'); }); } diff --git a/src/Http/Controllers/Web/MovedBrowserRedirectController.php b/src/Http/Controllers/Web/MovedBrowserRedirectController.php new file mode 100644 index 0000000..b95da5a --- /dev/null +++ b/src/Http/Controllers/Web/MovedBrowserRedirectController.php @@ -0,0 +1,28 @@ +getQueryString(); + + return new RedirectResponse(url($target) . ($query !== null && $query !== '' ? '?' . $query : ''), 301); + } +} diff --git a/src/Http/Middleware/HandleInertiaRequests.php b/src/Http/Middleware/HandleInertiaRequests.php index c260356..824ec54 100644 --- a/src/Http/Middleware/HandleInertiaRequests.php +++ b/src/Http/Middleware/HandleInertiaRequests.php @@ -45,7 +45,10 @@ public function share(Request $request): array 'ichava' => fn () => [ ...$this->hostFields(), + // `browserPath`: where the browser page and its icon pages live + // (`browser.path`), root-relative: `/{prefix}/icons` unless moved. 'prefix' => Helpers::routePrefix(), + 'browserPath' => '/' . Helpers::browserPath(), 'perPage' => Helpers::configInt('ichava.icon-browser.browser.per_page', 24), 'defaultTheme' => config('ichava.icon-browser.browser.default_theme', 'light'), 'ui' => $this->ui(), diff --git a/src/Http/Middleware/ValidateIchavaRoute.php b/src/Http/Middleware/ValidateIchavaRoute.php index 6b97080..61c5b82 100644 --- a/src/Http/Middleware/ValidateIchavaRoute.php +++ b/src/Http/Middleware/ValidateIchavaRoute.php @@ -26,10 +26,12 @@ final class ValidateIchavaRoute public function handle(Request $request, Closure $next): Response { $prefix = Helpers::routePrefix(); + $browser = Helpers::browserPath(); $path = $request->path(); - // Ensure request is for an Ichava route - if (! Str::startsWith($path, $prefix)) { + // Ensure request is for an Ichava route: under the prefix, or the browser page + // and its icon pages wherever the host moved them (`browser.path`). + if (! Str::startsWith($path, $prefix) && $path !== $browser && ! Str::startsWith($path, $browser . '/')) { abort(404, 'Not an Ichava route'); } diff --git a/src/Support/Helpers.php b/src/Support/Helpers.php index 0b3c3db..21bb517 100644 --- a/src/Support/Helpers.php +++ b/src/Support/Helpers.php @@ -25,6 +25,11 @@ final class Helpers */ public const string ROUTE_PREFIX_KEY = 'ichava.ichava-core.prefix'; + /** + * The config key for the browser page's own path (`ICHAVA_BROWSER_PATH`). + */ + public const string BROWSER_PATH_KEY = 'ichava.icon-browser.browser.path'; + /** * The URL prefix every browser route mounts under (`/{prefix}/icons`, * `/{prefix}/api/v1/...`). One reader, so routes, shared props and @@ -37,6 +42,47 @@ public static function routePrefix(): string return is_string($prefix) && $prefix !== '' ? $prefix : 'ichava'; } + /** + * Where the browser page is served, without slashes: `{prefix}/icons` unless the + * host moved it with `ichava.icon-browser.browser.path` (e.g. `app`). Icon pages + * follow it (`{path}/{id}`); everything else, the REST API included, stays under + * the prefix. + * + * A value that is empty, not a plain path (letters, digits, `-`, `_`, `.`, and `/` + * between segments), the bare prefix or under `{prefix}/api` is ignored, so a typo + * cannot mount the browser somewhere unexpected or over another route. + */ + public static function browserPath(): string + { + $default = self::routePrefix() . '/icons'; + $path = config(self::BROWSER_PATH_KEY); + + if (! is_string($path)) { + return $default; + } + + $path = trim($path, " \t\n\r\0\x0B/"); + $prefix = self::routePrefix(); + + // The bare prefix is the home redirect's own URL, and `{prefix}/api` is the REST + // API's: mounting the page on either would loop or shadow. + $reserved = $path === $prefix || $path === $prefix . '/api' || str_starts_with($path, $prefix . '/api/'); + + $plain = preg_match('#^[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$#', $path) === 1 + && array_intersect(explode('/', $path), ['.', '..']) === []; + + return $path !== '' && ! $reserved && $plain ? $path : $default; + } + + /** + * Whether the host moved the browser page off `{prefix}/icons`. When it did, the + * old URLs answer with a permanent redirect to the new ones. + */ + public static function browserMoved(): bool + { + return self::browserPath() !== self::routePrefix() . '/icons'; + } + /** * The per-route throttle middleware, or none when rate limiting is off. * diff --git a/tests/Feature/BrowserPathTest.php b/tests/Feature/BrowserPathTest.php new file mode 100644 index 0000000..89d47b6 --- /dev/null +++ b/tests/Feature/BrowserPathTest.php @@ -0,0 +1,61 @@ +toBe('ichava/icons') + ->and(Helpers::browserMoved())->toBeFalse() + ->and(route('ichava.inertia.browser', absolute: false))->toBe('/ichava/icons') + ->and(route('ichava.inertia.icons.show', ['id' => 3], absolute: false))->toBe('/ichava/icons/3'); + + $html = (string) $this->get('/ichava/icons?q=home')->assertOk()->getContent(); + expect($html)->toContain(''); + $this->get('/ichava')->assertRedirect('/ichava/icons'); +}); + +it('registers no redirect routes when the browser has not moved', function (): void { + $moved = collect(app('router')->getRoutes()->getRoutesByName()) + ->keys() + ->filter(fn (string $name): bool => str_starts_with($name, 'ichava.inertia.moved.')); + + expect($moved->all())->toBe([]); +}); + +it('adds no canonical link to the other pages', function (): void { + $this->get('/ichava/settings')->assertOk()->assertDontSee('rel="canonical"', false); +}); + +it('ignores a path that is empty, malformed or reserved', function (mixed $value): void { + config([Helpers::BROWSER_PATH_KEY => $value]); + + expect(Helpers::browserPath())->toBe('ichava/icons') + ->and(Helpers::browserMoved())->toBeFalse(); +})->with([ + 'null' => [null], + 'empty' => [''], + 'slashes' => ['///'], + 'not a path' => ['app?x=1'], + 'traversal' => ['app/../admin'], + 'spaces' => ['my app'], + 'double /' => ['a//b'], + 'array' => [['app']], + 'the prefix' => ['ichava'], + 'the API' => ['ichava/api'], + 'under API' => ['ichava/api/v1/icons'], +]); + +it('accepts a plain nested path', function (): void { + config([Helpers::BROWSER_PATH_KEY => '/tools/icon-browser/']); + + expect(Helpers::browserPath())->toBe('tools/icon-browser') + ->and(Helpers::browserMoved())->toBeTrue(); +}); diff --git a/tests/MovedBrowser/BrowserPathTest.php b/tests/MovedBrowser/BrowserPathTest.php new file mode 100644 index 0000000..89b86e6 --- /dev/null +++ b/tests/MovedBrowser/BrowserPathTest.php @@ -0,0 +1,83 @@ +toBe(MovedBrowserTestCase::PATH) + ->and(Helpers::browserMoved())->toBeTrue(); +}); + +it('names the moved page with the same route names', function (): void { + expect(route('ichava.inertia.browser', absolute: false))->toBe('/app') + ->and(route('ichava.inertia.icons.show', ['id' => 7], absolute: false))->toBe('/app/7'); +}); + +it('serves the browser at the new path, with a canonical link to it', function (): void { + $html = (string) $this->get('/app?q=home')->assertOk()->getContent(); + + expect($html)->toContain(''); +}); + +it('sends an unknown icon page back to the browser at the new path', function (): void { + $this->get('/app/999999')->assertRedirect('/app'); +}); + +it('redirects the old browser URL permanently, keeping the query', function (): void { + $response = $this->get('/ichava/icons?icon=12&q=home'); + + $response->assertStatus(301); + expect($response->headers->get('Location'))->toBe('http://localhost/app?icon=12&q=home'); +}); + +it('redirects the old browser URL without a query to the bare new one', function (): void { + $response = $this->get('/ichava/icons'); + + $response->assertStatus(301); + expect($response->headers->get('Location'))->toBe('http://localhost/app'); +}); + +it('redirects an old icon page permanently to the same icon at the new path', function (): void { + $response = $this->get('/ichava/icons/7?ref=x'); + + $response->assertStatus(301); + expect($response->headers->get('Location'))->toBe('http://localhost/app/7?ref=x'); +}); + +it('sends the bare prefix to the new path', function (): void { + $this->get('/ichava')->assertRedirect('/app'); +}); + +it('keeps the REST API and the other pages under the prefix', function (): void { + expect(app('router')->getRoutes()->getByName('ichava.api.v1.icons.index')?->uri())->toStartWith('ichava/api/v1/') + ->and(route('ichava.inertia.settings.index', absolute: false))->toBe('/ichava/settings'); + + $this->getJson('/ichava/api/v1/icons')->assertOk(); + $this->getJson('/app/api/v1/icons')->assertNotFound(); +}); + +it('shares the path with the Inertia pages, for their icon links', function (): void { + $request = Request::create('/app'); + $request->setLaravelSession(app('session.store')); + + $shared = value(app(HandleInertiaRequests::class)->share($request)['ichava']); + + expect($shared['browserPath'])->toBe('/app') + ->and($shared['routes']['browser'])->toBe('http://localhost/app'); +}); + +it('still refuses paths outside the prefix and the browser path', function (): void { + $this->get('/apple')->assertNotFound(); + $this->get('/elsewhere/icons')->assertNotFound(); +}); diff --git a/tests/MovedBrowserTestCase.php b/tests/MovedBrowserTestCase.php new file mode 100644 index 0000000..a110ef9 --- /dev/null +++ b/tests/MovedBrowserTestCase.php @@ -0,0 +1,27 @@ +set('ichava.icon-browser.browser.path', '/' . self::PATH . '/'); + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 4865da7..b2afa89 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -6,6 +6,7 @@ use Simtabi\Laranail\Ichava\IconBrowser\Tests\TestCase; use Simtabi\Laranail\Ichava\IconBrowser\Tests\ApiDisabledTestCase; use Simtabi\Laranail\Ichava\IconBrowser\Tests\CustomPrefixTestCase; +use Simtabi\Laranail\Ichava\IconBrowser\Tests\MovedBrowserTestCase; use Simtabi\Laranail\Ichava\IconBrowser\Tests\ConfiguredHostTestCase; use Simtabi\Laranail\Ichava\IconBrowser\Tests\ShortPrefixOffTestCase; use Simtabi\Laranail\Ichava\IconBrowser\Tests\InertiaDisabledTestCase; @@ -45,3 +46,8 @@ ->extend(ShortPrefixOffTestCase::class) ->use(RefreshDatabase::class) ->in('ShortPrefixOff'); + +pest() + ->extend(MovedBrowserTestCase::class) + ->use(RefreshDatabase::class) + ->in('MovedBrowser'); From 1c2143e349ffee331fbc0a4d2cfdfc2c0028f960 Mon Sep 17 00:00:00 2001 From: Imani Manyara Date: Fri, 6 Nov 2026 14:54:36 -0500 Subject: [PATCH 2/3] chore(vendor): re-vendor react-icon-browser at 152a0a5 The shared popover caret and the movable cookies notice (react-icon-browser#214), with inertia-app rebuilt by build:prod. --- CHANGELOG.md | 4 + public/assets/css/inertia-app.css | 2 +- ...rtia-app-AboutDialog-3ajb2X6F-DM1pJq4p.js} | 2 +- ...a-app-CommandPalette-CeLqONKy-CM6p54oT.js} | 2 +- ...ia-app-DevToolsPanel-CbR2FqMo-BwngrwJW.js} | 2 +- ...inertia-app-IconAsset-BVRD6Pac-CSRgaY2J.js | 7 ++ ...inertia-app-IconAsset-FkELwaHd-BzwD7rAe.js | 7 -- .../js/inertia-app-IconGrid-BpMT6A7e.js | 1 + .../js/inertia-app-IconGrid-D57OdMii.js | 1 - ...tia-app-IconGridList-queHa-WS-DRhqvWJx.js} | 2 +- .../js/inertia-app-IconImage-Dly5unlp.js | 1 - .../assets/js/inertia-app-Index-BBk01dCM.js | 1 - ...3KeIf.js => inertia-app-Index-C53rMazL.js} | 2 +- .../assets/js/inertia-app-Index-CXX1d36q.js | 1 + .../assets/js/inertia-app-Index-DJSzYEHN.js | 1 + .../assets/js/inertia-app-Index-DnKX2ymS.js | 1 - ...rtia-app-InviteModal-rzJXh-JT-BaFZmyxB.js} | 2 +- ...ia-app-LibraryDialog-BI-n9Q34-DYJp3UJB.js} | 4 +- ...pp-ManageAccessModal-DWXbmvPr-BU19uFqK.js} | 2 +- ...tia-app-ProfileModal-BLtyRn5O-C_X-HYs9.js} | 2 +- ...a-app-SettingsDialog-BDvBI59--CYj5zloZ.js} | 2 +- ...haredCollectionsModal-BZX3DorI-D1ikdKHA.js | 2 - ...haredCollectionsModal-fAi7Feba-CGaDvQKd.js | 2 + public/assets/js/inertia-app-Show-C9J5Q-C2.js | 1 - public/assets/js/inertia-app-Show-CMgdbkJm.js | 1 - public/assets/js/inertia-app-Show-DLV1frH9.js | 1 + public/assets/js/inertia-app-Show-DVnPrbmA.js | 1 + ...nertia-app-TourModal-DUnXa682-DRZ7G0nY.js} | 2 +- ...js => inertia-app-ar-DN72wH1H-BcUtj7OD.js} | 2 +- .../js/inertia-app-browserPath-B3iDZllO.js | 1 + ...js => inertia-app-de-C11_iEFd-B-InrnJd.js} | 2 +- ...js => inertia-app-es-BQadDI5z-DrVHPb0D.js} | 2 +- ...js => inertia-app-fr-C3YrRQ0m-iRjMJz01.js} | 2 +- ...js => inertia-app-pt-DiIN_EjM-rcG3_wrX.js} | 2 +- ...js => inertia-app-sw-s0osxpLo-BAasjxL6.js} | 2 +- public/assets/js/inertia-app.js | 38 ++++----- ...og-CGqzashF.js => AboutDialog-3ajb2X6F.js} | 2 +- ...B56GcMV-.js => CommandPalette-CeLqONKy.js} | 2 +- ...-DTB9sYP1.js => DevToolsPanel-CbR2FqMo.js} | 2 +- .../{Glyph-COTXfEez.js => Glyph-ifDznOhl.js} | 2 +- ...sset-FkELwaHd.js => IconAsset-BVRD6Pac.js} | 2 +- ...t-iQlQf8KS.js => IconGridList-queHa-WS.js} | 2 +- ...al-DEOvhubP.js => InviteModal-rzJXh-JT.js} | 2 +- ...-CnY6y-Bu.js => LibraryDialog-BI-n9Q34.js} | 4 +- ...Czo_m.js => ManageAccessModal-DWXbmvPr.js} | 2 +- ...l-xQHmeSHo.js => ProfileModal-BLtyRn5O.js} | 2 +- ...GjjGUP9m.js => SettingsDialog-BDvBI59-.js} | 2 +- ....js => SharedCollectionsModal-fAi7Feba.js} | 4 +- ...odal-DbBt_9oo.js => TourModal-DUnXa682.js} | 2 +- ...1PobAI5.js => TreatmentEngine-B0FTr-Fl.js} | 2 +- .../{ar-KqjMLU4x.js => ar-DN72wH1H.js} | 2 +- ...{avatar-Pj1cM2_n.js => avatar-D043d4aw.js} | 2 +- .../chromeIcons-ITgd2uHk.js | 3 + .../chromeIcons-lD-AVLJG.js | 3 - .../{de-BcvR7fQw.js => de-C11_iEFd.js} | 2 +- .../{es-DjG-LZWR.js => es-BQadDI5z.js} | 2 +- .../{fr-DlFtsXOL.js => fr-C3YrRQ0m.js} | 2 +- ...Icon-BG6Av6o9.js => glyphIcon-D8F12BiB.js} | 2 +- resources/vendor/react-icon-browser/index.js | 12 +-- .../{label-BVeClFNr.js => label-JRlP-MMU.js} | 2 +- .../{pt-D4AEPCUA.js => pt-DiIN_EjM.js} | 2 +- ...ppets-iLD7Oe4v.js => snippets-CIeWnjDR.js} | 4 +- .../vendor/react-icon-browser/styles.css | 2 +- .../{sw-BYnIwpgn.js => sw-s0osxpLo.js} | 2 +- .../components/consent/ConsentBanner.d.ts | 6 ++ .../shared-assets/banners/notice-banner.d.ts | 7 +- .../types/components/ui/Menu.d.ts | 7 +- .../types/components/ui/PopoverCaret.d.ts | 19 +++++ .../types/components/ui/caretGeometry.d.ts | 38 +++++++++ .../types/components/ui/chromeIcons.d.ts | 1 + .../types/core/locales/ar.d.ts | 2 + .../types/core/locales/de.d.ts | 2 + .../types/core/locales/en.d.ts | 2 + .../types/core/locales/es.d.ts | 2 + .../types/core/locales/fr.d.ts | 2 + .../types/core/locales/pt.d.ts | 2 + .../types/core/locales/sw.d.ts | 2 + .../types/hooks/useDraggableNotice.d.ts | 61 ++++++++++++++ .../vendor/react-icon-browser/vendored.json | 81 ++++++++++--------- 79 files changed, 285 insertions(+), 133 deletions(-) rename public/assets/js/{inertia-app-AboutDialog-CGqzashF-F_I0sByD.js => inertia-app-AboutDialog-3ajb2X6F-DM1pJq4p.js} (94%) rename public/assets/js/{inertia-app-CommandPalette-B56GcMV--CdUOHWmd.js => inertia-app-CommandPalette-CeLqONKy-CM6p54oT.js} (66%) rename public/assets/js/{inertia-app-DevToolsPanel-DTB9sYP1-BJUOq8ZE.js => inertia-app-DevToolsPanel-CbR2FqMo-BwngrwJW.js} (66%) create mode 100644 public/assets/js/inertia-app-IconAsset-BVRD6Pac-CSRgaY2J.js delete mode 100644 public/assets/js/inertia-app-IconAsset-FkELwaHd-BzwD7rAe.js create mode 100644 public/assets/js/inertia-app-IconGrid-BpMT6A7e.js delete mode 100644 public/assets/js/inertia-app-IconGrid-D57OdMii.js rename public/assets/js/{inertia-app-IconGridList-iQlQf8KS-7OMpKWq2.js => inertia-app-IconGridList-queHa-WS-DRhqvWJx.js} (99%) delete mode 100644 public/assets/js/inertia-app-IconImage-Dly5unlp.js delete mode 100644 public/assets/js/inertia-app-Index-BBk01dCM.js rename public/assets/js/{inertia-app-Index-1133KeIf.js => inertia-app-Index-C53rMazL.js} (92%) create mode 100644 public/assets/js/inertia-app-Index-CXX1d36q.js create mode 100644 public/assets/js/inertia-app-Index-DJSzYEHN.js delete mode 100644 public/assets/js/inertia-app-Index-DnKX2ymS.js rename public/assets/js/{inertia-app-InviteModal-DEOvhubP-THBDQYtg.js => inertia-app-InviteModal-rzJXh-JT-BaFZmyxB.js} (54%) rename public/assets/js/{inertia-app-LibraryDialog-CnY6y-Bu-D7fSLD2J.js => inertia-app-LibraryDialog-BI-n9Q34-DYJp3UJB.js} (55%) rename public/assets/js/{inertia-app-ManageAccessModal-W_vCzo_m-BykNxvRI.js => inertia-app-ManageAccessModal-DWXbmvPr-BU19uFqK.js} (61%) rename public/assets/js/{inertia-app-ProfileModal-xQHmeSHo-DAfWO3Qs.js => inertia-app-ProfileModal-BLtyRn5O-C_X-HYs9.js} (57%) rename public/assets/js/{inertia-app-SettingsDialog-GjjGUP9m-ZF1gbaHL.js => inertia-app-SettingsDialog-BDvBI59--CYj5zloZ.js} (71%) delete mode 100644 public/assets/js/inertia-app-SharedCollectionsModal-BZX3DorI-D1ikdKHA.js create mode 100644 public/assets/js/inertia-app-SharedCollectionsModal-fAi7Feba-CGaDvQKd.js delete mode 100644 public/assets/js/inertia-app-Show-C9J5Q-C2.js delete mode 100644 public/assets/js/inertia-app-Show-CMgdbkJm.js create mode 100644 public/assets/js/inertia-app-Show-DLV1frH9.js create mode 100644 public/assets/js/inertia-app-Show-DVnPrbmA.js rename public/assets/js/{inertia-app-TourModal-DbBt_9oo-B3k4A0Mc.js => inertia-app-TourModal-DUnXa682-DRZ7G0nY.js} (50%) rename public/assets/js/{inertia-app-ar-KqjMLU4x-CwNHLZ2t.js => inertia-app-ar-DN72wH1H-BcUtj7OD.js} (58%) create mode 100644 public/assets/js/inertia-app-browserPath-B3iDZllO.js rename public/assets/js/{inertia-app-de-BcvR7fQw-D7Qjtime.js => inertia-app-de-C11_iEFd-B-InrnJd.js} (57%) rename public/assets/js/{inertia-app-es-DjG-LZWR-iJ0g6-EW.js => inertia-app-es-BQadDI5z-DrVHPb0D.js} (57%) rename public/assets/js/{inertia-app-fr-DlFtsXOL-BoDT2dPO.js => inertia-app-fr-C3YrRQ0m-iRjMJz01.js} (57%) rename public/assets/js/{inertia-app-pt-D4AEPCUA-T_ohx0UI.js => inertia-app-pt-DiIN_EjM-rcG3_wrX.js} (57%) rename public/assets/js/{inertia-app-sw-BYnIwpgn-wSKwGhMY.js => inertia-app-sw-s0osxpLo-BAasjxL6.js} (57%) rename resources/vendor/react-icon-browser/{AboutDialog-CGqzashF.js => AboutDialog-3ajb2X6F.js} (96%) rename resources/vendor/react-icon-browser/{CommandPalette-B56GcMV-.js => CommandPalette-CeLqONKy.js} (86%) rename resources/vendor/react-icon-browser/{DevToolsPanel-DTB9sYP1.js => DevToolsPanel-CbR2FqMo.js} (67%) rename resources/vendor/react-icon-browser/{Glyph-COTXfEez.js => Glyph-ifDznOhl.js} (73%) rename resources/vendor/react-icon-browser/{IconAsset-FkELwaHd.js => IconAsset-BVRD6Pac.js} (95%) rename resources/vendor/react-icon-browser/{IconGridList-iQlQf8KS.js => IconGridList-queHa-WS.js} (94%) rename resources/vendor/react-icon-browser/{InviteModal-DEOvhubP.js => InviteModal-rzJXh-JT.js} (87%) rename resources/vendor/react-icon-browser/{LibraryDialog-CnY6y-Bu.js => LibraryDialog-BI-n9Q34.js} (87%) rename resources/vendor/react-icon-browser/{ManageAccessModal-W_vCzo_m.js => ManageAccessModal-DWXbmvPr.js} (91%) rename resources/vendor/react-icon-browser/{ProfileModal-xQHmeSHo.js => ProfileModal-BLtyRn5O.js} (88%) rename resources/vendor/react-icon-browser/{SettingsDialog-GjjGUP9m.js => SettingsDialog-BDvBI59-.js} (87%) rename resources/vendor/react-icon-browser/{SharedCollectionsModal-BZX3DorI.js => SharedCollectionsModal-fAi7Feba.js} (82%) rename resources/vendor/react-icon-browser/{TourModal-DbBt_9oo.js => TourModal-DUnXa682.js} (82%) rename resources/vendor/react-icon-browser/{TreatmentEngine-B1PobAI5.js => TreatmentEngine-B0FTr-Fl.js} (99%) rename resources/vendor/react-icon-browser/{ar-KqjMLU4x.js => ar-DN72wH1H.js} (58%) rename resources/vendor/react-icon-browser/{avatar-Pj1cM2_n.js => avatar-D043d4aw.js} (99%) create mode 100644 resources/vendor/react-icon-browser/chromeIcons-ITgd2uHk.js delete mode 100644 resources/vendor/react-icon-browser/chromeIcons-lD-AVLJG.js rename resources/vendor/react-icon-browser/{de-BcvR7fQw.js => de-C11_iEFd.js} (58%) rename resources/vendor/react-icon-browser/{es-DjG-LZWR.js => es-BQadDI5z.js} (58%) rename resources/vendor/react-icon-browser/{fr-DlFtsXOL.js => fr-C3YrRQ0m.js} (58%) rename resources/vendor/react-icon-browser/{glyphIcon-BG6Av6o9.js => glyphIcon-D8F12BiB.js} (86%) rename resources/vendor/react-icon-browser/{label-BVeClFNr.js => label-JRlP-MMU.js} (95%) rename resources/vendor/react-icon-browser/{pt-D4AEPCUA.js => pt-DiIN_EjM.js} (58%) rename resources/vendor/react-icon-browser/{snippets-iLD7Oe4v.js => snippets-CIeWnjDR.js} (97%) rename resources/vendor/react-icon-browser/{sw-BYnIwpgn.js => sw-s0osxpLo.js} (57%) create mode 100644 resources/vendor/react-icon-browser/types/components/ui/PopoverCaret.d.ts create mode 100644 resources/vendor/react-icon-browser/types/components/ui/caretGeometry.d.ts create mode 100644 resources/vendor/react-icon-browser/types/hooks/useDraggableNotice.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aed3de..5b03185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to `ichava/icon-browser` follow [Keep a Changelog](https://k ### Fixed +- **Re-vendored `ichava/react-icon-browser` at `152a0a5`** (react-icon-browser#214): one caret for every dropdown and popover, drawn in the panel's own colours over its edge and centred on the trigger (the header menus drew none), and the "Cookies & storage" notice moves by its grip or the arrow keys, inside the window, for the session. `inertia-app.*` rebuilt with `npm run build:prod`. + - **The browser page works under a host's nonce-based CSP.** A host that sends its own policy with a per-request nonce (`script-src 'self' 'nonce-…'`, as ichava.com does) hands that nonce to Vite; the page now puts it on the loader's `