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
6 changes: 6 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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: <site-url>/api/star-video/github/callback
STAR_VIDEO_GITHUB_CLIENT_ID=""
STAR_VIDEO_GITHUB_CLIENT_SECRET=""
53 changes: 53 additions & 0 deletions apps/web/src/app/api/star-video/github/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
82 changes: 82 additions & 0 deletions apps/web/src/app/api/star-video/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 13 additions & 4 deletions apps/web/src/app/api/star-video/repo/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Comment on lines +45 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Rejected tokens disable lookup fallback

When a visitor's OAuth token is revoked, expired, or rate-limited, its still-valid cookie makes this branch skip Redis and query GitHub without the shared credential; the failure is returned as a 503 with no fallback, leaving public-repository lookups broken until the cookie expires or is manually removed.

);
if (!result.ok) {
if (result.kind === "unavailable") {
return NextResponse.json(
Expand Down
67 changes: 46 additions & 21 deletions apps/web/src/components/star-video/repo-input-form.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
"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";

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<HTMLFormElement>) => {
event.preventDefault();
Expand All @@ -23,26 +34,40 @@ export function RepoInputForm() {
};

return (
<form
className="flex w-full max-w-[35rem] flex-col gap-2.5 sm:flex-row sm:items-center sm:gap-2.5 sm:rounded-full sm:bg-white sm:py-2.5 sm:pr-2.5 sm:pl-5 dark:sm:bg-white/[0.06] sm:[box-shadow:#ECECEC_0_0_0_0.0625rem,#28282814_0_0.0625rem_0.1875rem] dark:sm:[box-shadow:#FFFFFF1F_0_0_0_0.0625rem]"
onSubmit={onSubmit}
>
<div className="flex min-w-0 grow items-center gap-2.5 rounded-full bg-white px-5 py-3 [box-shadow:#ECECEC_0_0_0_0.0625rem,#28282814_0_0.0625rem_0.1875rem] sm:bg-transparent sm:p-0 dark:bg-white/[0.06] dark:sm:bg-transparent sm:[box-shadow:none] dark:[box-shadow:#FFFFFF1F_0_0_0_0.0625rem] dark:sm:[box-shadow:none]">
<GitHubMark className="size-4.5 shrink-0 text-[#1E1E1E80] dark:text-white/50" />
<input
aria-label="GitHub repository"
className="w-full min-w-0 bg-transparent font-sans text-[#1E1E1E] text-[1rem] leading-[1.25] tracking-[-0.01em] outline-none placeholder:text-[#1E1E1E66] dark:text-white dark:placeholder:text-white/40"
onChange={(event) => setValue(event.target.value)}
placeholder="owner/name"
value={value}
/>
</div>
<button
className="cta-gradient-primary-flat flex shrink-0 cursor-pointer items-center justify-center rounded-full px-5 py-3 font-sans font-semibold text-[0.9375rem] text-white leading-[1.29] sm:px-4.5 sm:py-2 sm:text-[0.875rem]"
type="submit"
<div className="flex w-full flex-col items-center gap-3">
<form
className="flex w-full max-w-[35rem] flex-col gap-2.5 sm:flex-row sm:items-center sm:gap-2.5 sm:rounded-full sm:bg-white sm:py-2.5 sm:pr-2.5 sm:pl-5 dark:sm:bg-white/[0.06] sm:[box-shadow:#ECECEC_0_0_0_0.0625rem,#28282814_0_0.0625rem_0.1875rem] dark:sm:[box-shadow:#FFFFFF1F_0_0_0_0.0625rem]"
onSubmit={onSubmit}
>
Generate video
</button>
</form>
<div className="flex min-w-0 grow items-center gap-2.5 rounded-full bg-white px-5 py-3 [box-shadow:#ECECEC_0_0_0_0.0625rem,#28282814_0_0.0625rem_0.1875rem] sm:bg-transparent sm:p-0 dark:bg-white/[0.06] dark:sm:bg-transparent sm:[box-shadow:none] dark:[box-shadow:#FFFFFF1F_0_0_0_0.0625rem] dark:sm:[box-shadow:none]">
<GitHubMark className="size-4.5 shrink-0 text-[#1E1E1E80] dark:text-white/50" />
<input
aria-label="GitHub repository"
className="w-full min-w-0 bg-transparent font-sans text-[#1E1E1E] text-[1rem] leading-[1.25] tracking-[-0.01em] outline-none placeholder:text-[#1E1E1E66] dark:text-white dark:placeholder:text-white/40"
onChange={(event) => setValue(event.target.value)}
placeholder="owner/name"
value={value}
/>
</div>
<button
className="cta-gradient-primary-flat flex shrink-0 cursor-pointer items-center justify-center rounded-full px-5 py-3 font-sans font-semibold text-[0.9375rem] text-white leading-[1.29] sm:px-4.5 sm:py-2 sm:text-[0.875rem]"
type="submit"
>
Generate video
</button>
</form>
{githubConnected ? (
<p className="font-sans text-[#1E1E1E66] text-xs dark:text-white/40">
GitHub connected. Lookups run with your account.
</p>
) : (
<a
className="font-sans text-[#1E1E1E66] text-xs underline underline-offset-2 transition-colors hover:text-[#1E1E1E99] dark:text-white/40 dark:hover:text-white/60"
href={buildGithubConnectHref(repoParam)}
>
Connect GitHub for the full stargazer crowd
</a>
)}
</div>
);
}
20 changes: 20 additions & 0 deletions apps/web/src/lib/star-video/github-connection.ts
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM: getServerGithubConnected always returns false causing hydration mismatch

The getServerGithubConnected() function (L11) unconditionally returns false. This function is passed as the getServerSnapshot argument to useSyncExternalStore in repo-input-form.tsx. When a user has the sv_github_connected=1 cookie set (from completing GitHub OAuth), isGithubConnected() (L8) reads document.cookie and returns true on the client, but the server snapshot is always false. This causes a React hydration mismatch: the server-rendered HTML shows the "Connect GitHub" link, then immediately re-renders to "GitHub connected" on the client. The sv_github_connected cookie is intentionally NOT httpOnly (so client JS can read it), meaning the server COULD read it from the request cookies during SSR — but getServerGithubConnected doesn't receive the request and just returns false. This is a UX/correctness bug, not a security issue.

Suggestion: Either pass the initial connection state from a server component (reading sv_github_connected from request cookies) into the client component as a prop, or suppress hydration warnings for this specific value. Alternatively, accept the mismatch and use useEffect to sync the client state after mount instead of useSyncExternalStore.

Prompt for AI agents
Check whether this issue is valid; if so, find the root cause and fix it. Read the referenced code to confirm the problem before changing it.

<issue at="apps/web/src/lib/star-video/github-connection.ts:11" severity="MEDIUM">getServerGithubConnected always returns false causing hydration mismatch — The `getServerGithubConnected()` function (L11) unconditionally returns `false`. This function is passed as the `getServerSnapshot` argument to `useSyncExternalStore` in `repo-input-form.tsx`. When a user has the `sv_github_connected=1` cookie set (from completing GitHub OAuth), `isGithubConnected()` (L8) reads `document.cookie` and returns `true` on the client, but the server snapshot is always `false`. This causes a React hydration mismatch: the server-rendered HTML shows the "Connect GitHub" link, then immediately re-renders to "GitHub connected" on the client. The `sv_github_connected` cookie is intentionally NOT httpOnly (so client JS can read it), meaning the server COULD read it from the request cookies during SSR — but `getServerGithubConnected` doesn't receive the request and just returns false. This is a UX/correctness bug, not a security issue. Fix: Either pass the initial connection state from a server component (reading `sv_github_connected` from request cookies) into the client component as a prop, or suppress hydration warnings for this specific value. Alternatively, accept the mismatch and use `useEffect` to sync the client state after mount instead of `useSyncExternalStore`.</issue>

Commit 142eaaf.

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)}`;
}
5 changes: 5 additions & 0 deletions apps/web/src/lib/star-video/github-cookies.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading