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 675ca531f5..2c926b4b3a 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 a977fc33bf..d91cc138a8 100644 --- a/packages/playground/storage/src/lib/git-sparse-checkout.ts +++ b/packages/playground/storage/src/lib/git-sparse-checkout.ts @@ -30,6 +30,18 @@ if (typeof globalThis.Buffer === 'undefined') { globalThis.Buffer = BufferPolyfill; } +/** + * Custom error class for git authentication failures. + */ +export class GitAuthenticationError extends Error { + constructor(public repoUrl: string, public status: number) { + super( + `Authentication required to access private repository: ${repoUrl}` + ); + this.name = 'GitAuthenticationError'; + } +} + /** * Downloads specific files from a git repository. * It uses the git protocol over HTTP to fetch the files. It only uses @@ -67,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( @@ -177,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 []; @@ -195,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}`); } @@ -239,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([ @@ -260,10 +289,20 @@ export async function listGitRefs( 'content-type': 'application/x-git-upload-pack-request', 'Content-Length': `${packbuffer.length}`, 'Git-Protocol': 'version=2', + ...additionalHeaders, }, body: packbuffer as any, }); + if (!response.ok) { + 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}` + ); + } + const refs: Record = {}; for await (const line of parseGitResponseLines(response)) { const spaceAt = line.indexOf(' '); @@ -376,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(); @@ -388,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( @@ -409,10 +456,20 @@ 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}`, + ...additionalHeaders, }, body: packbuffer as any, }); + if (!response.ok) { + 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}` + ); + } + const iterator = streamToIterator(response.body!); const parsed = await parseUploadPackResponse(iterator); const packfile = Buffer.from((await collect(parsed.packfile)) as any); @@ -539,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) => @@ -558,10 +619,20 @@ 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}`, + ...additionalHeaders, }, body: packbuffer as any, }); + if (!response.ok) { + 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}` + ); + } + 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..aa83d04ffd --- /dev/null +++ b/packages/playground/website/src/components/github-private-repo-auth-modal/index.tsx @@ -0,0 +1,87 @@ +import { Modal } from '../modal'; +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'; +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 + 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: +

+

+ + github.com/{displayRepoName} + +

+

+ If you have a GitHub account with access to this repository, + you can connect it to continue. +

+ +

+ + + 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..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 @@ -25,15 +25,20 @@ 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 + 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/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 7f5e87e613..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 @@ -17,12 +17,17 @@ 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 import { corsProxyUrl } from 'virtual:cors-proxy-url'; import { modalSlugs } from '../../../components/layout'; +import { createGitHubAuthHeaders } from '../../../github/git-auth-helpers'; export function bootSiteClient( siteSlug: string, @@ -151,6 +156,7 @@ export function bootSiteClient( : [], shouldInstallWordPress: !isWordPressInstalled, corsProxy: corsProxyUrl, + gitAdditionalHeaders: createGitHubAuthHeaders(), }); // @TODO: Remove backcompat code after 2024-12-01. @@ -198,6 +204,21 @@ export function bootSiteClient( (e as any).originalErrorClassName === 'ArtifactExpiredError' ) { dispatch(setActiveSiteError('github-artifact-expired')); + } else if ( + (e as any).name === 'GitAuthenticationError' || + (e as any).originalErrorClassName === + '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)); + } + 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..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; @@ -34,10 +35,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, @@ -85,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; }, @@ -122,7 +132,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)); @@ -135,6 +146,7 @@ export const listenToOnlineOfflineEventsMiddleware: Middleware = export const { setActiveModal, setActiveSiteError, + setGitHubAuthRepoUrl, setOffline, setSiteManagerOpen, setSiteManagerSection, 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')!;