Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta name="pollinations-token" ...>` 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.
192 changes: 191 additions & 1 deletion src/pollinations-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.') };
Expand Down Expand Up @@ -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;
Expand Down
114 changes: 114 additions & 0 deletions tests/pollinations-token-url.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
}