From 4346dddd406552f4914ee7428474e09fb575ec02 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Sun, 2 Nov 2025 22:45:31 +0100 Subject: [PATCH 01/14] Add support for private github repos via git:directory --- .../storage/src/lib/git-sparse-checkout.ts | 167 ++++++++++++++++-- .../github-private-repo-auth-modal/index.tsx | 56 ++++++ .../website/src/components/layout/index.tsx | 4 + .../github/acquire-oauth-token-if-needed.tsx | 17 +- .../playground/website/src/github/state.ts | 8 + .../src/lib/state/redux/boot-site-client.ts | 7 + .../website/src/lib/state/redux/slice-ui.ts | 8 +- packages/playground/website/vite.oauth.ts | 2 +- 8 files changed, 245 insertions(+), 24 deletions(-) create mode 100644 packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts index a977fc33bf..c8dfd52c09 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -30,6 +30,93 @@ if (typeof globalThis.Buffer === 'undefined') { globalThis.Buffer = BufferPolyfill; } +/** + * Module-level storage for GitHub authentication token. + * This is set by browser-specific code when GitHub OAuth is available. + */ +let gitHubAuthToken: string | undefined; + +/** + * Sets the GitHub authentication token to use for git protocol requests. + * This is intended to be called by browser-specific initialization code + * where GitHub OAuth is available. + * + * @param token The GitHub OAuth token, or undefined to clear it + */ +export function setGitHubAuthToken(token: string | undefined) { + gitHubAuthToken = token; +} + +/** + * Custom error class for GitHub authentication failures. + */ +export class GitHubAuthenticationError extends Error { + constructor(public repoUrl: string, public status: number) { + super( + `Authentication required to access private GitHub repository: ${repoUrl}` + ); + this.name = 'GitHubAuthenticationError'; + } +} + +/** + * Checks if a URL is a GitHub URL by parsing the hostname. + * Handles both direct GitHub URLs and CORS-proxied URLs. + * + * @param url The URL to check + * @returns true if the URL is definitively a GitHub URL, false otherwise + */ +function isGitHubUrl(url: string): boolean { + try { + const parsedUrl = new URL(url); + + // Direct GitHub URL - check hostname + if (parsedUrl.hostname === 'github.com') { + return true; + } + + // CORS-proxied GitHub URL - the actual GitHub URL should be in the query string + // Format: https://proxy.com/cors-proxy.php?https://github.com/... + // We need to extract and validate the proxied URL's hostname + const queryString = parsedUrl.search.substring(1); // Remove leading '?' + if (queryString) { + // Try to extract a URL from the query string + // Match URLs that start with http:// or https:// + const urlMatch = queryString.match(/^(https?:\/\/[^\s&]+)/); + if (urlMatch) { + try { + const proxiedUrl = new URL(urlMatch[1]); + if (proxiedUrl.hostname === 'github.com') { + return true; + } + } catch { + // Invalid proxied URL, ignore + } + } + } + + return false; + } catch { + // If URL parsing fails, return false + return false; + } +} + +/** + * Adds GitHub authentication headers to a headers object if a token is available + * and the URL is a GitHub URL. + */ +function addGitHubAuthHeaders(headers: HeadersInit, url: string): void { + if (gitHubAuthToken && isGitHubUrl(url)) { + // GitHub Git protocol requires Basic Auth with token as username and empty password + const basicAuth = btoa(`${gitHubAuthToken}:`); + headers['Authorization'] = `Basic ${basicAuth}`; + // Tell CORS proxy to forward the Authorization header + // Must be lowercase because the CORS proxy lowercases header names for comparison + headers['X-Cors-Proxy-Allowed-Request-Headers'] = 'authorization'; + } +} + /** * Downloads specific files from a git repository. * It uses the git protocol over HTTP to fetch the files. It only uses @@ -253,17 +340,33 @@ export async function listGitRefs( ])) as any ); + const headers: HeadersInit = { + Accept: 'application/x-git-upload-pack-advertisement', + 'content-type': 'application/x-git-upload-pack-request', + 'Content-Length': `${packbuffer.length}`, + 'Git-Protocol': 'version=2', + }; + + addGitHubAuthHeaders(headers, repoUrl); + const response = await fetch(repoUrl + '/git-upload-pack', { method: 'POST', - headers: { - Accept: 'application/x-git-upload-pack-advertisement', - 'content-type': 'application/x-git-upload-pack-request', - 'Content-Length': `${packbuffer.length}`, - 'Git-Protocol': 'version=2', - }, + headers, body: packbuffer as any, }); + if (!response.ok) { + if ( + (response.status === 401 || response.status === 403) && + isGitHubUrl(repoUrl) + ) { + throw new GitHubAuthenticationError(repoUrl, response.status); + } + throw new Error( + `Failed to fetch git refs from ${repoUrl}: ${response.status} ${response.statusText}` + ); + } + const refs: Record = {}; for await (const line of parseGitResponseLines(response)) { const spaceAt = line.indexOf(' '); @@ -403,16 +506,32 @@ async function fetchWithoutBlobs(repoUrl: string, commitHash: string) { ])) as any ); + const headers: HeadersInit = { + Accept: 'application/x-git-upload-pack-advertisement', + 'content-type': 'application/x-git-upload-pack-request', + 'Content-Length': `${packbuffer.length}`, + }; + + addGitHubAuthHeaders(headers, repoUrl); + const response = await fetch(repoUrl + '/git-upload-pack', { method: 'POST', - headers: { - Accept: 'application/x-git-upload-pack-advertisement', - 'content-type': 'application/x-git-upload-pack-request', - 'Content-Length': `${packbuffer.length}`, - }, + headers, body: packbuffer as any, }); + if (!response.ok) { + if ( + (response.status === 401 || response.status === 403) && + isGitHubUrl(repoUrl) + ) { + throw new GitHubAuthenticationError(repoUrl, response.status); + } + throw new Error( + `Failed to fetch git objects from ${repoUrl}: ${response.status} ${response.statusText}` + ); + } + const iterator = streamToIterator(response.body!); const parsed = await parseUploadPackResponse(iterator); const packfile = Buffer.from((await collect(parsed.packfile)) as any); @@ -552,16 +671,32 @@ async function fetchObjects(url: string, objectHashes: string[]) { ])) as any ); + const headers: HeadersInit = { + Accept: 'application/x-git-upload-pack-advertisement', + 'content-type': 'application/x-git-upload-pack-request', + 'Content-Length': `${packbuffer.length}`, + }; + + addGitHubAuthHeaders(headers, url); + const response = await fetch(url + '/git-upload-pack', { method: 'POST', - headers: { - Accept: 'application/x-git-upload-pack-advertisement', - 'content-type': 'application/x-git-upload-pack-request', - 'Content-Length': `${packbuffer.length}`, - }, + headers, body: packbuffer as any, }); + if (!response.ok) { + if ( + (response.status === 401 || response.status === 403) && + isGitHubUrl(url) + ) { + throw new GitHubAuthenticationError(url, response.status); + } + throw new Error( + `Failed to fetch git objects from ${url}: ${response.status} ${response.statusText}` + ); + } + const iterator = streamToIterator(response.body!); const parsed = await parseUploadPackResponse(iterator); const packfile = Buffer.from((await collect(parsed.packfile)) as any); diff --git a/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx new file mode 100644 index 0000000000..12f9bd1153 --- /dev/null +++ b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx @@ -0,0 +1,56 @@ +import { Modal } from '../modal'; +import { useAppDispatch } from '../../lib/state/redux/store'; +import { setActiveModal } from '../../lib/state/redux/slice-ui'; +import { Icon } from '@wordpress/components'; +import { GitHubIcon } from '../../github/github'; +import css from '../../github/github-oauth-guard/style.module.css'; + +const OAUTH_FLOW_URL = 'oauth.php?redirect=1'; + +export function GitHubPrivateRepoAuthModal() { + const dispatch = useAppDispatch(); + + // Remove the modal parameter from the redirect URI + // so it doesn't persist after OAuth completes + const redirectUrl = new URL(window.location.href); + redirectUrl.searchParams.delete('modal'); + + const urlParams = new URLSearchParams(); + urlParams.set('redirect_uri', redirectUrl.toString()); + const oauthUrl = `${OAUTH_FLOW_URL}&${urlParams.toString()}`; + + return ( + dispatch(setActiveModal(null))} + > +
+

+ This blueprint requires access to a private GitHub + repository. +

+

+ To continue, please connect your GitHub account with + WordPress Playground. +

+ +

+ + + Connect your GitHub account + +

+

+ + Your access token is stored only in memory and will be + cleared when you close this tab. + +

+
+
+ ); +} diff --git a/packages/playground/website/src/components/layout/index.tsx b/packages/playground/website/src/components/layout/index.tsx index 9523bd0946..4ef23807bd 100644 --- a/packages/playground/website/src/components/layout/index.tsx +++ b/packages/playground/website/src/components/layout/index.tsx @@ -31,6 +31,7 @@ import { PreviewPRModal } from '../../github/preview-pr'; import { MissingSiteModal } from '../missing-site-modal'; import { RenameSiteModal } from '../rename-site-modal'; import { SaveSiteModal } from '../save-site-modal'; +import { GitHubPrivateRepoAuthModal } from '../github-private-repo-auth-modal'; acquireOAuthTokenIfNeeded(); @@ -41,6 +42,7 @@ export const modalSlugs = { IMPORT_FORM: 'import-form', GITHUB_IMPORT: 'github-import', GITHUB_EXPORT: 'github-export', + GITHUB_PRIVATE_REPO_AUTH: 'github-private-repo-auth', PREVIEW_PR_WP: 'preview-pr-wordpress', PREVIEW_PR_GUTENBERG: 'preview-pr-gutenberg', MISSING_SITE_PROMPT: 'missing-site-prompt', @@ -216,6 +218,8 @@ function Modals(blueprint: BlueprintV1Declaration) { return ; } else if (currentModal === modalSlugs.SAVE_SITE) { return ; + } else if (currentModal === modalSlugs.GITHUB_PRIVATE_REPO_AUTH) { + return ; } if (query.get('gh-ensure-auth') === 'yes') { diff --git a/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx b/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx index d7a3080dd1..cd2a01d286 100644 --- a/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx +++ b/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx @@ -1,5 +1,6 @@ import { setOAuthToken, oAuthState } from './state'; import { oauthCode } from './github-oauth-guard'; +import { setGitHubAuthToken } from '@wp-playground/storage'; export async function acquireOAuthTokenIfNeeded() { if (!oauthCode) { @@ -25,15 +26,21 @@ export async function acquireOAuthTokenIfNeeded() { }); const body = await response.json(); setOAuthToken(body.access_token); + setGitHubAuthToken(body.access_token); + + // Remove the ?code=... from the URL and clean up any modal state + const url = new URL(window.location.href); + url.searchParams.delete('code'); + url.searchParams.delete('modal'); + // Keep the hash (it contains the blueprint) + + // Reload the page to retry the blueprint with the new token + // This is necessary because the blueprint failed before we had the token + window.location.href = url.toString(); } finally { oAuthState.value = { ...oAuthState.value, isAuthorizing: false, }; } - - // Remove the ?code=... from the URL - const url = new URL(window.location.href); - url.searchParams.delete('code'); - window.history.replaceState(null, '', url.toString()); } diff --git a/packages/playground/website/src/github/state.ts b/packages/playground/website/src/github/state.ts index bb7b1eb521..3e9069b3a7 100644 --- a/packages/playground/website/src/github/state.ts +++ b/packages/playground/website/src/github/state.ts @@ -1,4 +1,5 @@ import { signal } from '@preact/signals-react'; +import { setGitHubAuthToken } from '@wp-playground/storage'; export interface GitHubOAuthState { token?: string; @@ -16,6 +17,11 @@ export const oAuthState = signal({ token: shouldStoreToken ? localStorage.getItem(TOKEN_KEY) || '' : '', }); +// Initialize the git-sparse-checkout module with the token if it exists +if (oAuthState.value.token) { + setGitHubAuthToken(oAuthState.value.token); +} + export function setOAuthToken(token?: string) { if (shouldStoreToken) { localStorage.setItem(TOKEN_KEY, token || ''); @@ -24,4 +30,6 @@ export function setOAuthToken(token?: string) { ...oAuthState.value, token, }; + // Also update the token in the git-sparse-checkout module + setGitHubAuthToken(token); } diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 7f5e87e613..ca0639b3d7 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -198,6 +198,13 @@ export function bootSiteClient( (e as any).originalErrorClassName === 'ArtifactExpiredError' ) { dispatch(setActiveSiteError('github-artifact-expired')); + } else if ( + (e as any).name === 'GitHubAuthenticationError' || + (e as any).originalErrorClassName === + 'GitHubAuthenticationError' || + (e as any).cause?.name === 'GitHubAuthenticationError' + ) { + dispatch(setActiveModal(modalSlugs.GITHUB_PRIVATE_REPO_AUTH)); } else { dispatch(setActiveSiteError('site-boot-failed')); dispatch(setActiveModal(modalSlugs.ERROR_REPORT)); diff --git a/packages/playground/website/src/lib/state/redux/slice-ui.ts b/packages/playground/website/src/lib/state/redux/slice-ui.ts index 0e4e23419a..2e45e7b3d9 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -34,10 +34,13 @@ const initialState: UIState = { * Don't show certain modals after a page refresh. * The save-site and error-report modals should only be triggered by user actions, * not by loading a URL with the modal parameter. + * The github-private-repo-auth modal should only be triggered by authentication errors, + * not by loading a URL with the modal parameter. */ activeModal: query.get('modal') === 'error-report' || - query.get('modal') === 'save-site' + query.get('modal') === 'save-site' || + query.get('modal') === 'github-private-repo-auth' ? null : query.get('modal') || null, offline: !navigator.onLine, @@ -122,7 +125,8 @@ export const listenToOnlineOfflineEventsMiddleware: Middleware = */ if ( query.get('modal') === 'error-report' || - query.get('modal') === 'save-site' + query.get('modal') === 'save-site' || + query.get('modal') === 'github-private-repo-auth' ) { setTimeout(() => { store.dispatch(uiSlice.actions.setActiveModal(null)); diff --git a/packages/playground/website/vite.oauth.ts b/packages/playground/website/vite.oauth.ts index ed665fdda5..b2ea337504 100644 --- a/packages/playground/website/vite.oauth.ts +++ b/packages/playground/website/vite.oauth.ts @@ -18,7 +18,7 @@ export const oAuthMiddleware = async ( if (query.get('redirect') === '1') { const params: Record = { client_id: CLIENT_ID!, - scope: 'public_repo', + scope: 'repo', }; if (query.has('redirect_uri')) { params.redirect_uri = query.get('redirect_uri')!; From ffc55a886118d3cea6946c8f06ab98a5369e73f7 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Sun, 2 Nov 2025 23:10:18 +0100 Subject: [PATCH 02/14] Fix type errors --- .../storage/src/lib/git-sparse-checkout.ts | 63 +++++++++---------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts index c8dfd52c09..03b22115d6 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -103,18 +103,20 @@ function isGitHubUrl(url: string): boolean { } /** - * Adds GitHub authentication headers to a headers object if a token is available - * and the URL is a GitHub URL. + * Returns GitHub authentication headers if a token is available and the URL is a GitHub URL. */ -function addGitHubAuthHeaders(headers: HeadersInit, url: string): void { +function getGitHubAuthHeaders(url: string): Record { if (gitHubAuthToken && isGitHubUrl(url)) { // GitHub Git protocol requires Basic Auth with token as username and empty password const basicAuth = btoa(`${gitHubAuthToken}:`); - headers['Authorization'] = `Basic ${basicAuth}`; - // Tell CORS proxy to forward the Authorization header - // Must be lowercase because the CORS proxy lowercases header names for comparison - headers['X-Cors-Proxy-Allowed-Request-Headers'] = 'authorization'; + return { + Authorization: `Basic ${basicAuth}`, + // Tell CORS proxy to forward the Authorization header + // Must be lowercase because the CORS proxy lowercases header names for comparison + 'X-Cors-Proxy-Allowed-Request-Headers': 'authorization', + }; } + return {}; } /** @@ -340,18 +342,15 @@ export async function listGitRefs( ])) as any ); - const headers: HeadersInit = { - Accept: 'application/x-git-upload-pack-advertisement', - 'content-type': 'application/x-git-upload-pack-request', - 'Content-Length': `${packbuffer.length}`, - 'Git-Protocol': 'version=2', - }; - - addGitHubAuthHeaders(headers, repoUrl); - const response = await fetch(repoUrl + '/git-upload-pack', { method: 'POST', - headers, + headers: { + Accept: 'application/x-git-upload-pack-advertisement', + 'content-type': 'application/x-git-upload-pack-request', + 'Content-Length': `${packbuffer.length}`, + 'Git-Protocol': 'version=2', + ...getGitHubAuthHeaders(repoUrl), + }, body: packbuffer as any, }); @@ -506,17 +505,14 @@ async function fetchWithoutBlobs(repoUrl: string, commitHash: string) { ])) as any ); - const headers: HeadersInit = { - Accept: 'application/x-git-upload-pack-advertisement', - 'content-type': 'application/x-git-upload-pack-request', - 'Content-Length': `${packbuffer.length}`, - }; - - addGitHubAuthHeaders(headers, repoUrl); - const response = await fetch(repoUrl + '/git-upload-pack', { method: 'POST', - headers, + headers: { + Accept: 'application/x-git-upload-pack-advertisement', + 'content-type': 'application/x-git-upload-pack-request', + 'Content-Length': `${packbuffer.length}`, + ...getGitHubAuthHeaders(repoUrl), + }, body: packbuffer as any, }); @@ -671,17 +667,14 @@ async function fetchObjects(url: string, objectHashes: string[]) { ])) as any ); - const headers: HeadersInit = { - Accept: 'application/x-git-upload-pack-advertisement', - 'content-type': 'application/x-git-upload-pack-request', - 'Content-Length': `${packbuffer.length}`, - }; - - addGitHubAuthHeaders(headers, url); - const response = await fetch(url + '/git-upload-pack', { method: 'POST', - headers, + headers: { + Accept: 'application/x-git-upload-pack-advertisement', + 'content-type': 'application/x-git-upload-pack-request', + 'Content-Length': `${packbuffer.length}`, + ...getGitHubAuthHeaders(url), + }, body: packbuffer as any, }); From 83f28b2cd744d459192715235ecfb0f306285780 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Sun, 2 Nov 2025 23:22:55 +0100 Subject: [PATCH 03/14] Better check for github URLs in a cors proxy url --- .../storage/src/lib/git-sparse-checkout.ts | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts index 03b22115d6..f9c1113eca 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -36,6 +36,15 @@ if (typeof globalThis.Buffer === 'undefined') { */ let gitHubAuthToken: string | undefined; +/** + * Known CORS proxy URL prefixes used by WordPress Playground. + * Keep up this synced with packages/playground/website-extras/vite.config.ts + */ +const KNOWN_CORS_PROXY_URLS = [ + 'https://wordpress-playground-cors-proxy.net/?', + 'http://127.0.0.1:5263/cors-proxy.php?', +]; + /** * Sets the GitHub authentication token to use for git protocol requests. * This is intended to be called by browser-specific initialization code @@ -61,7 +70,7 @@ export class GitHubAuthenticationError extends Error { /** * Checks if a URL is a GitHub URL by parsing the hostname. - * Handles both direct GitHub URLs and CORS-proxied URLs. + * Handles both direct GitHub URLs and CORS-proxied GitHub URLs. * * @param url The URL to check * @returns true if the URL is definitively a GitHub URL, false otherwise @@ -70,40 +79,32 @@ function isGitHubUrl(url: string): boolean { try { const parsedUrl = new URL(url); - // Direct GitHub URL - check hostname if (parsedUrl.hostname === 'github.com') { return true; } - // CORS-proxied GitHub URL - the actual GitHub URL should be in the query string - // Format: https://proxy.com/cors-proxy.php?https://github.com/... - // We need to extract and validate the proxied URL's hostname - const queryString = parsedUrl.search.substring(1); // Remove leading '?' - if (queryString) { - // Try to extract a URL from the query string - // Match URLs that start with http:// or https:// - const urlMatch = queryString.match(/^(https?:\/\/[^\s&]+)/); - if (urlMatch) { + for (const proxyUrl of KNOWN_CORS_PROXY_URLS) { + if (url.startsWith(proxyUrl)) { + const proxiedUrl = url.substring(proxyUrl.length); try { - const proxiedUrl = new URL(urlMatch[1]); - if (proxiedUrl.hostname === 'github.com') { - return true; - } + const proxiedParsedUrl = new URL(proxiedUrl); + return proxiedParsedUrl.hostname === 'github.com'; } catch { - // Invalid proxied URL, ignore + return false; } } } return false; } catch { - // If URL parsing fails, return false return false; } } /** * Returns GitHub authentication headers if a token is available and the URL is a GitHub URL. + * + * @param url The URL to check */ function getGitHubAuthHeaders(url: string): Record { if (gitHubAuthToken && isGitHubUrl(url)) { From 0f49a3335a56bde347864eb7f7ecd59bc0d1896e Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Mon, 3 Nov 2025 06:59:48 +0100 Subject: [PATCH 04/14] Show the github url --- .../github-private-repo-auth-modal/index.tsx | 39 +++++++++++++++++-- .../src/lib/state/redux/boot-site-client.ts | 14 ++++++- .../website/src/lib/state/redux/slice-ui.ts | 8 ++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx index 12f9bd1153..f785a9b69c 100644 --- a/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx +++ b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx @@ -1,5 +1,5 @@ import { Modal } from '../modal'; -import { useAppDispatch } from '../../lib/state/redux/store'; +import { useAppDispatch, useAppSelector } from '../../lib/state/redux/store'; import { setActiveModal } from '../../lib/state/redux/slice-ui'; import { Icon } from '@wordpress/components'; import { GitHubIcon } from '../../github/github'; @@ -7,8 +7,34 @@ import css from '../../github/github-oauth-guard/style.module.css'; const OAUTH_FLOW_URL = 'oauth.php?redirect=1'; +function extractRepoName(url: string): string { + try { + // Handle CORS-proxied URLs - extract the actual GitHub URL + const corsProxyPrefixes = [ + 'https://wordpress-playground-cors-proxy.net/?', + 'http://127.0.0.1:5263/cors-proxy.php?', + ]; + let githubUrl = url; + for (const prefix of corsProxyPrefixes) { + if (url.startsWith(prefix)) { + githubUrl = url.substring(prefix.length); + break; + } + } + + // Extract owner/repo from GitHub URL + const match = githubUrl.match(/github\.com\/([^\/]+\/[^\/]+)/); + return match ? match[1] : url; + } catch { + return url; + } +} + export function GitHubPrivateRepoAuthModal() { const dispatch = useAppDispatch(); + const repoUrl = useAppSelector((state) => state.ui.githubAuthRepoUrl); + + const displayRepoName = repoUrl ? extractRepoName(repoUrl) : ''; // Remove the modal parameter from the redirect URI // so it doesn't persist after OAuth completes @@ -27,11 +53,16 @@ export function GitHubPrivateRepoAuthModal() {

This blueprint requires access to a private GitHub - repository. + repository: +

+

+ + github.com/{displayRepoName} +

- To continue, please connect your GitHub account with - WordPress Playground. + If you have a GitHub account with access to this repository, + you can connect it to continue.

diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index ca0639b3d7..94418ca657 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -17,7 +17,11 @@ import { setupPostMessageRelay } from '@php-wasm/web'; import { startPlaygroundWeb } from '@wp-playground/client'; import type { PlaygroundClient } from '@wp-playground/remote'; import { getRemoteUrl } from '../../config'; -import { setActiveModal, setActiveSiteError } from './slice-ui'; +import { + setActiveModal, + setActiveSiteError, + setGitHubAuthRepoUrl, +} from './slice-ui'; import type { PlaygroundDispatch, PlaygroundReduxState } from './store'; import { selectSiteBySlug } from './slice-sites'; // @ts-ignore @@ -204,6 +208,14 @@ export function bootSiteClient( 'GitHubAuthenticationError' || (e as any).cause?.name === 'GitHubAuthenticationError' ) { + // Extract repo URL from the error + const repoUrl = + (e as any).repoUrl || + (e as any).cause?.repoUrl || + undefined; + if (repoUrl) { + dispatch(setGitHubAuthRepoUrl(repoUrl)); + } dispatch(setActiveModal(modalSlugs.GITHUB_PRIVATE_REPO_AUTH)); } else { dispatch(setActiveSiteError('site-boot-failed')); diff --git a/packages/playground/website/src/lib/state/redux/slice-ui.ts b/packages/playground/website/src/lib/state/redux/slice-ui.ts index 2e45e7b3d9..1f0b62043b 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -17,6 +17,7 @@ export interface UIState { error?: SiteError; }; activeModal: string | null; + githubAuthRepoUrl?: string; offline: boolean; siteManagerIsOpen: boolean; siteManagerSection: SiteManagerSection; @@ -88,6 +89,12 @@ const uiSlice = createSlice({ state.activeModal = action.payload; }, + setGitHubAuthRepoUrl: ( + state, + action: PayloadAction + ) => { + state.githubAuthRepoUrl = action.payload; + }, setOffline: (state, action: PayloadAction) => { state.offline = action.payload; }, @@ -139,6 +146,7 @@ export const listenToOnlineOfflineEventsMiddleware: Middleware = export const { setActiveModal, setActiveSiteError, + setGitHubAuthRepoUrl, setOffline, setSiteManagerOpen, setSiteManagerSection, From 5814a6bd2a554ce428d95b07db41e12c0839e2a5 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Mon, 3 Nov 2025 08:04:27 +0100 Subject: [PATCH 05/14] Remove superfluous backslashes --- .../src/components/github-private-repo-auth-modal/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx index f785a9b69c..aa83d04ffd 100644 --- a/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx +++ b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx @@ -23,7 +23,7 @@ function extractRepoName(url: string): string { } // Extract owner/repo from GitHub URL - const match = githubUrl.match(/github\.com\/([^\/]+\/[^\/]+)/); + const match = githubUrl.match(/github\.com\/([^/]+\/[^/]+)/); return match ? match[1] : url; } catch { return url; From 2a90a9570624c715ec0aeff92ee6dfa1f4d4142a Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Wed, 5 Nov 2025 09:40:57 +0100 Subject: [PATCH 06/14] Rename authentication error to GitAuthenticationError and avoid Github specific code in the git library --- .../blueprints/src/lib/v1/compile.ts | 12 ++ .../blueprints/src/lib/v1/resources.ts | 32 +++- .../client/src/blueprints-v1-handler.ts | 2 + packages/playground/client/src/index.ts | 4 + .../storage/src/lib/git-sparse-checkout.ts | 164 ++++++------------ .../github/acquire-oauth-token-if-needed.tsx | 2 - .../playground/website/src/github/state.ts | 8 - .../src/lib/state/redux/boot-site-client.ts | 8 +- 8 files changed, 101 insertions(+), 131 deletions(-) diff --git a/packages/playground/blueprints/src/lib/v1/compile.ts b/packages/playground/blueprints/src/lib/v1/compile.ts index b2b3b7bb89..bb200035dd 100644 --- a/packages/playground/blueprints/src/lib/v1/compile.ts +++ b/packages/playground/blueprints/src/lib/v1/compile.ts @@ -83,6 +83,10 @@ export interface CompileBlueprintV1Options { * A filesystem to use for the blueprint. */ streamBundledFile?: StreamBundledFile; + /** + * Additional headers to pass to git operations. + */ + gitAdditionalHeaders?: Record; /** * Additional steps to add to the blueprint. */ @@ -142,6 +146,7 @@ function compileBlueprintJson( onBlueprintValidated = () => {}, corsProxy, streamBundledFile, + gitAdditionalHeaders, additionalSteps, }: CompileBlueprintV1Options = {} ): CompiledBlueprintV1 { @@ -321,6 +326,7 @@ function compileBlueprintJson( totalProgressWeight, corsProxy, streamBundledFile, + gitAdditionalHeaders, }) ); @@ -514,6 +520,10 @@ interface CompileStepArgsOptions { * A filesystem to use for the "blueprint" resource type. */ streamBundledFile?: StreamBundledFile; + /** + * Additional headers to pass to git operations. + */ + gitAdditionalHeaders?: Record; } /** @@ -532,6 +542,7 @@ function compileStep( totalProgressWeight, corsProxy, streamBundledFile, + gitAdditionalHeaders, }: CompileStepArgsOptions ): { run: CompiledV1Step; step: S; resources: Array> } { const stepProgress = rootProgressTracker.stage( @@ -546,6 +557,7 @@ function compileStep( semaphore, corsProxy, streamBundledFile, + gitAdditionalHeaders, }); } args[key] = value; diff --git a/packages/playground/blueprints/src/lib/v1/resources.ts b/packages/playground/blueprints/src/lib/v1/resources.ts index c20c06b1c7..c281f4d3f4 100644 --- a/packages/playground/blueprints/src/lib/v1/resources.ts +++ b/packages/playground/blueprints/src/lib/v1/resources.ts @@ -157,12 +157,14 @@ export abstract class Resource { progress, corsProxy, streamBundledFile, + gitAdditionalHeaders, }: { /** Optional semaphore to limit concurrent downloads */ semaphore?: Semaphore; progress?: ProgressTracker; corsProxy?: string; streamBundledFile?: StreamBundledFile; + gitAdditionalHeaders?: Record; } ): Resource { let resource: Resource; @@ -185,6 +187,7 @@ export abstract class Resource { case 'git:directory': resource = new GitDirectoryResource(ref, progress, { corsProxy, + additionalHeaders: gitAdditionalHeaders, }); break; case 'literal:directory': @@ -556,12 +559,18 @@ export class UrlResource extends FetchResource { */ export class GitDirectoryResource extends Resource { private reference: GitDirectoryReference; - private options?: { corsProxy?: string }; + private options?: { + corsProxy?: string; + additionalHeaders?: Record; + }; constructor( reference: GitDirectoryReference, _progress?: ProgressTracker, - options?: { corsProxy?: string } + options?: { + corsProxy?: string; + additionalHeaders?: Record; + } ) { super(); this.reference = reference; @@ -574,11 +583,19 @@ export class GitDirectoryResource extends Resource { ? `${this.options.corsProxy}${this.reference.url}` : this.reference.url; - const commitHash = await resolveCommitHash(repoUrl, { - value: this.reference.ref, - type: this.reference.refType ?? 'infer', - }); - const allFiles = await listGitFiles(repoUrl, commitHash); + const commitHash = await resolveCommitHash( + repoUrl, + { + value: this.reference.ref, + type: this.reference.refType ?? 'infer', + }, + this.options?.additionalHeaders + ); + const allFiles = await listGitFiles( + repoUrl, + commitHash, + this.options?.additionalHeaders + ); const requestedPath = (this.reference.path ?? '').replace(/^\/+/, ''); const filesToClone = listDescendantFiles(allFiles, requestedPath); @@ -588,6 +605,7 @@ export class GitDirectoryResource extends Resource { filesToClone, { withObjects: this.reference['.git'], + additionalHeaders: this.options?.additionalHeaders, } ); let files = checkout.files; diff --git a/packages/playground/client/src/blueprints-v1-handler.ts b/packages/playground/client/src/blueprints-v1-handler.ts index d989091cd1..cb60d9a43c 100644 --- a/packages/playground/client/src/blueprints-v1-handler.ts +++ b/packages/playground/client/src/blueprints-v1-handler.ts @@ -21,6 +21,7 @@ export class BlueprintsV1Handler { onBlueprintValidated, onBlueprintStepCompleted, corsProxy, + gitAdditionalHeaders, mounts, sapiName, scope, @@ -72,6 +73,7 @@ export class BlueprintsV1Handler { onStepCompleted: onBlueprintStepCompleted, onBlueprintValidated, corsProxy, + gitAdditionalHeaders, }); await runBlueprintV1Steps(compiled, playground); } diff --git a/packages/playground/client/src/index.ts b/packages/playground/client/src/index.ts index 4d97aca96a..5312980aed 100644 --- a/packages/playground/client/src/index.ts +++ b/packages/playground/client/src/index.ts @@ -85,6 +85,10 @@ export interface StartPlaygroundOptions { * your Blueprint to replace all cross-origin URLs with the proxy URL. */ corsProxy?: string; + /** + * Additional headers to pass to git operations. + */ + gitAdditionalHeaders?: Record; /** * The version of the SQLite driver to use. * Defaults to the latest development version. diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts index f9c1113eca..d91cc138a8 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -31,95 +31,17 @@ if (typeof globalThis.Buffer === 'undefined') { } /** - * Module-level storage for GitHub authentication token. - * This is set by browser-specific code when GitHub OAuth is available. + * Custom error class for git authentication failures. */ -let gitHubAuthToken: string | undefined; - -/** - * Known CORS proxy URL prefixes used by WordPress Playground. - * Keep up this synced with packages/playground/website-extras/vite.config.ts - */ -const KNOWN_CORS_PROXY_URLS = [ - 'https://wordpress-playground-cors-proxy.net/?', - 'http://127.0.0.1:5263/cors-proxy.php?', -]; - -/** - * Sets the GitHub authentication token to use for git protocol requests. - * This is intended to be called by browser-specific initialization code - * where GitHub OAuth is available. - * - * @param token The GitHub OAuth token, or undefined to clear it - */ -export function setGitHubAuthToken(token: string | undefined) { - gitHubAuthToken = token; -} - -/** - * Custom error class for GitHub authentication failures. - */ -export class GitHubAuthenticationError extends Error { +export class GitAuthenticationError extends Error { constructor(public repoUrl: string, public status: number) { super( - `Authentication required to access private GitHub repository: ${repoUrl}` + `Authentication required to access private repository: ${repoUrl}` ); - this.name = 'GitHubAuthenticationError'; + this.name = 'GitAuthenticationError'; } } -/** - * Checks if a URL is a GitHub URL by parsing the hostname. - * Handles both direct GitHub URLs and CORS-proxied GitHub URLs. - * - * @param url The URL to check - * @returns true if the URL is definitively a GitHub URL, false otherwise - */ -function isGitHubUrl(url: string): boolean { - try { - const parsedUrl = new URL(url); - - if (parsedUrl.hostname === 'github.com') { - return true; - } - - for (const proxyUrl of KNOWN_CORS_PROXY_URLS) { - if (url.startsWith(proxyUrl)) { - const proxiedUrl = url.substring(proxyUrl.length); - try { - const proxiedParsedUrl = new URL(proxiedUrl); - return proxiedParsedUrl.hostname === 'github.com'; - } catch { - return false; - } - } - } - - return false; - } catch { - return false; - } -} - -/** - * Returns GitHub authentication headers if a token is available and the URL is a GitHub URL. - * - * @param url The URL to check - */ -function getGitHubAuthHeaders(url: string): Record { - if (gitHubAuthToken && isGitHubUrl(url)) { - // GitHub Git protocol requires Basic Auth with token as username and empty password - const basicAuth = btoa(`${gitHubAuthToken}:`); - return { - Authorization: `Basic ${basicAuth}`, - // Tell CORS proxy to forward the Authorization header - // Must be lowercase because the CORS proxy lowercases header names for comparison - 'X-Cors-Proxy-Allowed-Request-Headers': 'authorization', - }; - } - return {}; -} - /** * Downloads specific files from a git repository. * It uses the git protocol over HTTP to fetch the files. It only uses @@ -157,14 +79,21 @@ export async function sparseCheckout( filesPaths: string[], options?: { withObjects?: boolean; + additionalHeaders?: Record; } ): Promise { - const treesPack = await fetchWithoutBlobs(repoUrl, commitHash); + const treesPack = await fetchWithoutBlobs( + repoUrl, + commitHash, + options?.additionalHeaders + ); const objects = await resolveObjects(treesPack.idx, commitHash, filesPaths); const blobOids = filesPaths.map((path) => objects[path].oid); const blobsPack = - blobOids.length > 0 ? await fetchObjects(repoUrl, blobOids) : null; + blobOids.length > 0 + ? await fetchObjects(repoUrl, blobOids, options?.additionalHeaders) + : null; const fetchedPaths: Record = {}; await Promise.all( @@ -267,9 +196,14 @@ const FULL_SHA_REGEX = /^[0-9a-f]{40}$/i; */ export async function listGitFiles( repoUrl: string, - commitHash: string + commitHash: string, + additionalHeaders?: Record ): Promise { - const treesPack = await fetchWithoutBlobs(repoUrl, commitHash); + const treesPack = await fetchWithoutBlobs( + repoUrl, + commitHash, + additionalHeaders + ); const rootTree = await resolveAllObjects(treesPack.idx, commitHash); if (!rootTree?.object) { return []; @@ -285,13 +219,17 @@ export async function listGitFiles( * @param ref The branch name or commit hash. * @returns The commit hash. */ -export async function resolveCommitHash(repoUrl: string, ref: GitRef) { +export async function resolveCommitHash( + repoUrl: string, + ref: GitRef, + additionalHeaders?: Record +) { const parsed = await parseGitRef(repoUrl, ref); if (parsed.resolvedOid) { return parsed.resolvedOid; } - const oid = await fetchRefOid(repoUrl, parsed.refname); + const oid = await fetchRefOid(repoUrl, parsed.refname, additionalHeaders); if (!oid) { throw new Error(`Git ref "${parsed.refname}" not found at ${repoUrl}`); } @@ -329,7 +267,8 @@ function gitTreeToFileTree(tree: GitTree): GitFileTree[] { */ export async function listGitRefs( repoUrl: string, - fullyQualifiedBranchPrefix: string + fullyQualifiedBranchPrefix: string, + additionalHeaders?: Record ) { const packbuffer = Buffer.from( (await collect([ @@ -350,17 +289,14 @@ export async function listGitRefs( 'content-type': 'application/x-git-upload-pack-request', 'Content-Length': `${packbuffer.length}`, 'Git-Protocol': 'version=2', - ...getGitHubAuthHeaders(repoUrl), + ...additionalHeaders, }, body: packbuffer as any, }); if (!response.ok) { - if ( - (response.status === 401 || response.status === 403) && - isGitHubUrl(repoUrl) - ) { - throw new GitHubAuthenticationError(repoUrl, response.status); + if (response.status === 401 || response.status === 403) { + throw new GitAuthenticationError(repoUrl, response.status); } throw new Error( `Failed to fetch git refs from ${repoUrl}: ${response.status} ${response.statusText}` @@ -479,8 +415,12 @@ async function parseGitRef( } } -async function fetchRefOid(repoUrl: string, refname: string) { - const refs = await listGitRefs(repoUrl, refname); +async function fetchRefOid( + repoUrl: string, + refname: string, + additionalHeaders?: Record +) { + const refs = await listGitRefs(repoUrl, refname, additionalHeaders); const candidates = [refname, `${refname}^{}`]; for (const candidate of candidates) { const sanitized = candidate.trim(); @@ -491,7 +431,11 @@ async function fetchRefOid(repoUrl: string, refname: string) { return null; } -async function fetchWithoutBlobs(repoUrl: string, commitHash: string) { +async function fetchWithoutBlobs( + repoUrl: string, + commitHash: string, + additionalHeaders?: Record +) { const packbuffer = Buffer.from( (await collect([ GitPktLine.encode( @@ -512,17 +456,14 @@ async function fetchWithoutBlobs(repoUrl: string, commitHash: string) { Accept: 'application/x-git-upload-pack-advertisement', 'content-type': 'application/x-git-upload-pack-request', 'Content-Length': `${packbuffer.length}`, - ...getGitHubAuthHeaders(repoUrl), + ...additionalHeaders, }, body: packbuffer as any, }); if (!response.ok) { - if ( - (response.status === 401 || response.status === 403) && - isGitHubUrl(repoUrl) - ) { - throw new GitHubAuthenticationError(repoUrl, response.status); + if (response.status === 401 || response.status === 403) { + throw new GitAuthenticationError(repoUrl, response.status); } throw new Error( `Failed to fetch git objects from ${repoUrl}: ${response.status} ${response.statusText}` @@ -655,7 +596,11 @@ async function resolveObjects( } // Request oid for each resolvedRef -async function fetchObjects(url: string, objectHashes: string[]) { +async function fetchObjects( + url: string, + objectHashes: string[], + additionalHeaders?: Record +) { const packbuffer = Buffer.from( (await collect([ ...objectHashes.map((objectHash) => @@ -674,17 +619,14 @@ async function fetchObjects(url: string, objectHashes: string[]) { Accept: 'application/x-git-upload-pack-advertisement', 'content-type': 'application/x-git-upload-pack-request', 'Content-Length': `${packbuffer.length}`, - ...getGitHubAuthHeaders(url), + ...additionalHeaders, }, body: packbuffer as any, }); if (!response.ok) { - if ( - (response.status === 401 || response.status === 403) && - isGitHubUrl(url) - ) { - throw new GitHubAuthenticationError(url, response.status); + if (response.status === 401 || response.status === 403) { + throw new GitAuthenticationError(url, response.status); } throw new Error( `Failed to fetch git objects from ${url}: ${response.status} ${response.statusText}` diff --git a/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx b/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx index cd2a01d286..286b5aa670 100644 --- a/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx +++ b/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx @@ -1,6 +1,5 @@ import { setOAuthToken, oAuthState } from './state'; import { oauthCode } from './github-oauth-guard'; -import { setGitHubAuthToken } from '@wp-playground/storage'; export async function acquireOAuthTokenIfNeeded() { if (!oauthCode) { @@ -26,7 +25,6 @@ export async function acquireOAuthTokenIfNeeded() { }); const body = await response.json(); setOAuthToken(body.access_token); - setGitHubAuthToken(body.access_token); // Remove the ?code=... from the URL and clean up any modal state const url = new URL(window.location.href); diff --git a/packages/playground/website/src/github/state.ts b/packages/playground/website/src/github/state.ts index 3e9069b3a7..bb7b1eb521 100644 --- a/packages/playground/website/src/github/state.ts +++ b/packages/playground/website/src/github/state.ts @@ -1,5 +1,4 @@ import { signal } from '@preact/signals-react'; -import { setGitHubAuthToken } from '@wp-playground/storage'; export interface GitHubOAuthState { token?: string; @@ -17,11 +16,6 @@ export const oAuthState = signal({ token: shouldStoreToken ? localStorage.getItem(TOKEN_KEY) || '' : '', }); -// Initialize the git-sparse-checkout module with the token if it exists -if (oAuthState.value.token) { - setGitHubAuthToken(oAuthState.value.token); -} - export function setOAuthToken(token?: string) { if (shouldStoreToken) { localStorage.setItem(TOKEN_KEY, token || ''); @@ -30,6 +24,4 @@ export function setOAuthToken(token?: string) { ...oAuthState.value, token, }; - // Also update the token in the git-sparse-checkout module - setGitHubAuthToken(token); } diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 94418ca657..3ad88d66ea 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -27,6 +27,7 @@ import { selectSiteBySlug } from './slice-sites'; // @ts-ignore import { corsProxyUrl } from 'virtual:cors-proxy-url'; import { modalSlugs } from '../../../components/layout'; +import { createGitHubAuthHeaders } from '../../github/git-auth-helpers'; export function bootSiteClient( siteSlug: string, @@ -155,6 +156,7 @@ export function bootSiteClient( : [], shouldInstallWordPress: !isWordPressInstalled, corsProxy: corsProxyUrl, + gitAdditionalHeaders: createGitHubAuthHeaders(), }); // @TODO: Remove backcompat code after 2024-12-01. @@ -203,10 +205,10 @@ export function bootSiteClient( ) { dispatch(setActiveSiteError('github-artifact-expired')); } else if ( - (e as any).name === 'GitHubAuthenticationError' || + (e as any).name === 'GitAuthenticationError' || (e as any).originalErrorClassName === - 'GitHubAuthenticationError' || - (e as any).cause?.name === 'GitHubAuthenticationError' + 'GitAuthenticationError' || + (e as any).cause?.name === 'GitAuthenticationError' ) { // Extract repo URL from the error const repoUrl = From 40fddb6f7fed33f67d1d108254f44c5b8b52184a Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Wed, 5 Nov 2025 09:59:15 +0100 Subject: [PATCH 07/14] Add missing git-auth-helpers --- .../website/src/github/git-auth-helpers.ts | 35 +++++++++++++++++++ .../src/lib/state/redux/boot-site-client.ts | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/playground/website/src/github/git-auth-helpers.ts diff --git a/packages/playground/website/src/github/git-auth-helpers.ts b/packages/playground/website/src/github/git-auth-helpers.ts new file mode 100644 index 0000000000..676efb3552 --- /dev/null +++ b/packages/playground/website/src/github/git-auth-helpers.ts @@ -0,0 +1,35 @@ +import { oAuthState } from './state'; + +const KNOWN_CORS_PROXY_URLS = [ + 'https://playground.wordpress.net/cors-proxy.php?', + 'https://wordpress-playground-cors-proxy.net/?', + 'http://127.0.0.1:5263/cors-proxy.php?', +]; + +export function isGitHubUrl(url: string): boolean { + if (url.includes('github.com')) { + return true; + } + for (const corsProxyUrl of KNOWN_CORS_PROXY_URLS) { + if ( + url.startsWith(corsProxyUrl) && + url.substring(corsProxyUrl.length).includes('github.com') + ) { + return true; + } + } + return false; +} + +export function createGitHubAuthHeaders(): Record { + const token = oAuthState.value.token; + if (!token) { + return {}; + } + + return { + Authorization: `Basic ${btoa(`${token}:`)}`, + // Tell the CORS proxy to forward the Authorization header + 'X-Cors-Proxy-Allowed-Request-Headers': 'Authorization', + }; +} diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 3ad88d66ea..96263d76b4 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -27,7 +27,7 @@ import { selectSiteBySlug } from './slice-sites'; // @ts-ignore import { corsProxyUrl } from 'virtual:cors-proxy-url'; import { modalSlugs } from '../../../components/layout'; -import { createGitHubAuthHeaders } from '../../github/git-auth-helpers'; +import { createGitHubAuthHeaders } from '../../../github/git-auth-helpers'; export function bootSiteClient( siteSlug: string, From cb9755b33914e9d8e294c3874a890c555d35c414 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Thu, 13 Nov 2025 10:14:00 +0100 Subject: [PATCH 08/14] Make sure to use the isGithubUrl --- .../blueprints/src/lib/v1/compile.ts | 6 ++- .../blueprints/src/lib/v1/resources.ts | 6 +-- packages/playground/client/src/index.ts | 3 +- .../storage/src/lib/git-sparse-checkout.ts | 35 ++++++++++++---- .../website/src/github/git-auth-helpers.ts | 42 +++++++++++-------- 5 files changed, 60 insertions(+), 32 deletions(-) diff --git a/packages/playground/blueprints/src/lib/v1/compile.ts b/packages/playground/blueprints/src/lib/v1/compile.ts index bb200035dd..deb7d6cc20 100644 --- a/packages/playground/blueprints/src/lib/v1/compile.ts +++ b/packages/playground/blueprints/src/lib/v1/compile.ts @@ -85,8 +85,9 @@ export interface CompileBlueprintV1Options { streamBundledFile?: StreamBundledFile; /** * Additional headers to pass to git operations. + * A function that returns headers based on the URL being accessed. */ - gitAdditionalHeaders?: Record; + gitAdditionalHeaders?: (url: string) => Record; /** * Additional steps to add to the blueprint. */ @@ -522,8 +523,9 @@ interface CompileStepArgsOptions { streamBundledFile?: StreamBundledFile; /** * Additional headers to pass to git operations. + * A function that returns headers based on the URL being accessed. */ - gitAdditionalHeaders?: Record; + gitAdditionalHeaders?: (url: string) => Record; } /** diff --git a/packages/playground/blueprints/src/lib/v1/resources.ts b/packages/playground/blueprints/src/lib/v1/resources.ts index c281f4d3f4..54adb153fe 100644 --- a/packages/playground/blueprints/src/lib/v1/resources.ts +++ b/packages/playground/blueprints/src/lib/v1/resources.ts @@ -164,7 +164,7 @@ export abstract class Resource { progress?: ProgressTracker; corsProxy?: string; streamBundledFile?: StreamBundledFile; - gitAdditionalHeaders?: Record; + gitAdditionalHeaders?: (url: string) => Record; } ): Resource { let resource: Resource; @@ -561,7 +561,7 @@ export class GitDirectoryResource extends Resource { private reference: GitDirectoryReference; private options?: { corsProxy?: string; - additionalHeaders?: Record; + additionalHeaders?: (url: string) => Record; }; constructor( @@ -569,7 +569,7 @@ export class GitDirectoryResource extends Resource { _progress?: ProgressTracker, options?: { corsProxy?: string; - additionalHeaders?: Record; + additionalHeaders?: (url: string) => Record; } ) { super(); diff --git a/packages/playground/client/src/index.ts b/packages/playground/client/src/index.ts index 5312980aed..0f97ca9b78 100644 --- a/packages/playground/client/src/index.ts +++ b/packages/playground/client/src/index.ts @@ -87,8 +87,9 @@ export interface StartPlaygroundOptions { corsProxy?: string; /** * Additional headers to pass to git operations. + * A function that returns headers based on the URL being accessed. */ - gitAdditionalHeaders?: Record; + gitAdditionalHeaders?: (url: string) => Record; /** * The version of the SQLite driver to use. * Defaults to the latest development version. diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts index d91cc138a8..0003cb2259 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -42,6 +42,19 @@ export class GitAuthenticationError extends Error { } } +export type GitAdditionalHeaders = (url: string) => Record; + +function resolveGitHeaders( + url: string, + headers?: GitAdditionalHeaders +): Record { + if (!headers || typeof headers !== 'function') { + return {}; + } + + return headers(url); +} + /** * Downloads specific files from a git repository. * It uses the git protocol over HTTP to fetch the files. It only uses @@ -79,20 +92,24 @@ export async function sparseCheckout( filesPaths: string[], options?: { withObjects?: boolean; - additionalHeaders?: Record; + additionalHeaders?: GitAdditionalHeaders; } ): Promise { const treesPack = await fetchWithoutBlobs( repoUrl, commitHash, - options?.additionalHeaders + resolveGitHeaders(repoUrl, options?.additionalHeaders) ); const objects = await resolveObjects(treesPack.idx, commitHash, filesPaths); const blobOids = filesPaths.map((path) => objects[path].oid); const blobsPack = blobOids.length > 0 - ? await fetchObjects(repoUrl, blobOids, options?.additionalHeaders) + ? await fetchObjects( + repoUrl, + blobOids, + resolveGitHeaders(repoUrl, options?.additionalHeaders) + ) : null; const fetchedPaths: Record = {}; @@ -197,12 +214,12 @@ const FULL_SHA_REGEX = /^[0-9a-f]{40}$/i; export async function listGitFiles( repoUrl: string, commitHash: string, - additionalHeaders?: Record + additionalHeaders?: GitAdditionalHeaders ): Promise { const treesPack = await fetchWithoutBlobs( repoUrl, commitHash, - additionalHeaders + resolveGitHeaders(repoUrl, additionalHeaders) ); const rootTree = await resolveAllObjects(treesPack.idx, commitHash); if (!rootTree?.object) { @@ -222,7 +239,7 @@ export async function listGitFiles( export async function resolveCommitHash( repoUrl: string, ref: GitRef, - additionalHeaders?: Record + additionalHeaders?: GitAdditionalHeaders ) { const parsed = await parseGitRef(repoUrl, ref); if (parsed.resolvedOid) { @@ -268,7 +285,7 @@ function gitTreeToFileTree(tree: GitTree): GitFileTree[] { export async function listGitRefs( repoUrl: string, fullyQualifiedBranchPrefix: string, - additionalHeaders?: Record + additionalHeaders?: GitAdditionalHeaders ) { const packbuffer = Buffer.from( (await collect([ @@ -289,7 +306,7 @@ export async function listGitRefs( 'content-type': 'application/x-git-upload-pack-request', 'Content-Length': `${packbuffer.length}`, 'Git-Protocol': 'version=2', - ...additionalHeaders, + ...resolveGitHeaders(repoUrl, additionalHeaders), }, body: packbuffer as any, }); @@ -418,7 +435,7 @@ async function parseGitRef( async function fetchRefOid( repoUrl: string, refname: string, - additionalHeaders?: Record + additionalHeaders?: GitAdditionalHeaders ) { const refs = await listGitRefs(repoUrl, refname, additionalHeaders); const candidates = [refname, `${refname}^{}`]; diff --git a/packages/playground/website/src/github/git-auth-helpers.ts b/packages/playground/website/src/github/git-auth-helpers.ts index 676efb3552..da512e07d0 100644 --- a/packages/playground/website/src/github/git-auth-helpers.ts +++ b/packages/playground/website/src/github/git-auth-helpers.ts @@ -7,29 +7,37 @@ const KNOWN_CORS_PROXY_URLS = [ ]; export function isGitHubUrl(url: string): boolean { - if (url.includes('github.com')) { - return true; - } for (const corsProxyUrl of KNOWN_CORS_PROXY_URLS) { - if ( - url.startsWith(corsProxyUrl) && - url.substring(corsProxyUrl.length).includes('github.com') - ) { - return true; + if (url.startsWith(corsProxyUrl)) { + url = url.substring(corsProxyUrl.length); + break; } } - return false; + + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + return hostname === 'github.com'; + } catch { + return false; + } } -export function createGitHubAuthHeaders(): Record { +export function createGitHubAuthHeaders(): ( + url: string +) => Record { const token = oAuthState.value.token; - if (!token) { - return {}; - } - return { - Authorization: `Basic ${btoa(`${token}:`)}`, - // Tell the CORS proxy to forward the Authorization header - 'X-Cors-Proxy-Allowed-Request-Headers': 'Authorization', + return (url: string) => { + if (!token || !isGitHubUrl(url)) { + return {}; + } + + const headers = { + Authorization: `Basic ${btoa(`${token}:`)}`, + // Tell the CORS proxy to forward the Authorization header + 'X-Cors-Proxy-Allowed-Request-Headers': 'Authorization', + }; + return headers; }; } From 9b7ef8300e7971be541485e555d2ff90f7274b9a Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 14 Nov 2025 09:46:47 +0100 Subject: [PATCH 09/14] Add tests and address review feedback --- .../src/lib/git-sparse-checkout.spec.ts | 134 +++++++++++++++++ .../storage/src/lib/git-sparse-checkout.ts | 19 +-- .../github/acquire-oauth-token-if-needed.tsx | 3 +- .../src/github/git-auth-helpers.spec.ts | 140 ++++++++++++++++++ .../website/src/github/git-auth-helpers.ts | 50 ++++--- 5 files changed, 313 insertions(+), 33 deletions(-) create mode 100644 packages/playground/website/src/github/git-auth-helpers.spec.ts diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts b/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts index 1026bdd5a4..6f333cc49c 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts @@ -3,7 +3,9 @@ import { sparseCheckout, listGitFiles, resolveCommitHash, + type GitAdditionalHeaders, } from './git-sparse-checkout'; +import { vi } from 'vitest'; describe('listRefs', () => { it('should return the latest commit hash for a given ref', async () => { @@ -190,3 +192,135 @@ describe('listGitFiles', () => { ); }); }); + +describe('gitAdditionalHeaders callback', () => { + const repoUrl = 'https://github.com/WordPress/wordpress-playground.git'; + + it('should invoke callback with the actual URL being fetched', async () => { + const headerCallback = vi.fn(() => ({})); + + await listGitRefs(repoUrl, 'refs/heads/trunk', headerCallback); + + expect(headerCallback).toHaveBeenCalledWith(repoUrl); + }); + + it('should successfully fetch when callback returns empty object', async () => { + const headerCallback: GitAdditionalHeaders = () => ({}); + + const refs = await listGitRefs( + repoUrl, + 'refs/heads/trunk', + headerCallback + ); + + expect(refs).toHaveProperty('refs/heads/trunk'); + expect(refs['refs/heads/trunk']).toMatch(/^[a-f0-9]{40}$/); + }); + + it('should pass callback through the full call chain', async () => { + const headerCallback = vi.fn(() => ({})); + + await resolveCommitHash( + repoUrl, + { value: 'trunk', type: 'branch' }, + headerCallback + ); + + expect(headerCallback).toHaveBeenCalledWith(repoUrl); + }); +}); + +describe('authentication error handling', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should throw GitAuthenticationError for 401 responses', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + const headerCallback: GitAdditionalHeaders = () => ({ + Authorization: 'Bearer token', + }); + + await expect( + listGitRefs( + 'https://github.com/user/private-repo', + 'refs/heads/main', + headerCallback + ) + ).rejects.toThrow( + 'Authentication required to access private repository' + ); + }); + + it('should throw GitAuthenticationError for 403 responses', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + }); + + const headerCallback: GitAdditionalHeaders = () => ({ + Authorization: 'Bearer token', + }); + + await expect( + listGitRefs( + 'https://github.com/user/private-repo', + 'refs/heads/main', + headerCallback + ) + ).rejects.toThrow( + 'Authentication required to access private repository' + ); + }); + + it('should throw generic error for 404 even with auth token (ambiguous: repo not found OR no access)', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const headerCallback: GitAdditionalHeaders = () => ({ + Authorization: 'Bearer token', + }); + + await expect( + listGitRefs( + 'https://github.com/user/repo-or-no-access', + 'refs/heads/main', + headerCallback + ) + ).rejects.toThrow( + 'Failed to fetch git refs from https://github.com/user/repo-or-no-access: 404 Not Found' + ); + }); + + it('should throw generic error for 404 without auth token', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect( + listGitRefs( + 'https://github.com/user/nonexistent-repo', + 'refs/heads/main' + ) + ).rejects.toThrow( + 'Failed to fetch git refs from https://github.com/user/nonexistent-repo: 404 Not Found' + ); + }); +}); diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts index 0003cb2259..dcf228dc73 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -44,17 +44,6 @@ export class GitAuthenticationError extends Error { export type GitAdditionalHeaders = (url: string) => Record; -function resolveGitHeaders( - url: string, - headers?: GitAdditionalHeaders -): Record { - if (!headers || typeof headers !== 'function') { - return {}; - } - - return headers(url); -} - /** * Downloads specific files from a git repository. * It uses the git protocol over HTTP to fetch the files. It only uses @@ -98,7 +87,7 @@ export async function sparseCheckout( const treesPack = await fetchWithoutBlobs( repoUrl, commitHash, - resolveGitHeaders(repoUrl, options?.additionalHeaders) + options?.additionalHeaders?.(repoUrl) ?? {} ); const objects = await resolveObjects(treesPack.idx, commitHash, filesPaths); @@ -108,7 +97,7 @@ export async function sparseCheckout( ? await fetchObjects( repoUrl, blobOids, - resolveGitHeaders(repoUrl, options?.additionalHeaders) + options?.additionalHeaders?.(repoUrl) ?? {} ) : null; @@ -219,7 +208,7 @@ export async function listGitFiles( const treesPack = await fetchWithoutBlobs( repoUrl, commitHash, - resolveGitHeaders(repoUrl, additionalHeaders) + additionalHeaders?.(repoUrl) ?? {} ); const rootTree = await resolveAllObjects(treesPack.idx, commitHash); if (!rootTree?.object) { @@ -306,7 +295,7 @@ export async function listGitRefs( 'content-type': 'application/x-git-upload-pack-request', 'Content-Length': `${packbuffer.length}`, 'Git-Protocol': 'version=2', - ...resolveGitHeaders(repoUrl, additionalHeaders), + ...(additionalHeaders?.(repoUrl) ?? {}), }, body: packbuffer as any, }); diff --git a/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx b/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx index 286b5aa670..a642d1e9dd 100644 --- a/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx +++ b/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx @@ -34,7 +34,8 @@ export async function acquireOAuthTokenIfNeeded() { // Reload the page to retry the blueprint with the new token // This is necessary because the blueprint failed before we had the token - window.location.href = url.toString(); + // Use replace() instead of href assignment to avoid Chrome Error 5 + window.location.replace(url.toString()); } finally { oAuthState.value = { ...oAuthState.value, diff --git a/packages/playground/website/src/github/git-auth-helpers.spec.ts b/packages/playground/website/src/github/git-auth-helpers.spec.ts new file mode 100644 index 0000000000..5e0c405855 --- /dev/null +++ b/packages/playground/website/src/github/git-auth-helpers.spec.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createGitHubAuthHeaders } from './git-auth-helpers'; +import { oAuthState } from './state'; + +vi.mock('virtual:cors-proxy-url', () => ({ + corsProxyUrl: 'https://corsproxyurl/', +})); + +describe('createGitHubAuthHeaders', () => { + beforeEach(() => { + oAuthState.value = { token: '', isAuthorizing: false }; + }); + + describe('with GitHub token present', () => { + beforeEach(() => { + oAuthState.value = { + token: 'gho_TestToken123', + isAuthorizing: false, + }; + }); + + it('includes Authorization header for github.com URLs', () => { + const getHeaders = createGitHubAuthHeaders(); + const headers = getHeaders('https://github.com/user/repo'); + + expect(headers).toHaveProperty('Authorization'); + expect(headers.Authorization).toMatch(/^Basic /); + expect(headers).toHaveProperty( + 'X-Cors-Proxy-Allowed-Request-Headers', + 'Authorization' + ); + }); + + it('includes Authorization header for api.github.com URLs', () => { + const getHeaders = createGitHubAuthHeaders(); + const headers = getHeaders('https://api.github.com/repos'); + + expect(headers).toHaveProperty('Authorization'); + }); + + it('includes Authorization header for GitHub URLs through CORS proxy', () => { + const getHeaders = createGitHubAuthHeaders(); + const headers = getHeaders( + 'https://corsproxyurl/?https://github.com/user/repo' + ); + + expect(headers).toHaveProperty('Authorization'); + expect(headers).toHaveProperty( + 'X-Cors-Proxy-Allowed-Request-Headers' + ); + }); + + it('does NOT include Authorization header for non-GitHub URLs', () => { + const getHeaders = createGitHubAuthHeaders(); + + expect(getHeaders('https://gitlab.com/user/repo')).toEqual({}); + expect(getHeaders('https://bitbucket.org/user/repo')).toEqual({}); + }); + + it('does NOT include Authorization header for malicious URLs (security)', () => { + const getHeaders = createGitHubAuthHeaders(); + + // github.com in path + expect(getHeaders('https://evil.com/github.com/fake')).toEqual({}); + + // github.com in query parameter + expect(getHeaders('https://evil.com?redirect=github.com')).toEqual( + {} + ); + + // look-alike domains + expect(getHeaders('https://github.com.evil.com')).toEqual({}); + expect(getHeaders('https://mygithub.com')).toEqual({}); + expect(getHeaders('https://fakegithub.com')).toEqual({}); + }); + + it('does NOT include Authorization header for non-GitHub URLs through CORS proxy', () => { + const getHeaders = createGitHubAuthHeaders(); + const headers = getHeaders( + 'https://corsproxyurl/?https://gitlab.com/user/repo' + ); + + expect(headers).toEqual({}); + }); + + it('does NOT include Authorization header for malicious URLs through CORS proxy (security)', () => { + const getHeaders = createGitHubAuthHeaders(); + + expect( + getHeaders( + 'https://corsproxyurl/?https://evil.com/github.com/fake' + ) + ).toEqual({}); + + expect( + getHeaders('https://corsproxyurl/?https://github.com.evil.com') + ).toEqual({}); + }); + }); + + describe('without GitHub token', () => { + beforeEach(() => { + oAuthState.value = { token: '', isAuthorizing: false }; + }); + + it('returns empty headers even for GitHub URLs', () => { + const getHeaders = createGitHubAuthHeaders(); + + expect(getHeaders('https://github.com/user/repo')).toEqual({}); + }); + }); + + describe('token encoding', () => { + it('encodes token correctly as Basic auth', () => { + oAuthState.value = { token: 'test-token', isAuthorizing: false }; + const getHeaders = createGitHubAuthHeaders(); + const headers = getHeaders('https://github.com/user/repo'); + + const decoded = atob(headers.Authorization.replace('Basic ', '')); + expect(decoded).toBe('test-token:'); + }); + + it('handles tokens with non-ASCII characters (UTF-8)', () => { + // This would fail with plain btoa(): "characters outside of the Latin1 range" + oAuthState.value = { + token: 'test-token-ąñ-emoji-🔑', + isAuthorizing: false, + }; + const getHeaders = createGitHubAuthHeaders(); + const headers = getHeaders('https://github.com/user/repo'); + + expect(headers).toHaveProperty('Authorization'); + expect(headers.Authorization).toMatch(/^Basic /); + + // Verify the encoding is valid base64 + const base64Part = headers.Authorization.replace('Basic ', ''); + expect(() => atob(base64Part)).not.toThrow(); + }); + }); +}); diff --git a/packages/playground/website/src/github/git-auth-helpers.ts b/packages/playground/website/src/github/git-auth-helpers.ts index da512e07d0..0a004b9750 100644 --- a/packages/playground/website/src/github/git-auth-helpers.ts +++ b/packages/playground/website/src/github/git-auth-helpers.ts @@ -1,23 +1,32 @@ import { oAuthState } from './state'; +import { corsProxyUrl } from 'virtual:cors-proxy-url'; -const KNOWN_CORS_PROXY_URLS = [ - 'https://playground.wordpress.net/cors-proxy.php?', - 'https://wordpress-playground-cors-proxy.net/?', - 'http://127.0.0.1:5263/cors-proxy.php?', -]; +function isGitHubUrl(url: string): boolean { + try { + const urlObj = new URL(url); + const corsProxyOrigin = new URL(corsProxyUrl).origin; -export function isGitHubUrl(url: string): boolean { - for (const corsProxyUrl of KNOWN_CORS_PROXY_URLS) { - if (url.startsWith(corsProxyUrl)) { - url = url.substring(corsProxyUrl.length); - break; + if (urlObj.origin === corsProxyOrigin && urlObj.search) { + const queryWithoutQuestion = urlObj.search.substring(1); + // Check if the query string starts with http:// or https:// + if (queryWithoutQuestion.match(/^https?:\/\//)) { + const decodedUrl = decodeURIComponent(queryWithoutQuestion); + try { + const targetUrlObj = new URL(decodedUrl); + const hostname = targetUrlObj.hostname; + return ( + hostname === 'github.com' || + hostname === 'api.github.com' + ); + } catch { + // If parsing the target URL fails, fall through to direct check + } + } } - } - try { - const urlObj = new URL(url); + // Direct URL check const hostname = urlObj.hostname; - return hostname === 'github.com'; + return hostname === 'github.com' || hostname === 'api.github.com'; } catch { return false; } @@ -33,11 +42,18 @@ export function createGitHubAuthHeaders(): ( return {}; } - const headers = { - Authorization: `Basic ${btoa(`${token}:`)}`, + const encoder = new TextEncoder(); + const data = encoder.encode(`${token}:`); + const binary = []; + for (let i = 0; i < data.length; i++) { + binary.push(String.fromCharCode(data[i])); + } + const encodedToken = btoa(binary.join('')); + + return { + Authorization: `Basic ${encodedToken}`, // Tell the CORS proxy to forward the Authorization header 'X-Cors-Proxy-Allowed-Request-Headers': 'Authorization', }; - return headers; }; } From a7e01e2bee810242807416e542a0527298b12a5e Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Fri, 14 Nov 2025 16:20:39 +0100 Subject: [PATCH 10/14] Fix type declarations --- packages/playground/website/src/github/git-auth-helpers.ts | 2 +- packages/playground/website/src/lib/types.d.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/playground/website/src/github/git-auth-helpers.ts b/packages/playground/website/src/github/git-auth-helpers.ts index 0a004b9750..8fbdbc69d8 100644 --- a/packages/playground/website/src/github/git-auth-helpers.ts +++ b/packages/playground/website/src/github/git-auth-helpers.ts @@ -37,7 +37,7 @@ export function createGitHubAuthHeaders(): ( ) => Record { const token = oAuthState.value.token; - return (url: string) => { + return (url: string): Record => { if (!token || !isGitHubUrl(url)) { return {}; } diff --git a/packages/playground/website/src/lib/types.d.ts b/packages/playground/website/src/lib/types.d.ts index e6f5be5cfb..b505b23620 100644 --- a/packages/playground/website/src/lib/types.d.ts +++ b/packages/playground/website/src/lib/types.d.ts @@ -3,3 +3,8 @@ declare module 'virtual:website-config' { export const remotePlaygroundOrigin: string; export const buildVersion: string; } + +// Defined in vite.config.ts +declare module 'virtual:cors-proxy-url' { + export const corsProxyUrl: string; +} From 8174ca0a213df9363a8aa8c2ed216ae903d50916 Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Sat, 15 Nov 2025 06:51:50 +0100 Subject: [PATCH 11/14] Simplify logic by avoiding processing cors wrapped urls --- .../blueprints/src/lib/v1/compile.ts | 12 +- .../blueprints/src/lib/v1/resources.ts | 118 ++++++++++-------- .../client/src/blueprints-v1-handler.ts | 4 +- packages/playground/client/src/index.ts | 2 +- .../src/lib/git-sparse-checkout.spec.ts | 46 +++---- .../storage/src/lib/git-sparse-checkout.ts | 21 ++-- .../github-private-repo-auth-modal/index.tsx | 33 ++--- .../src/github/git-auth-helpers.spec.ts | 59 ++------- .../website/src/github/git-auth-helpers.ts | 33 ++--- .../src/lib/state/redux/boot-site-client.ts | 22 +++- .../playground/website/src/lib/types.d.ts | 5 - 11 files changed, 146 insertions(+), 209 deletions(-) diff --git a/packages/playground/blueprints/src/lib/v1/compile.ts b/packages/playground/blueprints/src/lib/v1/compile.ts index deb7d6cc20..d00038450c 100644 --- a/packages/playground/blueprints/src/lib/v1/compile.ts +++ b/packages/playground/blueprints/src/lib/v1/compile.ts @@ -87,7 +87,7 @@ export interface CompileBlueprintV1Options { * Additional headers to pass to git operations. * A function that returns headers based on the URL being accessed. */ - gitAdditionalHeaders?: (url: string) => Record; + gitAdditionalHeadersCallback?: (url: string) => Record; /** * Additional steps to add to the blueprint. */ @@ -147,7 +147,7 @@ function compileBlueprintJson( onBlueprintValidated = () => {}, corsProxy, streamBundledFile, - gitAdditionalHeaders, + gitAdditionalHeadersCallback, additionalSteps, }: CompileBlueprintV1Options = {} ): CompiledBlueprintV1 { @@ -327,7 +327,7 @@ function compileBlueprintJson( totalProgressWeight, corsProxy, streamBundledFile, - gitAdditionalHeaders, + gitAdditionalHeadersCallback, }) ); @@ -525,7 +525,7 @@ interface CompileStepArgsOptions { * Additional headers to pass to git operations. * A function that returns headers based on the URL being accessed. */ - gitAdditionalHeaders?: (url: string) => Record; + gitAdditionalHeadersCallback?: (url: string) => Record; } /** @@ -544,7 +544,7 @@ function compileStep( totalProgressWeight, corsProxy, streamBundledFile, - gitAdditionalHeaders, + gitAdditionalHeadersCallback, }: CompileStepArgsOptions ): { run: CompiledV1Step; step: S; resources: Array> } { const stepProgress = rootProgressTracker.stage( @@ -559,7 +559,7 @@ function compileStep( semaphore, corsProxy, streamBundledFile, - gitAdditionalHeaders, + gitAdditionalHeadersCallback, }); } args[key] = value; diff --git a/packages/playground/blueprints/src/lib/v1/resources.ts b/packages/playground/blueprints/src/lib/v1/resources.ts index 54adb153fe..93ad66957c 100644 --- a/packages/playground/blueprints/src/lib/v1/resources.ts +++ b/packages/playground/blueprints/src/lib/v1/resources.ts @@ -7,6 +7,7 @@ import type { FileTree, UniversalPHP } from '@php-wasm/universal'; import type { Semaphore } from '@php-wasm/util'; import { randomFilename } from '@php-wasm/util'; import { + GitAuthenticationError, listDescendantFiles, listGitFiles, resolveCommitHash, @@ -157,14 +158,16 @@ export abstract class Resource { progress, corsProxy, streamBundledFile, - gitAdditionalHeaders, + gitAdditionalHeadersCallback, }: { /** Optional semaphore to limit concurrent downloads */ semaphore?: Semaphore; progress?: ProgressTracker; corsProxy?: string; streamBundledFile?: StreamBundledFile; - gitAdditionalHeaders?: (url: string) => Record; + gitAdditionalHeadersCallback?: ( + url: string + ) => Record; } ): Resource { let resource: Resource; @@ -187,7 +190,7 @@ export abstract class Resource { case 'git:directory': resource = new GitDirectoryResource(ref, progress, { corsProxy, - additionalHeaders: gitAdditionalHeaders, + additionalHeaders: gitAdditionalHeadersCallback, }); break; case 'literal:directory': @@ -579,60 +582,77 @@ export class GitDirectoryResource extends Resource { } async resolve() { + const additionalHeaders = + this.options?.additionalHeaders?.(this.reference.url) ?? {}; + const repoUrl = this.options?.corsProxy ? `${this.options.corsProxy}${this.reference.url}` : this.reference.url; - const commitHash = await resolveCommitHash( - repoUrl, - { - value: this.reference.ref, - type: this.reference.refType ?? 'infer', - }, - this.options?.additionalHeaders - ); - const allFiles = await listGitFiles( - repoUrl, - commitHash, - this.options?.additionalHeaders - ); - - const requestedPath = (this.reference.path ?? '').replace(/^\/+/, ''); - const filesToClone = listDescendantFiles(allFiles, requestedPath); - const checkout = await sparseCheckout( - repoUrl, - commitHash, - filesToClone, - { - withObjects: this.reference['.git'], - additionalHeaders: this.options?.additionalHeaders, - } - ); - let files = checkout.files; + try { + const commitHash = await resolveCommitHash( + repoUrl, + { + value: this.reference.ref, + type: this.reference.refType ?? 'infer', + }, + additionalHeaders + ); + const allFiles = await listGitFiles( + repoUrl, + commitHash, + additionalHeaders + ); - // Remove the path prefix from the cloned file names. - files = mapKeys(files, (name) => - name.substring(requestedPath.length).replace(/^\/+/, '') - ); - if (this.reference['.git']) { - const gitFiles = await createDotGitDirectory({ - repoUrl: this.reference.url, + const requestedPath = (this.reference.path ?? '').replace( + /^\/+/, + '' + ); + const filesToClone = listDescendantFiles(allFiles, requestedPath); + const checkout = await sparseCheckout( + repoUrl, commitHash, - ref: this.reference.ref, - refType: this.reference.refType, - objects: checkout.objects ?? [], - fileOids: checkout.fileOids ?? {}, - pathPrefix: requestedPath, - }); - files = { - ...gitFiles, - ...files, + filesToClone, + { + withObjects: this.reference['.git'], + additionalHeaders, + } + ); + let files = checkout.files; + + // Remove the path prefix from the cloned file names. + files = mapKeys(files, (name) => + name.substring(requestedPath.length).replace(/^\/+/, '') + ); + if (this.reference['.git']) { + const gitFiles = await createDotGitDirectory({ + repoUrl: this.reference.url, + commitHash, + ref: this.reference.ref, + refType: this.reference.refType, + objects: checkout.objects ?? [], + fileOids: checkout.fileOids ?? {}, + pathPrefix: requestedPath, + }); + files = { + ...gitFiles, + ...files, + }; + } + return { + name: this.filename, + files, }; + } catch (error) { + if (error instanceof GitAuthenticationError) { + // Unwrap and re-throw with the original URL (without CORS proxy) + throw new GitAuthenticationError( + this.reference.url, + error.status + ); + } + throw error; } - return { - name: this.filename, - files, - }; } /** diff --git a/packages/playground/client/src/blueprints-v1-handler.ts b/packages/playground/client/src/blueprints-v1-handler.ts index cb60d9a43c..4ea030750a 100644 --- a/packages/playground/client/src/blueprints-v1-handler.ts +++ b/packages/playground/client/src/blueprints-v1-handler.ts @@ -21,7 +21,7 @@ export class BlueprintsV1Handler { onBlueprintValidated, onBlueprintStepCompleted, corsProxy, - gitAdditionalHeaders, + gitAdditionalHeadersCallback, mounts, sapiName, scope, @@ -73,7 +73,7 @@ export class BlueprintsV1Handler { onStepCompleted: onBlueprintStepCompleted, onBlueprintValidated, corsProxy, - gitAdditionalHeaders, + gitAdditionalHeadersCallback, }); await runBlueprintV1Steps(compiled, playground); } diff --git a/packages/playground/client/src/index.ts b/packages/playground/client/src/index.ts index 0f97ca9b78..834b7f97cb 100644 --- a/packages/playground/client/src/index.ts +++ b/packages/playground/client/src/index.ts @@ -89,7 +89,7 @@ export interface StartPlaygroundOptions { * Additional headers to pass to git operations. * A function that returns headers based on the URL being accessed. */ - gitAdditionalHeaders?: (url: string) => Record; + gitAdditionalHeadersCallback?: (url: string) => Record; /** * The version of the SQLite driver to use. * Defaults to the latest development version. diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts b/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts index 6f333cc49c..60acfd3d2f 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.spec.ts @@ -193,40 +193,28 @@ describe('listGitFiles', () => { }); }); -describe('gitAdditionalHeaders callback', () => { +describe('gitAdditionalHeaders', () => { const repoUrl = 'https://github.com/WordPress/wordpress-playground.git'; - it('should invoke callback with the actual URL being fetched', async () => { - const headerCallback = vi.fn(() => ({})); + it('should successfully fetch when headers is empty object', async () => { + const headers: GitAdditionalHeaders = {}; - await listGitRefs(repoUrl, 'refs/heads/trunk', headerCallback); - - expect(headerCallback).toHaveBeenCalledWith(repoUrl); - }); - - it('should successfully fetch when callback returns empty object', async () => { - const headerCallback: GitAdditionalHeaders = () => ({}); - - const refs = await listGitRefs( - repoUrl, - 'refs/heads/trunk', - headerCallback - ); + const refs = await listGitRefs(repoUrl, 'refs/heads/trunk', headers); expect(refs).toHaveProperty('refs/heads/trunk'); expect(refs['refs/heads/trunk']).toMatch(/^[a-f0-9]{40}$/); }); - it('should pass callback through the full call chain', async () => { - const headerCallback = vi.fn(() => ({})); + it('should pass headers through the full call chain', async () => { + const headers: GitAdditionalHeaders = {}; await resolveCommitHash( repoUrl, { value: 'trunk', type: 'branch' }, - headerCallback + headers ); - expect(headerCallback).toHaveBeenCalledWith(repoUrl); + expect(headers).toBeDefined(); }); }); @@ -248,15 +236,15 @@ describe('authentication error handling', () => { statusText: 'Unauthorized', }); - const headerCallback: GitAdditionalHeaders = () => ({ + const headers: GitAdditionalHeaders = { Authorization: 'Bearer token', - }); + }; await expect( listGitRefs( 'https://github.com/user/private-repo', 'refs/heads/main', - headerCallback + headers ) ).rejects.toThrow( 'Authentication required to access private repository' @@ -270,15 +258,15 @@ describe('authentication error handling', () => { statusText: 'Forbidden', }); - const headerCallback: GitAdditionalHeaders = () => ({ + const headers: GitAdditionalHeaders = { Authorization: 'Bearer token', - }); + }; await expect( listGitRefs( 'https://github.com/user/private-repo', 'refs/heads/main', - headerCallback + headers ) ).rejects.toThrow( 'Authentication required to access private repository' @@ -292,15 +280,15 @@ describe('authentication error handling', () => { statusText: 'Not Found', }); - const headerCallback: GitAdditionalHeaders = () => ({ + const headers: GitAdditionalHeaders = { Authorization: 'Bearer token', - }); + }; await expect( listGitRefs( 'https://github.com/user/repo-or-no-access', 'refs/heads/main', - headerCallback + headers ) ).rejects.toThrow( 'Failed to fetch git refs from https://github.com/user/repo-or-no-access: 404 Not Found' diff --git a/packages/playground/storage/src/lib/git-sparse-checkout.ts b/packages/playground/storage/src/lib/git-sparse-checkout.ts index dcf228dc73..dc9cbfb0a9 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -42,7 +42,7 @@ export class GitAuthenticationError extends Error { } } -export type GitAdditionalHeaders = (url: string) => Record; +export type GitAdditionalHeaders = Record; /** * Downloads specific files from a git repository. @@ -84,21 +84,18 @@ export async function sparseCheckout( additionalHeaders?: GitAdditionalHeaders; } ): Promise { + const additionalHeaders = options?.additionalHeaders || {}; const treesPack = await fetchWithoutBlobs( repoUrl, commitHash, - options?.additionalHeaders?.(repoUrl) ?? {} + additionalHeaders ); const objects = await resolveObjects(treesPack.idx, commitHash, filesPaths); const blobOids = filesPaths.map((path) => objects[path].oid); const blobsPack = blobOids.length > 0 - ? await fetchObjects( - repoUrl, - blobOids, - options?.additionalHeaders?.(repoUrl) ?? {} - ) + ? await fetchObjects(repoUrl, blobOids, additionalHeaders) : null; const fetchedPaths: Record = {}; @@ -203,12 +200,12 @@ const FULL_SHA_REGEX = /^[0-9a-f]{40}$/i; export async function listGitFiles( repoUrl: string, commitHash: string, - additionalHeaders?: GitAdditionalHeaders + additionalHeaders: GitAdditionalHeaders = {} ): Promise { const treesPack = await fetchWithoutBlobs( repoUrl, commitHash, - additionalHeaders?.(repoUrl) ?? {} + additionalHeaders ); const rootTree = await resolveAllObjects(treesPack.idx, commitHash); if (!rootTree?.object) { @@ -228,7 +225,7 @@ export async function listGitFiles( export async function resolveCommitHash( repoUrl: string, ref: GitRef, - additionalHeaders?: GitAdditionalHeaders + additionalHeaders: GitAdditionalHeaders = {} ) { const parsed = await parseGitRef(repoUrl, ref); if (parsed.resolvedOid) { @@ -274,7 +271,7 @@ function gitTreeToFileTree(tree: GitTree): GitFileTree[] { export async function listGitRefs( repoUrl: string, fullyQualifiedBranchPrefix: string, - additionalHeaders?: GitAdditionalHeaders + additionalHeaders: GitAdditionalHeaders = {} ) { const packbuffer = Buffer.from( (await collect([ @@ -295,7 +292,7 @@ export async function listGitRefs( 'content-type': 'application/x-git-upload-pack-request', 'Content-Length': `${packbuffer.length}`, 'Git-Protocol': 'version=2', - ...(additionalHeaders?.(repoUrl) ?? {}), + ...additionalHeaders, }, body: packbuffer as any, }); diff --git a/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx index aa83d04ffd..6d0a75061b 100644 --- a/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx +++ b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx @@ -4,40 +4,21 @@ import { setActiveModal } from '../../lib/state/redux/slice-ui'; import { Icon } from '@wordpress/components'; import { GitHubIcon } from '../../github/github'; import css from '../../github/github-oauth-guard/style.module.css'; +import { staticAnalyzeGitHubURL } from '../../github/analyze-github-url'; const OAUTH_FLOW_URL = 'oauth.php?redirect=1'; -function extractRepoName(url: string): string { - try { - // Handle CORS-proxied URLs - extract the actual GitHub URL - const corsProxyPrefixes = [ - 'https://wordpress-playground-cors-proxy.net/?', - 'http://127.0.0.1:5263/cors-proxy.php?', - ]; - let githubUrl = url; - for (const prefix of corsProxyPrefixes) { - if (url.startsWith(prefix)) { - githubUrl = url.substring(prefix.length); - break; - } - } - - // Extract owner/repo from GitHub URL - const match = githubUrl.match(/github\.com\/([^/]+\/[^/]+)/); - return match ? match[1] : url; - } catch { - return url; - } -} - export function GitHubPrivateRepoAuthModal() { const dispatch = useAppDispatch(); const repoUrl = useAppSelector((state) => state.ui.githubAuthRepoUrl); - const displayRepoName = repoUrl ? extractRepoName(repoUrl) : ''; + if (!repoUrl) { + return null; + } + + const { owner, repo } = staticAnalyzeGitHubURL(repoUrl); + const displayRepoName = owner && repo ? `${owner}/${repo}` : repoUrl; - // Remove the modal parameter from the redirect URI - // so it doesn't persist after OAuth completes const redirectUrl = new URL(window.location.href); redirectUrl.searchParams.delete('modal'); diff --git a/packages/playground/website/src/github/git-auth-helpers.spec.ts b/packages/playground/website/src/github/git-auth-helpers.spec.ts index 5e0c405855..63313009b1 100644 --- a/packages/playground/website/src/github/git-auth-helpers.spec.ts +++ b/packages/playground/website/src/github/git-auth-helpers.spec.ts @@ -1,12 +1,8 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createGitHubAuthHeaders } from './git-auth-helpers'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { createGitAuthHeaders } from './git-auth-helpers'; import { oAuthState } from './state'; -vi.mock('virtual:cors-proxy-url', () => ({ - corsProxyUrl: 'https://corsproxyurl/', -})); - -describe('createGitHubAuthHeaders', () => { +describe('createGitAuthHeaders', () => { beforeEach(() => { oAuthState.value = { token: '', isAuthorizing: false }; }); @@ -20,7 +16,7 @@ describe('createGitHubAuthHeaders', () => { }); it('includes Authorization header for github.com URLs', () => { - const getHeaders = createGitHubAuthHeaders(); + const getHeaders = createGitAuthHeaders(); const headers = getHeaders('https://github.com/user/repo'); expect(headers).toHaveProperty('Authorization'); @@ -32,33 +28,21 @@ describe('createGitHubAuthHeaders', () => { }); it('includes Authorization header for api.github.com URLs', () => { - const getHeaders = createGitHubAuthHeaders(); + const getHeaders = createGitAuthHeaders(); const headers = getHeaders('https://api.github.com/repos'); expect(headers).toHaveProperty('Authorization'); }); - it('includes Authorization header for GitHub URLs through CORS proxy', () => { - const getHeaders = createGitHubAuthHeaders(); - const headers = getHeaders( - 'https://corsproxyurl/?https://github.com/user/repo' - ); - - expect(headers).toHaveProperty('Authorization'); - expect(headers).toHaveProperty( - 'X-Cors-Proxy-Allowed-Request-Headers' - ); - }); - it('does NOT include Authorization header for non-GitHub URLs', () => { - const getHeaders = createGitHubAuthHeaders(); + const getHeaders = createGitAuthHeaders(); expect(getHeaders('https://gitlab.com/user/repo')).toEqual({}); expect(getHeaders('https://bitbucket.org/user/repo')).toEqual({}); }); it('does NOT include Authorization header for malicious URLs (security)', () => { - const getHeaders = createGitHubAuthHeaders(); + const getHeaders = createGitAuthHeaders(); // github.com in path expect(getHeaders('https://evil.com/github.com/fake')).toEqual({}); @@ -73,29 +57,6 @@ describe('createGitHubAuthHeaders', () => { expect(getHeaders('https://mygithub.com')).toEqual({}); expect(getHeaders('https://fakegithub.com')).toEqual({}); }); - - it('does NOT include Authorization header for non-GitHub URLs through CORS proxy', () => { - const getHeaders = createGitHubAuthHeaders(); - const headers = getHeaders( - 'https://corsproxyurl/?https://gitlab.com/user/repo' - ); - - expect(headers).toEqual({}); - }); - - it('does NOT include Authorization header for malicious URLs through CORS proxy (security)', () => { - const getHeaders = createGitHubAuthHeaders(); - - expect( - getHeaders( - 'https://corsproxyurl/?https://evil.com/github.com/fake' - ) - ).toEqual({}); - - expect( - getHeaders('https://corsproxyurl/?https://github.com.evil.com') - ).toEqual({}); - }); }); describe('without GitHub token', () => { @@ -104,7 +65,7 @@ describe('createGitHubAuthHeaders', () => { }); it('returns empty headers even for GitHub URLs', () => { - const getHeaders = createGitHubAuthHeaders(); + const getHeaders = createGitAuthHeaders(); expect(getHeaders('https://github.com/user/repo')).toEqual({}); }); @@ -113,7 +74,7 @@ describe('createGitHubAuthHeaders', () => { describe('token encoding', () => { it('encodes token correctly as Basic auth', () => { oAuthState.value = { token: 'test-token', isAuthorizing: false }; - const getHeaders = createGitHubAuthHeaders(); + const getHeaders = createGitAuthHeaders(); const headers = getHeaders('https://github.com/user/repo'); const decoded = atob(headers.Authorization.replace('Basic ', '')); @@ -126,7 +87,7 @@ describe('createGitHubAuthHeaders', () => { token: 'test-token-ąñ-emoji-🔑', isAuthorizing: false, }; - const getHeaders = createGitHubAuthHeaders(); + const getHeaders = createGitAuthHeaders(); const headers = getHeaders('https://github.com/user/repo'); expect(headers).toHaveProperty('Authorization'); diff --git a/packages/playground/website/src/github/git-auth-helpers.ts b/packages/playground/website/src/github/git-auth-helpers.ts index 8fbdbc69d8..3f1520d6ad 100644 --- a/packages/playground/website/src/github/git-auth-helpers.ts +++ b/packages/playground/website/src/github/git-auth-helpers.ts @@ -1,30 +1,8 @@ import { oAuthState } from './state'; -import { corsProxyUrl } from 'virtual:cors-proxy-url'; function isGitHubUrl(url: string): boolean { try { const urlObj = new URL(url); - const corsProxyOrigin = new URL(corsProxyUrl).origin; - - if (urlObj.origin === corsProxyOrigin && urlObj.search) { - const queryWithoutQuestion = urlObj.search.substring(1); - // Check if the query string starts with http:// or https:// - if (queryWithoutQuestion.match(/^https?:\/\//)) { - const decodedUrl = decodeURIComponent(queryWithoutQuestion); - try { - const targetUrlObj = new URL(decodedUrl); - const hostname = targetUrlObj.hostname; - return ( - hostname === 'github.com' || - hostname === 'api.github.com' - ); - } catch { - // If parsing the target URL fails, fall through to direct check - } - } - } - - // Direct URL check const hostname = urlObj.hostname; return hostname === 'github.com' || hostname === 'api.github.com'; } catch { @@ -32,7 +10,14 @@ function isGitHubUrl(url: string): boolean { } } -export function createGitHubAuthHeaders(): ( +export function shouldShowGitHubAuthModal(url: string | undefined): boolean { + if (!url) { + return false; + } + return isGitHubUrl(url); +} + +export function createGitAuthHeaders(): ( url: string ) => Record { const token = oAuthState.value.token; @@ -52,7 +37,7 @@ export function createGitHubAuthHeaders(): ( return { Authorization: `Basic ${encodedToken}`, - // Tell the CORS proxy to forward the Authorization header + // Tell a CORS proxy to forward the Authorization header 'X-Cors-Proxy-Allowed-Request-Headers': 'Authorization', }; }; diff --git a/packages/playground/website/src/lib/state/redux/boot-site-client.ts b/packages/playground/website/src/lib/state/redux/boot-site-client.ts index 96263d76b4..cb5f800022 100644 --- a/packages/playground/website/src/lib/state/redux/boot-site-client.ts +++ b/packages/playground/website/src/lib/state/redux/boot-site-client.ts @@ -27,7 +27,10 @@ import { selectSiteBySlug } from './slice-sites'; // @ts-ignore import { corsProxyUrl } from 'virtual:cors-proxy-url'; import { modalSlugs } from '../../../components/layout'; -import { createGitHubAuthHeaders } from '../../../github/git-auth-helpers'; +import { + createGitAuthHeaders, + shouldShowGitHubAuthModal, +} from '../../../github/git-auth-helpers'; export function bootSiteClient( siteSlug: string, @@ -156,7 +159,7 @@ export function bootSiteClient( : [], shouldInstallWordPress: !isWordPressInstalled, corsProxy: corsProxyUrl, - gitAdditionalHeaders: createGitHubAuthHeaders(), + gitAdditionalHeadersCallback: createGitAuthHeaders(), }); // @TODO: Remove backcompat code after 2024-12-01. @@ -210,15 +213,22 @@ export function bootSiteClient( 'GitAuthenticationError' || (e as any).cause?.name === 'GitAuthenticationError' ) { - // Extract repo URL from the error const repoUrl = (e as any).repoUrl || (e as any).cause?.repoUrl || undefined; - if (repoUrl) { - dispatch(setGitHubAuthRepoUrl(repoUrl)); + + if (shouldShowGitHubAuthModal(repoUrl)) { + if (repoUrl) { + dispatch(setGitHubAuthRepoUrl(repoUrl)); + } + dispatch( + setActiveModal(modalSlugs.GITHUB_PRIVATE_REPO_AUTH) + ); + } else { + dispatch(setActiveSiteError('site-boot-failed')); + dispatch(setActiveModal(modalSlugs.ERROR_REPORT)); } - dispatch(setActiveModal(modalSlugs.GITHUB_PRIVATE_REPO_AUTH)); } else { dispatch(setActiveSiteError('site-boot-failed')); dispatch(setActiveModal(modalSlugs.ERROR_REPORT)); diff --git a/packages/playground/website/src/lib/types.d.ts b/packages/playground/website/src/lib/types.d.ts index b505b23620..e6f5be5cfb 100644 --- a/packages/playground/website/src/lib/types.d.ts +++ b/packages/playground/website/src/lib/types.d.ts @@ -3,8 +3,3 @@ declare module 'virtual:website-config' { export const remotePlaygroundOrigin: string; export const buildVersion: string; } - -// Defined in vite.config.ts -declare module 'virtual:cors-proxy-url' { - export const corsProxyUrl: string; -} From d23017d6744c491331dc735986f419b89785bb8f Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Sat, 15 Nov 2025 07:13:42 +0100 Subject: [PATCH 12/14] Add more tests --- .../blueprints/src/lib/v1/resources.spec.ts | 91 ++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/playground/blueprints/src/lib/v1/resources.spec.ts b/packages/playground/blueprints/src/lib/v1/resources.spec.ts index 3eceae1996..33255c6613 100644 --- a/packages/playground/blueprints/src/lib/v1/resources.spec.ts +++ b/packages/playground/blueprints/src/lib/v1/resources.spec.ts @@ -3,7 +3,7 @@ import { GitDirectoryResource, BundledResource, } from './resources'; -import { expect, describe, it, vi, beforeEach } from 'vitest'; +import { expect, describe, it, vi, beforeEach, afterEach } from 'vitest'; import { StreamedFile } from '@php-wasm/stream-compression'; import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises'; import { tmpdir } from 'os'; @@ -232,6 +232,95 @@ describe('GitDirectoryResource', () => { expect(name).toBe('https-github.com-WordPress-link-manager-trunk'); }); }); + + describe('CORS handling', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should unwrap CORS URL in GitAuthenticationError', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + const githubUrl = 'https://github.com/user/private-repo'; + const resource = new GitDirectoryResource( + { + resource: 'git:directory', + url: githubUrl, + ref: 'main', + }, + undefined, + { + corsProxy: 'https://cors-proxy.com/', + } + ); + + await expect(resource.resolve()).rejects.toMatchObject({ + name: 'GitAuthenticationError', + repoUrl: githubUrl, + status: 401, + }); + }); + + it('should preserve GitHub URL in GitAuthenticationError without CORS proxy', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + const githubUrl = 'https://github.com/user/private-repo'; + const resource = new GitDirectoryResource({ + resource: 'git:directory', + url: githubUrl, + ref: 'main', + }); + + await expect(resource.resolve()).rejects.toMatchObject({ + name: 'GitAuthenticationError', + repoUrl: githubUrl, + status: 401, + }); + }); + + it('should call gitAdditionalHeadersCallback without CORS proxy', async () => { + const githubUrl = 'https://github.com/user/private-repo'; + const headerCallback = vi.fn().mockReturnValue({ + Authorization: 'Bearer test-token', + }); + + const resource = new GitDirectoryResource( + { + resource: 'git:directory', + url: githubUrl, + ref: 'main', + }, + undefined, + { + additionalHeaders: headerCallback, + } + ); + + // Call resolve - it will fail but that's okay, we just want to verify the callback + try { + await resource.resolve(); + } catch { + // Expected to fail - we're not mocking the entire git resolution + } + + // Verify the callback was called with the GitHub URL (not CORS-wrapped) + expect(headerCallback).toHaveBeenCalledWith(githubUrl); + }); + }); }); describe('BlueprintResource', () => { From 791c2a9757790fb50feaa2cebe113d1b4f343e3b Mon Sep 17 00:00:00 2001 From: Alex Kirk Date: Tue, 18 Nov 2025 17:28:39 +0100 Subject: [PATCH 13/14] Replace the code out of the URL via replaceState --- .../website/src/github/acquire-oauth-token-if-needed.tsx | 9 +-------- .../website/src/github/github-oauth-guard/index.tsx | 4 +++- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx b/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx index a642d1e9dd..f4aa62517c 100644 --- a/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx +++ b/packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx @@ -26,16 +26,9 @@ export async function acquireOAuthTokenIfNeeded() { const body = await response.json(); setOAuthToken(body.access_token); - // Remove the ?code=... from the URL and clean up any modal state const url = new URL(window.location.href); url.searchParams.delete('code'); - url.searchParams.delete('modal'); - // Keep the hash (it contains the blueprint) - - // Reload the page to retry the blueprint with the new token - // This is necessary because the blueprint failed before we had the token - // Use replace() instead of href assignment to avoid Chrome Error 5 - window.location.replace(url.toString()); + window.history.replaceState({}, '', url.toString()); } finally { oAuthState.value = { ...oAuthState.value, diff --git a/packages/playground/website/src/github/github-oauth-guard/index.tsx b/packages/playground/website/src/github/github-oauth-guard/index.tsx index d409d8f84e..774980ee28 100644 --- a/packages/playground/website/src/github/github-oauth-guard/index.tsx +++ b/packages/playground/website/src/github/github-oauth-guard/index.tsx @@ -58,7 +58,9 @@ export default function GitHubOAuthGuard({ } const urlParams = new URLSearchParams(); - urlParams.set('redirect_uri', window.location.href); + const cleanUrl = new URL(window.location.href); + cleanUrl.searchParams.delete('code'); + urlParams.set('redirect_uri', cleanUrl.toString()); const oauthUrl = `${OAUTH_FLOW_URL}&${urlParams.toString()}`; return ( Date: Wed, 19 Nov 2025 10:24:43 +0100 Subject: [PATCH 14/14] Address review feedback --- packages/playground/website/src/github/git-auth-helpers.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/playground/website/src/github/git-auth-helpers.ts b/packages/playground/website/src/github/git-auth-helpers.ts index 3f1520d6ad..a6fc250438 100644 --- a/packages/playground/website/src/github/git-auth-helpers.ts +++ b/packages/playground/website/src/github/git-auth-helpers.ts @@ -11,10 +11,7 @@ function isGitHubUrl(url: string): boolean { } export function shouldShowGitHubAuthModal(url: string | undefined): boolean { - if (!url) { - return false; - } - return isGitHubUrl(url); + return !!url && isGitHubUrl(url); } export function createGitAuthHeaders(): ( @@ -27,6 +24,7 @@ export function createGitAuthHeaders(): ( return {}; } + // Avoid InvalidCharacterError from btoa() with non-Latin1 characters const encoder = new TextEncoder(); const data = encoder.encode(`${token}:`); const binary = [];