Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ NODE_ENV=development
PUBLIC_ORG=
PRIVATE_ORG=

# GitHub Enterprise (GHE.com Data Residency / GHES) configuration.
# Leave unset for github.com. For GHE/GHES, set each custom URL explicitly.
# 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=
# GITHUB_GRAPHQL_URL=
# NEXT_PUBLIC_GITHUB_SERVER_URL=
# NEXT_PUBLIC_GITHUB_API_URL=
# NEXT_PUBLIC_GITHUB_GRAPHQL_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
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ARG NEXT_PUBLIC_GITHUB_SERVER_URL
ARG NEXT_PUBLIC_GITHUB_API_URL
ARG NEXT_PUBLIC_GITHUB_GRAPHQL_URL

ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_GITHUB_SERVER_URL=${NEXT_PUBLIC_GITHUB_SERVER_URL:-https://github.com}
ENV NEXT_PUBLIC_GITHUB_API_URL=${NEXT_PUBLIC_GITHUB_API_URL:-https://api.github.com}
ENV NEXT_PUBLIC_GITHUB_GRAPHQL_URL=${NEXT_PUBLIC_GITHUB_GRAPHQL_URL:-https://api.github.com/graphql}

RUN npm run build
RUN npm prune --omit=dev
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,44 @@ 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. Configure the server, REST API, GraphQL API, and client bundle URLs explicitly for your environment.

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

# REST API and GraphQL URLs for the same GitHub host.
GITHUB_API_URL=https://api.acme.ghe.com
GITHUB_GRAPHQL_URL=https://api.acme.ghe.com/graphql

# Same values as above, but inlined into client bundles at build time.
NEXT_PUBLIC_GITHUB_API_URL=https://api.acme.ghe.com
NEXT_PUBLIC_GITHUB_GRAPHQL_URL=https://api.acme.ghe.com/graphql

# 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
13 changes: 13 additions & 0 deletions docs/developing.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,19 @@ 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`, `NEXT_PUBLIC_GITHUB_API_URL`, and `NEXT_PUBLIC_GITHUB_GRAPHQL_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 \
NEXT_PUBLIC_GITHUB_GRAPHQL_URL=https://api.acme.ghe.com/graphql \
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.
27 changes: 26 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 each custom URL explicitly for your GHE
// deployment.
GITHUB_SERVER_URL: z.string().url().optional(),
GITHUB_API_URL: z.string().url().optional(),
GITHUB_GRAPHQL_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,14 @@ export const env = createEnv({
*
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
*/
client: {},
client: {
// Mirrors of GitHub URL configuration 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(),
NEXT_PUBLIC_GITHUB_GRAPHQL_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 +135,13 @@ 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_GRAPHQL_URL: process.env.GITHUB_GRAPHQL_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,
NEXT_PUBLIC_GITHUB_GRAPHQL_URL: process.env.NEXT_PUBLIC_GITHUB_GRAPHQL_URL,
ALLOWED_HANDLES: process.env.ALLOWED_HANDLES,
ALLOWED_ORGS: process.env.ALLOWED_ORGS,
SKIP_BRANCH_PROTECTION_CREATION:
Expand Down
16 changes: 15 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,19 @@ if (!process.env.PUBLIC_ORG) {

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

const apiBaseUrl =
process.env.GITHUB_API_URL ??
process.env.NEXT_PUBLIC_GITHUB_API_URL ??
'https://api.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 +48,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
3 changes: 2 additions & 1 deletion src/app/components/header/ForkHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Text,
} from '@primer/react'
import { ForkData } from 'hooks/useFork'
import { getGitHubServerUrl } from 'utils/github-urls'

interface ForkHeaderProps {
forkData: ForkData
Expand Down Expand Up @@ -49,7 +50,7 @@ export const ForkHeader = ({ forkData }: ForkHeaderProps) => {
<Text sx={{ color: 'fg.muted' }}>
Forked from{' '}
<Link
href={`https://github.com/${forkData.parent?.owner.login}/${forkData.parent?.name}`}
href={`${getGitHubServerUrl()}/${forkData.parent?.owner.login}/${forkData.parent?.name}`}
target="_blank"
rel="noreferrer noopener"
sx={{ color: 'fg.muted' }}
Expand Down
Loading
Loading