diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 9997d42c..c55e25a1 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -10,6 +10,7 @@ import { categoryTheme, currentCategory, detectCategoryFromRoute } from './stores/category.js'; import { location } from 'svelte-spa-router'; import { resetPageMeta } from './lib/meta.js'; + import { normalizeLocation } from './lib/normalizePath.js'; // Early OAuth result detection — runs before routes mount. // Backend redirects here with ?oauth_platform=X&oauth_verified=true/false&oauth_error=... @@ -48,22 +49,12 @@ } } - // The portal uses hash routing. Normalize pasted/local links such as - // /claim/poap/:token so mint links work on localhost and 127.0.0.1. - { - const path = window.location.pathname; - const shouldNormalize = - !window.location.hash && - (path.startsWith('/claim/poap/') || path.startsWith('/community/poaps/')); - - if (shouldNormalize) { - window.history.replaceState( - {}, - '', - `/#${path}${window.location.search || ''}` - ); - } - } + // The portal uses hash routing. Direct/path-based links (sidebar hrefs opened + // in a new tab, refreshes of a path route, shared or indexed links such as + // /testnets and /metrics) arrive without a hash and would otherwise 404. + // Rewrite any such path into its hash equivalent so the router resolves it; + // unknown paths still fall through to the router's own NotFound view. + normalizeLocation(window); // State for sidebar toggle on mobile and collapse on desktop let sidebarOpen = $state(false); diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index bcb6b646..b5018e6e 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -140,7 +140,7 @@ {#if !collapsed && getActiveSection() === 'global'}
{ e.preventDefault(); navigate('/testnets'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/testnets') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -149,7 +149,7 @@ Testnets { e.preventDefault(); navigate('/metrics'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/metrics') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -193,7 +193,7 @@ {#if !collapsed && getActiveSection() === 'builder'}
{ e.preventDefault(); navigate('/builders/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/contributions') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -202,7 +202,7 @@ Contributions { e.preventDefault(); navigate('/builders/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/leaderboard') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -211,7 +211,7 @@ Leaderboard { e.preventDefault(); navigate('/builders/resources'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/resources') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -246,7 +246,7 @@ {#if !collapsed && getActiveSection() === 'validator'}
{ e.preventDefault(); navigate('/validators/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/contributions') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -255,7 +255,7 @@ Contributions { e.preventDefault(); navigate('/validators/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/leaderboard') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -264,7 +264,7 @@ Leaderboard { e.preventDefault(); navigate('/validators/participants'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/participants') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -273,7 +273,7 @@ Participants { e.preventDefault(); navigate('/validators/waitlist'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/waitlist') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -308,7 +308,7 @@ {#if !collapsed && getActiveSection() === 'community'}
{ e.preventDefault(); navigate('/community/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/contributions') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -317,7 +317,7 @@ Contributions { e.preventDefault(); navigate('/community/referrals'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/referrals') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -326,7 +326,7 @@ Referrals { e.preventDefault(); navigate('/community/poaps'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/poaps') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -362,7 +362,7 @@ {#if !collapsed && getActiveSection() === 'steward' && $userStore.user?.steward}
{ e.preventDefault(); navigate('/stewards/submissions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/submissions') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -371,7 +371,7 @@ Contribution Submissions { e.preventDefault(); navigate('/stewards/manage-users'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/manage-users') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -566,7 +566,7 @@ {#if getActiveSection() === 'global'}
{ e.preventDefault(); navigate('/testnets'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/testnets') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -575,7 +575,7 @@ Testnets { e.preventDefault(); navigate('/metrics'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/metrics') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -608,7 +608,7 @@ {#if getActiveSection() === 'builder'}
{ e.preventDefault(); navigate('/builders/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/contributions') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -617,7 +617,7 @@ Contributions { e.preventDefault(); navigate('/builders/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/leaderboard') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -626,7 +626,7 @@ Leaderboard { e.preventDefault(); navigate('/builders/resources'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/resources') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -654,7 +654,7 @@ {#if getActiveSection() === 'validator'}
{ e.preventDefault(); navigate('/validators/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/contributions') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -663,7 +663,7 @@ Contributions { e.preventDefault(); navigate('/validators/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/leaderboard') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -672,7 +672,7 @@ Leaderboard { e.preventDefault(); navigate('/validators/participants'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/participants') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -681,7 +681,7 @@ Participants { e.preventDefault(); navigate('/validators/waitlist'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/waitlist') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -709,7 +709,7 @@ {#if getActiveSection() === 'community'}
{ e.preventDefault(); navigate('/community/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/contributions') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -718,7 +718,7 @@ Contributions { e.preventDefault(); navigate('/community/referrals'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/referrals') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -727,7 +727,7 @@ Referrals { e.preventDefault(); navigate('/community/poaps'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/poaps') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -755,7 +755,7 @@ {#if getActiveSection() === 'steward' && $userStore.user?.steward}
{ e.preventDefault(); navigate('/stewards/submissions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/submissions') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -764,7 +764,7 @@ Contribution Submissions { e.preventDefault(); navigate('/stewards/manage-users'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/manage-users') ? 'border-[#19A663]' : 'border-[#f5f5f5]' diff --git a/frontend/src/lib/normalizePath.js b/frontend/src/lib/normalizePath.js new file mode 100644 index 00000000..0f24e838 --- /dev/null +++ b/frontend/src/lib/normalizePath.js @@ -0,0 +1,84 @@ +/** + * Path-to-hash route normalization for the hash-based portal router. + * + * The portal uses `svelte-spa-router`, which resolves routes from the URL hash + * (e.g. `/#/testnets`). Several entry points still produce plain path URLs + * (e.g. `/testnets`, `/metrics`): sidebar `href` attributes, links opened in a + * new tab, refreshes of a path-based route, and shared/indexed links. When such + * a URL is opened directly, the hash is empty, the router has nothing to match, + * and the user lands on a 404 / NotFound view. + * + * This module decides whether a plain path URL should be rewritten into its + * hash equivalent so the router can resolve it. It is deliberately + * route-agnostic: any unknown path is forwarded to the router, which renders + * its own NotFound for genuinely missing routes. Static assets and known + * server-handled prefixes (OAuth, API) are left untouched. + */ + +// Prefixes that are handled outside the SPA router and must never be +// rewritten into hash routes. +const RESERVED_PREFIXES = ['/api', '/oauth', '/static', '/assets', '/media']; + +/** + * Returns true when `pathname` looks like a request for a static file, e.g. + * `/favicon.ico`, `/robots.txt`, `/assets/app.123.js`. Such requests must not + * be turned into hash routes. We treat a final path segment that contains a + * dot as a file request. + * + * @param {string} pathname + * @returns {boolean} + */ +function looksLikeStaticFile(pathname) { + const lastSegment = pathname.split('/').pop() || ''; + return lastSegment.includes('.'); +} + +/** + * Given a `window.location`-like object, compute the normalized URL the app + * should switch to, or `null` when no normalization is needed. + * + * Normalization applies when ALL of the following hold: + * - there is no existing hash (a hash route is already resolvable), and + * - the path is not the root `/` (root maps to `#/` implicitly), and + * - the path is not a reserved/server-handled prefix, and + * - the path does not look like a static file request. + * + * The returned URL preserves the original query string and moves the path into + * the hash, e.g. `/testnets?foo=1` -> `/#/testnets?foo=1`. + * + * @param {{ pathname: string, hash?: string, search?: string }} location + * @returns {string | null} the URL to replace, or null if no change is needed + */ +export function computeNormalizedUrl(location) { + const pathname = location.pathname || '/'; + const hash = location.hash || ''; + const search = location.search || ''; + + if (hash) return null; + if (pathname === '/' || pathname === '') return null; + if (RESERVED_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return null; + if (looksLikeStaticFile(pathname)) return null; + + return `/#${pathname}${search}`; +} + +/** + * Side-effecting helper for use at app startup. Rewrites the current URL via + * `history.replaceState` when normalization is needed, so the hash router can + * resolve direct/path-based links without a full navigation or 404. + * + * Safe to call when `window`/`history` are unavailable (e.g. SSR/tests): it + * simply does nothing and returns false. + * + * @param {Window} [win=window] + * @returns {boolean} true if the URL was rewritten + */ +export function normalizeLocation(win = typeof window !== 'undefined' ? window : undefined) { + if (!win || !win.location || !win.history) return false; + + const target = computeNormalizedUrl(win.location); + if (!target) return false; + + win.history.replaceState({}, '', target); + return true; +} diff --git a/frontend/src/tests/normalizePath.test.js b/frontend/src/tests/normalizePath.test.js new file mode 100644 index 00000000..e2ae4b8a --- /dev/null +++ b/frontend/src/tests/normalizePath.test.js @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { computeNormalizedUrl, normalizeLocation } from '../lib/normalizePath.js'; + +describe('computeNormalizedUrl', () => { + it('rewrites a plain path route into its hash equivalent', () => { + expect(computeNormalizedUrl({ pathname: '/testnets', hash: '', search: '' })) + .toBe('/#/testnets'); + expect(computeNormalizedUrl({ pathname: '/metrics', hash: '', search: '' })) + .toBe('/#/metrics'); + }); + + it('preserves the query string when rewriting', () => { + expect(computeNormalizedUrl({ pathname: '/metrics', hash: '', search: '?range=30d' })) + .toBe('/#/metrics?range=30d'); + }); + + it('rewrites nested path routes', () => { + expect(computeNormalizedUrl({ pathname: '/builders/leaderboard', hash: '', search: '' })) + .toBe('/#/builders/leaderboard'); + }); + + it('returns null when a hash is already present', () => { + expect(computeNormalizedUrl({ pathname: '/testnets', hash: '#/testnets', search: '' })) + .toBeNull(); + expect(computeNormalizedUrl({ pathname: '/', hash: '#/metrics', search: '' })) + .toBeNull(); + }); + + it('returns null for the root path', () => { + expect(computeNormalizedUrl({ pathname: '/', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '', hash: '', search: '' })).toBeNull(); + }); + + it('leaves reserved/server-handled prefixes untouched', () => { + expect(computeNormalizedUrl({ pathname: '/api/users', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/oauth/callback', hash: '', search: '?code=x' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/assets/app.js', hash: '', search: '' })).toBeNull(); + }); + + it('leaves static file requests untouched', () => { + expect(computeNormalizedUrl({ pathname: '/favicon.ico', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/robots.txt', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/sitemap.xml', hash: '', search: '' })).toBeNull(); + }); + + it('tolerates missing hash/search fields', () => { + expect(computeNormalizedUrl({ pathname: '/leaderboard' })).toBe('/#/leaderboard'); + }); +}); + +describe('normalizeLocation', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('rewrites the URL via history.replaceState when normalization is needed', () => { + const replaceState = vi.fn(); + const win = { + location: { pathname: '/testnets', hash: '', search: '' }, + history: { replaceState }, + }; + + const changed = normalizeLocation(win); + + expect(changed).toBe(true); + expect(replaceState).toHaveBeenCalledWith({}, '', '/#/testnets'); + }); + + it('does nothing when a hash route is already present', () => { + const replaceState = vi.fn(); + const win = { + location: { pathname: '/', hash: '#/testnets', search: '' }, + history: { replaceState }, + }; + + const changed = normalizeLocation(win); + + expect(changed).toBe(false); + expect(replaceState).not.toHaveBeenCalled(); + }); + + it('returns false safely when window/history is unavailable', () => { + expect(normalizeLocation(undefined)).toBe(false); + expect(normalizeLocation({})).toBe(false); + expect(normalizeLocation({ location: { pathname: '/testnets' } })).toBe(false); + }); +});