diff --git a/README.md b/README.md index 4eb4e1e..8fba584 100644 --- a/README.md +++ b/README.md @@ -42,5 +42,9 @@ expects the token to be provided at runtime so it is never bundled into the stat - **Local development** – Either define `POLLI_TOKEN`/`VITE_POLLI_TOKEN` in your shell when running `npm run dev`, add a `` tag to `index.html`, or inject `window.__POLLINATIONS_TOKEN__` before the application bootstraps. +- **Static overrides** – When a dynamic endpoint is unavailable, append a `token` query parameter + to the page URL (e.g. `https://example.github.io/chatdemo/?token=your-secret`). The application + will capture the token, remove it from the visible URL, and apply it to subsequent Pollinations + requests. If the token cannot be resolved the UI remains disabled and an error is shown in the status banner. diff --git a/src/pollinations-client.js b/src/pollinations-client.js index 2fb0975..ea4db81 100644 --- a/src/pollinations-client.js +++ b/src/pollinations-client.js @@ -32,7 +32,13 @@ async function ensureToken() { } async function resolveToken() { - const attempts = [fetchTokenFromApi, readTokenFromMeta, readTokenFromWindow, readTokenFromEnv]; + const attempts = [ + readTokenFromUrl, + readTokenFromMeta, + readTokenFromWindow, + readTokenFromEnv, + fetchTokenFromApi, + ]; const errors = []; for (const attempt of attempts) { @@ -101,6 +107,40 @@ async function fetchTokenFromApi() { } } +function readTokenFromUrl() { + const location = getCurrentLocation(); + if (!location) { + return { token: null, source: 'url', error: new Error('Location is unavailable.') }; + } + + const { url, searchParams, hashParams, rawFragments } = parseLocation(location); + const tokenKeys = new Set(); + const candidates = []; + + collectTokenCandidates(searchParams, tokenKeys, candidates); + collectTokenCandidates(hashParams, tokenKeys, candidates); + + if (candidates.length === 0 && rawFragments.length > 0) { + const regex = /(token[^=:#/?&]*)([:=])([^#&/?]+)/gi; + for (const fragment of rawFragments) { + let match; + while ((match = regex.exec(fragment))) { + tokenKeys.add(match[1]); + candidates.push(match[3]); + } + } + } + + const token = extractTokenValue(candidates); + if (!token) { + return { token: null, source: 'url' }; + } + + sanitizeUrlToken(location, url, tokenKeys); + + return { token, source: 'url' }; +} + function readTokenFromMeta() { if (typeof document === 'undefined') { return { token: null, source: 'meta', error: new Error('Document is unavailable.') }; @@ -170,6 +210,156 @@ function readTokenFromEnv() { return { token, source: 'env' }; } +function getCurrentLocation() { + if (typeof window !== 'undefined' && window?.location) { + return window.location; + } + if (typeof globalThis !== 'undefined' && globalThis?.location) { + return globalThis.location; + } + return null; +} + +function parseLocation(location) { + const result = { + url: null, + searchParams: new URLSearchParams(), + hashParams: new URLSearchParams(), + rawFragments: [], + }; + + let baseHref = ''; + if (typeof location.href === 'string' && location.href) { + baseHref = location.href; + } else { + const origin = typeof location.origin === 'string' ? location.origin : 'http://localhost'; + const path = typeof location.pathname === 'string' ? location.pathname : '/'; + const search = typeof location.search === 'string' ? location.search : ''; + const hash = typeof location.hash === 'string' ? location.hash : ''; + baseHref = `${origin.replace(/\/?$/, '')}${path.startsWith('/') ? path : `/${path}`}${search}${hash}`; + } + + try { + const base = typeof location.origin === 'string' && location.origin ? location.origin : undefined; + result.url = base ? new URL(baseHref, base) : new URL(baseHref); + } catch { + try { + result.url = new URL(baseHref, 'http://localhost'); + } catch { + result.url = null; + } + } + + if (result.url) { + result.searchParams = new URLSearchParams(result.url.searchParams); + const hash = typeof result.url.hash === 'string' ? result.url.hash.replace(/^#/, '') : ''; + if (hash) { + result.hashParams = new URLSearchParams(hash); + result.rawFragments.push(hash); + } + } else { + const search = typeof location.search === 'string' ? location.search.replace(/^\?/, '') : ''; + const hash = typeof location.hash === 'string' ? location.hash.replace(/^#/, '') : ''; + result.searchParams = new URLSearchParams(search); + result.hashParams = new URLSearchParams(hash); + if (hash) { + result.rawFragments.push(hash); + } + } + + const hrefFragment = typeof location.href === 'string' ? location.href : ''; + if (hrefFragment) { + result.rawFragments.push(hrefFragment); + } + + return result; +} + +function collectTokenCandidates(params, tokenKeys, candidates) { + if (!params) return; + for (const key of params.keys()) { + if (typeof key !== 'string') continue; + if (!key.toLowerCase().includes('token')) continue; + tokenKeys.add(key); + const values = params.getAll(key); + for (const value of values) { + candidates.push(value); + } + } +} + +function sanitizeUrlToken(location, url, tokenKeys) { + if (!location || !tokenKeys || tokenKeys.size === 0) { + return; + } + + const effectiveUrl = url ?? parseLocation(location).url; + if (!effectiveUrl) { + return; + } + + let modified = false; + for (const key of tokenKeys) { + if (effectiveUrl.searchParams.has(key)) { + effectiveUrl.searchParams.delete(key); + modified = true; + } + } + + const originalHash = effectiveUrl.hash; + if (typeof originalHash === 'string' && originalHash.length > 1) { + const hashParams = new URLSearchParams(originalHash.slice(1)); + let hashModified = false; + for (const key of tokenKeys) { + if (hashParams.has(key)) { + hashParams.delete(key); + hashModified = true; + } + } + if (hashModified) { + const nextHash = hashParams.toString(); + effectiveUrl.hash = nextHash ? `#${nextHash}` : ''; + modified = true; + } + } + + if (!modified) { + return; + } + + const history = + (typeof window !== 'undefined' && window?.history) || + (typeof globalThis !== 'undefined' && globalThis?.history) || + null; + const nextUrl = effectiveUrl.toString(); + + if (history?.replaceState) { + try { + history.replaceState(history.state ?? null, '', nextUrl); + return; + } catch { + // ignore history errors + } + } + + if (typeof location.assign === 'function') { + try { + location.assign(nextUrl); + return; + } catch { + // ignore assignment errors + } + } + + if ('href' in location) { + try { + location.href = nextUrl; + } catch { + // ignore inability to mutate href + } + } +} + function determineDevelopmentEnvironment(importMetaEnv, processEnv) { if (importMetaEnv && typeof importMetaEnv.DEV !== 'undefined') { return !!importMetaEnv.DEV; diff --git a/tests/pollinations-token-url.test.mjs b/tests/pollinations-token-url.test.mjs new file mode 100644 index 0000000..2cf7d5b --- /dev/null +++ b/tests/pollinations-token-url.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { createPollinationsClient, __testing } from '../src/pollinations-client.js'; + +export const name = 'Pollinations client resolves tokens from URL parameters'; + +function createStubResponse(status = 404) { + return { + status, + ok: status >= 200 && status < 300, + headers: { + get() { + return null; + }, + }, + async json() { + return {}; + }, + async text() { + return ''; + }, + }; +} + +export async function run() { + const originalFetch = globalThis.fetch; + const originalWindow = globalThis.window; + const originalDocument = globalThis.document; + const originalLocation = globalThis.location; + const originalHistory = globalThis.history; + + try { + globalThis.fetch = async () => createStubResponse(404); + + const url = new URL('https://demo.example.com/chat/?foo=bar&token=url-token#pane'); + + const location = { + href: url.toString(), + origin: url.origin, + pathname: url.pathname, + search: url.search, + hash: url.hash, + }; + + const historyCalls = []; + const history = { + state: null, + replaceState(state, _title, newUrl) { + this.state = state; + historyCalls.push(newUrl); + const parsed = new URL(newUrl); + location.href = parsed.toString(); + location.search = parsed.search; + location.hash = parsed.hash; + }, + }; + + globalThis.window = { + location, + history, + }; + globalThis.location = location; + globalThis.history = history; + globalThis.document = { + querySelector() { + return null; + }, + location: { origin: url.origin }, + }; + + __testing.resetTokenCache(); + + const { client, tokenSource } = await createPollinationsClient(); + assert.equal(tokenSource, 'url'); + + const token = await client._auth.getToken(); + assert.equal(token, 'url-token'); + + assert.ok(historyCalls.length >= 1, 'history.replaceState should be invoked to clean the URL'); + const cleanedUrl = new URL(location.href); + assert.equal(cleanedUrl.searchParams.has('token'), false, 'token should be stripped from the query string'); + } finally { + if (originalFetch) { + globalThis.fetch = originalFetch; + } else { + delete globalThis.fetch; + } + + if (typeof originalWindow === 'undefined') { + delete globalThis.window; + } else { + globalThis.window = originalWindow; + } + + if (typeof originalLocation === 'undefined') { + delete globalThis.location; + } else { + globalThis.location = originalLocation; + } + + if (typeof originalDocument === 'undefined') { + delete globalThis.document; + } else { + globalThis.document = originalDocument; + } + + if (typeof originalHistory === 'undefined') { + delete globalThis.history; + } else { + globalThis.history = originalHistory; + } + + __testing.resetTokenCache(); + } +}