diff --git a/apps/web/.env.example b/apps/web/.env.example index 9da7bef01..6e11ce2e7 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -11,3 +11,9 @@ NOTRA_CONSOLE_URL="" # Used by the Repo Star Video tool to read stars + stargazers. # Use a dedicated read-only token / GitHub App token, not a personal one. GITHUB_TOKEN="" + +# GitHub OAuth App for the Repo Star Video tool. Visitors can connect their +# GitHub account so lookups run against their own rate limit. +# Callback URL: /api/star-video/github/callback +STAR_VIDEO_GITHUB_CLIENT_ID="" +STAR_VIDEO_GITHUB_CLIENT_SECRET="" diff --git a/apps/web/src/app/api/star-video/github/authorize/route.ts b/apps/web/src/app/api/star-video/github/authorize/route.ts new file mode 100644 index 000000000..ebdc9364c --- /dev/null +++ b/apps/web/src/app/api/star-video/github/authorize/route.ts @@ -0,0 +1,53 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { + GITHUB_STATE_COOKIE, + GITHUB_STATE_MAX_AGE_SECONDS, +} from "@/lib/star-video/github-cookies"; +import { + buildGithubAuthorizeUrl, + createOAuthState, + getGithubOAuthConfig, +} from "@/lib/star-video/github-oauth"; +import { githubReturnRepoSchema } from "@/schemas/star-video"; + +export const runtime = "nodejs"; + +export function GET(request: NextRequest) { + const parsedRepo = githubReturnRepoSchema.safeParse( + request.nextUrl.searchParams.get("repo") ?? "" + ); + const repo = parsedRepo.success ? parsedRepo.data : null; + + const returnUrl = new URL("/repo-star-video", request.nextUrl.origin); + if (repo) { + returnUrl.searchParams.set("repo", repo); + } + + const config = getGithubOAuthConfig(); + if (!config) { + return NextResponse.redirect(returnUrl); + } + + const state = createOAuthState(); + const redirectUri = new URL( + "/api/star-video/github/callback", + request.nextUrl.origin + ).toString(); + + const response = NextResponse.redirect( + buildGithubAuthorizeUrl(config.clientId, redirectUri, state) + ); + response.cookies.set( + GITHUB_STATE_COOKIE, + JSON.stringify({ state, repo: repo ?? undefined }), + { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: GITHUB_STATE_MAX_AGE_SECONDS, + } + ); + return response; +} diff --git a/apps/web/src/app/api/star-video/github/callback/route.ts b/apps/web/src/app/api/star-video/github/callback/route.ts new file mode 100644 index 000000000..88ec12a6b --- /dev/null +++ b/apps/web/src/app/api/star-video/github/callback/route.ts @@ -0,0 +1,82 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { + GITHUB_CONNECTED_COOKIE, + GITHUB_COOKIE_MAX_AGE_SECONDS, + GITHUB_STATE_COOKIE, + GITHUB_TOKEN_COOKIE, +} from "@/lib/star-video/github-cookies"; +import { + encryptGithubToken, + exchangeGithubCode, + getGithubOAuthConfig, +} from "@/lib/star-video/github-oauth"; +import { + githubCallbackQuerySchema, + githubOAuthStateSchema, +} from "@/schemas/star-video"; + +export const runtime = "nodejs"; + +function parseStateCookie(value: string | undefined) { + if (!value) { + return null; + } + try { + const parsed = githubOAuthStateSchema.safeParse(JSON.parse(value)); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +export async function GET(request: NextRequest) { + const stateData = parseStateCookie( + request.cookies.get(GITHUB_STATE_COOKIE)?.value + ); + + const returnUrl = new URL("/repo-star-video", request.nextUrl.origin); + if (stateData?.repo) { + returnUrl.searchParams.set("repo", stateData.repo); + } + + const response = NextResponse.redirect(returnUrl); + response.cookies.delete(GITHUB_STATE_COOKIE); + + const config = getGithubOAuthConfig(); + if (!(config && stateData)) { + return response; + } + + const query = githubCallbackQuerySchema.safeParse({ + code: request.nextUrl.searchParams.get("code") ?? "", + state: request.nextUrl.searchParams.get("state") ?? "", + }); + if (!query.success || query.data.state !== stateData.state) { + return response; + } + + const redirectUri = new URL( + "/api/star-video/github/callback", + request.nextUrl.origin + ).toString(); + const token = await exchangeGithubCode(query.data.code, redirectUri, config); + if (!token) { + return response; + } + + const cookieOptions = { + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: GITHUB_COOKIE_MAX_AGE_SECONDS, + } as const; + + response.cookies.set( + GITHUB_TOKEN_COOKIE, + encryptGithubToken(token, config.clientSecret), + { ...cookieOptions, httpOnly: true } + ); + response.cookies.set(GITHUB_CONNECTED_COOKIE, "1", cookieOptions); + return response; +} diff --git a/apps/web/src/app/api/star-video/repo/route.ts b/apps/web/src/app/api/star-video/repo/route.ts index a3e7381e4..c693eed47 100644 --- a/apps/web/src/app/api/star-video/repo/route.ts +++ b/apps/web/src/app/api/star-video/repo/route.ts @@ -5,6 +5,7 @@ import { getCachedRepoStarData, setCachedRepoStarData, } from "@/lib/star-video/cache"; +import { readGithubToken } from "@/lib/star-video/github-oauth"; import { loadRepoStarData } from "@/lib/star-video/load-repo"; import { enforceStarVideoRateLimit } from "@/lib/star-video/ratelimit"; import { repoQuerySchema } from "@/schemas/star-video"; @@ -39,13 +40,21 @@ export async function GET(request: NextRequest) { const { owner, repo } = parsed.data; const id = `${owner}/${repo}`.toLowerCase(); + const githubToken = readGithubToken(request); - const cached = await Effect.runPromise(getCachedRepoStarData(id)); - if (cached) { - return NextResponse.json(cached); + if (!githubToken) { + const cached = await Effect.runPromise(getCachedRepoStarData(id)); + if (cached) { + return NextResponse.json(cached); + } } - const result = await loadRepoStarData(owner, repo, id); + const result = await loadRepoStarData( + owner, + repo, + id, + githubToken ?? undefined + ); if (!result.ok) { if (result.kind === "unavailable") { return NextResponse.json( diff --git a/apps/web/src/components/star-video/repo-input-form.tsx b/apps/web/src/components/star-video/repo-input-form.tsx index fb0e1664f..8af46be0c 100644 --- a/apps/web/src/components/star-video/repo-input-form.tsx +++ b/apps/web/src/components/star-video/repo-input-form.tsx @@ -1,9 +1,15 @@ "use client"; import { useQueryState } from "nuqs"; -import { type FormEvent, useState } from "react"; +import { type FormEvent, useState, useSyncExternalStore } from "react"; import { toast } from "sonner"; import { GitHubMark } from "@/components/star-video/github-mark"; +import { + buildGithubConnectHref, + getServerGithubConnected, + isGithubConnected, + subscribeToGithubConnection, +} from "@/lib/star-video/github-connection"; import { parseRepoInput } from "@/lib/star-video/parse-repo"; const DEFAULT_INPUT = "usenotra/notra"; @@ -11,6 +17,11 @@ const DEFAULT_INPUT = "usenotra/notra"; export function RepoInputForm() { const [repoParam, setRepoParam] = useQueryState("repo"); const [value, setValue] = useState(repoParam ?? DEFAULT_INPUT); + const githubConnected = useSyncExternalStore( + subscribeToGithubConnection, + isGithubConnected, + getServerGithubConnected + ); const onSubmit = (event: FormEvent) => { event.preventDefault(); @@ -23,26 +34,40 @@ export function RepoInputForm() { }; return ( -
-
- - setValue(event.target.value)} - placeholder="owner/name" - value={value} - /> -
- -
+
+ + setValue(event.target.value)} + placeholder="owner/name" + value={value} + /> +
+ + + {githubConnected ? ( +

+ GitHub connected. Lookups run with your account. +

+ ) : ( + + Connect GitHub for the full stargazer crowd + + )} + ); } diff --git a/apps/web/src/lib/star-video/github-connection.ts b/apps/web/src/lib/star-video/github-connection.ts new file mode 100644 index 000000000..090c13b75 --- /dev/null +++ b/apps/web/src/lib/star-video/github-connection.ts @@ -0,0 +1,20 @@ +import { GITHUB_CONNECTED_COOKIE } from "./github-cookies"; + +export function subscribeToGithubConnection(): () => void { + return () => undefined; +} + +export function isGithubConnected(): boolean { + return document.cookie.split("; ").includes(`${GITHUB_CONNECTED_COOKIE}=1`); +} + +export function getServerGithubConnected(): boolean { + return false; +} + +export function buildGithubConnectHref(repoParam: string | null): string { + if (!repoParam) { + return "/api/star-video/github/authorize"; + } + return `/api/star-video/github/authorize?repo=${encodeURIComponent(repoParam)}`; +} diff --git a/apps/web/src/lib/star-video/github-cookies.ts b/apps/web/src/lib/star-video/github-cookies.ts new file mode 100644 index 000000000..75b5bf1a5 --- /dev/null +++ b/apps/web/src/lib/star-video/github-cookies.ts @@ -0,0 +1,5 @@ +export const GITHUB_CONNECTED_COOKIE = "sv_github_connected"; +export const GITHUB_TOKEN_COOKIE = "sv_github_token"; +export const GITHUB_STATE_COOKIE = "sv_github_state"; +export const GITHUB_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; +export const GITHUB_STATE_MAX_AGE_SECONDS = 60 * 10; diff --git a/apps/web/src/lib/star-video/github-oauth.ts b/apps/web/src/lib/star-video/github-oauth.ts new file mode 100644 index 000000000..bf4ee0b90 --- /dev/null +++ b/apps/web/src/lib/star-video/github-oauth.ts @@ -0,0 +1,115 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from "node:crypto"; +import type { NextRequest } from "next/server"; +import { githubAccessTokenSchema } from "@/schemas/star-video"; +import type { GithubOAuthConfig } from "@/types/star-video"; +import { GITHUB_TOKEN_COOKIE } from "./github-cookies"; + +const AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; +const TOKEN_URL = "https://github.com/login/oauth/access_token"; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const STATE_LENGTH = 16; + +export function getGithubOAuthConfig(): GithubOAuthConfig | null { + const clientId = process.env.STAR_VIDEO_GITHUB_CLIENT_ID; + const clientSecret = process.env.STAR_VIDEO_GITHUB_CLIENT_SECRET; + if (!(clientId && clientSecret)) { + return null; + } + return { clientId, clientSecret }; +} + +export function createOAuthState(): string { + return randomBytes(STATE_LENGTH).toString("hex"); +} + +export function buildGithubAuthorizeUrl( + clientId: string, + redirectUri: string, + state: string +): string { + const url = new URL(AUTHORIZE_URL); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("state", state); + return url.toString(); +} + +export async function exchangeGithubCode( + code: string, + redirectUri: string, + config: GithubOAuthConfig +): Promise { + try { + const res = await fetch(TOKEN_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + if (!res.ok) { + return null; + } + const parsed = githubAccessTokenSchema.safeParse(await res.json()); + return parsed.success ? parsed.data.access_token : null; + } catch { + return null; + } +} + +function encryptionKey(secret: string): Buffer { + return createHash("sha256").update(secret).digest(); +} + +export function encryptGithubToken(token: string, secret: string): string { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv("aes-256-gcm", encryptionKey(secret), iv); + const encrypted = Buffer.concat([ + cipher.update(token, "utf8"), + cipher.final(), + ]); + return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString( + "base64url" + ); +} + +function decryptGithubToken(value: string, secret: string): string | null { + try { + const raw = Buffer.from(value, "base64url"); + const iv = raw.subarray(0, IV_LENGTH); + const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = raw.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + const decipher = createDecipheriv("aes-256-gcm", encryptionKey(secret), iv); + decipher.setAuthTag(authTag); + return Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]).toString("utf8"); + } catch { + return null; + } +} + +export function readGithubToken(request: NextRequest): string | null { + const config = getGithubOAuthConfig(); + if (!config) { + return null; + } + const cookie = request.cookies.get(GITHUB_TOKEN_COOKIE)?.value; + if (!cookie) { + return null; + } + return decryptGithubToken(cookie, config.clientSecret); +} diff --git a/apps/web/src/lib/star-video/load-repo.ts b/apps/web/src/lib/star-video/load-repo.ts index 9b658d49f..9ddf7c4aa 100644 --- a/apps/web/src/lib/star-video/load-repo.ts +++ b/apps/web/src/lib/star-video/load-repo.ts @@ -11,15 +11,17 @@ const inflight = new Map>(); export function loadRepoStarData( owner: string, repo: string, - id: string + id: string, + token?: string ): Promise { - const existing = inflight.get(id); + const inflightKey = token ? `${id}:user` : id; + const existing = inflight.get(inflightKey); if (existing) { return existing; } const promise = Effect.runPromise( - fetchRepoStarData(owner, repo).pipe( + fetchRepoStarData(owner, repo, token).pipe( Effect.match({ onSuccess: (data): LoadRepoResult => ({ ok: true, data }), onFailure: (error): LoadRepoResult => ({ @@ -30,9 +32,9 @@ export function loadRepoStarData( ) ); - inflight.set(id, promise); + inflight.set(inflightKey, promise); promise.finally(() => { - inflight.delete(id); + inflight.delete(inflightKey); }); return promise; } diff --git a/apps/web/src/lib/star-video/stargazers.ts b/apps/web/src/lib/star-video/stargazers.ts index 6d6beb7f2..f3b20d083 100644 --- a/apps/web/src/lib/star-video/stargazers.ts +++ b/apps/web/src/lib/star-video/stargazers.ts @@ -15,24 +15,32 @@ class RepoUnavailable extends Data.TaggedError("RepoUnavailable")<{ readonly repo: string; }> {} -function buildHeaders(): Record { +function buildHeaders(token?: string): Record { const headers: Record = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "notra-star-video", }; - if (process.env.GITHUB_TOKEN) { - headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + const auth = token ?? process.env.GITHUB_TOKEN; + if (auth) { + headers.Authorization = `Bearer ${auth}`; } return headers; } -async function fetchJson(url: string): Promise { +function buildFetchOptions(token?: string): RequestInit { + if (token) { + return { headers: buildHeaders(token), cache: "no-store" }; + } + return { + headers: buildHeaders(), + next: { revalidate: REVALIDATE_SECONDS }, + }; +} + +async function fetchJson(url: string, token?: string): Promise { try { - const res = await fetch(url, { - headers: buildHeaders(), - next: { revalidate: REVALIDATE_SECONDS }, - }); + const res = await fetch(url, buildFetchOptions(token)); if (!res.ok) { return null; } @@ -45,13 +53,11 @@ async function fetchJson(url: string): Promise { const HTTP_NOT_FOUND = 404; async function fetchRepoMeta( - base: string + base: string, + token?: string ): Promise<{ status: number; data: GitHubRepo | null }> { try { - const res = await fetch(base, { - headers: buildHeaders(), - next: { revalidate: REVALIDATE_SECONDS }, - }); + const res = await fetch(base, buildFetchOptions(token)); if (!res.ok) { return { status: res.status, data: null }; } @@ -81,10 +87,11 @@ function toAvatarUrls(users: GitHubUser[] | null): string[] { return urls; } -async function fetchAvatars(base: string): Promise { - if (process.env.GITHUB_TOKEN) { +async function fetchAvatars(base: string, token?: string): Promise { + if (token || process.env.GITHUB_TOKEN) { const stargazers = await fetchJson( - `${base}/stargazers?per_page=100` + `${base}/stargazers?per_page=100`, + token ); const fromStars = toAvatarUrls(stargazers); if (fromStars.length > 0) { @@ -93,7 +100,8 @@ async function fetchAvatars(base: string): Promise { } const contributors = await fetchJson( - `${base}/contributors?per_page=100` + `${base}/contributors?per_page=100`, + token ); const fromContributors = toAvatarUrls(contributors); if (fromContributors.length > 0) { @@ -101,7 +109,8 @@ async function fetchAvatars(base: string): Promise { } const commits = await fetchJson>( - `${base}/commits?per_page=100` + `${base}/commits?per_page=100`, + token ); return toAvatarUrls( (commits ?? []) @@ -112,11 +121,12 @@ async function fetchAvatars(base: string): Promise { export const fetchRepoStarData = Effect.fn("fetchRepoStarData")(function* ( owner: string, - repo: string + repo: string, + token?: string ) { const base = `https://api.github.com/repos/${owner}/${repo}`; - const meta = yield* Effect.promise(() => fetchRepoMeta(base)); + const meta = yield* Effect.promise(() => fetchRepoMeta(base, token)); if (meta.status === HTTP_NOT_FOUND) { return yield* Effect.fail(new RepoNotFound({ owner, repo })); @@ -132,7 +142,7 @@ export const fetchRepoStarData = Effect.fn("fetchRepoStarData")(function* ( return yield* Effect.fail(new RepoNotFound({ owner, repo })); } - const avatars = yield* Effect.promise(() => fetchAvatars(base)); + const avatars = yield* Effect.promise(() => fetchAvatars(base, token)); const resolvedOwner = repoData.full_name.split("/")[0] ?? owner; return { diff --git a/apps/web/src/schemas/star-video.ts b/apps/web/src/schemas/star-video.ts index 6ffb861f8..b1cd57520 100644 --- a/apps/web/src/schemas/star-video.ts +++ b/apps/web/src/schemas/star-video.ts @@ -50,3 +50,26 @@ export const repoQuerySchema = z.object({ owner: ownerSlug, repo: repoSlug, }); + +const REPO_PAIR = /^[\w.-]+\/[\w.-]+$/; +const MAX_REPO_PAIR_LENGTH = 201; + +export const githubReturnRepoSchema = z + .string() + .trim() + .max(MAX_REPO_PAIR_LENGTH) + .regex(REPO_PAIR); + +export const githubOAuthStateSchema = z.object({ + state: z.string().min(1), + repo: githubReturnRepoSchema.optional(), +}); + +export const githubCallbackQuerySchema = z.object({ + code: z.string().min(1), + state: z.string().min(1), +}); + +export const githubAccessTokenSchema = z.object({ + access_token: z.string().min(1), +}); diff --git a/apps/web/src/types/star-video.ts b/apps/web/src/types/star-video.ts index c76445041..10948666c 100644 --- a/apps/web/src/types/star-video.ts +++ b/apps/web/src/types/star-video.ts @@ -6,6 +6,11 @@ export type StarVideoInputProps = { backgroundColor: string; }; +export interface GithubOAuthConfig { + clientId: string; + clientSecret: string; +} + export interface RepoStarData { id: string; owner: string; diff --git a/turbo.json b/turbo.json index abdf95758..f0a78016d 100644 --- a/turbo.json +++ b/turbo.json @@ -19,6 +19,8 @@ "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GITHUB_TOKEN", + "STAR_VIDEO_GITHUB_CLIENT_ID", + "STAR_VIDEO_GITHUB_CLIENT_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "INTEGRATION_ENCRYPTION_KEY",