diff --git a/apps/web/.env.example b/apps/web/.env.example index ef5b241e..0ba5618a 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -13,6 +13,14 @@ BETTER_AUTH_SECRET=generate_a_32_char_random_string BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000 +# ────────────────────────────────────────────── +# GitHub Enterprise Server (optional — defaults to github.com) +# ────────────────────────────────────────────── +# NEXT_PUBLIC_GITHUB_WEB_URL=https://gh.zlt.dev +# GITHUB_API_URL=https://gh.zlt.dev/api/v3 +# GITHUB_GRAPHQL_URL=https://gh.zlt.dev/api/graphql +# GITHUB_RAW_URL=https://gh.zlt.dev/raw + # ────────────────────────────────────────────── # Database (required) # ────────────────────────────────────────────── diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 4c17e89c..0ec6579b 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -40,6 +40,9 @@ const nextConfig: NextConfig = { }, // reactCompiler: true, images: { + // GHES private mode requires cookie auth for avatars — the optimizer can't fetch them. + // Disable optimization when targeting GHES so images load directly in the browser. + ...(process.env.NEXT_PUBLIC_GITHUB_WEB_URL && { unoptimized: true }), ...(process.env.NODE_ENV === "development" && { dangerouslyAllowLocalIP: true, }), @@ -53,6 +56,23 @@ const nextConfig: NextConfig = { { protocol: "https", hostname: "repository-images.githubusercontent.com" }, { protocol: "https", hostname: "better-hub.com" }, { protocol: "https", hostname: "images.better-auth.com" }, + // GHES hostname for avatars etc. (reads NEXT_PUBLIC_GITHUB_WEB_URL at build time) + ...(process.env.NEXT_PUBLIC_GITHUB_WEB_URL + ? [ + { + protocol: "https" as const, + hostname: new URL( + process.env + .NEXT_PUBLIC_GITHUB_WEB_URL, + ).hostname, + }, + // GHES serves avatars from avatars. + { + protocol: "https" as const, + hostname: `avatars.${new URL(process.env.NEXT_PUBLIC_GITHUB_WEB_URL).hostname}`, + }, + ] + : []), ], }, async headers() { diff --git a/apps/web/src/app/(app)/[owner]/page.tsx b/apps/web/src/app/(app)/[owner]/page.tsx index 6af180c9..44a582ca 100644 --- a/apps/web/src/app/(app)/[owner]/page.tsx +++ b/apps/web/src/app/(app)/[owner]/page.tsx @@ -13,6 +13,7 @@ import { import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; import { OrgDetailContent } from "@/components/orgs/org-detail-content"; import { UserProfileContent } from "@/components/users/user-profile-content"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; export async function generateMetadata({ params, @@ -78,7 +79,7 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s avatar_url: orgData.avatar_url, html_url: orgData.html_url ?? - `https://github.com/${orgData.login}`, + `${GITHUB_WEB_URL}/${orgData.login}`, description: orgData.description ?? null, blog: orgData.blog || null, location: orgData.location || null, diff --git a/apps/web/src/app/(app)/error.tsx b/apps/web/src/app/(app)/error.tsx index fe3ebcb3..6cdc9ca8 100644 --- a/apps/web/src/app/(app)/error.tsx +++ b/apps/web/src/app/(app)/error.tsx @@ -18,6 +18,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { signOut } from "@/lib/auth-client"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; function parseRateLimitFromDigest(message: string) { // The error message is serialized by Next.js, try to detect rate limit @@ -326,7 +327,7 @@ function RateLimitUI({ reset }: { reset: () => void }) { : "Sign in"} a.login.localeCompare(b.login)); diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/blob/[...path]/page.tsx b/apps/web/src/app/(app)/repos/[owner]/[repo]/blob/[...path]/page.tsx index 7f89432b..0a73f62c 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/blob/[...path]/page.tsx +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/blob/[...path]/page.tsx @@ -7,6 +7,7 @@ import { extractRepoPermissions, } from "@/lib/github"; import { parseRefAndPath, formatBytes, getLanguageFromFilename } from "@/lib/github-utils"; +import { GITHUB_RAW_URL } from "@/lib/github-config"; import { CodeViewer } from "@/components/repo/code-viewer"; import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; import { MarkdownBlobView } from "@/components/repo/markdown-blob-view"; @@ -59,7 +60,7 @@ export default async function BlobPage({ // Handle images if (IMAGE_EXTENSIONS.has(ext)) { - const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`; + const rawUrl = `${GITHUB_RAW_URL}/${owner}/${repo}/${ref}/${path}`; return (
{/* eslint-disable-next-line @next/next/no-img-element */} diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/issues/actions.ts b/apps/web/src/app/(app)/repos/[owner]/[repo]/issues/actions.ts index 4d0c6b3f..42e50822 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/issues/actions.ts +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/issues/actions.ts @@ -1,6 +1,7 @@ "use server"; import { getAuthenticatedUser, getOctokit, invalidateRepoIssuesCache } from "@/lib/github"; +import { GITHUB_RAW_URL } from "@/lib/github-config"; import { getErrorMessage } from "@/lib/utils"; import { revalidatePath } from "next/cache"; import { invalidateRepoCache } from "@/lib/repo-data-cache-vc"; @@ -600,7 +601,7 @@ export async function uploadImage( return { success: true, - url: `https://raw.githubusercontent.com/${owner}/${repo}/${targetBranch}/${path}`, + url: `${GITHUB_RAW_URL}/${owner}/${repo}/${targetBranch}/${path}`, }; } catch (err: any) { if (err.status === 422) { @@ -608,7 +609,7 @@ export async function uploadImage( const fallbackBranch = branch ?? "main"; return { success: true, - url: `https://raw.githubusercontent.com/${owner}/${repo}/${fallbackBranch}/${path}`, + url: `${GITHUB_RAW_URL}/${owner}/${repo}/${fallbackBranch}/${path}`, }; } if (err.status === 404 && attempt < 15) { diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/layout.tsx b/apps/web/src/app/(app)/repos/[owner]/[repo]/layout.tsx index 801b937a..e45edd28 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/layout.tsx +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/layout.tsx @@ -23,9 +23,10 @@ import { import { setCachedRepoTree } from "@/lib/repo-data-cache"; import { waitUntil } from "@vercel/functions"; import { ExternalLink, ShieldAlert, AlertCircle } from "lucide-react"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; function RepoErrorPage({ owner, repo, error }: { owner: string; repo: string; error: string }) { - const githubUrl = `https://github.com/${owner}/${repo}`; + const githubUrl = `${GITHUB_WEB_URL}/${owner}/${repo}`; const isOAuthRestriction = error.includes("OAuth App access restrictions"); const isNotFound = error === "Repository not found"; diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/new/page.tsx b/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/new/page.tsx index 87b3a098..a28d50a9 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/new/page.tsx +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/new/page.tsx @@ -24,7 +24,7 @@ import { Quote, Search, } from "lucide-react"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/pr-actions.ts b/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/pr-actions.ts index d8ead0b6..f2da6e42 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/pr-actions.ts +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/pulls/pr-actions.ts @@ -8,6 +8,7 @@ import { invalidateAllPRBundlesForRepo, getRepoBranches, } from "@/lib/github"; +import { GITHUB_GRAPHQL_URL } from "@/lib/github-config"; import { getErrorMessage } from "@/lib/utils"; import { revalidatePath } from "next/cache"; import { invalidateRepoCache } from "@/lib/repo-data-cache-vc"; @@ -220,7 +221,7 @@ export async function markPRReadyForReview(owner: string, repo: string, pullNumb if (!token) return { error: "Not authenticated" }; try { - const idResponse = await fetch("https://api.github.com/graphql", { + const idResponse = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -241,7 +242,7 @@ export async function markPRReadyForReview(owner: string, repo: string, pullNumb return { error: "Could not find pull request" }; } - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -469,7 +470,7 @@ export async function resolveReviewThread( if (!token) return { error: "Not authenticated" }; try { - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -505,7 +506,7 @@ export async function unresolveReviewThread( if (!token) return { error: "Not authenticated" }; try { - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, diff --git a/apps/web/src/app/(app)/repos/[owner]/[repo]/readme-actions.ts b/apps/web/src/app/(app)/repos/[owner]/[repo]/readme-actions.ts index 297c70da..8d9894c0 100644 --- a/apps/web/src/app/(app)/repos/[owner]/[repo]/readme-actions.ts +++ b/apps/web/src/app/(app)/repos/[owner]/[repo]/readme-actions.ts @@ -1,6 +1,7 @@ "use server"; import { getOctokit, getGitHubToken } from "@/lib/github"; +import { GITHUB_API_URL } from "@/lib/github-config"; import { renderMarkdownToHtml } from "@/components/shared/markdown-renderer"; import { setCachedReadmeHtml } from "@/lib/readme-cache"; import { @@ -190,7 +191,7 @@ export async function fetchUsedBy(owner: string, repo: string): Promise diff --git a/apps/web/src/app/api/ai/ghost/route.ts b/apps/web/src/app/api/ai/ghost/route.ts index e2a30cd1..4c96cfcc 100644 --- a/apps/web/src/app/api/ai/ghost/route.ts +++ b/apps/web/src/app/api/ai/ghost/route.ts @@ -17,6 +17,7 @@ import { embedText } from "@/lib/mixedbread"; import { rerankResults } from "@/lib/mixedbread"; import { searchEmbeddings, type ContentType } from "@/lib/embedding-store"; import { toAppUrl } from "@/lib/github-utils"; +import { GITHUB_WEB_URL, GITHUB_HOSTNAME } from "@/lib/github-config"; import { getUserSettings } from "@/lib/user-settings-store"; import { checkUsageLimit } from "@/lib/billing/usage-limit"; import { getBillingErrorCode } from "@/lib/billing/config"; @@ -2729,11 +2730,11 @@ The sandbox has git, node, npm, python, and common dev tools. repoName = repo; await sandbox.commands.run( - `git config --global credential.helper store && printf 'protocol=https\\nhost=github.com\\nusername=x-access-token\\npassword=%s\\n' '${githubToken.replace(/'/g, "'\\''")}' | git credential approve`, + `git config --global credential.helper store && printf 'protocol=https\\nhost=${GITHUB_HOSTNAME}\\nusername=x-access-token\\npassword=%s\\n' '${githubToken.replace(/'/g, "'\\''")}' | git credential approve`, ); await sandbox.commands.run( - `git clone --depth 1 ${branch ? `-b ${branch}` : ""} https://github.com/${owner}/${repo}.git ${repoPath}`, + `git clone --depth 1 ${branch ? `-b ${branch}` : ""} ${GITHUB_WEB_URL}/${owner}/${repo}.git ${repoPath}`, { timeoutMs: 300_000 }, ); } catch (e: unknown) { diff --git a/apps/web/src/app/api/github-avatar/route.ts b/apps/web/src/app/api/github-avatar/route.ts new file mode 100644 index 00000000..2da7b3e5 --- /dev/null +++ b/apps/web/src/app/api/github-avatar/route.ts @@ -0,0 +1,164 @@ +import { NextRequest, NextResponse } from "next/server"; +import { IS_GHES, GITHUB_HOSTNAME, GITHUB_WEB_URL } from "@/lib/github-config"; + +/** + * Proxies GitHub avatar images through the server. + * + * GHES private mode requires web session cookies for avatar URLs + * (avatars subdomain). API tokens don't work for this. We create + * a GHES web session by programmatically logging in, cache the + * session cookies, and use them to proxy avatar images. + */ + +const allowedHosts = new Set([ + "avatars.githubusercontent.com", + ...(IS_GHES ? [GITHUB_HOSTNAME, `avatars.${GITHUB_HOSTNAME}`] : []), +]); + +// Cache: GHES web session cookies (server-side only) +let ghesSession: { cookies: string; expiresAt: number } | null = null; + +async function getGhesWebSession(): Promise { + if (ghesSession && ghesSession.expiresAt > Date.now()) { + return ghesSession.cookies; + } + + const username = process.env.GHES_AVATAR_USERNAME; + const password = process.env.GHES_AVATAR_PASSWORD; + + if (!username || !password) { + return null; + } + + try { + // Step 1: Get the login page to extract authenticity_token and initial cookies + const loginResp = await fetch(`${GITHUB_WEB_URL}/login`, { + redirect: "manual", + }); + const loginCookies = (loginResp.headers.getSetCookie?.() ?? []) + .map((c) => c.split(";")[0]) + .join("; "); + const loginHtml = await loginResp.text(); + const tokenMatch = loginHtml.match(/name="authenticity_token"\s+value="([^"]+)"/); + if (!tokenMatch) return null; + + // Step 2: POST to /session to log in + const sessionResp = await fetch(`${GITHUB_WEB_URL}/session`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Cookie: loginCookies, + }, + body: new URLSearchParams({ + authenticity_token: tokenMatch[1], + login: username, + password: password, + commit: "Sign in", + }), + redirect: "manual", + }); + + const sessionCookies = sessionResp.headers.getSetCookie?.() ?? []; + if (sessionCookies.length === 0) return null; + + const allCookies = [ + loginCookies, + ...sessionCookies.map((c) => c.split(";")[0]), + ].join("; "); + + // Cache for 4 hours (GHES sessions last longer but refresh to be safe) + ghesSession = { + cookies: allCookies, + expiresAt: Date.now() + 4 * 60 * 60 * 1000, + }; + + return allCookies; + } catch { + return null; + } +} + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get("url"); + if (!url) { + return NextResponse.json({ error: "Missing url parameter" }, { status: 400 }); + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }); + } + + if (!allowedHosts.has(parsed.hostname)) { + return NextResponse.json({ error: "Host not allowed" }, { status: 403 }); + } + + try { + const headers: Record = {}; + + if (IS_GHES) { + // GHES private mode: need web session cookies for avatar subdomain + const cookies = await getGhesWebSession(); + if (cookies) { + headers.Cookie = cookies; + } + } + + let upstream = await fetch(url, { headers, redirect: "follow" }); + let contentType = upstream.headers.get("content-type") || ""; + + // If we got a non-image response and have a session, it may have expired — retry once + if ( + IS_GHES && + headers.Cookie && + (!upstream.ok || !contentType.startsWith("image/")) + ) { + ghesSession = null; + const freshCookies = await getGhesWebSession(); + if (freshCookies) { + headers.Cookie = freshCookies; + upstream = await fetch(url, { headers, redirect: "follow" }); + contentType = upstream.headers.get("content-type") || ""; + } + } + + if (upstream.ok && contentType.startsWith("image/")) { + return new NextResponse(upstream.body, { + headers: { + "Content-Type": contentType, + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + }, + }); + } + + // Fallback: generate a colored circle SVG based on user ID + const pathParts = parsed.pathname.split("/").filter(Boolean); + const userId = pathParts[1] || "0"; + const colors = [ + "#e17055", + "#00b894", + "#6c5ce7", + "#fdcb6e", + "#0984e3", + "#d63031", + "#00cec9", + "#a29bfe", + "#fab1a0", + "#74b9ff", + ]; + const color = colors[parseInt(userId) % colors.length]; + const svg = ``; + + return new NextResponse(svg, { + headers: { + "Content-Type": "image/svg+xml", + "Cache-Control": "private, max-age=60", + }, + }); + } catch { + return NextResponse.json({ error: "Failed to fetch avatar" }, { status: 502 }); + } +} diff --git a/apps/web/src/app/api/github-image/route.ts b/apps/web/src/app/api/github-image/route.ts index 671c8106..fbd51458 100644 --- a/apps/web/src/app/api/github-image/route.ts +++ b/apps/web/src/app/api/github-image/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getGitHubToken } from "@/lib/github"; +import { GITHUB_RAW_URL } from "@/lib/github-config"; const MIME_TYPES: Record = { png: "image/png", @@ -38,7 +39,7 @@ export async function GET(request: NextRequest) { * base64 content only for ≤1MB, raw format corrupts binary * @see https://docs.github.com/en/rest/repos/contents?apiVersion=2022-11-28#get-repository-content * */ - const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${path}`; + const rawUrl = `${GITHUB_RAW_URL}/${owner}/${repo}/${ref}/${path}`; const upstream = await fetch(rawUrl, { headers: { Authorization: `Bearer ${token}`, diff --git a/apps/web/src/app/api/job-logs/route.ts b/apps/web/src/app/api/job-logs/route.ts index 8c11d9f4..00a038ef 100644 --- a/apps/web/src/app/api/job-logs/route.ts +++ b/apps/web/src/app/api/job-logs/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getGitHubToken } from "@/lib/github"; +import { GITHUB_API_URL } from "@/lib/github-config"; type AnnotationType = "error" | "warning" | "debug" | "notice" | null; @@ -192,14 +193,14 @@ export async function GET(request: NextRequest) { }; const [logsRes, jobRes] = await Promise.all([ fetch( - `https://api.github.com/repos/${encodedOwner}/${encodedRepo}/actions/jobs/${encodedJobId}/logs`, + `${GITHUB_API_URL}/repos/${encodedOwner}/${encodedRepo}/actions/jobs/${encodedJobId}/logs`, { headers: commonHeaders, redirect: "follow", }, ), fetch( - `https://api.github.com/repos/${encodedOwner}/${encodedRepo}/actions/jobs/${encodedJobId}`, + `${GITHUB_API_URL}/repos/${encodedOwner}/${encodedRepo}/actions/jobs/${encodedJobId}`, { headers: commonHeaders, }, diff --git a/apps/web/src/app/api/user-settings/validate-pat/route.ts b/apps/web/src/app/api/user-settings/validate-pat/route.ts index d02f1cf2..952f8180 100644 --- a/apps/web/src/app/api/user-settings/validate-pat/route.ts +++ b/apps/web/src/app/api/user-settings/validate-pat/route.ts @@ -1,5 +1,6 @@ import { Octokit } from "@octokit/rest"; import { auth } from "@/lib/auth"; +import { GITHUB_API_URL } from "@/lib/github-config"; import { getErrorMessage, getErrorStatus } from "@/lib/utils"; import { headers } from "next/headers"; @@ -17,7 +18,7 @@ export async function POST(request: Request) { } try { - const octokit = new Octokit({ auth: pat }); + const octokit = new Octokit({ auth: pat, baseUrl: GITHUB_API_URL }); const [userResp, rateLimitResp] = await Promise.all([ octokit.users.getAuthenticated(), octokit.rateLimit.get(), diff --git a/apps/web/src/components/actions/actions-list.tsx b/apps/web/src/components/actions/actions-list.tsx index fc08d55a..4cc48fb2 100644 --- a/apps/web/src/components/actions/actions-list.tsx +++ b/apps/web/src/components/actions/actions-list.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useRef, useEffect, useCallback } from "react"; import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { Play, GitBranch, diff --git a/apps/web/src/components/actions/run-comparison-page.tsx b/apps/web/src/components/actions/run-comparison-page.tsx index e2dfda9c..5075eb93 100644 --- a/apps/web/src/components/actions/run-comparison-page.tsx +++ b/apps/web/src/components/actions/run-comparison-page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { Loader2, ArrowLeftRight, ArrowLeft, Copy, Check } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/apps/web/src/components/actions/run-detail.tsx b/apps/web/src/components/actions/run-detail.tsx index 89df9439..d389ca54 100644 --- a/apps/web/src/components/actions/run-detail.tsx +++ b/apps/web/src/components/actions/run-detail.tsx @@ -1,7 +1,7 @@ "use client"; import { Fragment, useState, useCallback, useRef } from "react"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { GitBranch, diff --git a/apps/web/src/components/actions/run-link-targets.ts b/apps/web/src/components/actions/run-link-targets.ts index 96b15716..4e7a41f7 100644 --- a/apps/web/src/components/actions/run-link-targets.ts +++ b/apps/web/src/components/actions/run-link-targets.ts @@ -1,3 +1,5 @@ +import { GITHUB_WEB_URL } from "@/lib/github-config"; + interface RepoOwnerRef { login?: string | null; } @@ -66,7 +68,7 @@ export function getRunLinkTargets( if (headSha) { if (prNumber) { - commitHref = `https://github.com/${owner}/${repo}/pull/${prNumber}/commits/${headSha}`; + commitHref = `${GITHUB_WEB_URL}/${owner}/${repo}/pull/${prNumber}/commits/${headSha}`; } else { commitHref = `/${owner}/${repo}/commit/${headSha}`; } diff --git a/apps/web/src/components/command-menu.tsx b/apps/web/src/components/command-menu.tsx index 67a94913..3b3e82ff 100644 --- a/apps/web/src/components/command-menu.tsx +++ b/apps/web/src/components/command-menu.tsx @@ -54,6 +54,7 @@ import { getPinnedUrlsForRepo, } from "@/app/(app)/repos/[owner]/[repo]/pin-actions"; import { SELECTABLE_MODELS } from "@/lib/billing/ai-models"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; interface SearchRepo { id: number; @@ -914,14 +915,14 @@ export function CommandMenu() { name: "New Repository", description: "Create a new repo on GitHub", keywords: ["create", "init", "start", "add repo"], - action: () => window.open("https://github.com/new", "_blank"), + action: () => window.open(`${GITHUB_WEB_URL}/new`, "_blank"), icon: FolderGit2, }, { name: "Open GitHub", description: "Go to github.com", keywords: ["website", "external", "browser"], - action: () => window.open("https://github.com", "_blank"), + action: () => window.open(GITHUB_WEB_URL, "_blank"), icon: ExternalLink, }, { @@ -1394,7 +1395,7 @@ export function CommandMenu() { }); items.push({ id: "account-profile", - action: () => window.open(`https://github.com/${activeLogin}`, "_blank"), + action: () => window.open(`${GITHUB_WEB_URL}/${activeLogin}`, "_blank"), keepOpen: false, }); items.push({ @@ -2649,7 +2650,7 @@ export function CommandMenu() { runCommand( () => window.open( - `https://github.com/${activeLogin}`, + `${GITHUB_WEB_URL}/${activeLogin}`, "_blank", ), ) diff --git a/apps/web/src/components/dashboard/dashboard-content.tsx b/apps/web/src/components/dashboard/dashboard-content.tsx index 50965179..5142cfc4 100644 --- a/apps/web/src/components/dashboard/dashboard-content.tsx +++ b/apps/web/src/components/dashboard/dashboard-content.tsx @@ -3,7 +3,7 @@ import { noSSR } from "foxact/no-ssr"; import { Suspense, useEffect, useState, useCallback, useTransition, useMemo } from "react"; import { useQueryState, parseAsStringLiteral } from "nuqs"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { GitPullRequest, diff --git a/apps/web/src/components/dashboard/recently-viewed.tsx b/apps/web/src/components/dashboard/recently-viewed.tsx index 0ee79f61..49c6cf51 100644 --- a/apps/web/src/components/dashboard/recently-viewed.tsx +++ b/apps/web/src/components/dashboard/recently-viewed.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useRef } from "react"; import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { History, GitPullRequest, CircleDot, ChevronRight, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { TimeAgo } from "@/components/ui/time-ago"; diff --git a/apps/web/src/components/discussion/discussion-comment-form.tsx b/apps/web/src/components/discussion/discussion-comment-form.tsx index 217906ee..1ca61421 100644 --- a/apps/web/src/components/discussion/discussion-comment-form.tsx +++ b/apps/web/src/components/discussion/discussion-comment-form.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { Loader2, CornerDownLeft } from "lucide-react"; import { cn } from "@/lib/utils"; import { TimeAgo } from "@/components/ui/time-ago"; diff --git a/apps/web/src/components/discussion/discussion-conversation.tsx b/apps/web/src/components/discussion/discussion-conversation.tsx index d563f70b..df9f864a 100644 --- a/apps/web/src/components/discussion/discussion-conversation.tsx +++ b/apps/web/src/components/discussion/discussion-conversation.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { CheckCircle2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler"; diff --git a/apps/web/src/components/discussion/discussion-header.tsx b/apps/web/src/components/discussion/discussion-header.tsx index 272af398..c94aeb30 100644 --- a/apps/web/src/components/discussion/discussion-header.tsx +++ b/apps/web/src/components/discussion/discussion-header.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { MessageCircle, CheckCircle2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { GitHubEmoji } from "@/components/shared/github-emoji"; diff --git a/apps/web/src/components/discussion/discussions-list.tsx b/apps/web/src/components/discussion/discussions-list.tsx index 10785101..9c93c256 100644 --- a/apps/web/src/components/discussion/discussions-list.tsx +++ b/apps/web/src/components/discussion/discussions-list.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useRef, useEffect, useCallback, useTransition } from "react"; import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { MessageCircle, CheckCircle2, ArrowUp, MessageSquare, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { TimeAgo } from "@/components/ui/time-ago"; diff --git a/apps/web/src/components/issue/issue-comment-form.tsx b/apps/web/src/components/issue/issue-comment-form.tsx index caa67e24..f0a06d5d 100644 --- a/apps/web/src/components/issue/issue-comment-form.tsx +++ b/apps/web/src/components/issue/issue-comment-form.tsx @@ -2,7 +2,7 @@ import { useState, useTransition, useRef, useEffect } from "react"; import { useRouter } from "next/navigation"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { Loader2, CornerDownLeft, diff --git a/apps/web/src/components/issue/issue-conversation.tsx b/apps/web/src/components/issue/issue-conversation.tsx index 67cc5086..a79b6cba 100644 --- a/apps/web/src/components/issue/issue-conversation.tsx +++ b/apps/web/src/components/issue/issue-conversation.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { RotateCcw } from "lucide-react"; import { MarkdownCopyHandler } from "@/components/shared/markdown-copy-handler"; import { EditableIssueDescription } from "@/components/issue/editable-issue-description"; diff --git a/apps/web/src/components/issue/issue-header.tsx b/apps/web/src/components/issue/issue-header.tsx index 0ed57cb4..e22ad7a5 100644 --- a/apps/web/src/components/issue/issue-header.tsx +++ b/apps/web/src/components/issue/issue-header.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { CircleDot, CheckCircle2, GitPullRequest, ExternalLink } from "lucide-react"; import { cn } from "@/lib/utils"; import { TimeAgo } from "@/components/ui/time-ago"; diff --git a/apps/web/src/components/issue/issue-participants.tsx b/apps/web/src/components/issue/issue-participants.tsx index 1c330c44..671b3373 100644 --- a/apps/web/src/components/issue/issue-participants.tsx +++ b/apps/web/src/components/issue/issue-participants.tsx @@ -1,4 +1,4 @@ -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; interface Participant { diff --git a/apps/web/src/components/issue/issue-sidebar.tsx b/apps/web/src/components/issue/issue-sidebar.tsx index 85fce397..6f9ede10 100644 --- a/apps/web/src/components/issue/issue-sidebar.tsx +++ b/apps/web/src/components/issue/issue-sidebar.tsx @@ -1,4 +1,4 @@ -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { CircleDot, diff --git a/apps/web/src/components/issue/issue-timeline-events.tsx b/apps/web/src/components/issue/issue-timeline-events.tsx index e4ab0798..8bb46af1 100644 --- a/apps/web/src/components/issue/issue-timeline-events.tsx +++ b/apps/web/src/components/issue/issue-timeline-events.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { CheckCircle2, GitCommitHorizontal, GitPullRequest, RotateCcw } from "lucide-react"; import { cn } from "@/lib/utils"; import { TimeAgo } from "@/components/ui/time-ago"; diff --git a/apps/web/src/components/issue/issues-list.tsx b/apps/web/src/components/issue/issues-list.tsx index ab131808..da65ae03 100644 --- a/apps/web/src/components/issue/issues-list.tsx +++ b/apps/web/src/components/issue/issues-list.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, useCallback, useTransition, useRef } from "react"; import { useSearchParams } from "next/navigation"; import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { CircleDot, CheckCircle2, diff --git a/apps/web/src/components/issue/older-activity-group.tsx b/apps/web/src/components/issue/older-activity-group.tsx index b3e47af4..0b7392a8 100644 --- a/apps/web/src/components/issue/older-activity-group.tsx +++ b/apps/web/src/components/issue/older-activity-group.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { ChevronRight, History } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/apps/web/src/components/layout/navbar.tsx b/apps/web/src/components/layout/navbar.tsx index 812ce87e..7a01914c 100644 --- a/apps/web/src/components/layout/navbar.tsx +++ b/apps/web/src/components/layout/navbar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import Link from "next/link"; +import { githubAvatarUrl } from "@/components/shared/github-avatar"; import { LogOut, ExternalLink, @@ -37,6 +38,7 @@ import { cn } from "@/lib/utils"; import { NotificationSheet } from "@/components/layout/notification-sheet"; import { $Session } from "@/lib/auth"; import type { NotificationItem } from "@/lib/github-types"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; interface AppNavbarProps { session: $Session; @@ -136,10 +138,10 @@ export function AppNavbar({ session, notifications }: AppNavbarProps) { } > {
@@ -318,7 +320,7 @@ export function AppNavbar({ session, notifications }: AppNavbarProps) { window.open( - `https://github.com/${gh.login}`, + `${GITHUB_WEB_URL}/${gh.login}`, "_blank", ) } diff --git a/apps/web/src/components/login-button.tsx b/apps/web/src/components/login-button.tsx index 290ba28f..8adbeed3 100644 --- a/apps/web/src/components/login-button.tsx +++ b/apps/web/src/components/login-button.tsx @@ -12,6 +12,7 @@ import { cn, safeRedirect } from "@/lib/utils"; import { CheckIcon, ChevronDown, Info, LockIcon, PlusIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; export function LoginButton({ redirectTo }: { redirectTo?: string }) { const router = useRouter(); @@ -48,8 +49,8 @@ export function LoginButton({ redirectTo }: { redirectTo?: string }) { for (const g of SCOPE_GROUPS) { if (selected.has(g.id)) scopes.push(...g.scopes); } - signIn.social({ - provider: "github", + signIn.oauth2({ + providerId: "github", callbackURL: safeRedirect(redirectTo), scopes, }); @@ -216,7 +217,7 @@ export function LoginButton({ redirectTo }: { redirectTo?: string }) { className="w-full bg-transparent border border-foreground/15 rounded-md px-3 py-2.5 text-sm text-foreground placeholder:text-foreground/25 focus:outline-none focus:border-foreground/30 transition-colors font-mono" /> diff --git a/apps/web/src/components/orgs/org-detail-content.tsx b/apps/web/src/components/orgs/org-detail-content.tsx index d484e550..8c923c0c 100644 --- a/apps/web/src/components/orgs/org-detail-content.tsx +++ b/apps/web/src/components/orgs/org-detail-content.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useQueryState, parseAsStringLiteral, parseAsString } from "nuqs"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { ArrowUpDown, diff --git a/apps/web/src/components/orgs/orgs-content.tsx b/apps/web/src/components/orgs/orgs-content.tsx index ce296f22..80c9039a 100644 --- a/apps/web/src/components/orgs/orgs-content.tsx +++ b/apps/web/src/components/orgs/orgs-content.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { Building2, ChevronRight, ExternalLink, Search } from "lucide-react"; diff --git a/apps/web/src/components/people/people-list.tsx b/apps/web/src/components/people/people-list.tsx index 2e6ad854..d6d615aa 100644 --- a/apps/web/src/components/people/people-list.tsx +++ b/apps/web/src/components/people/people-list.tsx @@ -2,7 +2,7 @@ import { useState, useMemo, useCallback } from "react"; import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { useRouter } from "next/navigation"; import { cn, formatNumber } from "@/lib/utils"; import { ListSearchInput, SortCycleButton } from "@/components/shared/list-controls"; diff --git a/apps/web/src/components/people/person-detail.tsx b/apps/web/src/components/people/person-detail.tsx index 650f0ced..17f93ce1 100644 --- a/apps/web/src/components/people/person-detail.tsx +++ b/apps/web/src/components/people/person-detail.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from "react"; import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { cn } from "@/lib/utils"; import type { PersonRepoActivity, ContributorWeek } from "@/lib/github"; diff --git a/apps/web/src/components/pr/message-actions-menu.tsx b/apps/web/src/components/pr/message-actions-menu.tsx index 23aaa8b4..c71ae3ca 100644 --- a/apps/web/src/components/pr/message-actions-menu.tsx +++ b/apps/web/src/components/pr/message-actions-menu.tsx @@ -11,6 +11,7 @@ import { } from "@/components/ui/dropdown-menu"; import { deletePRComment } from "@/app/(app)/repos/[owner]/[repo]/pulls/pr-actions"; import { deleteIssueComment } from "@/app/(app)/repos/[owner]/[repo]/issues/issue-actions"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; type MessageActionsMenuProps = { owner: string; @@ -41,7 +42,7 @@ export function MessageActionsMenu({ const number = contentType === "pr" ? pullNumber : issueNumber; const urlType = contentType === "pr" ? "pull" : "issues"; - const commentUrl = `https://github.com/${owner}/${repo}/${urlType}/${number}#issuecomment-${commentId}`; + const commentUrl = `${GITHUB_WEB_URL}/${owner}/${repo}/${urlType}/${number}#issuecomment-${commentId}`; useEffect(() => { if (copied) { diff --git a/apps/web/src/components/pr/pr-author-dossier.tsx b/apps/web/src/components/pr/pr-author-dossier.tsx index a1057d10..a938655a 100644 --- a/apps/web/src/components/pr/pr-author-dossier.tsx +++ b/apps/web/src/components/pr/pr-author-dossier.tsx @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"; import { TimeAgo } from "@/components/ui/time-ago"; import type { ScoreResult } from "@/lib/contributor-score"; import { UserTooltip } from "@/components/shared/user-tooltip"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; interface AuthorOrg { login: string; @@ -301,7 +302,7 @@ export function PRAuthorDossier({ key={ o.login } - href={`https://github.com/${o.login}`} + href={`${GITHUB_WEB_URL}/${o.login}`} target="_blank" rel="noopener noreferrer" title={ diff --git a/apps/web/src/components/pr/pr-merge-panel.tsx b/apps/web/src/components/pr/pr-merge-panel.tsx index 373c8886..e2147827 100644 --- a/apps/web/src/components/pr/pr-merge-panel.tsx +++ b/apps/web/src/components/pr/pr-merge-panel.tsx @@ -50,6 +50,7 @@ import { useMutationEvents } from "@/components/shared/mutation-event-provider"; import { useQueryClient } from "@tanstack/react-query"; import { PRChecksPanel } from "@/components/pr/pr-checks-panel"; import type { CheckStatus } from "@/lib/github"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; interface PRMergePanelProps { owner: string; @@ -593,7 +594,7 @@ export function PRMergePanel({ )}
diff --git a/apps/web/src/components/repo/code-toolbar.tsx b/apps/web/src/components/repo/code-toolbar.tsx index 5ed7a104..15c76b08 100644 --- a/apps/web/src/components/repo/code-toolbar.tsx +++ b/apps/web/src/components/repo/code-toolbar.tsx @@ -12,6 +12,7 @@ import { Loader2, Monitor, } from "lucide-react"; +import { GITHUB_WEB_URL, GITHUB_HOSTNAME } from "@/lib/github-config"; interface EnrichedBranch { name: string; @@ -54,10 +55,10 @@ export function CodeToolbar({ const cloneUrl = cloneProtocol === "https" - ? `https://github.com/${owner}/${repo}.git` - : `git@github.com:${owner}/${repo}.git`; + ? `${GITHUB_WEB_URL}/${owner}/${repo}.git` + : `git@${GITHUB_HOSTNAME}:${owner}/${repo}.git`; - const zipUrl = `https://github.com/${owner}/${repo}/archive/${currentRef}.zip`; + const zipUrl = `${GITHUB_WEB_URL}/${owner}/${repo}/archive/${currentRef}.zip`; const filteredBranches = localBranches.filter((b) => b.name.toLowerCase().includes(branchSearch.toLowerCase()), @@ -360,7 +361,7 @@ export function CodeToolbar({
diff --git a/apps/web/src/components/repo/commit-detail.tsx b/apps/web/src/components/repo/commit-detail.tsx index fa981269..daad5ff5 100644 --- a/apps/web/src/components/repo/commit-detail.tsx +++ b/apps/web/src/components/repo/commit-detail.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useCallback, useEffect } from "react"; import Link from "next/link"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { parseDiffPatch, type DiffLine, type DiffSegment } from "@/lib/github-utils"; import type { SyntaxToken } from "@/lib/shiki"; import { cn } from "@/lib/utils"; diff --git a/apps/web/src/components/repo/commits-list.tsx b/apps/web/src/components/repo/commits-list.tsx index f78ca2e4..8b5ad167 100644 --- a/apps/web/src/components/repo/commits-list.tsx +++ b/apps/web/src/components/repo/commits-list.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useTransition, useMemo, useRef, useEffect, useCallback } from "react"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { GitBranch, diff --git a/apps/web/src/components/repo/insights-view.tsx b/apps/web/src/components/repo/insights-view.tsx index bb67e4ad..834febbd 100644 --- a/apps/web/src/components/repo/insights-view.tsx +++ b/apps/web/src/components/repo/insights-view.tsx @@ -8,6 +8,7 @@ import type { WeeklyParticipation, ContributorStats, } from "@/lib/github"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; // --- Language colors --- const LANG_COLORS: Record = { @@ -404,7 +405,7 @@ function ContributorsSection({ contributors }: { contributors: ContributorStats[ {c.login}
diff --git a/apps/web/src/components/settings/tabs/account-tab.tsx b/apps/web/src/components/settings/tabs/account-tab.tsx index 8e94ee78..0d5dbee2 100644 --- a/apps/web/src/components/settings/tabs/account-tab.tsx +++ b/apps/web/src/components/settings/tabs/account-tab.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import { githubAvatarUrl } from "@/components/shared/github-avatar"; import { LogOut, Trash2, @@ -18,6 +19,7 @@ import { SCOPE_GROUPS, scopesToGroupIds } from "@/lib/github-scopes"; import type { UserSettings } from "@/lib/user-settings-store"; import type { GitHubProfile } from "../settings-dialog"; import { PermissionBadge } from "@/components/shared/permission-badge"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; interface AccountTabProps { user: { @@ -87,8 +89,8 @@ export function AccountTab({ user, settings, onUpdate, githubProfile }: AccountT for (const g of SCOPE_GROUPS) { if (selected.has(g.id)) scopes.push(...g.scopes); } - signIn.social({ - provider: "github", + signIn.oauth2({ + providerId: "github", callbackURL: "/dashboard", scopes, }); @@ -131,7 +133,7 @@ export function AccountTab({ user, settings, onUpdate, githubProfile }: AccountT
{user.image ? ( {user.name} @@ -149,7 +151,7 @@ export function AccountTab({ user, settings, onUpdate, githubProfile }: AccountT {user.name} setCopied(false), 1200); diff --git a/apps/web/src/components/shared/github-avatar.tsx b/apps/web/src/components/shared/github-avatar.tsx index e423194f..1705347c 100644 --- a/apps/web/src/components/shared/github-avatar.tsx +++ b/apps/web/src/components/shared/github-avatar.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import { GITHUB_HOSTNAME, IS_GHES } from "@/lib/github-config"; /** * Renders a GitHub avatar that bypasses the Next.js image optimizer. @@ -6,6 +7,9 @@ import Image from "next/image"; * GitHub's avatar CDN already serves properly sized images via `?s=SIZE`, * so proxying through `/_next/image` adds latency and can timeout for * GitHub-App installation avatars (the `/in/…` URLs). + * + * On GHES private mode, avatar URLs require cookie auth the browser doesn't + * have, so we proxy them through `/api/github-avatar` with the user's token. */ export function GithubAvatar({ src, @@ -31,13 +35,31 @@ export function GithubAvatar({ ); } -const GH_AVATAR_HOST = "avatars.githubusercontent.com"; +// On github.com avatars come from avatars.githubusercontent.com; +// on GHES they come from avatars. or the GHES host itself. +const AVATAR_HOSTS = new Set([ + "avatars.githubusercontent.com", + GITHUB_HOSTNAME, + `avatars.${GITHUB_HOSTNAME}`, +]); + +function isGhesAvatarUrl(hostname: string): boolean { + return IS_GHES && AVATAR_HOSTS.has(hostname); +} -function githubAvatarUrl(src: string, size: number): string { +/** + * Rewrites a GitHub avatar URL to a proxied URL on GHES (private mode). + * Exported so other components can use it for raw avatar URLs. + */ +export function githubAvatarUrl(src: string, size?: number): string { try { const u = new URL(src); - if (u.hostname === GH_AVATAR_HOST) { - u.searchParams.set("s", String(size * 2)); + if (AVATAR_HOSTS.has(u.hostname)) { + if (size) u.searchParams.set("s", String(size * 2)); + // On GHES private mode, proxy through our API to add auth + if (isGhesAvatarUrl(u.hostname)) { + return `/api/github-avatar?url=${encodeURIComponent(u.toString())}`; + } return u.toString(); } } catch { diff --git a/apps/web/src/components/shared/github-image.tsx b/apps/web/src/components/shared/github-image.tsx new file mode 100644 index 00000000..d8618eab --- /dev/null +++ b/apps/web/src/components/shared/github-image.tsx @@ -0,0 +1,35 @@ +"use client"; + +import NextImage, { type ImageProps } from "next/image"; +import { IS_GHES, GITHUB_HOSTNAME } from "@/lib/github-config"; + +/** + * Hostnames whose images need to be proxied through the server. + * On github.com this is empty, so the component is a plain passthrough. + */ +const PROXY_HOSTS = IS_GHES + ? new Set([GITHUB_HOSTNAME, `avatars.${GITHUB_HOSTNAME}`]) + : new Set(); + +function proxySrc(src: string | undefined): string | undefined { + if (!src || !IS_GHES) return src; + try { + const u = new URL(src); + if (PROXY_HOSTS.has(u.hostname)) { + return `/api/github-avatar?url=${encodeURIComponent(src)}`; + } + } catch { + // relative URL or invalid — leave as-is + } + return src; +} + +/** + * Drop-in replacement for next/image that handles GitHub avatar auth. + * On GHES, proxies avatar URLs through the server-side session proxy. + * On github.com, this is a passthrough to next/image. + */ +export default function GitHubImage(props: ImageProps) { + const src = typeof props.src === "string" ? proxySrc(props.src) : props.src; + return ; +} diff --git a/apps/web/src/components/shared/github-link-interceptor.tsx b/apps/web/src/components/shared/github-link-interceptor.tsx index a147844a..b05f7ac8 100644 --- a/apps/web/src/components/shared/github-link-interceptor.tsx +++ b/apps/web/src/components/shared/github-link-interceptor.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { parseGitHubUrl, toInternalUrl } from "@/lib/github-utils"; +import { GITHUB_HOSTNAME } from "@/lib/github-config"; export function GitHubLinkInterceptor({ children }: { children: React.ReactNode }) { const router = useRouter(); @@ -27,7 +28,7 @@ export function GitHubLinkInterceptor({ children }: { children: React.ReactNode // Only intercept github.com links try { const url = new URL(href); - if (url.hostname !== "github.com") return; + if (url.hostname !== GITHUB_HOSTNAME) return; } catch { return; } diff --git a/apps/web/src/components/shared/mention-suggestion.tsx b/apps/web/src/components/shared/mention-suggestion.tsx index 974c87cc..b8e11ea0 100644 --- a/apps/web/src/components/shared/mention-suggestion.tsx +++ b/apps/web/src/components/shared/mention-suggestion.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useCallback, forwardRef, useImperativeHandle } from "react"; import { createRoot, type Root } from "react-dom/client"; import { createPortal } from "react-dom"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { cn } from "@/lib/utils"; import type { SuggestionProps, SuggestionKeyDownProps } from "@tiptap/suggestion"; diff --git a/apps/web/src/components/shared/reaction-display.tsx b/apps/web/src/components/shared/reaction-display.tsx index 0a2a9b3c..0187ce1c 100644 --- a/apps/web/src/components/shared/reaction-display.tsx +++ b/apps/web/src/components/shared/reaction-display.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback, useTransition } from "react"; import { createPortal } from "react-dom"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { SmilePlus } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/apps/web/src/components/shared/user-tooltip.tsx b/apps/web/src/components/shared/user-tooltip.tsx index 26e58e20..c0277e05 100644 --- a/apps/web/src/components/shared/user-tooltip.tsx +++ b/apps/web/src/components/shared/user-tooltip.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import { useQuery } from "@tanstack/react-query"; import { Users, diff --git a/apps/web/src/components/trending/trending-content.tsx b/apps/web/src/components/trending/trending-content.tsx index 11a1ef40..807c304f 100644 --- a/apps/web/src/components/trending/trending-content.tsx +++ b/apps/web/src/components/trending/trending-content.tsx @@ -1,7 +1,7 @@ "use client"; import { useQueryState, parseAsStringLiteral } from "nuqs"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { Star, GitFork, Flame } from "lucide-react"; import { cn, formatNumber } from "@/lib/utils"; diff --git a/apps/web/src/components/users/user-profile-content.tsx b/apps/web/src/components/users/user-profile-content.tsx index 7704c0bc..5465ed90 100644 --- a/apps/web/src/components/users/user-profile-content.tsx +++ b/apps/web/src/components/users/user-profile-content.tsx @@ -27,7 +27,7 @@ import { Users, X, } from "lucide-react"; -import Image from "next/image"; +import Image from "@/components/shared/github-image"; import Link from "next/link"; import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; diff --git a/apps/web/src/lib/ai-auth.ts b/apps/web/src/lib/ai-auth.ts index 4cfb76e7..dc2d4daa 100644 --- a/apps/web/src/lib/ai-auth.ts +++ b/apps/web/src/lib/ai-auth.ts @@ -1,10 +1,11 @@ import { Octokit } from "@octokit/rest"; import { getServerSession } from "@/lib/auth"; +import { GITHUB_API_URL } from "@/lib/github-config"; export async function getOctokitFromSession(): Promise { const token = await getGitHubToken(); if (!token) return null; - return new Octokit({ auth: token }); + return new Octokit({ auth: token, baseUrl: GITHUB_API_URL }); } export async function getGitHubToken(): Promise { diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 5ffa1125..87964564 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,5 +1,5 @@ import { createAuthClient } from "better-auth/react"; -import { inferAdditionalFields } from "better-auth/client/plugins"; +import { inferAdditionalFields, genericOAuthClient } from "better-auth/client/plugins"; import { auth } from "./auth"; import { dashClient, sentinelClient } from "@better-auth/infra/client"; import { stripeClient } from "@better-auth/stripe/client"; @@ -7,6 +7,7 @@ import { stripeClient } from "@better-auth/stripe/client"; export const authClient = createAuthClient({ plugins: [ inferAdditionalFields(), + genericOAuthClient(), dashClient(), sentinelClient(), stripeClient({ subscription: true }), diff --git a/apps/web/src/lib/auth-plugins/pat-signin.ts b/apps/web/src/lib/auth-plugins/pat-signin.ts index 438a4ab8..1798b61c 100644 --- a/apps/web/src/lib/auth-plugins/pat-signin.ts +++ b/apps/web/src/lib/auth-plugins/pat-signin.ts @@ -4,6 +4,7 @@ import { symmetricEncrypt } from "better-auth/crypto"; import { Octokit } from "@octokit/rest"; import { z } from "zod"; import type { BetterAuthPlugin } from "better-auth"; +import { GITHUB_API_URL } from "@/lib/github-config"; export const patSignIn = (): BetterAuthPlugin => ({ id: "pat-signin", @@ -27,7 +28,7 @@ export const patSignIn = (): BetterAuthPlugin => ({ const { internalAdapter, secret } = ctx.context; // --- Validate PAT against GitHub --- - const octokit = new Octokit({ auth: pat }); + const octokit = new Octokit({ auth: pat, baseUrl: GITHUB_API_URL }); let githubUser: Awaited< ReturnType >["data"]; diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 0b4d3d6b..597c0e07 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -3,27 +3,25 @@ import { prismaAdapter } from "better-auth/adapters/prisma"; import { prisma } from "./db"; import { Octokit } from "@octokit/rest"; import { redis } from "./redis"; +import { GITHUB_API_URL, GITHUB_WEB_URL } from "./github-config"; import { waitUntil } from "@vercel/functions"; import { all } from "better-all"; import { headers } from "next/headers"; import { cache } from "react"; import { dash, sentinel } from "@better-auth/infra"; -import { createHash } from "@better-auth/utils/hash"; -import { admin, oAuthProxy } from "better-auth/plugins"; +import { admin, genericOAuth, oAuthProxy } from "better-auth/plugins"; import { stripe } from "@better-auth/stripe"; import { getStripeClient, isStripeEnabled } from "./billing/stripe"; import { grantSignupCredits } from "./billing/credit"; import { patSignIn } from "./auth-plugins/pat-signin"; async function getOctokitUser(token: string) { - const cached = await redis.get>( - `github_user:${token}`, - ); - if (cached) return cached; - const octokit = new Octokit({ auth: token }); + const cacheKey = `github_user:${token.slice(-8)}`; + const cached = await redis.get>(cacheKey); + if (cached) return { data: cached }; + const octokit = new Octokit({ auth: token, baseUrl: GITHUB_API_URL }); const githubUser = await octokit.users.getAuthenticated(); - const hash = await createHash("SHA-256", "base64").digest(token); - waitUntil(redis.set(`github_user:${hash}`, JSON.stringify(githubUser.data), { ex: 3600 })); + waitUntil(redis.set(cacheKey, githubUser.data, { ex: 3600 })); return githubUser; } @@ -74,6 +72,48 @@ export const auth = betterAuth({ }), ] : []), + genericOAuth({ + config: [ + { + providerId: "github", + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + authorizationUrl: `${GITHUB_WEB_URL}/login/oauth/authorize`, + tokenUrl: `${GITHUB_WEB_URL}/login/oauth/access_token`, + scopes: ["read:user", "user:email", "public_repo"], + async getUserInfo(tokens) { + const octokit = new Octokit({ + auth: tokens.accessToken, + baseUrl: GITHUB_API_URL, + }); + const [{ data: user }, { data: emails }] = + await Promise.all([ + octokit.users.getAuthenticated(), + octokit.users.listEmailsForAuthenticatedUser(), + ]); + const primary = emails.find( + (e) => e.primary && e.verified, + ); + return { + id: String(user.id), + name: user.name || user.login, + email: + user.email || + primary?.email || + emails[0]?.email || + null, + image: user.avatar_url, + emailVerified: true, + login: user.login, + }; + }, + mapProfileToUser: async (profile) => + ({ + githubLogin: profile.login, + }) as Record, + }, + ], + }), ...(process.env.VERCEL ? [oAuthProxy({ productionURL: "https://www.better-hub.com" })] : []), @@ -100,19 +140,6 @@ export const auth = betterAuth({ //to update scopes updateAccountOnSignIn: true, }, - socialProviders: { - github: { - clientId: process.env.GITHUB_CLIENT_ID!, - clientSecret: process.env.GITHUB_CLIENT_SECRET!, - // Minimal default — the sign-in UI lets users opt into more - scope: ["read:user", "user:email", "public_repo"], - async mapProfileToUser(profile) { - return { - githubLogin: profile.login, - }; - }, - }, - }, session: { cookieCache: { enabled: true, @@ -127,6 +154,8 @@ export const auth = betterAuth({ "https://better-hub-*-better-auth.vercel.app", // Beta site "https://beta.better-hub.com", + // Local dev + "http://gpu.server:3001", ], advanced: { ipAddress: { @@ -159,7 +188,8 @@ export const getServerSession = cache(async () => { try { const githubUser = await getOctokitUser(account.accessToken); githubUserData = githubUser?.data ?? null; - } catch { + } catch (err) { + console.error("[getServerSession] getOctokitUser failed:", err); // GitHub API may be rate-limited; don't treat as unauthenticated. } if (!githubUserData) { diff --git a/apps/web/src/lib/github-config.ts b/apps/web/src/lib/github-config.ts new file mode 100644 index 00000000..1bdc0499 --- /dev/null +++ b/apps/web/src/lib/github-config.ts @@ -0,0 +1,25 @@ +// Environment-driven GitHub endpoint configuration. +// Defaults to github.com so nothing changes for existing users. + +export const GITHUB_WEB_URL = + process.env.NEXT_PUBLIC_GITHUB_WEB_URL || + process.env.GITHUB_WEB_URL || + "https://github.com"; + +export const GITHUB_API_URL = process.env.GITHUB_API_URL || "https://api.github.com"; + +export const GITHUB_GRAPHQL_URL = process.env.GITHUB_GRAPHQL_URL || `${GITHUB_API_URL}/graphql`; + +// Derive hostname for URL parsing (e.g. "github.com" or "gh.zlt.dev") +export const GITHUB_HOSTNAME = new URL(GITHUB_WEB_URL).hostname; + +// Raw content URL — on github.com this is raw.githubusercontent.com, +// on GHES it's typically https:///raw +export const GITHUB_RAW_URL = + process.env.GITHUB_RAW_URL || + (GITHUB_HOSTNAME === "github.com" + ? "https://raw.githubusercontent.com" + : `${GITHUB_WEB_URL}/raw`); + +// Whether we're pointing at a GHES instance (not github.com) +export const IS_GHES = GITHUB_HOSTNAME !== "github.com"; diff --git a/apps/web/src/lib/github-utils.ts b/apps/web/src/lib/github-utils.ts index bdf490f2..75cf18c2 100644 --- a/apps/web/src/lib/github-utils.ts +++ b/apps/web/src/lib/github-utils.ts @@ -1,3 +1,5 @@ +import { GITHUB_HOSTNAME } from "./github-config"; + export const LANGUAGE_COLORS: Record = { TypeScript: "#3178c6", JavaScript: "#f1e05a", @@ -239,7 +241,7 @@ function parsePositiveInt(value: string | undefined): number | null { export function parseGitHubUrl(htmlUrl: string): ParsedGitHubUrl | null { try { const url = new URL(htmlUrl); - if (url.hostname !== "github.com") return null; + if (url.hostname !== GITHUB_HOSTNAME) return null; const parts = url.pathname.split("/").filter(Boolean); if (parts.length === 0) return null; diff --git a/apps/web/src/lib/github.ts b/apps/web/src/lib/github.ts index d61cec3b..f6c35ab5 100644 --- a/apps/web/src/lib/github.ts +++ b/apps/web/src/lib/github.ts @@ -2,6 +2,7 @@ import { Octokit } from "@octokit/rest"; import { headers } from "next/headers"; import { cache } from "react"; import { $Session, getServerSession } from "./auth"; +import { GITHUB_API_URL, GITHUB_GRAPHQL_URL, GITHUB_WEB_URL } from "./github-config"; import { claimDueGithubSyncJobs, deleteGithubCacheByPrefix, @@ -480,7 +481,7 @@ const getGitHubAuthContext = cache(async (): Promise = return { userId: session.user.id, token, - octokit: new Octokit({ auth: token }), + octokit: new Octokit({ auth: token, baseUrl: GITHUB_API_URL }), forceRefresh, githubUser: session.githubUser, }; @@ -673,7 +674,7 @@ async function fetchUserEventsFromGitHub(octokit: Octokit, username: string, per async function fetchUserEventsPublicUnauthenticated(username: string, perPage: number) { const response = await fetch( - `https://api.github.com/users/${encodeURIComponent(username)}/events/public?per_page=${perPage}`, + `${GITHUB_API_URL}/users/${encodeURIComponent(username)}/events/public?per_page=${perPage}`, { headers: { Accept: "application/vnd.github+json", @@ -857,7 +858,7 @@ async function fetchContributionsFromGitHub(token: string, username: string) { `; const [calendarResponse, prResponse, prReviewResponse, issueResponse] = await Promise.all([ - fetch("https://api.github.com/graphql", { + fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers, body: JSON.stringify({ @@ -865,7 +866,7 @@ async function fetchContributionsFromGitHub(token: string, username: string) { variables: { username }, }), }), - fetch("https://api.github.com/graphql", { + fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers, body: JSON.stringify({ @@ -873,7 +874,7 @@ async function fetchContributionsFromGitHub(token: string, username: string) { variables: { username }, }), }), - fetch("https://api.github.com/graphql", { + fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers, body: JSON.stringify({ @@ -881,7 +882,7 @@ async function fetchContributionsFromGitHub(token: string, username: string) { variables: { username }, }), }), - fetch("https://api.github.com/graphql", { + fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers, body: JSON.stringify({ @@ -916,7 +917,7 @@ async function fetchContributionsFromGitHub(token: string, username: string) { } } `; - const yearsResponse = await fetch("https://api.github.com/graphql", { + const yearsResponse = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -955,7 +956,7 @@ async function fetchContributionsFromGitHub(token: string, username: string) { } } `; - const yearsResponse = await fetch("https://api.github.com/graphql", { + const yearsResponse = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -1025,7 +1026,7 @@ async function fetchContributionsFromGitHub(token: string, username: string) { } `; - const historicalResponse = await fetch("https://api.github.com/graphql", { + const historicalResponse = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -1402,7 +1403,7 @@ async function fetchUserProfileFromGitHub(octokit: Octokit, username: string) { ?.avatar_url as string) ?? "", html_url: (appData.html_url as string) ?? - `https://github.com/apps/${username.toLowerCase()}`, + `${GITHUB_WEB_URL}/apps/${username.toLowerCase()}`, bio: (appData.description as string) ?? null, blog: (appData.external_url as string) ?? null, location: null, @@ -1455,7 +1456,7 @@ async function enrichMissingRepoLanguagesFromGraphQL< const query = `query(${variableDefinitions}) { ${aliases.join("\n")} }`; try { - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -1647,7 +1648,7 @@ async function ghConditionalGet( "X-GitHub-Api-Version": "2022-11-28", }; if (etag) headers["If-None-Match"] = etag; - const resp = await fetch(`https://api.github.com${path}`, { headers, cache: "no-store" }); + const resp = await fetch(`${GITHUB_API_URL}${path}`, { headers, cache: "no-store" }); if (resp.status === 304) return { notModified: true }; if (!resp.ok) { const body = await resp.text().catch(() => ""); @@ -2699,7 +2700,7 @@ export async function checkIsStarred(owner: string, repo: string): Promise { try { - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -4407,7 +4408,7 @@ async function fetchRepoDiscussionsPageGraphQL( repo: string, after?: string | null, ): Promise { - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -4467,7 +4468,7 @@ async function fetchDiscussionDetailGraphQL( repo: string, number: number, ): Promise<{ detail: DiscussionDetail; comments: DiscussionComment[] } | null> { - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -4604,7 +4605,7 @@ export async function addDiscussionCommentViaGraphQL( const authCtx = await getGitHubAuthContext(); if (!authCtx) return null; - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${authCtx.token}`, @@ -4644,7 +4645,7 @@ export async function createDiscussionViaGraphQL( const authCtx = await getGitHubAuthContext(); if (!authCtx) return null; - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${authCtx.token}`, @@ -4686,7 +4687,7 @@ export async function addDiscussionReaction( const authCtx = await getGitHubAuthContext(); if (!authCtx) return { success: false, error: "Not authenticated" }; - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${authCtx.token}`, @@ -4716,7 +4717,7 @@ export async function removeDiscussionReaction( const authCtx = await getGitHubAuthContext(); if (!authCtx) return { success: false, error: "Not authenticated" }; - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${authCtx.token}`, @@ -4749,7 +4750,7 @@ export async function toggleDiscussionUpvote( ? REMOVE_DISCUSSION_UPVOTE_MUTATION : ADD_DISCUSSION_UPVOTE_MUTATION; - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${authCtx.token}`, @@ -4789,7 +4790,7 @@ export async function toggleDiscussionCommentUpvote( ? REMOVE_DISCUSSION_COMMENT_UPVOTE_MUTATION : ADD_DISCUSSION_COMMENT_UPVOTE_MUTATION; - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${authCtx.token}`, @@ -4961,7 +4962,7 @@ async function fetchRepoIssuesPageGraphQL( owner: string, repo: string, ): Promise { - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -5157,7 +5158,7 @@ export async function enrichPRsWithStats(owner: string, repo: string, prs: { num const query = `query { repository(owner: "${owner}", name: "${repo}") { ${prFragments.join(" ")} } }`; try { - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -5369,7 +5370,7 @@ export async function getRepoPullRequestsWithStats( }`; try { - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -5632,7 +5633,7 @@ export async function batchFetchCheckStatuses( }`; try { - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -6708,7 +6709,7 @@ async function fetchRepoPageDataGraphQL( } `; - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -6809,7 +6810,7 @@ async function fetchRepoPageDataGraphQL( : null, pushed_at: r.pushedAt ?? "", size: r.diskUsage ?? 0, - html_url: r.url ?? `https://github.com/${owner}/${repo}`, + html_url: r.url ?? `${GITHUB_WEB_URL}/${owner}/${repo}`, homepage: r.homepageUrl || null, parent: parentNode ? { @@ -7182,7 +7183,7 @@ export async function getAuthorDossier( } `; - const response = await fetch("https://api.github.com/graphql", { + const response = await fetch(GITHUB_GRAPHQL_URL, { method: "POST", headers: { Authorization: `Bearer ${token}`, diff --git a/apps/web/src/lib/og/og-data.ts b/apps/web/src/lib/og/og-data.ts index 16480331..9554e941 100644 --- a/apps/web/src/lib/og/og-data.ts +++ b/apps/web/src/lib/og/og-data.ts @@ -1,7 +1,9 @@ // Lightweight data fetching for OG images. // SECURITY: Only returns data for PUBLIC repositories to prevent leaking private repo data. -const GITHUB_API = "https://api.github.com"; +import { GITHUB_API_URL } from "@/lib/github-config"; + +const GITHUB_API = GITHUB_API_URL; function ghHeaders(): HeadersInit { const h: Record = { diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 213819c0..2d2b8de4 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { getSessionCookie } from "better-auth/cookies"; +import { GITHUB_WEB_URL } from "@/lib/github-config"; const publicPaths = ["/", "/api/auth", "/api/inngest"]; @@ -33,7 +34,7 @@ export default async function middleware(request: NextRequest) { const isPackRequest = GIT_SERVICES.has(repoPath); if (segments.length >= 3 && (isInfoRefsRequest || isPackRequest)) { - const githubUrl = new URL(`https://github.com${pathname}`); + const githubUrl = new URL(`${GITHUB_WEB_URL}${pathname}`); githubUrl.search = request.nextUrl.search; return NextResponse.redirect(githubUrl, 307); }