diff --git a/packages/astro/src/index.ts b/packages/astro/src/index.ts index aaa43c7b9..96d43a640 100644 --- a/packages/astro/src/index.ts +++ b/packages/astro/src/index.ts @@ -36,6 +36,11 @@ import "@keystatic/core/ui"; ` ); + // Astro's `base` config already prefixes injected route patterns, + // so we use bare /keystatic and /api/keystatic patterns here. + // The config.basePath option in @keystatic/core handles the + // runtime path resolution for fetch calls, redirects, etc. + injectRoute({ // @ts-ignore entryPoint: '@keystatic/astro/internal/keystatic-astro-page.astro', diff --git a/packages/keystatic/src/api/api-node.ts b/packages/keystatic/src/api/api-node.ts index a1b9d65cc..b9dc740b8 100644 --- a/packages/keystatic/src/api/api-node.ts +++ b/packages/keystatic/src/api/api-node.ts @@ -8,6 +8,7 @@ import { redirect, } from './internal-utils'; import { readToDirEntries, getAllowedDirectories } from './read-local'; +import { getKeystaticBasePath } from '../app/utils'; import { blobSha } from '../app/trees'; import { randomBytes } from 'node:crypto'; import { base64UrlDecode } from '#base64'; @@ -34,7 +35,8 @@ const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); export async function handleGitHubAppCreation( req: KeystaticRequest, - slugEnvVarName: string | undefined + slugEnvVarName: string | undefined, + config?: Config ): Promise { const searchParams = new URL(req.url, 'https://localhost').searchParams; const code = searchParams.get('code'); @@ -85,7 +87,8 @@ ${ const newEnv = prevEnv ? `${prevEnv}\n\n${toAddToEnv}` : toAddToEnv; await fs.writeFile('.env', newEnv); await wait(200); - return redirect('/keystatic/created-github-app?slug=' + ghAppDataResult.slug); + const uiBase = config ? getKeystaticBasePath(config) : '/keystatic'; + return redirect(`${uiBase}/created-github-app?slug=` + ghAppDataResult.slug); } export function localModeApiHandler( diff --git a/packages/keystatic/src/api/generic.test.ts b/packages/keystatic/src/api/generic.test.ts new file mode 100644 index 000000000..48fc394b5 --- /dev/null +++ b/packages/keystatic/src/api/generic.test.ts @@ -0,0 +1,353 @@ +/** @jest-environment node */ +import { expect, test, describe, jest, beforeEach, afterEach } from '@jest/globals'; +import { makeGenericAPIRouteHandler } from './generic'; +import { Config } from '../config'; +import { KeystaticRequest, KeystaticResponse } from './internal-utils'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Creates a minimal KeystaticRequest for testing route matching. */ +function makeRequest( + url: string, + options?: { method?: string; headers?: Record } +): KeystaticRequest { + const headers = new Map(Object.entries(options?.headers ?? {})); + return { + url, + method: options?.method ?? 'GET', + headers: { get: (name: string) => headers.get(name.toLowerCase()) ?? null }, + json: async () => ({}), + }; +} + +function makeGitHubConfig(basePath?: string): Config { + return { + storage: { kind: 'github', repo: 'owner/repo' }, + basePath, + } as Config; +} + +function makeLocalConfig(basePath?: string): Config { + return { + storage: { kind: 'local' }, + basePath, + } as Config; +} + +function makeCloudConfig(basePath?: string): Config { + return { + storage: { kind: 'cloud' }, + cloud: { project: 'team/project' }, + basePath, + } as Config; +} + +/** Extract Location header from a redirect response. */ +function getLocation(res: KeystaticResponse): string | undefined { + if (!Array.isArray(res.headers)) return undefined; + const entry = (res.headers as [string, string][]).find( + ([key]) => key === 'Location' + ); + return entry?.[1]; +} + +// --------------------------------------------------------------------------- +// GitHub storage: route matching with basePath +// --------------------------------------------------------------------------- + +describe('makeGenericAPIRouteHandler — GitHub storage route matching', () => { + const secret = 'a'.repeat(40); + + function makeHandler(basePath?: string) { + return makeGenericAPIRouteHandler({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + secret, + config: makeGitHubConfig(basePath), + }); + } + + describe('without basePath (default paths)', () => { + test('login route redirects to GitHub OAuth', async () => { + const handler = makeHandler(); + const res = await handler( + makeRequest('https://example.com/api/keystatic/github/login') + ); + expect(res.status).toBe(307); + const location = getLocation(res); + expect(location).toContain('github.com/login/oauth/authorize'); + }); + + test('login redirect_uri uses default /api/keystatic prefix', async () => { + const handler = makeHandler(); + const res = await handler( + makeRequest('https://example.com/api/keystatic/github/login') + ); + const location = getLocation(res); + expect(location).toContain( + encodeURIComponent( + 'https://example.com/api/keystatic/github/oauth/callback' + ) + ); + }); + + test('unmatched route returns 404', async () => { + const handler = makeHandler(); + const res = await handler( + makeRequest('https://example.com/api/keystatic/nonexistent') + ); + expect(res.status).toBe(404); + }); + }); + + describe('with basePath="/blog"', () => { + test('login route matches under /blog/api/keystatic/', async () => { + const handler = makeHandler('/blog'); + const res = await handler( + makeRequest('https://example.com/blog/api/keystatic/github/login') + ); + expect(res.status).toBe(307); + const location = getLocation(res); + expect(location).toContain('github.com/login/oauth/authorize'); + }); + + test('login redirect_uri includes basePath', async () => { + const handler = makeHandler('/blog'); + const res = await handler( + makeRequest('https://example.com/blog/api/keystatic/github/login') + ); + const location = getLocation(res); + expect(location).toContain( + encodeURIComponent( + 'https://example.com/blog/api/keystatic/github/oauth/callback' + ) + ); + }); + + test('unmatched route returns 404', async () => { + const handler = makeHandler('/blog'); + const res = await handler( + makeRequest('https://example.com/blog/api/keystatic/nonexistent') + ); + expect(res.status).toBe(404); + }); + }); + + describe('with deeply nested basePath', () => { + test('routes match under nested prefix', async () => { + const handler = makeHandler('/app/sub'); + const res = await handler( + makeRequest( + 'https://example.com/app/sub/api/keystatic/github/login' + ) + ); + expect(res.status).toBe(307); + const location = getLocation(res); + expect(location).toContain('github.com/login/oauth/authorize'); + }); + + test('redirect_uri includes nested basePath', async () => { + const handler = makeHandler('/app/sub'); + const res = await handler( + makeRequest( + 'https://example.com/app/sub/api/keystatic/github/login' + ) + ); + const location = getLocation(res); + expect(location).toContain( + encodeURIComponent( + 'https://example.com/app/sub/api/keystatic/github/oauth/callback' + ) + ); + }); + }); +}); + +// --------------------------------------------------------------------------- +// GitHub storage: redirect paths use basePath +// --------------------------------------------------------------------------- + +describe('makeGenericAPIRouteHandler — redirect paths', () => { + const secret = 'a'.repeat(40); + + describe('logout redirects to UI basePath', () => { + test('without basePath, redirects to /keystatic', async () => { + const handler = makeGenericAPIRouteHandler({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + secret, + config: makeGitHubConfig(), + }); + // Mock fetch so the DELETE to GitHub doesn't actually fire + const originalFetch = globalThis.fetch; + globalThis.fetch = jest.fn(() => + Promise.resolve(new Response(null, { status: 204 })) + ) as any; + try { + const res = await handler( + makeRequest('https://example.com/api/keystatic/github/logout', { + headers: { + cookie: 'keystatic-gh-access-token=fake-token', + }, + }) + ); + expect(res.status).toBe(307); + expect(getLocation(res)).toBe('/keystatic'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test('with basePath="/blog", redirects to /blog/keystatic', async () => { + const handler = makeGenericAPIRouteHandler({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + secret, + config: makeGitHubConfig('/blog'), + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = jest.fn(() => + Promise.resolve(new Response(null, { status: 204 })) + ) as any; + try { + const res = await handler( + makeRequest( + 'https://example.com/blog/api/keystatic/github/logout', + { + headers: { + cookie: 'keystatic-gh-access-token=fake-token', + }, + } + ) + ); + expect(res.status).toBe(307); + expect(getLocation(res)).toBe('/blog/keystatic'); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// GitHub storage (dev mode, missing credentials): redirect paths +// --------------------------------------------------------------------------- + +describe('makeGenericAPIRouteHandler — dev mode (missing credentials)', () => { + const originalNodeEnv = process.env.NODE_ENV; + + beforeEach(() => { + process.env.NODE_ENV = 'development'; + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + test('login redirects to /keystatic/setup without basePath', async () => { + const handler = makeGenericAPIRouteHandler({ + config: makeGitHubConfig(), + }); + const res = await handler( + makeRequest('https://example.com/api/keystatic/github/login') + ); + expect(res.status).toBe(307); + expect(getLocation(res)).toBe('/keystatic/setup'); + }); + + test('login redirects to /blog/keystatic/setup with basePath="/blog"', async () => { + const handler = makeGenericAPIRouteHandler({ + config: makeGitHubConfig('/blog'), + }); + const res = await handler( + makeRequest('https://example.com/blog/api/keystatic/github/login') + ); + expect(res.status).toBe(307); + expect(getLocation(res)).toBe('/blog/keystatic/setup'); + }); + + test('repo-not-found redirects to setup with basePath', async () => { + const handler = makeGenericAPIRouteHandler({ + config: makeGitHubConfig('/blog'), + }); + const res = await handler( + makeRequest( + 'https://example.com/blog/api/keystatic/github/repo-not-found' + ) + ); + expect(res.status).toBe(307); + expect(getLocation(res)).toBe('/blog/keystatic/setup'); + }); + + test('logout redirects to setup with basePath', async () => { + const handler = makeGenericAPIRouteHandler({ + config: makeGitHubConfig('/blog'), + }); + const res = await handler( + makeRequest('https://example.com/blog/api/keystatic/github/logout') + ); + expect(res.status).toBe(307); + expect(getLocation(res)).toBe('/blog/keystatic/setup'); + }); +}); + +// --------------------------------------------------------------------------- +// Cloud storage: always 404 +// --------------------------------------------------------------------------- + +describe('makeGenericAPIRouteHandler — cloud storage', () => { + test('returns 404 regardless of basePath', async () => { + const handler = makeGenericAPIRouteHandler({ + config: makeCloudConfig('/blog'), + }); + const res = await handler( + makeRequest('https://example.com/blog/api/keystatic/anything') + ); + expect(res.status).toBe(404); + }); + + test('returns 404 without basePath', async () => { + const handler = makeGenericAPIRouteHandler({ + config: makeCloudConfig(), + }); + const res = await handler( + makeRequest('https://example.com/api/keystatic/anything') + ); + expect(res.status).toBe(404); + }); +}); + +// --------------------------------------------------------------------------- +// basePath normalization consistency between API and route matching +// --------------------------------------------------------------------------- + +describe('basePath normalization in route handler', () => { + const secret = 'a'.repeat(40); + + const variants = [ + { input: '/blog', label: 'with leading slash' }, + { input: 'blog', label: 'without leading slash' }, + { input: '/blog/', label: 'with trailing slash' }, + { input: 'blog/', label: 'with both issues' }, + ]; + + for (const { input, label } of variants) { + test(`basePath "${input}" (${label}) correctly matches routes`, async () => { + const handler = makeGenericAPIRouteHandler({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + secret, + config: makeGitHubConfig(input), + }); + // All variants should normalize to /blog, so the URL uses /blog/api/keystatic/ + const res = await handler( + makeRequest('https://example.com/blog/api/keystatic/github/login') + ); + expect(res.status).toBe(307); + const location = getLocation(res); + expect(location).toContain('github.com/login/oauth/authorize'); + }); + } +}); diff --git a/packages/keystatic/src/api/generic.ts b/packages/keystatic/src/api/generic.ts index f74d5e810..1aab9f075 100644 --- a/packages/keystatic/src/api/generic.ts +++ b/packages/keystatic/src/api/generic.ts @@ -10,6 +10,10 @@ import { handleGitHubAppCreation, localModeApiHandler } from '#api-handler'; import { webcrypto } from '#webcrypto'; import { bytesToHex } from '../hex'; import { decryptValue, encryptValue } from './encryption'; +import { + getKeystaticApiBasePath, + getKeystaticBasePath, +} from '../app/utils'; export type APIRouteConfig = { /** @default process.env.KEYSTATIC_GITHUB_CLIENT_ID */ @@ -62,6 +66,12 @@ export function makeGenericAPIRouteHandler( config: _config.config, }; + const apiBasePath = getKeystaticApiBasePath(_config2.config); + const uiBasePath = getKeystaticBasePath(_config2.config); + const stripRegex = new RegExp( + `^${apiBasePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/?` + ); + const getParams = (req: KeystaticRequest) => { let url; try { @@ -76,7 +86,7 @@ export function makeGenericAPIRouteHandler( ); } return url.pathname - .replace(/^\/api\/keystatic\/?/, '') + .replace(stripRegex, '') .split('/') .map(x => decodeURIComponent(x)) .filter(Boolean); @@ -119,14 +129,14 @@ export function makeGenericAPIRouteHandler( const params = getParams(req); const joined = params.join('/'); if (joined === 'github/created-app') { - return createdGithubApp(req, options?.slugEnvName); + return createdGithubApp(req, options?.slugEnvName, _config2.config); } if ( joined === 'github/login' || joined === 'github/repo-not-found' || joined === 'github/logout' ) { - return redirect('/keystatic/setup'); + return redirect(`${uiBasePath}/setup`); } return { status: 404, body: 'Not Found' }; }; @@ -172,7 +182,7 @@ export function makeGenericAPIRouteHandler( } ); } - return redirect('/keystatic', [ + return redirect(uiBasePath, [ ['Set-Cookie', immediatelyExpiringCookie('keystatic-gh-access-token')], ['Set-Cookie', immediatelyExpiringCookie('keystatic-gh-refresh-token')], ]); @@ -252,7 +262,7 @@ async function githubOauthCallback( status: 200, }; } - return redirect(`/keystatic${from ? `/${from}` : ''}`, headers); + return redirect(`${getKeystaticBasePath(config.config)}${from ? `/${from}` : ''}`, headers); } async function getTokenCookies( @@ -355,7 +365,7 @@ async function githubRepoNotFound( ): Promise { const headers = await refreshGitHubAuth(req, config); if (headers) { - return redirect('/keystatic/repo-not-found', headers); + return redirect(`${getKeystaticBasePath(config.config)}/repo-not-found`, headers); } return githubLogin(req, config); } @@ -375,7 +385,7 @@ async function githubLogin( url.searchParams.set('client_id', config.clientId); url.searchParams.set( 'redirect_uri', - `${reqUrl.origin}/api/keystatic/github/oauth/callback` + `${reqUrl.origin}${getKeystaticApiBasePath(config.config)}/github/oauth/callback` ); if (from === '/') { return redirect(url.toString()); @@ -399,12 +409,13 @@ async function githubLogin( async function createdGithubApp( req: KeystaticRequest, - slugEnvVarName: string | undefined + slugEnvVarName: string | undefined, + config?: Config ): Promise { if (process.env.NODE_ENV !== 'development') { return { status: 400, body: 'App setup only allowed in development' }; } - return handleGitHubAppCreation(req, slugEnvVarName); + return handleGitHubAppCreation(req, slugEnvVarName, config); } function immediatelyExpiringCookie(name: string) { diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx index 7da56b782..809d92d45 100644 --- a/packages/keystatic/src/app/ItemPage.tsx +++ b/packages/keystatic/src/app/ItemPage.tsx @@ -154,6 +154,7 @@ function ItemPageInner( initialFiles: props.initialFiles, storage: config.storage, basePath: currentBasePath, + config, }); const onDelete = useEventCallback(async () => { @@ -298,7 +299,7 @@ function ItemPageInner( { - const itemBasePath = `/keystatic/branch/${encodeURIComponent( + const itemBasePath = `${props.basePath}/branch/${encodeURIComponent( newBranch )}/collection/${encodeURIComponent(collection)}/item/`; router.push(itemBasePath + encodeURIComponent(itemSlug)); diff --git a/packages/keystatic/src/app/SingletonPage.tsx b/packages/keystatic/src/app/SingletonPage.tsx index e64eef51e..19132f5ea 100644 --- a/packages/keystatic/src/app/SingletonPage.tsx +++ b/packages/keystatic/src/app/SingletonPage.tsx @@ -30,6 +30,7 @@ import { isGitHubConfig, useShowRestoredDraftMessage, } from './utils'; +import { useAppState } from './shell/context'; import { CreateBranchDuringUpdateDialog } from './ItemPage'; import { PageBody, PageHeader, PageRoot } from './shell/page'; @@ -93,6 +94,7 @@ function SingletonPageInner( const { schema, singletonConfig } = useSingleton(props.singleton); const router = useRouter(); + const { basePath } = useAppState(); const previewHref = useMemo(() => { if (!singletonConfig.previewUrl) return undefined; @@ -305,7 +307,7 @@ function SingletonPageInner( branchOid={baseCommit} onCreate={async newBranch => { router.push( - `/keystatic/branch/${encodeURIComponent( + `${basePath}/branch/${encodeURIComponent( newBranch )}/singleton/${encodeURIComponent(props.singleton)}` ); diff --git a/packages/keystatic/src/app/auth.ts b/packages/keystatic/src/app/auth.ts index 7eee7e98a..312a5d62a 100644 --- a/packages/keystatic/src/app/auth.ts +++ b/packages/keystatic/src/app/auth.ts @@ -1,6 +1,7 @@ import { parse } from 'cookie'; import * as s from 'superstruct'; import { Config } from '../config'; +import { getKeystaticApiBasePath } from './utils'; const storedTokenSchema = s.object({ token: s.string(), @@ -54,9 +55,10 @@ export async function getAuth(config: Config) { if (config.storage.kind === 'github' && !token) { if (!_refreshTokenPromise) { + const apiBase = getKeystaticApiBasePath(config); _refreshTokenPromise = (async () => { try { - const res = await fetch('/api/keystatic/github/refresh-token', { + const res = await fetch(`${apiBase}/github/refresh-token`, { method: 'POST', }); if (res.status === 200) { diff --git a/packages/keystatic/src/app/base-path.test.ts b/packages/keystatic/src/app/base-path.test.ts new file mode 100644 index 000000000..d061a4cb4 --- /dev/null +++ b/packages/keystatic/src/app/base-path.test.ts @@ -0,0 +1,177 @@ +/** @jest-environment node */ +import { expect, test, describe } from '@jest/globals'; +import { + getConfigBasePath, + getKeystaticBasePath, + getKeystaticApiBasePath, +} from './utils'; +import { Config } from '../config'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Minimal Config object for testing basePath utilities. */ +function makeConfig(basePath?: string): Config { + return { + storage: { kind: 'local' }, + basePath, + } as Config; +} + +// --------------------------------------------------------------------------- +// getConfigBasePath +// --------------------------------------------------------------------------- + +describe('getConfigBasePath', () => { + test('returns empty string when basePath is undefined', () => { + expect(getConfigBasePath(makeConfig())).toBe(''); + }); + + test('returns empty string when basePath is empty string', () => { + expect(getConfigBasePath(makeConfig(''))).toBe(''); + }); + + test('preserves leading slash', () => { + expect(getConfigBasePath(makeConfig('/blog'))).toBe('/blog'); + }); + + test('adds leading slash when missing', () => { + expect(getConfigBasePath(makeConfig('blog'))).toBe('/blog'); + }); + + test('strips trailing slash', () => { + expect(getConfigBasePath(makeConfig('/blog/'))).toBe('/blog'); + }); + + test('handles both missing leading and extra trailing slash', () => { + expect(getConfigBasePath(makeConfig('blog/'))).toBe('/blog'); + }); + + test('handles deeply nested paths', () => { + expect(getConfigBasePath(makeConfig('/app/sub/path'))).toBe( + '/app/sub/path' + ); + }); + + test('handles deeply nested paths without leading slash', () => { + expect(getConfigBasePath(makeConfig('app/sub/path/'))).toBe( + '/app/sub/path' + ); + }); + + test('handles single slash', () => { + // A basePath of '/' means "root" — same as no prefix + expect(getConfigBasePath(makeConfig('/'))).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// getKeystaticBasePath +// --------------------------------------------------------------------------- + +describe('getKeystaticBasePath', () => { + test('defaults to /keystatic with no basePath', () => { + expect(getKeystaticBasePath(makeConfig())).toBe('/keystatic'); + }); + + test('prefixes with basePath', () => { + expect(getKeystaticBasePath(makeConfig('/blog'))).toBe('/blog/keystatic'); + }); + + test('normalizes basePath before prefixing', () => { + expect(getKeystaticBasePath(makeConfig('blog/'))).toBe('/blog/keystatic'); + }); + + test('works with nested basePath', () => { + expect(getKeystaticBasePath(makeConfig('/app/docs'))).toBe( + '/app/docs/keystatic' + ); + }); +}); + +// --------------------------------------------------------------------------- +// getKeystaticApiBasePath +// --------------------------------------------------------------------------- + +describe('getKeystaticApiBasePath', () => { + test('defaults to /api/keystatic with no basePath', () => { + expect(getKeystaticApiBasePath(makeConfig())).toBe('/api/keystatic'); + }); + + test('prefixes with basePath', () => { + expect(getKeystaticApiBasePath(makeConfig('/blog'))).toBe( + '/blog/api/keystatic' + ); + }); + + test('normalizes basePath before prefixing', () => { + expect(getKeystaticApiBasePath(makeConfig('blog/'))).toBe( + '/blog/api/keystatic' + ); + }); + + test('works with nested basePath', () => { + expect(getKeystaticApiBasePath(makeConfig('/app/docs'))).toBe( + '/app/docs/api/keystatic' + ); + }); +}); + +// --------------------------------------------------------------------------- +// Backwards compatibility: no basePath produces original hardcoded paths +// --------------------------------------------------------------------------- + +describe('backwards compatibility', () => { + const storageKinds: Array = [ + { kind: 'local' }, + { kind: 'github', repo: 'owner/repo' }, + { kind: 'cloud' }, + ]; + + for (const storage of storageKinds) { + const label = storage.kind; + + test(`${label}: no basePath → /keystatic`, () => { + const config = { storage } as Config; + expect(getKeystaticBasePath(config)).toBe('/keystatic'); + }); + + test(`${label}: no basePath → /api/keystatic`, () => { + const config = { storage } as Config; + expect(getKeystaticApiBasePath(config)).toBe('/api/keystatic'); + }); + + test(`${label}: basePath='/blog' → /blog/keystatic`, () => { + const config = { storage, basePath: '/blog' } as Config; + expect(getKeystaticBasePath(config)).toBe('/blog/keystatic'); + }); + + test(`${label}: basePath='/blog' → /blog/api/keystatic`, () => { + const config = { storage, basePath: '/blog' } as Config; + expect(getKeystaticApiBasePath(config)).toBe('/blog/api/keystatic'); + }); + } +}); + +// --------------------------------------------------------------------------- +// Edge cases: special characters in basePath +// --------------------------------------------------------------------------- + +describe('special characters in basePath', () => { + test('path with hyphen', () => { + expect(getKeystaticBasePath(makeConfig('/my-blog'))).toBe( + '/my-blog/keystatic' + ); + }); + + test('path with underscore', () => { + expect(getKeystaticBasePath(makeConfig('/my_blog'))).toBe( + '/my_blog/keystatic' + ); + }); + + test('path with dots', () => { + expect(getKeystaticBasePath(makeConfig('/v1.0'))).toBe('/v1.0/keystatic'); + }); +}); diff --git a/packages/keystatic/src/app/cloud-auth-callback.tsx b/packages/keystatic/src/app/cloud-auth-callback.tsx index cec4eae10..f80f464d8 100644 --- a/packages/keystatic/src/app/cloud-auth-callback.tsx +++ b/packages/keystatic/src/app/cloud-auth-callback.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import * as s from 'superstruct'; import { Config } from '../config'; import { useRouter } from './router'; +import { useAppState } from './shell/context'; import { KEYSTATIC_CLOUD_API_URL, KEYSTATIC_CLOUD_HEADERS } from './utils'; import { Flex } from '@keystar/ui/layout'; @@ -20,6 +21,7 @@ const tokenResponseSchema = s.type({ export function KeystaticCloudAuthCallback({ config }: { config: Config }) { const router = useRouter(); + const { basePath } = useAppState(); const url = new URL(window.location.href); const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); @@ -44,7 +46,7 @@ export function KeystaticCloudAuthCallback({ config }: { config: Config }) { body: new URLSearchParams({ code, client_id: project, - redirect_uri: `${window.location.origin}/keystatic/cloud/oauth/callback`, + redirect_uri: `${window.location.origin}${basePath}/cloud/oauth/callback`, code_verifier: storedState.code_verifier, grant_type: 'authorization_code', }).toString(), @@ -70,12 +72,12 @@ export function KeystaticCloudAuthCallback({ config }: { config: Config }) { validUntil: Date.now() + parsed.expires_in * 1000, }) ); - router.push(`/keystatic/${storedState.from}`); + router.push(`${basePath}/${storedState.from}`); })().catch(error => { setError(error); }); } - }, [code, state, router, storedState, config]); + }, [code, state, router, storedState, config, basePath]); if (!config.cloud?.project) { return Missing Keystatic Cloud config; } diff --git a/packages/keystatic/src/app/create-item.tsx b/packages/keystatic/src/app/create-item.tsx index 0c2a9d8a8..9f99d5698 100644 --- a/packages/keystatic/src/app/create-item.tsx +++ b/packages/keystatic/src/app/create-item.tsx @@ -596,7 +596,7 @@ function CreateItemInner(props: { branchOid={baseCommit} onCreate={async newBranch => { router.push( - `/keystatic/branch/${encodeURIComponent( + `${props.basePath}/branch/${encodeURIComponent( newBranch )}/collection/${encodeURIComponent(props.collection)}/create` ); @@ -606,7 +606,7 @@ function CreateItemInner(props: { const slug = getSlugFromState(collectionConfig, props.state); router.push( - `/keystatic/branch/${encodeURIComponent( + `${props.basePath}/branch/${encodeURIComponent( newBranch )}/collection/${encodeURIComponent( props.collection diff --git a/packages/keystatic/src/app/onboarding/setup.tsx b/packages/keystatic/src/app/onboarding/setup.tsx index 5d1dc58b5..7d07f425e 100644 --- a/packages/keystatic/src/app/onboarding/setup.tsx +++ b/packages/keystatic/src/app/onboarding/setup.tsx @@ -7,8 +7,11 @@ import { TextField } from '@keystar/ui/text-field'; import { Heading, Text } from '@keystar/ui/typography'; import { GitHubConfig } from '../..'; import { parseRepoConfig } from '../repo-config'; +import { getKeystaticApiBasePath, getKeystaticBasePath } from '../utils'; export function KeystaticSetup(props: { config: GitHubConfig }) { + const uiBase = getKeystaticBasePath(props.config); + const apiBase = getKeystaticApiBasePath(props.config); const [deployedURL, setDeployedURL] = useState(''); const [organization, setOrganization] = useState(''); return ( @@ -77,17 +80,17 @@ export function KeystaticSetup(props: { config: GitHubConfig }) { parseRepoConfig(props.config.storage.repo).owner } Keystatic`, url: deployedURL - ? new URL('/keystatic', deployedURL).toString() - : `${window.location.origin}/keystatic`, + ? new URL(uiBase, deployedURL).toString() + : `${window.location.origin}${uiBase}`, public: true, - redirect_url: `${window.location.origin}/api/keystatic/github/created-app`, + redirect_url: `${window.location.origin}${apiBase}/github/created-app`, callback_urls: [ - `${window.location.origin}/api/keystatic/github/oauth/callback`, - `http://127.0.0.1/api/keystatic/github/oauth/callback`, + `${window.location.origin}${apiBase}/github/oauth/callback`, + `http://127.0.0.1${apiBase}/github/oauth/callback`, ...(deployedURL ? [ new URL( - '/api/keystatic/github/oauth/callback', + `${apiBase}/github/oauth/callback`, deployedURL ).toString(), ] diff --git a/packages/keystatic/src/app/presence.tsx b/packages/keystatic/src/app/presence.tsx index 0b2d89142..74fc52057 100644 --- a/packages/keystatic/src/app/presence.tsx +++ b/packages/keystatic/src/app/presence.tsx @@ -3,18 +3,20 @@ import { useAwarenessStates } from './shell/collab'; import { Avatar } from '@keystar/ui/avatar'; import { useCloudInfo } from './shell/data'; import { useRouter } from './router'; +import { useAppState } from './shell/context'; export function PresenceAvatars() { const cloudInfo = useCloudInfo(); const awarenessStates = useAwarenessStates(); const router = useRouter(); + const { basePath } = useAppState(); if (!cloudInfo) return null; return ( {[...awarenessStates.values()].map(val => { if ( !val.user || - router.href !== `/keystatic/branch/${val.branch}/${val.location}` + router.href !== `${basePath}/branch/${val.branch}/${val.location}` ) { return null; } diff --git a/packages/keystatic/src/app/provider.tsx b/packages/keystatic/src/app/provider.tsx index b39da5d08..e9b2902eb 100644 --- a/packages/keystatic/src/app/provider.tsx +++ b/packages/keystatic/src/app/provider.tsx @@ -27,6 +27,7 @@ import { Config } from '../config'; import { ThemeProvider, useTheme } from './shell/theme'; import { parseRepoConfig } from './repo-config'; import { useRouter } from './router'; +import { getKeystaticApiBasePath } from './utils'; // NOTE: scroll behaviour is handled by shell components injectGlobal({ body: { overflow: 'hidden' } }); @@ -69,7 +70,7 @@ export function createUrqlClient(config: Config): Client { !authState ) { if (config.storage.kind === 'github') { - window.location.href = '/api/keystatic/github/login'; + window.location.href = `${getKeystaticApiBasePath(config)}/github/login`; } else { redirectToCloudAuth('', config); } diff --git a/packages/keystatic/src/app/router.tsx b/packages/keystatic/src/app/router.tsx index 30334a13a..9a2ec9851 100644 --- a/packages/keystatic/src/app/router.tsx +++ b/packages/keystatic/src/app/router.tsx @@ -4,8 +4,10 @@ import React, { startTransition, useContext, useEffect, + useMemo, useState, } from 'react'; +import { useAppState } from './shell/context'; export type Router = { push: (path: string) => void; @@ -19,13 +21,21 @@ export type Router = { const RouterContext = createContext(null); export function RouterProvider(props: { children: ReactNode }) { + const { basePath } = useAppState(); const [url, setUrl] = useState(() => window.location.href); + // Build a regex that strips the keystatic base path prefix. + // e.g. basePath='/blog/keystatic' → /^\/blog\/keystatic\/?/ + const stripRegex = useMemo( + () => new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/?`), + [basePath] + ); + function navigate(url: string, replace: boolean) { const newUrl = new URL(url, window.location.href); if ( newUrl.origin !== window.location.origin || - !newUrl.pathname.startsWith('/keystatic') + !newUrl.pathname.startsWith(basePath) ) { window.location.assign(newUrl); return; @@ -42,7 +52,7 @@ export function RouterProvider(props: { children: ReactNode }) { navigate(path, false); } const parsedUrl = new URL(url); - const replaced = parsedUrl.pathname.replace(/^\/keystatic\/?/, ''); + const replaced = parsedUrl.pathname.replace(stripRegex, ''); const params = replaced === '' ? [] : replaced.split('/').map(decodeURIComponent); const router = { diff --git a/packages/keystatic/src/app/shell/context.tsx b/packages/keystatic/src/app/shell/context.tsx index 742017b31..71d95c9db 100644 --- a/packages/keystatic/src/app/shell/context.tsx +++ b/packages/keystatic/src/app/shell/context.tsx @@ -20,10 +20,15 @@ export function useConfig(): Config { // Meta context // ----------------------------------------------------------------------------- -type AppStateType = { basePath: string }; +type AppStateType = { + basePath: string; + /** Base path for API fetch calls (e.g. '/api/keystatic' or '/blog/api/keystatic'). */ + apiBasePath: string; +}; export const AppStateContext = createContext({ basePath: '/keystatic', + apiBasePath: '/api/keystatic', }); export function useAppState() { diff --git a/packages/keystatic/src/app/shell/data.tsx b/packages/keystatic/src/app/shell/data.tsx index d19ac523e..c8fcfec13 100644 --- a/packages/keystatic/src/app/shell/data.tsx +++ b/packages/keystatic/src/app/shell/data.tsx @@ -30,6 +30,7 @@ import { } from '../useData'; import { getEntriesInCollectionWithTreeKey, + getKeystaticApiBasePath, isGitHubConfig, KEYSTATIC_CLOUD_API_URL, KEYSTATIC_CLOUD_HEADERS, @@ -51,11 +52,12 @@ import { import { CollabProvider } from './collab'; import { EmptyRepo } from './empty-repo'; -export function fetchLocalTree(sha: string) { +export function fetchLocalTree(sha: string, config: Config) { if (treeCache.has(sha)) { return treeCache.get(sha)!; } - const promise = fetch('/api/keystatic/tree', { headers: { 'no-cors': '1' } }) + const apiBase = getKeystaticApiBasePath(config); + const promise = fetch(`${apiBase}/tree`, { headers: { 'no-cors': '1' } }) .then(x => x.json()) .then(async (entries: TreeEntry[]) => hydrateTreeCacheWithEntries(entries)); treeCache.set(sha, promise); @@ -77,7 +79,7 @@ export function LocalAppShellProvider(props: { const [currentTreeSha, setCurrentTreeSha] = useState('initial'); const tree = useData( - useCallback(() => fetchLocalTree(currentTreeSha), [currentTreeSha]) + useCallback(() => fetchLocalTree(currentTreeSha, props.config), [currentTreeSha, props.config]) ); const allTreeData = useMemo( @@ -365,10 +367,11 @@ export function GitHubAppShellProvider(props: { return getChangedData(props.config, allTreeData.scoped.merged.data); }, [allTreeData, props.config]); + const apiBase = getKeystaticApiBasePath(props.config); useEffect(() => { if (error?.response?.status === 401) { if (isGitHubConfig(props.config)) { - window.location.href = `/api/keystatic/github/login?from=${router.params + window.location.href = `${apiBase}/github/login?from=${router.params .map(encodeURIComponent) .join('/')}`; } else { @@ -386,11 +389,11 @@ export function GitHubAppShellProvider(props: { (err?.originalError as any)?.type === 'FORBIDDEN' ) ) { - window.location.href = `/api/keystatic/github/repo-not-found?from=${router.params + window.location.href = `${apiBase}/github/repo-not-found?from=${router.params .map(encodeURIComponent) .join('/')}`; } - }, [error, router, repo?.id, props.config]); + }, [error, router, repo?.id, props.config, apiBase]); const branches = useMemo((): Map => { return new Map( repo?.refs?.nodes?.flatMap(x => { diff --git a/packages/keystatic/src/app/shell/index.tsx b/packages/keystatic/src/app/shell/index.tsx index dabfce36e..30878e09f 100644 --- a/packages/keystatic/src/app/shell/index.tsx +++ b/packages/keystatic/src/app/shell/index.tsx @@ -4,7 +4,7 @@ import { alertCircleIcon } from '@keystar/ui/icon/icons/alertCircleIcon'; import { Config } from '../../config'; -import { isGitHubConfig, isLocalConfig } from '../utils'; +import { getKeystaticApiBasePath, isGitHubConfig, isLocalConfig } from '../utils'; import { AppStateContext, ConfigContext } from './context'; import { @@ -66,7 +66,7 @@ export const AppShell = (props: { const inner = ( - + {content} diff --git a/packages/keystatic/src/app/shell/sidebar/components.tsx b/packages/keystatic/src/app/shell/sidebar/components.tsx index 169ed93dc..44b386400 100644 --- a/packages/keystatic/src/app/shell/sidebar/components.tsx +++ b/packages/keystatic/src/app/shell/sidebar/components.tsx @@ -58,6 +58,7 @@ import { useImageLibraryURL } from '../../../component-blocks/cloud-image-previe import { clearObjectCache } from '../../object-cache'; import { clearDrafts } from '../../persistence'; import { getCloudAuth } from '../../auth'; +import { getKeystaticApiBasePath } from '../../utils'; type MenuItem = { icon: ReactElement; @@ -168,7 +169,7 @@ export function UserMenu(user: { label: 'Log out', href: config.storage.kind === 'github' - ? '/api/keystatic/github/logout' + ? `${getKeystaticApiBasePath(config)}/github/logout` : undefined, icon: logOutIcon, }, diff --git a/packages/keystatic/src/app/ui.tsx b/packages/keystatic/src/app/ui.tsx index e4501a355..61060b773 100644 --- a/packages/keystatic/src/app/ui.tsx +++ b/packages/keystatic/src/app/ui.tsx @@ -3,6 +3,7 @@ import { ReactNode, useContext, useEffect, + useMemo, useState, } from 'react'; @@ -29,6 +30,8 @@ import { RepoNotFound } from './onboarding/repo-not-found'; import { AppSlugProvider } from './onboarding/install-app'; import { useRouter, RouterProvider } from './router'; import { + getKeystaticApiBasePath, + getKeystaticBasePath, isCloudConfig, isGitHubConfig, isLocalConfig, @@ -43,6 +46,7 @@ import { KeystaticCloudAuthCallback } from './cloud-auth-callback'; import { getAuth } from './auth'; import { assertValidRepoConfig } from './repo-config'; import { NotFoundBoundary, notFound } from './not-found'; +import { AppStateContext } from './shell/context'; function parseParamsWithoutBranch(params: string[]) { if (params.length === 0) { @@ -69,17 +73,19 @@ function parseParamsWithoutBranch(params: string[]) { function RedirectToBranch(props: { config: Config }) { const { push } = useRouter(); const { data, error } = useContext(GitHubAppShellDataContext)!; + const apiBase = getKeystaticApiBasePath(props.config); + const uiBase = getKeystaticBasePath(props.config); useEffect(() => { if (error?.response?.status === 401) { if (props.config.storage.kind === 'github') { - window.location.href = '/api/keystatic/github/login'; + window.location.href = `${apiBase}/github/login`; } else { redirectToCloudAuth('', props.config); } } if (data?.repository?.defaultBranchRef) { push( - `/keystatic/branch/${encodeURIComponent( + `${uiBase}/branch/${encodeURIComponent( data.repository.defaultBranchRef.name )}` ); @@ -91,9 +97,9 @@ function RedirectToBranch(props: { config: Config }) { 'NOT_FOUND') || (error?.graphQLErrors?.[0]?.originalError as any)?.type === 'FORBIDDEN' ) { - window.location.href = '/api/keystatic/github/repo-not-found'; + window.location.href = `${apiBase}/github/repo-not-found`; } - }, [data, error, push, props.config]); + }, [data, error, push, props.config, apiBase, uiBase]); return null; } @@ -139,11 +145,12 @@ function PageInner({ config }: { config: Config }) { return Not found; } branch = params[1]; - basePath = `/keystatic/branch/${encodeURIComponent(branch)}`; + const uiBase = getKeystaticBasePath(config); + basePath = `${uiBase}/branch/${encodeURIComponent(branch)}`; parsedParams = parseParamsWithoutBranch(params.slice(2)); } else { parsedParams = parseParamsWithoutBranch(params); - basePath = '/keystatic'; + basePath = getKeystaticBasePath(config); } return wrapper( @@ -229,10 +236,11 @@ function AuthWrapper(props: { } if (state === 'explicit-auth') { if (props.config.storage.kind === 'github') { + const apiBase = getKeystaticApiBasePath(props.config); return (