Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ NODE_ENV=development
PUBLIC_ORG=
PRIVATE_ORG=

# GitHub Enterprise (GHE.com Data Residency / GHES) configuration.
# Leave unset for github.com. For GHE.com Data Residency, set GITHUB_SERVER_URL
# to your tenant URL (e.g. https://acme.ghe.com). For GHES, set it to your
# server URL (e.g. https://ghes.example.com). GITHUB_API_URL is derived
# automatically but can be overridden if needed. GraphQL is derived from the
# REST API base and uses /api/graphql on GHES.
# NEXT_PUBLIC_* variants must also be set at build time (Docker build args) so
# client bundles and UI links target the correct host.
GITHUB_SERVER_URL=
GITHUB_API_URL=
NEXT_PUBLIC_GITHUB_SERVER_URL=
NEXT_PUBLIC_GITHUB_API_URL=

# Committer email domain used on sync commits. Defaults to
# `users.noreply.github.com`. Set explicitly for GHE/GHES (the exact value
# depends on instance configuration). If you leave it unset on a non-github.com
# deployment, the app logs a warning and still falls back to the github.com
# noreply domain for compatibility.
GITHUB_USER_EMAIL_DOMAIN=

# Used to skip branch protection creation if organization level branch protections are used instead
SKIP_BRANCH_PROTECTION_CREATION=

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ build
!.env.example
.DS_Store
next-env.d.ts
tsconfig.tsbuildinfo
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ARG NEXT_PUBLIC_GITHUB_SERVER_URL
ARG NEXT_PUBLIC_GITHUB_API_URL

ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_GITHUB_SERVER_URL=$NEXT_PUBLIC_GITHUB_SERVER_URL
ENV NEXT_PUBLIC_GITHUB_API_URL=$NEXT_PUBLIC_GITHUB_API_URL

RUN npm run build
RUN npm prune --omit=dev
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,45 @@ PRIVATE_ORG=name-of-your-ghec-org # Where your private mirrors will be creat

The authentication of the UI will still need to be a user's github.com user, but the app will be able to create forks and mirrors in the GHEC instance.

## Integrating the App into GHE.com (Data Residency) or GHES

The app also supports GitHub Enterprise Cloud with Data Residency (`*.ghe.com`) and GitHub Enterprise Server. Authentication, OAuth, REST/GraphQL API calls, git remotes and UI links are all driven by the GitHub host you configure.

Set the following environment variables in addition to the GHEC variables above:

```sh
# Base URL of your GHE instance (no trailing slash).
# GHE.com Data Residency: https://<tenant>.ghe.com
# GHES: https://ghes.example.com
GITHUB_SERVER_URL=https://acme.ghe.com

# Same value as GITHUB_SERVER_URL, but inlined into client bundles at build time.
# Required for client-side hooks and UI links to point at the correct host.
NEXT_PUBLIC_GITHUB_SERVER_URL=https://acme.ghe.com

# Optional REST API base URL. Auto-derived from GITHUB_SERVER_URL:
# github.com -> https://api.github.com
# <tenant>.ghe.com -> https://api.<tenant>.ghe.com
# <ghes-host> -> https://<ghes-host>/api/v3
# GraphQL is derived from this value and uses /api/graphql on GHES.
# Override only if the auto-derivation does not match your instance.
GITHUB_API_URL=
NEXT_PUBLIC_GITHUB_API_URL=

# Committer email domain used on sync commits. Defaults to `users.noreply.github.com`.
# Set explicitly for GHE/GHES (value depends on instance configuration), e.g.:
# users.noreply.acme.ghe.com
# users.noreply.ghes.example.com
GITHUB_USER_EMAIL_DOMAIN=users.noreply.acme.ghe.com
```

Notes:

- The OAuth App / GitHub App, organizations, members and forks must all live on the same GHE instance.
- The `NEXT_PUBLIC_*` variables are inlined into the client bundle at build time. When building the Docker image, pass them as build args (e.g. `--build-arg NEXT_PUBLIC_GITHUB_SERVER_URL=https://acme.ghe.com`). The bundled `Dockerfile` already forwards them into the `npm run build` step.
- If you leave `GITHUB_USER_EMAIL_DOMAIN` unset on a non-github.com deployment, the app still falls back to `users.noreply.github.com` for compatibility, but it now logs a warning so you can correct the configuration.
- The local webhook relay (`npm run webhook`) uses `github-app-webhook-relay-polling` against the GitHub App hook deliveries endpoint. It is best-effort on GHE; in production, use real webhook deliveries configured directly on your GitHub App.

## Usage

Once the app is installed, follow this document on [Using the Private Mirrors App](docs/using-the-app.md) to get the repository fork and mirrors set up for work.
Expand Down
12 changes: 12 additions & 0 deletions docs/developing.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ npm run build

This will create an optimized production build of the app in the `out` directory.

### Building for GHE.com / GHES

The `NEXT_PUBLIC_GITHUB_SERVER_URL` and `NEXT_PUBLIC_GITHUB_API_URL` env vars are inlined into the client bundle at build time. When targeting a GHE.com Data Residency tenant or a GHES instance, you must set them before running `npm run build` (or pass them as Docker build args). The bundled `Dockerfile` already forwards these build args into `npm run build`. For example:

```sh
NEXT_PUBLIC_GITHUB_SERVER_URL=https://acme.ghe.com \
NEXT_PUBLIC_GITHUB_API_URL=https://api.acme.ghe.com \
npm run build
```

See the [GHE.com / GHES section in the README](../README.md#integrating-the-app-into-ghecom-data-residency-or-ghes) for the full list of environment variables.

## Deployment

To deploy the app, follow the instructions for your preferred hosting provider. The app can be deployed to any hosting provider that supports Next.js/Docker.
24 changes: 23 additions & 1 deletion env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ export const env = createEnv({
NODE_ENV: z.string().optional().default('development'),
PUBLIC_ORG: z.string().optional(),
PRIVATE_ORG: z.string().optional(),
// GitHub Enterprise (GHE.com Data Residency / GHES) configuration.
// When unset, defaults target github.com / api.github.com so existing
// deployments are unaffected. Set GITHUB_SERVER_URL to your GHE base URL
// (e.g. https://acme.ghe.com or https://ghes.example.com); GITHUB_API_URL
// is derived automatically but can be overridden.
GITHUB_SERVER_URL: z.string().url().optional(),
GITHUB_API_URL: z.string().url().optional(),
// Optional override for the committer email domain used on sync commits.
// Defaults to `users.noreply.github.com` for github.com; for GHE/GHES set
// this explicitly (the value depends on instance configuration).
GITHUB_USER_EMAIL_DOMAIN: z.string().optional(),
// Custom validation for a comma separated list of strings
// ex: ajhenry,github,ahpook
ALLOWED_HANDLES: z
Expand Down Expand Up @@ -98,7 +109,13 @@ export const env = createEnv({
*
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
*/
client: {},
client: {
// Mirrors of GITHUB_SERVER_URL / GITHUB_API_URL that are also available in
// client bundles. Used by client-side hooks (Octokit) and UI link builders.
// These are inlined at build time, so they must be set during `npm run build`.
NEXT_PUBLIC_GITHUB_SERVER_URL: z.string().url().optional(),
NEXT_PUBLIC_GITHUB_API_URL: z.string().url().optional(),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
Expand All @@ -117,6 +134,11 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
PUBLIC_ORG: process.env.PUBLIC_ORG,
PRIVATE_ORG: process.env.PRIVATE_ORG,
GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL,
GITHUB_API_URL: process.env.GITHUB_API_URL,
GITHUB_USER_EMAIL_DOMAIN: process.env.GITHUB_USER_EMAIL_DOMAIN,
NEXT_PUBLIC_GITHUB_SERVER_URL: process.env.NEXT_PUBLIC_GITHUB_SERVER_URL,
NEXT_PUBLIC_GITHUB_API_URL: process.env.NEXT_PUBLIC_GITHUB_API_URL,
ALLOWED_HANDLES: process.env.ALLOWED_HANDLES,
ALLOWED_ORGS: process.env.ALLOWED_ORGS,
SKIP_BRANCH_PROTECTION_CREATION:
Expand Down
39 changes: 38 additions & 1 deletion scripts/webhook-relay.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { sign } from '@octokit/webhooks-methods'
import WebhookRelay from 'github-app-webhook-relay-polling'
import crypto from 'node:crypto'
import { App } from 'octokit'
import { App, Octokit } from 'octokit'

import './proxy.mjs'

Expand All @@ -14,6 +14,42 @@ if (!process.env.PUBLIC_ORG) {

const url = `${process.env.NEXTAUTH_URL}/api/webhooks`

// Keep this fallback in sync with deriveApiUrlFromServerUrl in
// src/utils/github-urls.ts. The relay only needs the REST API base URL; GraphQL
// callers must use /api/graphql on GHES.
const deriveApiUrl = (serverUrl) => {
try {
const u = new URL(serverUrl)
const host = u.host.toLowerCase()
if (host === 'github.com' || host === 'www.github.com') {
return 'https://api.github.com'
}
if (host === 'ghe.com' || host.endsWith('.ghe.com')) {
return `${u.protocol}//api.${host}`
}
return `${u.protocol}//${u.host}/api/v3`
} catch {
return 'https://api.github.com'
}
}

const apiBaseUrl =
process.env.GITHUB_API_URL ??
process.env.NEXT_PUBLIC_GITHUB_API_URL ??
deriveApiUrl(
process.env.GITHUB_SERVER_URL ??
process.env.NEXT_PUBLIC_GITHUB_SERVER_URL ??
'https://github.com',
)

if (apiBaseUrl !== 'https://api.github.com') {
console.warn(
`[webhook-relay] Using API base URL: ${apiBaseUrl}. The polling webhook relay relies on the GitHub App hook deliveries endpoint and may not work against all GHE deployments.`,
)
}

const RelayOctokit = Octokit.defaults({ baseUrl: apiBaseUrl })

const privateKey =
process.env.PRIVATE_KEY &&
!process.env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----')
Expand All @@ -35,6 +71,7 @@ const setupForwarder = (organizationOwner) => {
// value does not matter, but has to be set.
secret: 'secret',
},
Octokit: RelayOctokit,
})

const relay = new WebhookRelay({
Expand Down
3 changes: 2 additions & 1 deletion src/app/[organizationId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import Fuse from 'fuse.js'
import { OrgHeader } from 'app/components/header/OrgHeader'
import { OrgBreadcrumbs } from 'app/components/breadcrumbs/OrgBreadcrumbs'
import { ErrorFlash } from 'app/components/flash/ErrorFlash'
import { getGitHubServerUrl } from 'utils/github-urls'

const Organization = () => {
const { organizationId } = useParams()
Expand Down Expand Up @@ -203,7 +204,7 @@ const Organization = () => {
<Text sx={{ color: 'fg.muted' }}>
Forked from{' '}
<Link
href={`https://github.com/${row.parent.owner.login}/${row.parent.name}`}
href={`${getGitHubServerUrl()}/${row.parent.owner.login}/${row.parent.name}`}
target="_blank"
rel="noreferrer noopener"
sx={{ color: 'fg.muted' }}
Expand Down
58 changes: 55 additions & 3 deletions src/app/api/auth/lib/nextauth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ import { AuthOptions, Profile } from 'next-auth'
import { JWT } from 'next-auth/jwt'
import GitHub from 'next-auth/providers/github'
import { logger } from '../../../../utils/logger'
import {
getGitHubApiUrl,
getOAuthAccessTokenUrl,
getOAuthAuthorizationUrl,
getOAuthIssuer,
} from '../../../../utils/github-urls'

import 'utils/proxy'

Expand Down Expand Up @@ -57,8 +63,7 @@ export const refreshAccessToken = async (
grant_type: 'refresh_token',
})

const url =
'https://github.com/login/oauth/access_token?' + params.toString()
const url = `${getOAuthAccessTokenUrl()}?${params.toString()}`

const response = await fetch(url, {
headers: {
Expand Down Expand Up @@ -97,6 +102,45 @@ export const refreshAccessToken = async (
}
}

const apiBaseUrl = getGitHubApiUrl()

export const createGitHubUserinfoRequest =
(apiBaseUrl: string) =>
async ({
client,
tokens,
}: {
client: { userinfo: (accessToken: string) => Promise<unknown> }
tokens: { access_token?: string | null }
}) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const profile = (await client.userinfo(tokens.access_token!)) as any

if (!profile.email) {
try {
const res = await fetch(`${apiBaseUrl}/user/emails`, {
headers: {
Authorization: `token ${tokens.access_token}`,
'User-Agent': 'private-mirrors-app',
},
})

if (res.ok) {
const emails: Array<{
email: string
primary: boolean
verified: boolean
}> = await res.json()
profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email
}
} catch (error) {
authLogger.warn('Failed to fetch user emails', { error })
}
}

return profile
}

export const nextAuthOptions: AuthOptions = {
pages: {
signIn: '/auth/login',
Expand All @@ -107,10 +151,18 @@ export const nextAuthOptions: AuthOptions = {
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
issuer: 'https://github.com/login/oauth',
issuer: getOAuthIssuer(),
authorization: {
url: getOAuthAuthorizationUrl(),
params: { scope: 'repo, user, read:org' },
},
token: getOAuthAccessTokenUrl(),
userinfo: {
url: `${apiBaseUrl}/user`,
// The built-in GitHub provider hardcodes `https://api.github.com/user/emails`
// for the email fallback. Override the request so we use the configured API host.
request: createGitHubUserinfoRequest(apiBaseUrl),
},
}),
],
secret: process.env.NEXTAUTH_SECRET!,
Expand Down
5 changes: 3 additions & 2 deletions src/app/components/dialog/CreateMirrorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@primer/react'
import { Dialog } from '@primer/react/drafts'
import { mirrorNameSchema } from 'server/repos/schema'
import { getGitHubServerUrl } from 'utils/github-urls'

import { useState } from 'react'

Expand Down Expand Up @@ -91,7 +92,7 @@ export const CreateMirrorDialog = ({
<FormControl.Caption>
This is a private mirror of{' '}
<Link
href={`https://github.com/${forkParentOwnerLogin}/${forkParentName}`}
href={`${getGitHubServerUrl()}/${forkParentOwnerLogin}/${forkParentName}`}
target="_blank"
rel="noreferrer noopener"
>
Expand Down Expand Up @@ -135,7 +136,7 @@ export const CreateMirrorDialog = ({
>
Forked from{' '}
<Link
href={`https://github.com/${forkParentOwnerLogin}/${forkParentName}`}
href={`${getGitHubServerUrl()}/${forkParentOwnerLogin}/${forkParentName}`}
target="_blank"
rel="noreferrer noopener"
sx={{ color: 'fg.muted' }}
Expand Down
5 changes: 3 additions & 2 deletions src/app/components/dialog/EditMirrorDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@primer/react'
import { Dialog } from '@primer/react/drafts'
import { mirrorNameSchema } from 'server/repos/schema'
import { getGitHubServerUrl } from 'utils/github-urls'

import { useEffect, useState } from 'react'

Expand Down Expand Up @@ -105,7 +106,7 @@ export const EditMirrorDialog = ({
<FormControl.Caption>
This is a private mirror of{' '}
<Link
href={`https://github.com/${forkParentOwnerLogin}/${forkParentName}`}
href={`${getGitHubServerUrl()}/${forkParentOwnerLogin}/${forkParentName}`}
target="_blank"
rel="noreferrer noopener"
>
Expand Down Expand Up @@ -149,7 +150,7 @@ export const EditMirrorDialog = ({
>
Forked from{' '}
<Link
href={`https://github.com/${forkParentOwnerLogin}/${forkParentName}`}
href={`${getGitHubServerUrl()}/${forkParentOwnerLogin}/${forkParentName}`}
target="_blank"
rel="noreferrer noopener"
sx={{ color: 'fg.muted' }}
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/flash/AppNotInstalledFlash.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AlertIcon } from '@primer/octicons-react'
import { Box, Flash, Link, Octicon } from '@primer/react'
import { getGitHubServerUrl } from 'utils/github-urls'

interface AppNotInstalledFlashProps {
orgLogin: string
Expand All @@ -24,7 +25,7 @@ export const AppNotInstalledFlash = ({
<Box sx={{ marginLeft: '20px' }}>
This organization does not have the required App installed. Visit{' '}
<Link
href={`https://github.com/organizations/${orgLogin}/settings/installations`}
href={`${getGitHubServerUrl()}/organizations/${orgLogin}/settings/installations`}
>
this page
</Link>{' '}
Expand Down
Loading
Loading