From 55403c15606b6d56b6173a2913bf5430e772ab7f Mon Sep 17 00:00:00 2001 From: cletqui Date: Wed, 2 Sep 2026 11:58:27 +0200 Subject: [PATCH 1/2] ci: remove perpetually-failing test and GitHub Pages deploy workflows Neither has passed since 2024. The test suite was removed in 86b3ae3 ('delay tests') and vitest exits 1 with no test files. The Pages deploy builds a Cloudflare Pages worker (_worker.js) that GitHub Pages cannot run, and also breaks on the npm cache step (repo uses bun.lock). Production deploys go through Cloudflare Pages' own Git integration. --- .github/workflows/deploy.yml | 46 ------------------------------------ .github/workflows/test.yml | 28 ---------------------- 2 files changed, 74 deletions(-) delete mode 100644 .github/workflows/deploy.yml delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index e3fa491..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy project to GitHub Pages - -on: - push: - branches: - - main - - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: true - -jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - name: Install dependencies - run: npm ci - - name: Build project - run: npm run build - - name: Setup Pages - uses: actions/configure-pages@v4 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - # Upload dist folder - path: "./dist" - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 3e9c884..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Run tests - -on: - push: - branches: - - "**" - pull_request: - branches: - - "**" - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: 20 - - - name: Install dependencies - run: npm install - - - name: Run tests - run: npm run test From 0aaf04b75a60d6d5108a744f66a1d44e489e83aa Mon Sep 17 00:00:00 2001 From: cletqui Date: Wed, 2 Sep 2026 12:07:09 +0200 Subject: [PATCH 2/2] fix: security, rate-limit and a11y hardening pass Bugs: - /api always returned 401 (apiAuth had no early return); also strip the "Bearer " prefix before using the header token and verify via the free GET /rate_limit endpoint - swagger UI/JSON were caught by apiAuth and unreachable; register them before the auth middleware so they stay public - OAuth state check passed when both cookie and param were undefined - OAuth tokens and client_secret travelled through URL query strings (browser history / Referer / CF logs). Callback now sets cookies directly and redirects to /; refresh happens inline; token endpoint gets POST body + Accept: application/json. Drops the /github/access_token bounce route. - open redirect via callback_url on /callback and /logout - cors advertised origin:* with credentials:true (spec-invalid) - malformed max_id cookie threw and 500'd the page Rate limiting: - getRandomRepository: ~3-6 API calls per load instead of up to ~1000 (random candidates, one detailed fetch each), self-correcting ceiling on empty pages, no per-repo console.log - optional GITHUB_TOKEN fallback so anonymous traffic isn't capped at 60/h - bump the stale hardcoded MAX_ID a11y / UX: - , keyboard-accessible refresh link, decorative alt="", dead Watch/Fork/Star buttons become real GitHub links, no -in- + + + {"Login with GitHub"} + ); }; @@ -27,12 +27,13 @@ const User = async ({ const { data } = await user; const { login, avatar_url } = data; return ( - + + + {login} + ); } catch (_) { return ; @@ -46,4 +47,4 @@ export const Login = async ({ octokit }: { octokit: Octokit }) => { ); -}; // TODO fix UI +}; diff --git a/src/components/repository.tsx b/src/components/repository.tsx index 2af537a..056404e 100644 --- a/src/components/repository.tsx +++ b/src/components/repository.tsx @@ -4,6 +4,21 @@ import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; import { timeAgo, dateOptions } from "../utils/time"; import { constructUrl } from "../utils/url"; +import { Loader } from "./loader"; + +type RepositoryData = RestEndpointMethodTypes["repos"]["get"]["response"]["data"]; + +const RepositoryError = (): JSX.Element => ( +
+

+ {"Couldn't load a repository right now β€” GitHub may be rate-limiting. "} + {"Try again"} + {" or "} + {"sign in"} + {" to lift the limit."} +

+
+); const LANGUAGE_COLORS: Record = { JavaScript: "#f1e05a", @@ -34,19 +49,29 @@ const LANGUAGE_COLORS: Record = { const getLanguageColor = (language: string | null): string => (language && LANGUAGE_COLORS[language]) || "#8b949e"; -export const Repository = async ({ +const ResolvedRepository = async ({ repository, }: { - repository: Promise< - RestEndpointMethodTypes["repos"]["get"]["response"]["data"] - >; + repository: Promise; +}) => { + try { + return ; + } catch (_) { + return ; + } +}; + +export const Repository = ({ + repository, +}: { + repository: Promise; }) => { return ( - {"error"}}> - + }> + ); -}; // TODO handle errors like https://docs.github.com/en/rest/guides/scripting-with-the-rest-api-and-javascript?apiVersion=2022-11-28#handling-rate-limit-errors +}; const Container = ({ repository, @@ -65,7 +90,6 @@ const Container = ({ pushed_at, homepage, stargazers_count, - watchers_count, language, forks_count, license, @@ -84,7 +108,7 @@ const Container = ({ href={owner_html_url} title={login} > - avatar + {login} @@ -101,41 +125,60 @@ const Container = ({ diff --git a/src/index.tsx b/src/index.tsx index be434ea..2d048c5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,6 @@ import { Context, Hono } from "hono"; import { logger } from "hono/logger"; +import { secureHeaders } from "hono/secure-headers"; import { Octokit } from "octokit"; import { renderer } from "./utils/renderer"; @@ -32,6 +33,12 @@ const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); /* MIDDLEWARES */ app.use(logger()); +app.use( + secureHeaders({ + xFrameOptions: "DENY", + referrerPolicy: "strict-origin-when-cross-origin", + }) +); app.use(renderer); app.use("/", handleMaxId); app.use("/", handleTokens); @@ -62,4 +69,10 @@ app.get( } ); +/* ERRORS */ +app.onError((err, c) => { + console.error(err); + return c.text("Something went wrong. Try reloading.", 500); +}); + export default app; diff --git a/src/routes/api.tsx b/src/routes/api.tsx index c7e8549..26befab 100644 --- a/src/routes/api.tsx +++ b/src/routes/api.tsx @@ -1,7 +1,6 @@ import { Context } from "hono"; -import { poweredBy } from "hono/powered-by"; import { prettyJSON } from "hono/pretty-json"; -import { trimTrailingSlash } from 'hono/trailing-slash' +import { trimTrailingSlash } from "hono/trailing-slash"; import { cors } from "hono/cors"; import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; import { swaggerUI } from "@hono/swagger-ui"; @@ -17,13 +16,9 @@ import { apiAuth, getRandomRepository, getRepository } from "../utils/octokit"; const app = new OpenAPIHono<{ Bindings: Bindings; Variables: Variables }>(); /* MIDDLEWARES */ -app.use(poweredBy()); app.use(prettyJSON()); -app.use(trimTrailingSlash()) -app.use(cors({ origin: "*", allowMethods: ["GET"], credentials: true })); -app.use(handleMaxId); -app.use(handleTokens); -app.use(apiAuth); +app.use(trimTrailingSlash()); +app.use(cors({ origin: "*", allowMethods: ["GET"] })); /* SECURITY */ app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { @@ -31,7 +26,7 @@ app.openAPIRegistry.registerComponent("securitySchemes", "Bearer", { scheme: "bearer", }); -/* SWAGGER */ +/* SWAGGER (public β€” must be registered before apiAuth) */ app.get("/swagger", swaggerUI({ url: `/api/swagger.json`, version: "3.1" })); app.doc31( "/swagger.json", @@ -64,6 +59,11 @@ app.doc31( } ); +/* AUTH (applies only to the data endpoints below) */ +app.use(handleMaxId); +app.use(handleTokens); +app.use(apiAuth); + /* ROUTES */ const route = createRoute({ method: "get", @@ -114,11 +114,12 @@ app.openapi( const repository = id ? await getRepository(octokit, Number(id)) : await getRandomRepository(octokit, max_id.id); - if (id && repository.id != Number(id)) { + if (id && repository.id !== Number(id)) { return c.redirect(`/api/${repository.id}`, 302); } return c.json(repository, 200); - } catch (error: any) { + } catch (error) { + console.error(error); return c.json({ message: "Failed to fetch repository data" }, 500); } } diff --git a/src/routes/github.tsx b/src/routes/github.tsx index 90cf839..8558d76 100644 --- a/src/routes/github.tsx +++ b/src/routes/github.tsx @@ -2,15 +2,18 @@ import { Context, Hono } from "hono"; import { Bindings, Variables } from ".."; import { generateState, handleState } from "../utils/state"; -import { handleAccess, handleLogout } from "../utils/tokens"; +import { handleLogout } from "../utils/tokens"; /* APP */ const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); +/* HELPERS */ +const safePath = (path: string | undefined): string => + path && path.startsWith("/") && !path.startsWith("//") ? path : "/"; + /* MIDDLEWARES */ app.use("/login", generateState); app.use("/callback", handleState); -app.use("/access_token", handleAccess); app.use("/logout", handleLogout); /* ENDPOINTS */ @@ -21,13 +24,14 @@ app.get( ): Promise => { const { CLIENT_ID } = c.env; const { state } = c.var; - const { url } = c.req; - const redirect_url = new URL(url); + const redirect_url = new URL(c.req.url); redirect_url.pathname = "/github/callback"; - const searchParams = new URLSearchParams(); - searchParams.append("client_id", CLIENT_ID); - searchParams.append("redirect_uri", redirect_url.toString()); - searchParams.append("state", state); + redirect_url.search = ""; + const searchParams = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: redirect_url.toString(), + state, + }); return c.redirect( `https://github.com/login/oauth/authorize?${searchParams.toString()}`, 302 @@ -38,29 +42,14 @@ app.get( app.get( "/callback", async (c: Context<{ Bindings: Bindings; Variables: Variables }>) => { - const { refresh_token, access_token, expires_in } = c.var; - const searchParams = new URLSearchParams(); - refresh_token && searchParams.append("refresh_token", refresh_token); - access_token && searchParams.append("access_token", access_token); - expires_in && searchParams.append("expires_in", expires_in); - searchParams.append("callback_url", "/"); - return c.redirect(`/github/access_token?${searchParams.toString()}`, 302); - } -); - -app.get( - "/access_token", - async (c: Context<{ Bindings: Bindings; Variables: Variables }>) => { - const { callback_url } = c.req.query(); - return c.redirect(callback_url || "/", 302); + return c.redirect(safePath(c.req.query("callback_url")), 302); } ); app.get( "/logout", async (c: Context<{ Bindings: Bindings; Variables: Variables }>) => { - const { callback_url } = c.req.query(); - return c.redirect(callback_url || "/", 302); + return c.redirect(safePath(c.req.query("callback_url")), 302); } ); diff --git a/src/routes/id.tsx b/src/routes/id.tsx index 9b3ee00..711d8d6 100644 --- a/src/routes/id.tsx +++ b/src/routes/id.tsx @@ -23,17 +23,25 @@ app.get( access_token, octokit, } = c.var; - const update = access_token ? await getMaxId(octokit, id) : id; - const timestamp = access_token ? new Date().getTime() : old; - setCookie(c, "max_id", `{ "id": ${update}, "timestamp": ${timestamp} }`, { + let update = id; + let timestamp = old; + if (access_token) { + try { + update = await getMaxId(octokit, id); + timestamp = Date.now(); + } catch (error) { + console.error(error); + } + } + setCookie(c, "max_id", JSON.stringify({ id: update, timestamp }), { path: "/", secure: true, - httpOnly: false, // true + httpOnly: false, maxAge: 31557600, sameSite: "Strict", prefix: "secure", }); - return c.json({ id: update, timestamp: timestamp }); + return c.json({ id: update, timestamp }); } ); diff --git a/src/utils/octokit.tsx b/src/utils/octokit.tsx index 5d83569..8ea1d9a 100644 --- a/src/utils/octokit.tsx +++ b/src/utils/octokit.tsx @@ -8,7 +8,8 @@ import { version } from "../../package.json"; import { Bindings, Variables } from ".."; /** - * Asynchronously verifies the token by checking the status of fetching repositories. + * Asynchronously verifies a token by calling the rate-limit endpoint (which does + * not itself consume rate-limit budget). * @async @function verifyToken * @param {string} token The token to verify. * @param {Context<{ Bindings: Bindings; Variables: Variables }>} c The Context object. @@ -18,9 +19,9 @@ const verifyToken = async ( token: string, c: Context<{ Bindings: Bindings; Variables: Variables }> ): Promise => { - const octokit = getOctokitInstance(c, token); try { - const { status } = await getRepos(octokit, "octocat", "Hello-World"); + const octokit = getOctokitInstance(c, token); + const { status } = await octokit.request("GET /rate_limit"); return status === 200; } catch (_) { return false; @@ -40,11 +41,12 @@ export const apiAuth = createMiddleware( next: Next ): Promise => { const { access_token } = c.var; - const accessToken = access_token || c.req.header("Authorization"); - if (accessToken && (await verifyToken(accessToken, c))) { - await next(); + const header = c.req.header("Authorization")?.replace(/^Bearer\s+/i, ""); + const token = access_token || header; + if (!token || !(await verifyToken(token, c))) { + return c.text("Unauthorized", 401); } - return c.text("Unauthorized", 401); + await next(); } ); @@ -68,7 +70,7 @@ export const handleOctokit = createMiddleware( /** * Creates and returns an instance of Octokit for GitHub API. - * @async @function getOctokitInstance + * @function getOctokitInstance * @param {Context<{ Bindings: Bindings; Variables: Variables }>} c - The context object. * @param {string} [token] - Optional token for authentication. * @returns {Octokit} An instance of Octokit. @@ -90,7 +92,7 @@ export const getOctokitInstance = ( }; /** - * Asynchronously fetches repositories from a specified ID. + * Asynchronously fetches a page of public repositories starting from a given ID. * @async @function getRepositories * @param {Octokit} octokit - The Octokit instance for GitHub API. * @param {number} since - The ID to start fetching repositories from. @@ -100,11 +102,7 @@ const getRepositories = async ( octokit: Octokit, since: number ): Promise => { - try { - return octokit.rest.repos.listPublic({ since }); // octokit.request("GET /repositories", { since }); - } catch (error: any) { - throw error; - } + return octokit.rest.repos.listPublic({ since }); }; /** @@ -120,11 +118,7 @@ export const getRepos = async ( owner: string, repo: string ): Promise => { - try { - return octokit.rest.repos.get({ owner, repo }); // octokit.request("GET /repos/{owner}/{repo}", { owner, repo }); - } catch (error: any) { - throw error; - } + return octokit.rest.repos.get({ owner, repo }); }; /** @@ -138,33 +132,41 @@ export const getRepository = async ( octokit: Octokit, id: number ): Promise => { - try { - const { data, status, url } = await getRepositories( - octokit, - Number(id) - 1 - ); // (id - 1) because since starts from next id - if (status === 200) { - if (data.length === 0) { - throw new Error("Repository not found"); - } else { - const repo = data[0]; - const { - name, - owner: { login }, - } = repo; - const { data: repository } = await getRepos(octokit, login, name); - return repository; - } - } else { - throw new Error(`${status} error at ${url}`); - } - } catch (error: any) { - throw error; + // (id - 1) because `since` starts from the next id + const { data, status, url } = await getRepositories(octokit, Number(id) - 1); + if (status !== 200) { + throw new Error(`${status} error at ${url}`); } + if (data.length === 0) { + throw new Error("Repository not found"); + } + const { + name, + owner: { login }, + } = data[0]; + const { data: repository } = await getRepos(octokit, login, name); + return repository; }; /** - * Asynchronously retrieves a random repository that has no stars, is not a forked repo and is not empty. + * Returns a new array with the elements of `items` in random order (Fisher-Yates). + * @function shuffle + */ +function shuffle(items: T[]): T[] { + const result = [...items]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +/** + * Asynchronously retrieves a random repository that has no stars, is not a fork and is not empty. + * + * Picks a random `since` offset, then inspects a handful of random candidates from + * that page (one detailed request each) instead of scanning the whole page. If a + * page comes back empty the ceiling is halved, so a stale `maxId` self-corrects. * @async @function getRandomRepository * @param {Octokit} octokit - The Octokit instance for GitHub API. * @param {number} maxId - The maximum ID to consider for repository selection. @@ -174,35 +176,36 @@ export const getRandomRepository = async ( octokit: Octokit, maxId: number ): Promise => { - try { - const maxIterations = 10; // max iterations - for (let loop = 0; loop < maxIterations; loop++) { - const since = Math.floor(Math.random() * maxId); - const { data: repositories } = await getRepositories(octokit, since); - const originalRepositories = repositories.filter((repo) => !repo.fork); - for (const repo of originalRepositories) { - const { - name, - owner: { login }, - } = repo; - try { - const { data: repos } = await getRepos(octokit, login, name); - const { id, stargazers_count, size } = repos; - if (stargazers_count === 0 && size > 0) { - return repos; - } - console.log( - `${login}/${name} (id: ${id}, stars: ${stargazers_count}, size: ${size})` - ); - } catch (error: any) { - console.log(`${login}/${name} (${error})`); + const maxIterations = 15; + const candidatesPerPage = 3; + let ceiling = maxId; + for (let loop = 0; loop < maxIterations; loop++) { + const since = Math.floor(Math.random() * ceiling); + const { data: repositories } = await getRepositories(octokit, since); + if (repositories.length === 0) { + ceiling = Math.max(1, Math.floor(ceiling / 2)); + continue; + } + const candidates = shuffle(repositories.filter((repo) => !repo.fork)).slice( + 0, + candidatesPerPage + ); + for (const repo of candidates) { + try { + const { data: repos } = await getRepos( + octokit, + repo.owner.login, + repo.name + ); + if (repos.stargazers_count === 0 && repos.size > 0) { + return repos; } + } catch (_) { + /* repo went private / was renamed / deleted since listing β€” skip */ } } - throw new Error(`No repository found with ${maxIterations} iterations`); - } catch (error: any) { - throw error; } + throw new Error(`No repository found after ${maxIterations} iterations`); }; /** @@ -233,11 +236,7 @@ export const getAuthenticatedUser = async ( ): Promise< RestEndpointMethodTypes["users"]["getAuthenticated"]["response"] > => { - try { - return octokit.rest.users.getAuthenticated(); // octokit.request("GET /user"); - } catch (error: any) { - throw error; - } + return octokit.rest.users.getAuthenticated(); }; /** @@ -252,9 +251,20 @@ export const handleMaxId = createMiddleware( c: Context<{ Bindings: Bindings; Variables: Variables }>, next: Next ) => { - const max_id = getCookie(c, "max_id", "secure"); - const MAX_ID = 822080279; // TODO TBU - c.set("max_id", max_id ? JSON.parse(max_id) : { id: MAX_ID, timestamp: 0 }); + const MAX_ID = 1_000_000_000; + const cookie = getCookie(c, "max_id", "secure"); + let max_id = { id: MAX_ID, timestamp: 0 }; + if (cookie) { + try { + const parsed = JSON.parse(cookie); + if (typeof parsed?.id === "number" && parsed.id > 0) { + max_id = { id: parsed.id, timestamp: Number(parsed.timestamp) || 0 }; + } + } catch (_) { + /* malformed cookie β€” fall back to the default */ + } + } + c.set("max_id", max_id); await next(); } ); diff --git a/src/utils/renderer.tsx b/src/utils/renderer.tsx index 2ae2ecf..8b1a944 100644 --- a/src/utils/renderer.tsx +++ b/src/utils/renderer.tsx @@ -8,14 +8,16 @@ import { fetchRepositoryData } from "./octokit"; import { Loader } from "../components/loader"; import { Login } from "../components/login"; +type RepositoryData = RestEndpointMethodTypes["repos"]["get"]["response"]["data"]; + const Head = ({ repository, }: { - repository: Promise< - RestEndpointMethodTypes["repos"]["get"]["response"]["data"] - >; + repository?: Promise; }) => { - const full_name = fetchRepositoryData(repository, "full_name"); + const full_name = repository + ? fetchRepositoryData(repository, "full_name").catch(() => "") + : ""; return ( @@ -41,11 +43,9 @@ const Header = (): JSX.Element => { return (
- + + +

{"PetitHub"}

@@ -86,9 +86,7 @@ const Footer = (): JSX.Element => { ); }; -const Body = async ({ children }: PropsWithChildren) => { - const c = useRequestContext(); - const { octokit } = c.var; +const Body = ({ children }: PropsWithChildren) => { return (
@@ -104,9 +102,9 @@ export const renderer = jsxRenderer( ({ children, repository, - }: PropsWithChildren<{ repository?: any }>): JSX.Element => { + }: PropsWithChildren<{ repository?: Promise }>): JSX.Element => { return ( - + diff --git a/src/utils/state.tsx b/src/utils/state.tsx index 03e9646..f705dc0 100644 --- a/src/utils/state.tsx +++ b/src/utils/state.tsx @@ -45,7 +45,7 @@ export const handleState = createMiddleware( ): Promise => { const secret = getCookie(c, "state", "secure"); const { state } = c.req.query(); - if (secret === state) { + if (secret && state && secret === state) { await handleRefresh(c, next); } else { console.error( @@ -57,10 +57,11 @@ export const handleState = createMiddleware( ); /** - * Generates a random string using Math.random() and Date.now(). + * Generates a cryptographically random hex string for use as an OAuth state token. * @function generateRandomString * @returns {string} A randomly generated string. */ const generateRandomString = (): string => { - return Math.floor(Math.random() * Date.now()).toString(36); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); }; diff --git a/src/utils/tokens.tsx b/src/utils/tokens.tsx index cadacaf..b560c57 100644 --- a/src/utils/tokens.tsx +++ b/src/utils/tokens.tsx @@ -5,6 +5,17 @@ import { createMiddleware } from "hono/factory"; import { Bindings, Variables } from ".."; import { handleOctokit } from "./octokit"; +const DEFAULT_EXPIRES_IN = "28800"; + +type GitHubTokenResponse = { + error?: string; + error_description?: string; + access_token?: string; + expires_in?: string; + refresh_token?: string; + refresh_token_expires_in?: string; +}; + /** * Middleware function to handle tokens, refresh access_token if needed and handle the octokit. * @async @function handleTokens @@ -22,11 +33,18 @@ export const handleTokens = createMiddleware( c.set("access_token", accessToken); c.set("refresh_token", refreshToken); if (refreshToken && !accessToken) { - const { path } = c.req; - return c.redirect( - `/github/access_token?refresh_token=${refreshToken}&callback_url=${path}`, - 302 - ); + const { CLIENT_ID, CLIENT_SECRET } = c.env; + const { access_token, expires_in, error, error_description } = + await fetchGitHubToken(CLIENT_ID, CLIENT_SECRET, { + grant_type: "refresh_token", + refresh_token: refreshToken, + }); + if (access_token) { + setToken(c, "access_token", access_token, expires_in); + } else { + console.error("token refresh failed", error, error_description); + unsetToken(c, "refresh_token"); + } } await handleOctokit(c, next); } @@ -38,21 +56,21 @@ export const handleTokens = createMiddleware( * @param {Context<{ Bindings: Bindings; Variables: Variables }>} c - The Context object. * @param {keyof Variables} key - The key to set the token value in the context and cookie. * @param {string} value - The value of the token to be set. - * @param {string} expires - The expiration time of the token in seconds. + * @param {string} [expires] - The expiration time of the token in seconds. */ const setToken = ( c: Context<{ Bindings: Bindings; Variables: Variables }>, key: keyof Variables, value: string, - expires: string + expires?: string ): void => { c.set(key, value); setCookie(c, key, value, { path: "/", secure: true, httpOnly: true, - maxAge: Number(expires), - sameSite: "Lax", // Strict + maxAge: Number(expires) || Number(DEFAULT_EXPIRES_IN), + sameSite: "Lax", prefix: "secure", }); }; @@ -72,35 +90,51 @@ export const unsetToken = ( path: "/", secure: true, httpOnly: true, - sameSite: "Lax", // Strict + sameSite: "Lax", prefix: "secure", }); }; /** - * Asynchronously fetches a refresh token using a code. - * @async @function fetchRefreshToken + * Exchanges a code or refresh token with GitHub's OAuth token endpoint. Credentials + * and grant parameters are sent in the request body (never the URL) and the JSON + * response is requested explicitly. + * @async @function fetchGitHubToken * @param {string} clientId The GitHub App client ID. * @param {string} clientSecret The GitHub App client secret. - * @param {string} code The code for authentication. - * @returns {Promise} A promise that resolves to an object containing the refresh token information. + * @param {Record} params Grant-specific parameters (`code` or `refresh_token` + `grant_type`). + * @returns {Promise} A promise that resolves to the token response. */ -const fetchRefreshToken = async ( +const fetchGitHubToken = async ( clientId: string, clientSecret: string, - code: string -): Promise => { - const response = await fetch( - `https://github.com/login/oauth/access_token?client_id=${clientId}&client_secret=${clientSecret}&code=${code}`, - { method: "POST" } - ); - const tokens = await response.text(); - const responseParams = new URLSearchParams(tokens); - return Object.fromEntries(responseParams); // TODO implement interface (any) + params: Record +): Promise => { + try { + const response = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + ...params, + }), + } + ); + return (await response.json()) as GitHubTokenResponse; + } catch (error) { + return { error: "request_failed", error_description: String(error) }; + } }; /** - * Middleware function to handle token refresh by fetching new tokens. + * Middleware function to complete the OAuth code exchange and persist the resulting + * tokens as secure cookies. Runs after the state check on `/github/callback`. * @async @function handleRefresh * @param {Context<{ Bindings: Bindings; Variables: Variables }>} c - The Context object. * @param {Next} next - The callback function to proceed to the next middleware. @@ -113,78 +147,23 @@ export const handleRefresh = createMiddleware( ): Promise => { const { CLIENT_ID, CLIENT_SECRET } = c.env; const { code } = c.req.query(); - const { - error, - error_description, - access_token, - expires_in, - refresh_token, - refresh_token_expires_in, - } = await fetchRefreshToken(CLIENT_ID, CLIENT_SECRET, code); - if (refresh_token) { - setToken(c, "refresh_token", refresh_token, refresh_token_expires_in); - } - if (access_token) { - c.set("access_token", access_token); - c.set("expires_in", expires_in || "28800"); - } else { - console.error(error, error_description); - } - await next(); - } -); - -/** - * Asynchronously fetches an access token using a refresh token. - * @async @function fetchAccessToken - * @param {string} clientId The GitHub App client ID. - * @param {string} clientSecret The GitHub App client secret. - * @param {string} refreshToken The refresh token to refresh. - * @returns {Promise} A promise that resolves to an object containing the access token information. - */ -const fetchAccessToken = async ( - clientId: string, - clientSecret: string, - refreshToken: string -): Promise => { - const response = await fetch( - `https://github.com/login/oauth/access_token?client_id=${clientId}&client_secret=${clientSecret}&refresh_token=${refreshToken}&grant_type=refresh_token`, - { method: "POST" } - ); - const tokens = await response.text(); - const responseParams = new URLSearchParams(tokens); - return Object.fromEntries(responseParams); // TODO implement interface (any) -}; - -/** - * Middleware function to handle access tokens based on the presence of access_token or refresh_token in the request query. - * @async @function handleAccess - * @param {Context<{ Bindings: Bindings; Variables: Variables }>} c - The Context object. - * @param {Next} next - The callback function to proceed to the next middleware. - * @returns {Promise} A promise that resolves after handling access tokens and potentially redirecting. - */ -export const handleAccess = createMiddleware( - async ( - c: Context<{ Bindings: Bindings; Variables: Variables }>, - next: Next - ): Promise => { - const { CLIENT_ID, CLIENT_SECRET } = c.env; - const { refresh_token, access_token, expires_in } = c.req.query(); - if (access_token) { - setToken(c, "access_token", access_token, expires_in || "28800"); - } else if (refresh_token) { + if (code) { const { error, error_description, - access_token: update, - expires_in: expires, - } = await fetchAccessToken(CLIENT_ID, CLIENT_SECRET, refresh_token); - if (update) { - setToken(c, "access_token", update, expires); - c.set("expires_in", expires); + access_token, + expires_in, + refresh_token, + refresh_token_expires_in, + } = await fetchGitHubToken(CLIENT_ID, CLIENT_SECRET, { code }); + if (refresh_token) { + setToken(c, "refresh_token", refresh_token, refresh_token_expires_in); + } + if (access_token) { + setToken(c, "access_token", access_token, expires_in); + c.set("expires_in", expires_in || DEFAULT_EXPIRES_IN); } else { - console.error(error, error_description); - return c.redirect("/github/login", 302); + console.error("code exchange failed", error, error_description); } } await next(); diff --git a/tsconfig.json b/tsconfig.json index 3882768..d3b3bcc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,9 +5,13 @@ "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, "lib": ["ESNext"], - "types": ["@cloudflare/workers-types", "vite/client", "vitest"], + "types": ["@cloudflare/workers-types", "vite/client"], "jsx": "react-jsx", "jsxImportSource": "hono/jsx" - } + }, + "include": ["src", "vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 47c494b..1122bd7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,15 +12,4 @@ export default defineConfig({ }), ], optimizeDeps: { include: ["hono", "octokit"] }, - test: { - include: ["**/*.test.tsx"], - globals: true, - poolOptions: { - workers: { - wrangler: { - configPath: "./wrangler.toml", - }, - }, - }, - }, }); diff --git a/wrangler.toml b/wrangler.toml index 2073c4f..36fb505 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,5 +1,3 @@ name = "petithub" pages_build_output_dir = "./dist" -compatibility_date = "2022-11-30" - -[vars] +compatibility_date = "2026-09-01"