diff --git a/website/snap/src/App.tsx b/website/snap/src/App.tsx index b253e28744..f50ba91bd0 100644 --- a/website/snap/src/App.tsx +++ b/website/snap/src/App.tsx @@ -47,6 +47,22 @@ async function copyText(value: string) { return copied; } +const APP_DETAIL_HISTORY_STATE = 'caposAppDetail'; + +function appNameFromLocation() { + return new URLSearchParams(window.location.search).get('app')?.trim() || null; +} + +function updateAppLocation(name: string | null, mode: 'push' | 'replace' = 'push') { + const url = new URL(window.location.href); + if (name) url.searchParams.set('app', name); + else url.searchParams.delete('app'); + const next = `${url.pathname}${url.search}${url.hash}`; + const currentState = window.history.state && typeof window.history.state === 'object' ? window.history.state : {}; + if (mode === 'push') window.history.pushState({ ...currentState, [APP_DETAIL_HISTORY_STATE]: true }, '', next); + else window.history.replaceState(currentState, '', next); +} + type AppAction = { kind: 'get' | 'install' | 'open' | 'installing'; progress?: number; @@ -235,6 +251,28 @@ function Storefront() { const [visibleCatalogCount, setVisibleCatalogCount] = useState(36); const [catalogLoading, setCatalogLoading] = useState(true); const [copiedInstall, setCopiedInstall] = useState(null); + useEffect(() => { + const openFromLocation = () => { + const name = appNameFromLocation(); + if (!name) { + setSelected(null); + setDetailLoadingName(null); + return; + } + setSelected(current => current?.name === name ? current : null); + setDetailLoadingName(name); + void getAppDetails(name).then(detail => { + if (appNameFromLocation() === name) setSelected(detail); + }).catch(() => { + if (appNameFromLocation() === name) setSelected(null); + }).finally(() => { + setDetailLoadingName(current => current === name ? null : current); + }); + }; + openFromLocation(); + window.addEventListener('popstate', openFromLocation); + return () => window.removeEventListener('popstate', openFromLocation); + }, []); useEffect(() => { let cancelled = false; let retryTimer: number | undefined; @@ -281,6 +319,7 @@ function Storefront() { setSearchLoading(Boolean(value.trim())); }; const openApp = (app: StoreApp) => { + updateAppLocation(app.name, appNameFromLocation() === app.name ? 'replace' : 'push'); setSelected(app); setDetailLoadingName(app.name); void getAppDetails(app.name).then(detail => { @@ -289,7 +328,13 @@ function Storefront() { setDetailLoadingName(current => current === app.name ? null : current); }); }; - const closeDetail = () => { setSelected(null); setDetailLoadingName(null); }; + const closeDetail = () => { + setSelected(null); + setDetailLoadingName(null); + if (!appNameFromLocation()) return; + if (window.history.state?.[APP_DETAIL_HISTORY_STATE]) window.history.back(); + else updateAppLocation(null, 'replace'); + }; const actionFor = (app: StoreApp, detail = false): AppAction => { if (!webdesktop.connected) { return detail diff --git a/website/snap/worker/index.ts b/website/snap/worker/index.ts index 6cafa06195..9b6822a2cb 100644 --- a/website/snap/worker/index.ts +++ b/website/snap/worker/index.ts @@ -13,6 +13,7 @@ type JsonValue = Record | unknown[]; const encoder = new TextEncoder(); const json = (value: JsonValue, status = 200, headers: HeadersInit = {}) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers } }); const now = () => Math.floor(Date.now() / 1000); +const SNAP_PACKAGE_NAME_RE = /^[a-z0-9][a-z0-9+.-]{0,62}$/; function ipOf(request: Request) { return request.headers.get('CF-Connecting-IP') || 'unknown'; } function safeVersion(value: string | null, env: Env) { const v = value || env.DEFAULT_VERSION || 'rolling'; return /^[A-Za-z0-9._-]{1,64}$/.test(v) ? v : env.DEFAULT_VERSION || 'rolling'; } async function body(request: Request): Promise { return request.json() as Promise; } @@ -385,7 +386,7 @@ async function searchStore(request: Request, env: Env) { async function appDetail(request: Request, env: Env) { const url = new URL(request.url); const version = safeVersion(url.searchParams.get('version'), env); const name = (url.searchParams.get('name') || '').trim().toLowerCase(); - if (!/^[a-z0-9][a-z0-9-]{0,79}$/.test(name)) return json({ error: 'Invalid app name.' }, 400); + if (!SNAP_PACKAGE_NAME_RE.test(name)) return json({ error: 'Invalid app name.' }, 400); const locals = await localApps(env, version); const local = locals.find(app => app.name === name); if (local) return json({ app: local }, 200, { 'cache-control': 'public, max-age=60' }); const sources = await upstreams(env, version); const first = sources.find(source => source.enabled && source.kind === 'canonical'); @@ -437,7 +438,7 @@ function validSnapObjectPath(objectPath: string, version: string) { async function startPackageUpload(request: Request, env: Env) { const input = await body<{ version: string; name: string; versionString: string; architecture: string; size: number }>(request); const version = safeVersion(input.version, env); - if (!/^[a-z0-9][a-z0-9+.-]{0,62}$/.test(input.name)) return json({ error: 'Invalid Snap package name.' }, 400); + if (!SNAP_PACKAGE_NAME_RE.test(input.name)) return json({ error: 'Invalid Snap package name.' }, 400); if (!/^[A-Za-z0-9._+~-]{1,80}$/.test(input.versionString)) return json({ error: 'Invalid version string.' }, 400); if (!['amd64', 'arm64', 'armhf', 'all'].includes(input.architecture)) return json({ error: 'Invalid architecture.' }, 400); if (!Number.isSafeInteger(input.size) || input.size <= 0) return json({ error: 'Invalid package size.' }, 400); diff --git a/website/snap/worker/store-proxy.test.mjs b/website/snap/worker/store-proxy.test.mjs index 4f68c176d9..e5ba2e8e4c 100644 --- a/website/snap/worker/store-proxy.test.mjs +++ b/website/snap/worker/store-proxy.test.mjs @@ -10,6 +10,8 @@ const upstreamRows = [{ enabled: 1, }]; +let localRows = []; + const env = { DEFAULT_VERSION: 'rolling', REPO_PUBLIC_BASE: 'https://repo.capos.top', @@ -19,7 +21,7 @@ const env = { if (/FROM apps a JOIN snap_revisions/.test(sql)) { return { bind() { - return { all: async () => ({ results: [] }) }; + return { all: async () => ({ results: localRows }) }; }, }; } @@ -195,6 +197,28 @@ try { assert.equal(detail.links.find(link => link.label === 'Contact')?.url, 'mailto:support@nextcloud.com'); assert.equal(detail.storeUrl, 'https://snapcraft.io/nextcloud'); + localRows = [{ + id: 'local-special-name', + name: 'local+tool.test', + display_name: 'Local Tool', + publisher: 'CapOS', + summary: 'Local package with Snap-compatible punctuation.', + description: 'Local package detail.', + category: 'Utilities', + icon_url: null, + accent: '#000000', + verified: 1, + featured: 0, + snap_version: '1.0.0', + channel: 'stable', + architecture: 'amd64', + webdesktop_mode: 'service', + }]; + const localDetailResponse = await worker.fetch(new Request('https://snap.capos.top/api/app?version=rolling&name=local%2Btool.test'), env, ctx); + assert.equal(localDetailResponse.status, 200); + assert.equal((await localDetailResponse.json()).app.name, 'local+tool.test'); + localRows = []; + assert.equal(new URL(upstreamRequests[0].url).origin, 'https://api.snapcraft.io'); assert.equal(upstreamRequests[0].headers.get('Snap-Device-Series'), '16'); console.log('snap store proxy tests: PASS');