Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -13855,7 +13855,7 @@
],
"responses": {
"200": {
"description": "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.",
"description": "Public GitHub repository stars/forks for the website chrome; any syntactically-valid owner/repo is accepted unless the deployment configures PUBLIC_REPO_STATS_ALLOWLIST.",
"content": {
"application/json": {
"schema": {
Expand Down
7 changes: 7 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@ declare global {
GITTENSOR_UPSTREAM_REF?: string;
GITTENSOR_REGISTRY_URL: string;
GITHUB_PUBLIC_TOKEN?: string;
/** Public repo-stats endpoint (#4612): comma-separated allowlist of "owner/repo" pairs (case-insensitive)
* permitted to query GET /v1/public/github/repos/:owner/:repo/stats. Default "" (unset) => allow any
* syntactically-valid owner/repo -- this route proxies the live GitHub API rather than gittensory's own
* installation DB, so (unlike the sibling badge.svg route) there is no "installed" gate to fall back on
* instead. Set this to restrict a deployment to specific repos. Mirrors GITTENSORY_PUBLIC_STATS_REPOS's
* comma-separated shape (src/review/public-stats.ts). See src/github/public.ts. */
PUBLIC_REPO_STATS_ALLOWLIST?: string;
/** #703: owner-gated global to apply upstream sigmoid time-decay in score previews. Default off. */
SCORING_TIME_DECAY_ENABLED?: string;
/** #776 agent-layer GLOBAL kill-switch — when truthy, halts ALL agent actions across every repo. */
Expand Down
31 changes: 24 additions & 7 deletions src/github/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@ type RepoStatsCacheEntry = {
staleUntilMs: number;
};

const PUBLIC_REPO_STATS_OWNER = "jsonbored";
const PUBLIC_REPO_STATS_REPO = "gittensory";
const REPO_STATS_CACHE_TTL_MS = 1000 * 60 * 10;
const REPO_STATS_STALE_TTL_MS = 1000 * 60 * 60 * 24;
const repoStatsCache = new Map<string, RepoStatsCacheEntry>();
Expand Down Expand Up @@ -122,8 +120,12 @@ export async function fetchPublicContributorProfile(login: string, env?: Pick<En
}
}

export async function fetchPublicRepoStats(env: Pick<Env, "GITHUB_PUBLIC_TOKEN">, owner: string, repo: string): Promise<PublicRepoStats> {
const repoFullName = publicRepoFullName(owner, repo);
export async function fetchPublicRepoStats(
env: Pick<Env, "GITHUB_PUBLIC_TOKEN" | "PUBLIC_REPO_STATS_ALLOWLIST">,
owner: string,
repo: string,
): Promise<PublicRepoStats> {
const repoFullName = publicRepoFullName(env, owner, repo);
const cacheKey = repoFullName.toLowerCase();
const nowMs = Date.now();
const cached = repoStatsCache.get(cacheKey);
Expand All @@ -143,15 +145,30 @@ export function clearPublicRepoStatsCacheForTests(): void {
repoStatsCache.clear();
}

function publicRepoFullName(owner: string, repo: string): string {
// Parses PUBLIC_REPO_STATS_ALLOWLIST into a lowercased "owner/repo" set, mirroring publicStatsProjects's
// comma-separated parsing in src/review/public-stats.ts. Empty/unset -> empty set -> no allowlist restriction
// (#4612): a self-hosted fork's own repo works out of the box, with no code change, exactly like the sibling
// badge.svg route already does for any installed repo.
function publicRepoStatsAllowlist(env: Pick<Env, "PUBLIC_REPO_STATS_ALLOWLIST">): Set<string> {
const allowlist = new Set<string>();
for (const entry of (env.PUBLIC_REPO_STATS_ALLOWLIST ?? "").split(",")) {
const project = entry.trim().toLowerCase();
if (project) allowlist.add(project);
}
return allowlist;
}

function publicRepoFullName(env: Pick<Env, "PUBLIC_REPO_STATS_ALLOWLIST">, owner: string, repo: string): string {
const ownerName = owner.trim();
const repoName = repo.trim();
if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(ownerName)) throw new Error("invalid_github_repo");
if (!/^[A-Za-z0-9._-]{1,100}$/.test(repoName) || repoName === "." || repoName === "..") throw new Error("invalid_github_repo");
const normalizedOwnerName = ownerName.toLowerCase();
const normalizedRepoName = repoName.toLowerCase();
if (normalizedOwnerName !== PUBLIC_REPO_STATS_OWNER || normalizedRepoName !== PUBLIC_REPO_STATS_REPO) throw new Error("invalid_github_repo");
return `${normalizedOwnerName}/${normalizedRepoName}`;
const normalizedFullName = `${normalizedOwnerName}/${normalizedRepoName}`;
const allowlist = publicRepoStatsAllowlist(env);
if (allowlist.size > 0 && !allowlist.has(normalizedFullName)) throw new Error("invalid_github_repo");
return normalizedFullName;
}

async function fetchRepoStatsFromGitHub(env: Pick<Env, "GITHUB_PUBLIC_TOKEN">, repoFullName: string, nowMs: number): Promise<PublicRepoStats> {
Expand Down
2 changes: 1 addition & 1 deletion src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ export function buildOpenApiSpec() {
path: "/v1/public/github/repos/{owner}/{repo}/stats",
request: { params: z.object({ owner: z.string(), repo: z.string() }) },
responses: {
200: { description: "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.", content: { "application/json": { schema: PublicRepoStatsSchema } } },
200: { description: "Public GitHub repository stars/forks for the website chrome; any syntactically-valid owner/repo is accepted unless the deployment configures PUBLIC_REPO_STATS_ALLOWLIST.", content: { "application/json": { schema: PublicRepoStatsSchema } } },
400: { description: "Invalid or non-allowlisted GitHub repository" },
503: { description: "GitHub repository stats are unavailable" },
},
Expand Down
60 changes: 48 additions & 12 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,54 @@ describe("api routes", () => {
expect(calls).toHaveLength(1);
});

it("serves public GitHub repo stats for any repo when PUBLIC_REPO_STATS_ALLOWLIST is unset (#4612)", async () => {
const app = createApp();
// No PUBLIC_REPO_STATS_ALLOWLIST override: a self-hoster's own fork must work without code changes.
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
const calls: string[] = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
calls.push(input.toString());
return Response.json({ full_name: "acme/widgets", html_url: "https://github.com/acme/widgets", stargazers_count: 5, forks_count: 1 });
});

const response = await app.request("/v1/public/github/repos/acme/widgets/stats", {}, env);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
repoFullName: "acme/widgets",
htmlUrl: "https://github.com/acme/widgets",
stargazers_count: 5,
forks_count: 1,
source: "github",
stale: false,
});
expect(calls).toEqual(["https://api.github.com/repos/acme/widgets"]);
});

it("rejects public GitHub repo stats for repos outside a configured PUBLIC_REPO_STATS_ALLOWLIST, tolerating whitespace/casing/empty entries (#4612)", async () => {
const app = createApp();
const env = createTestEnv({
GITHUB_PUBLIC_TOKEN: "public-token",
PUBLIC_REPO_STATS_ALLOWLIST: " JSONbored/gittensory , Acme/Widgets ,,",
});
const calls: string[] = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
calls.push(input.toString());
return Response.json({ stargazers_count: 9, forks_count: 2 });
});

// A listed repo (with mismatched casing, exercising the trim + lowercase parsing) is served.
const allowed = await app.request("/v1/public/github/repos/ACME/WIDGETS/stats", {}, env);
expect(allowed.status).toBe(200);
await expect(allowed.json()).resolves.toMatchObject({ stargazers_count: 9, forks_count: 2, source: "github" });
expect(calls).toEqual(["https://api.github.com/repos/acme/widgets"]);

// A repo absent from the allowlist is rejected before ever calling GitHub.
const rejected = await app.request("/v1/public/github/repos/Attacker/missing-one/stats", {}, env);
expect(rejected.status).toBe(400);
await expect(rejected.json()).resolves.toMatchObject({ error: "invalid_github_repo" });
expect(calls).toHaveLength(1);
});

it("normalizes allowlisted public GitHub repo stats casing before fetching and caching", async () => {
const app = createApp();
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
Expand Down Expand Up @@ -408,18 +456,6 @@ describe("api routes", () => {
expect(fetchMock).not.toHaveBeenCalled();
});

it("rejects non-allowlisted public GitHub repo stats paths before calling GitHub", async () => {
const app = createApp();
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" });
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

const response = await app.request("/v1/public/github/repos/Attacker/missing-one/stats", {}, env);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({ error: "invalid_github_repo" });
expect(fetchMock).not.toHaveBeenCalled();
});

it("serves registry drift through the canonical registry change endpoint", async () => {
const app = createApp();
const env = createTestEnv();
Expand Down
Loading