Skip to content

Commit 4f2f5f3

Browse files
committed
Add support for private github repos via git:directory
1 parent 0935274 commit 4f2f5f3

File tree

8 files changed

+245
-24
lines changed

8 files changed

+245
-24
lines changed

packages/playground/storage/src/lib/git-sparse-checkout.ts

Lines changed: 151 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,93 @@ if (typeof globalThis.Buffer === 'undefined') {
3030
globalThis.Buffer = BufferPolyfill;
3131
}
3232

33+
/**
34+
* Module-level storage for GitHub authentication token.
35+
* This is set by browser-specific code when GitHub OAuth is available.
36+
*/
37+
let gitHubAuthToken: string | undefined;
38+
39+
/**
40+
* Sets the GitHub authentication token to use for git protocol requests.
41+
* This is intended to be called by browser-specific initialization code
42+
* where GitHub OAuth is available.
43+
*
44+
* @param token The GitHub OAuth token, or undefined to clear it
45+
*/
46+
export function setGitHubAuthToken(token: string | undefined) {
47+
gitHubAuthToken = token;
48+
}
49+
50+
/**
51+
* Custom error class for GitHub authentication failures.
52+
*/
53+
export class GitHubAuthenticationError extends Error {
54+
constructor(public repoUrl: string, public status: number) {
55+
super(
56+
`Authentication required to access private GitHub repository: ${repoUrl}`
57+
);
58+
this.name = 'GitHubAuthenticationError';
59+
}
60+
}
61+
62+
/**
63+
* Checks if a URL is a GitHub URL by parsing the hostname.
64+
* Handles both direct GitHub URLs and CORS-proxied URLs.
65+
*
66+
* @param url The URL to check
67+
* @returns true if the URL is definitively a GitHub URL, false otherwise
68+
*/
69+
function isGitHubUrl(url: string): boolean {
70+
try {
71+
const parsedUrl = new URL(url);
72+
73+
// Direct GitHub URL - check hostname
74+
if (parsedUrl.hostname === 'github.com') {
75+
return true;
76+
}
77+
78+
// CORS-proxied GitHub URL - the actual GitHub URL should be in the query string
79+
// Format: https://proxy.com/cors-proxy.php?https://github.com/...
80+
// We need to extract and validate the proxied URL's hostname
81+
const queryString = parsedUrl.search.substring(1); // Remove leading '?'
82+
if (queryString) {
83+
// Try to extract a URL from the query string
84+
// Match URLs that start with http:// or https://
85+
const urlMatch = queryString.match(/^(https?:\/\/[^\s&]+)/);
86+
if (urlMatch) {
87+
try {
88+
const proxiedUrl = new URL(urlMatch[1]);
89+
if (proxiedUrl.hostname === 'github.com') {
90+
return true;
91+
}
92+
} catch {
93+
// Invalid proxied URL, ignore
94+
}
95+
}
96+
}
97+
98+
return false;
99+
} catch {
100+
// If URL parsing fails, return false
101+
return false;
102+
}
103+
}
104+
105+
/**
106+
* Adds GitHub authentication headers to a headers object if a token is available
107+
* and the URL is a GitHub URL.
108+
*/
109+
function addGitHubAuthHeaders(headers: HeadersInit, url: string): void {
110+
if (gitHubAuthToken && isGitHubUrl(url)) {
111+
// GitHub Git protocol requires Basic Auth with token as username and empty password
112+
const basicAuth = btoa(`${gitHubAuthToken}:`);
113+
headers['Authorization'] = `Basic ${basicAuth}`;
114+
// Tell CORS proxy to forward the Authorization header
115+
// Must be lowercase because the CORS proxy lowercases header names for comparison
116+
headers['X-Cors-Proxy-Allowed-Request-Headers'] = 'authorization';
117+
}
118+
}
119+
33120
/**
34121
* Downloads specific files from a git repository.
35122
* It uses the git protocol over HTTP to fetch the files. It only uses
@@ -253,17 +340,33 @@ export async function listGitRefs(
253340
])) as any
254341
);
255342

343+
const headers: HeadersInit = {
344+
Accept: 'application/x-git-upload-pack-advertisement',
345+
'content-type': 'application/x-git-upload-pack-request',
346+
'Content-Length': `${packbuffer.length}`,
347+
'Git-Protocol': 'version=2',
348+
};
349+
350+
addGitHubAuthHeaders(headers, repoUrl);
351+
256352
const response = await fetch(repoUrl + '/git-upload-pack', {
257353
method: 'POST',
258-
headers: {
259-
Accept: 'application/x-git-upload-pack-advertisement',
260-
'content-type': 'application/x-git-upload-pack-request',
261-
'Content-Length': `${packbuffer.length}`,
262-
'Git-Protocol': 'version=2',
263-
},
354+
headers,
264355
body: packbuffer as any,
265356
});
266357

358+
if (!response.ok) {
359+
if (
360+
(response.status === 401 || response.status === 403) &&
361+
isGitHubUrl(repoUrl)
362+
) {
363+
throw new GitHubAuthenticationError(repoUrl, response.status);
364+
}
365+
throw new Error(
366+
`Failed to fetch git refs from ${repoUrl}: ${response.status} ${response.statusText}`
367+
);
368+
}
369+
267370
const refs: Record<string, string> = {};
268371
for await (const line of parseGitResponseLines(response)) {
269372
const spaceAt = line.indexOf(' ');
@@ -403,16 +506,32 @@ async function fetchWithoutBlobs(repoUrl: string, commitHash: string) {
403506
])) as any
404507
);
405508

509+
const headers: HeadersInit = {
510+
Accept: 'application/x-git-upload-pack-advertisement',
511+
'content-type': 'application/x-git-upload-pack-request',
512+
'Content-Length': `${packbuffer.length}`,
513+
};
514+
515+
addGitHubAuthHeaders(headers, repoUrl);
516+
406517
const response = await fetch(repoUrl + '/git-upload-pack', {
407518
method: 'POST',
408-
headers: {
409-
Accept: 'application/x-git-upload-pack-advertisement',
410-
'content-type': 'application/x-git-upload-pack-request',
411-
'Content-Length': `${packbuffer.length}`,
412-
},
519+
headers,
413520
body: packbuffer as any,
414521
});
415522

523+
if (!response.ok) {
524+
if (
525+
(response.status === 401 || response.status === 403) &&
526+
isGitHubUrl(repoUrl)
527+
) {
528+
throw new GitHubAuthenticationError(repoUrl, response.status);
529+
}
530+
throw new Error(
531+
`Failed to fetch git objects from ${repoUrl}: ${response.status} ${response.statusText}`
532+
);
533+
}
534+
416535
const iterator = streamToIterator(response.body!);
417536
const parsed = await parseUploadPackResponse(iterator);
418537
const packfile = Buffer.from((await collect(parsed.packfile)) as any);
@@ -552,16 +671,32 @@ async function fetchObjects(url: string, objectHashes: string[]) {
552671
])) as any
553672
);
554673

674+
const headers: HeadersInit = {
675+
Accept: 'application/x-git-upload-pack-advertisement',
676+
'content-type': 'application/x-git-upload-pack-request',
677+
'Content-Length': `${packbuffer.length}`,
678+
};
679+
680+
addGitHubAuthHeaders(headers, url);
681+
555682
const response = await fetch(url + '/git-upload-pack', {
556683
method: 'POST',
557-
headers: {
558-
Accept: 'application/x-git-upload-pack-advertisement',
559-
'content-type': 'application/x-git-upload-pack-request',
560-
'Content-Length': `${packbuffer.length}`,
561-
},
684+
headers,
562685
body: packbuffer as any,
563686
});
564687

688+
if (!response.ok) {
689+
if (
690+
(response.status === 401 || response.status === 403) &&
691+
isGitHubUrl(url)
692+
) {
693+
throw new GitHubAuthenticationError(url, response.status);
694+
}
695+
throw new Error(
696+
`Failed to fetch git objects from ${url}: ${response.status} ${response.statusText}`
697+
);
698+
}
699+
565700
const iterator = streamToIterator(response.body!);
566701
const parsed = await parseUploadPackResponse(iterator);
567702
const packfile = Buffer.from((await collect(parsed.packfile)) as any);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Modal } from '../modal';
2+
import { useAppDispatch } from '../../lib/state/redux/store';
3+
import { setActiveModal } from '../../lib/state/redux/slice-ui';
4+
import { Icon } from '@wordpress/components';
5+
import { GitHubIcon } from '../../github/github';
6+
import css from '../../github/github-oauth-guard/style.module.css';
7+
8+
const OAUTH_FLOW_URL = 'oauth.php?redirect=1';
9+
10+
export function GitHubPrivateRepoAuthModal() {
11+
const dispatch = useAppDispatch();
12+
13+
// Remove the modal parameter from the redirect URI
14+
// so it doesn't persist after OAuth completes
15+
const redirectUrl = new URL(window.location.href);
16+
redirectUrl.searchParams.delete('modal');
17+
18+
const urlParams = new URLSearchParams();
19+
urlParams.set('redirect_uri', redirectUrl.toString());
20+
const oauthUrl = `${OAUTH_FLOW_URL}&${urlParams.toString()}`;
21+
22+
return (
23+
<Modal
24+
title="Connect to GitHub"
25+
onRequestClose={() => dispatch(setActiveModal(null))}
26+
>
27+
<div>
28+
<p>
29+
This blueprint requires access to a private GitHub
30+
repository.
31+
</p>
32+
<p>
33+
To continue, please connect your GitHub account with
34+
WordPress Playground.
35+
</p>
36+
37+
<p>
38+
<a
39+
aria-label="Connect your GitHub account"
40+
className={css.githubButton}
41+
href={oauthUrl}
42+
>
43+
<Icon icon={GitHubIcon} />
44+
Connect your GitHub account
45+
</a>
46+
</p>
47+
<p>
48+
<small>
49+
Your access token is stored only in memory and will be
50+
cleared when you close this tab.
51+
</small>
52+
</p>
53+
</div>
54+
</Modal>
55+
);
56+
}

packages/playground/website/src/components/layout/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { PreviewPRModal } from '../../github/preview-pr';
3131
import { MissingSiteModal } from '../missing-site-modal';
3232
import { RenameSiteModal } from '../rename-site-modal';
3333
import { SaveSiteModal } from '../save-site-modal';
34+
import { GitHubPrivateRepoAuthModal } from '../github-private-repo-auth-modal';
3435

3536
acquireOAuthTokenIfNeeded();
3637

@@ -41,6 +42,7 @@ export const modalSlugs = {
4142
IMPORT_FORM: 'import-form',
4243
GITHUB_IMPORT: 'github-import',
4344
GITHUB_EXPORT: 'github-export',
45+
GITHUB_PRIVATE_REPO_AUTH: 'github-private-repo-auth',
4446
PREVIEW_PR_WP: 'preview-pr-wordpress',
4547
PREVIEW_PR_GUTENBERG: 'preview-pr-gutenberg',
4648
MISSING_SITE_PROMPT: 'missing-site-prompt',
@@ -216,6 +218,8 @@ function Modals(blueprint: BlueprintV1Declaration) {
216218
return <RenameSiteModal />;
217219
} else if (currentModal === modalSlugs.SAVE_SITE) {
218220
return <SaveSiteModal />;
221+
} else if (currentModal === modalSlugs.GITHUB_PRIVATE_REPO_AUTH) {
222+
return <GitHubPrivateRepoAuthModal />;
219223
}
220224

221225
if (query.get('gh-ensure-auth') === 'yes') {

packages/playground/website/src/github/acquire-oauth-token-if-needed.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { setOAuthToken, oAuthState } from './state';
22
import { oauthCode } from './github-oauth-guard';
3+
import { setGitHubAuthToken } from '@wp-playground/storage';
34

45
export async function acquireOAuthTokenIfNeeded() {
56
if (!oauthCode) {
@@ -25,15 +26,21 @@ export async function acquireOAuthTokenIfNeeded() {
2526
});
2627
const body = await response.json();
2728
setOAuthToken(body.access_token);
29+
setGitHubAuthToken(body.access_token);
30+
31+
// Remove the ?code=... from the URL and clean up any modal state
32+
const url = new URL(window.location.href);
33+
url.searchParams.delete('code');
34+
url.searchParams.delete('modal');
35+
// Keep the hash (it contains the blueprint)
36+
37+
// Reload the page to retry the blueprint with the new token
38+
// This is necessary because the blueprint failed before we had the token
39+
window.location.href = url.toString();
2840
} finally {
2941
oAuthState.value = {
3042
...oAuthState.value,
3143
isAuthorizing: false,
3244
};
3345
}
34-
35-
// Remove the ?code=... from the URL
36-
const url = new URL(window.location.href);
37-
url.searchParams.delete('code');
38-
window.history.replaceState(null, '', url.toString());
3946
}

packages/playground/website/src/github/state.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { signal } from '@preact/signals-react';
2+
import { setGitHubAuthToken } from '@wp-playground/storage';
23

34
export interface GitHubOAuthState {
45
token?: string;
@@ -16,6 +17,11 @@ export const oAuthState = signal<GitHubOAuthState>({
1617
token: shouldStoreToken ? localStorage.getItem(TOKEN_KEY) || '' : '',
1718
});
1819

20+
// Initialize the git-sparse-checkout module with the token if it exists
21+
if (oAuthState.value.token) {
22+
setGitHubAuthToken(oAuthState.value.token);
23+
}
24+
1925
export function setOAuthToken(token?: string) {
2026
if (shouldStoreToken) {
2127
localStorage.setItem(TOKEN_KEY, token || '');
@@ -24,4 +30,6 @@ export function setOAuthToken(token?: string) {
2430
...oAuthState.value,
2531
token,
2632
};
33+
// Also update the token in the git-sparse-checkout module
34+
setGitHubAuthToken(token);
2735
}

packages/playground/website/src/lib/state/redux/boot-site-client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,13 @@ export function bootSiteClient(
198198
(e as any).originalErrorClassName === 'ArtifactExpiredError'
199199
) {
200200
dispatch(setActiveSiteError('github-artifact-expired'));
201+
} else if (
202+
(e as any).name === 'GitHubAuthenticationError' ||
203+
(e as any).originalErrorClassName ===
204+
'GitHubAuthenticationError' ||
205+
(e as any).cause?.name === 'GitHubAuthenticationError'
206+
) {
207+
dispatch(setActiveModal(modalSlugs.GITHUB_PRIVATE_REPO_AUTH));
201208
} else {
202209
dispatch(setActiveSiteError('site-boot-failed'));
203210
dispatch(setActiveModal(modalSlugs.ERROR_REPORT));

packages/playground/website/src/lib/state/redux/slice-ui.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,13 @@ const initialState: UIState = {
3434
* Don't show certain modals after a page refresh.
3535
* The save-site and error-report modals should only be triggered by user actions,
3636
* not by loading a URL with the modal parameter.
37+
* The github-private-repo-auth modal should only be triggered by authentication errors,
38+
* not by loading a URL with the modal parameter.
3739
*/
3840
activeModal:
3941
query.get('modal') === 'error-report' ||
40-
query.get('modal') === 'save-site'
42+
query.get('modal') === 'save-site' ||
43+
query.get('modal') === 'github-private-repo-auth'
4144
? null
4245
: query.get('modal') || null,
4346
offline: !navigator.onLine,
@@ -122,7 +125,8 @@ export const listenToOnlineOfflineEventsMiddleware: Middleware =
122125
*/
123126
if (
124127
query.get('modal') === 'error-report' ||
125-
query.get('modal') === 'save-site'
128+
query.get('modal') === 'save-site' ||
129+
query.get('modal') === 'github-private-repo-auth'
126130
) {
127131
setTimeout(() => {
128132
store.dispatch(uiSlice.actions.setActiveModal(null));

packages/playground/website/vite.oauth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const oAuthMiddleware = async (
1818
if (query.get('redirect') === '1') {
1919
const params: Record<string, string> = {
2020
client_id: CLIENT_ID!,
21-
scope: 'public_repo',
21+
scope: 'repo',
2222
};
2323
if (query.has('redirect_uri')) {
2424
params.redirect_uri = query.get('redirect_uri')!;

0 commit comments

Comments
 (0)