import { requestCodeIndex } from "~/server/code/search-index"; import { createServerFn } from "@tanstack/react-start"; import { kickRepoInsights } from "~/server/insights/repo-insights-do"; import { assertBelowCap, CAPS, limitWrite, limitAccountWrite, withinRateLimit, MAX_PAGE } from "~/server/limits"; import { z } from "zod"; import { MAX_TOPIC_LENGTH, MAX_TOPICS, normalizeTopics, parseTopics, TopicError } from "~/lib/topics"; import { requireViewer, viewerMiddleware } from "./middleware"; import { all, batch, first, run } from "~/server/db"; import { addCounter, counterTotals } from "~/server/counters"; import { REPO_STARS } from "~/server/counter-keys"; import { repoMoveStatements } from "~/server/redirects"; import { syncHostingForRepo } from "~/server/sites/sync"; import { isScratchRepo } from "~/server/sites/config"; import { assertOwnerNotDisabled, assertNotArchived, canCreateInAccount, hasPermission, hasPrivateForkAncestor, requireRepoAccess, toRepoSummaries, toRepoSummary, visibleReposClause, } from "~/server/repos"; import { createRepository, deleteRepository, forkRepository, importRepository, refreshRepoStatus, reindexRepo, } from "~/server/repo-ops"; import { listRefs } from "~/server/git/refs"; import { containersEnabled } from "~/server/editor/container-config"; import { containersAllowedFor } from "~/server/editor/container-meter"; import { ownerAccount, setCollaborator as setRepoCollaborator } from "~/server/collaborators"; import { assertCanFork } from "~/server/fork-policy"; import { purgeRepoCaches } from "~/server/git/cache-purge"; import { dropHiddenRefsForRepo, requestHiddenRefCleanup } from "~/server/git/pull-bridge"; import { syncRepoAdvisoryIndex } from "~/server/security/advisory-packages"; import { purgeInaccessibleFollowers } from "~/server/access-revocation"; import { waitUntil } from "cloudflare:workers"; import { badRequest, conflict, forbidden } from "~/server/errors"; import { emitWebhook, publicUrl } from "~/server/events/emit"; import { repoName, repoRef, website } from "~/lib/validation"; import type { RepoRow } from "~/lib/types"; import { ACTIONS_POLICIES } from "~/lib/types"; import { assertMayDeleteRepo } from "~/server/verified-writes"; import { repoCardVisible, repoOgImage } from "~/server/og/data"; const PAGE_SIZE = 30; async function assertOwnerCap(ownerId: string): Promise { assertBelowCap( (await first<{ count: number }>("SELECT COUNT(*) AS count FROM repos WHERE owner_id = ?", ownerId))?.count ?? 0, CAPS.reposPerOwner, "repositories", ); } export const getRepoHeader = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const access = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const repo = await refreshRepoStatus(access.repo); const [forkParent, starred, watching] = await Promise.all([ repo.fork_of_id ? first<{ owner_name: string; name: string }>( "SELECT a.name AS owner_name, r.name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?", repo.fork_of_id, ) : Promise.resolve(null), context.viewer ? first("SELECT 1 AS ok FROM stars WHERE user_id = ? AND repo_id = ?", context.viewer.id, repo.id) : Promise.resolve(null), context.viewer ? first("SELECT 1 AS ok FROM watches WHERE user_id = ? AND repo_id = ?", context.viewer.id, repo.id) : Promise.resolve(null), ]); const [refs, ogImage, [stars]] = await Promise.all([ repo.status === "ready" ? listRefs(repo) : Promise.resolve({ head: null, branches: [], tags: [] }), repoOgImage(access.repo), // The star counter flushes to D1 every few seconds; the header shows the exact live total. counterTotals("repo-counts", repo.id, REPO_STARS).catch((): [number, number, number] => [repo.stars_count, 0, 0]), ]); const canWrite = hasPermission(access.permission, "write"); return { repo: toRepoSummary( { ...repo, stars_count: stars }, forkParent ? { owner: forkParent.owner_name, name: forkParent.name } : null, ), permission: access.permission, containersAllowed: canWrite && containersEnabled() && context.viewer !== null && (await containersAllowedFor(context.viewer.id)), starred: starred !== null, watching: watching !== null, cloneUrl: publicUrl(`/${repo.owner_name}/${repo.name}.git`), branchCount: refs.branches.length, tagCount: refs.tags.length, isEmpty: refs.branches.length === 0, ownerDisabled: Boolean(access.repo.owner_disabled_at), hidden: Boolean(repo.hidden_at), og: { image: ogImage, shareable: repoCardVisible(access.repo) }, }; }); const createRepoInput = z.object({ owner: z.string(), name: repoName, description: z.string().max(350).nullable(), visibility: z.enum(["public", "private"]), initReadme: z.boolean(), importUrl: z.url().nullable(), importToken: z.string().max(500).nullable().default(null), }); export const createRepo = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(createRepoInput) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const owner = await ownerAccount(data.owner); if (!(await canCreateInAccount(owner.id, viewer))) throw forbidden("You cannot create repositories here"); await assertOwnerCap(owner.id); const common = { owner, name: data.name, description: data.description, visibility: data.visibility, actor: viewer, }; const repo = data.importUrl ? await importRepository({ ...common, sourceUrl: data.importUrl, branch: null, token: data.importToken }) : await createRepository({ ...common, initReadme: data.initReadme }); return { owner: repo.owner_name, name: repo.name }; }); export const forkRepo = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ targetOwner: z.string(), name: repoName.optional() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "read"); const owner = await ownerAccount(data.targetOwner); if (!(await canCreateInAccount(owner.id, viewer))) throw forbidden("You cannot create repositories here"); await assertCanFork(repo, owner, viewer); await assertOwnerCap(owner.id); const fork = await forkRepository({ source: repo, owner, name: data.name ?? repo.name, actor: viewer }); return { owner: fork.owner_name, name: fork.name }; }); export const toggleStar = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ star: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "read"); if (data.star) { const inserted = await run( "INSERT OR IGNORE INTO stars (user_id, repo_id, created_at) VALUES (?, ?, ?)", viewer.id, repo.id, Date.now(), ); if (inserted.meta.changes > 0) { await addCounter("repo-counts", repo.id, REPO_STARS, [1, 0, 0]); if (await withinRateLimit("HEAVY_LIMITER", `star-hook:user:${viewer.id}`)) { await emitWebhook(repo.id, "star", { action: "created", sender: { login: viewer.name, id: viewer.id } }); } } } else { const removed = await run("DELETE FROM stars WHERE user_id = ? AND repo_id = ?", viewer.id, repo.id); if (removed.meta.changes > 0) await addCounter("repo-counts", repo.id, REPO_STARS, [-1, 0, 0]); } return { ok: true }; }); const settingsInput = repoRef.extend({ name: repoName, description: z.string().max(350).nullable(), website, /** Absent leaves the topics as they are. */ topics: z .array(z.string().max(MAX_TOPIC_LENGTH * 2)) .max(MAX_TOPICS * 2) .optional(), visibility: z.enum(["public", "private"]), defaultBranch: z.string().min(1), hasIssues: z.boolean(), hasActions: z.boolean(), actionsPolicy: z.enum(ACTIONS_POLICIES).optional(), allowForking: z.boolean().default(false), isArchived: z.boolean(), }); function normalized(value: string | null | undefined): string | null { return value?.trim() || null; } function sameTopics(input: readonly string[], current: readonly string[]): boolean { try { return JSON.stringify(normalizeTopics(input)) === JSON.stringify(current); } catch { return false; } } function validTopics(input: readonly string[] | undefined, current: string[]): string[] { if (input === undefined) return current; try { return normalizeTopics(input); } catch (error) { if (error instanceof TopicError) throw badRequest(error.message); throw error; } } /** * While a repository stays archived, the settings form may only make it private (narrowing access); anything * else needs unarchiving first, which this same form does by clearing `isArchived`. The form does not * prefill the website, so a null website counts as unchanged. */ function onlyHidesArchived(repo: RepoRow, data: z.infer): boolean { return ( data.name === repo.name && normalized(data.description) === normalized(repo.description) && (data.topics === undefined || sameTopics(data.topics, parseTopics(repo.topics))) && (data.website === null || normalized(data.website) === normalized(repo.website)) && (data.visibility === repo.visibility || data.visibility === "private") && data.defaultBranch === repo.default_branch && data.hasIssues === (repo.has_issues === 1) && data.hasActions === (repo.has_actions === 1) && (data.actionsPolicy ?? repo.actions_policy ?? "all") === (repo.actions_policy ?? "all") && data.allowForking === (repo.allow_forking === 1) ); } export const updateRepoSettings = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(settingsInput) .handler(async ({ data, context }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); assertOwnerNotDisabled(repo, context.viewer); if (data.isArchived && !onlyHidesArchived(repo, data)) assertNotArchived(repo); if (data.hasActions && isScratchRepo(repo)) throw badRequest("Scratch repositories cannot enable Actions"); if (data.name !== repo.name) { const clash = await first( "SELECT id FROM repos WHERE owner_id = ? AND name = ? AND id != ?", repo.owner_id, data.name, repo.id, ); if (clash) throw conflict("A repository with that name already exists"); } if (data.visibility === "public" && repo.visibility !== "public" && (await hasPrivateForkAncestor(repo))) { throw badRequest("Forks of private repositories cannot be made public"); } const topics = validTopics(data.topics, parseTopics(repo.topics)); const refs = await listRefs(repo); if (refs.branches.length > 0 && !refs.branches.some((branch) => branch.name === data.defaultBranch)) { throw badRequest("Default branch must be an existing branch"); } await run( `UPDATE repos SET name = ?, description = ?, website = ?, topics = ?, visibility = ?, default_branch = ?, has_issues = ?, has_actions = ?, actions_policy = ?, allow_forking = ?, is_archived = ?, updated_at = ? WHERE id = ?`, data.name, data.description, data.website, JSON.stringify(topics), data.visibility, data.defaultBranch, data.hasIssues ? 1 : 0, data.hasActions ? 1 : 0, data.actionsPolicy ?? repo.actions_policy ?? "all", data.allowForking ? 1 : 0, data.isArchived ? 1 : 0, Date.now(), repo.id, ); if (data.defaultBranch !== repo.default_branch) { await kickRepoInsights(repo.id); await requestCodeIndex(repo.id); } if (data.name.toLowerCase() !== repo.name.toLowerCase()) { await batch( repoMoveStatements( repo.id, { owner: repo.owner_name, name: repo.name }, { owner: repo.owner_name, name: data.name }, ), ); } await syncHostingForRepo(repo.id); if (data.visibility !== repo.visibility) { await requestHiddenRefCleanup(repo.id); waitUntil(dropHiddenRefsForRepo(repo.id)); waitUntil(purgeRepoCaches(repo).catch((error: unknown) => console.warn("cache purge failed", repo.id, error))); if (data.visibility === "private") await purgeInaccessibleFollowers(repo.id); await syncRepoAdvisoryIndex(repo.id).catch((error: unknown) => console.error("Advisory index sync failed", repo.id, error), ); } await reindexRepo({ id: repo.id, owner_name: repo.owner_name, name: data.name, description: data.description, topics, }); return { owner: repo.owner_name, name: data.name }; }); export const deleteRepo = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ confirm: z.string() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); if (data.confirm !== `${repo.owner_name}/${repo.name}`) throw badRequest("Type the full repository name to confirm"); assertMayDeleteRepo(repo, viewer); await deleteRepository(repo, viewer); return { ok: true }; }); /** Failed imports and forks have nothing worth protecting, so admins can remove them without the typed confirmation. */ export const deleteFailedRepo = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); if (repo.status !== "failed") throw badRequest("Only failed repositories can be removed this way"); assertMayDeleteRepo(repo, viewer); await deleteRepository(repo, viewer); return { importSourceUrl: repo.import_source_url, name: repo.name }; }); export const listCollaborators = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); return all<{ user_id: string; name: string; permission: string }>( `SELECT c.user_id, a.name, c.permission FROM collaborators c JOIN accounts a ON a.id = c.user_id WHERE c.repo_id = ? ORDER BY a.name`, repo.id, ); }); export const setCollaborator = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ username: z.string().min(1), permission: z.enum(["read", "write", "admin", "none"]) })) .handler(async ({ data, context }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "admin"); if (data.permission !== "none") assertNotArchived(repo); await setRepoCollaborator(repo, data.username, data.permission); return { ok: true }; }); export const exploreRepos = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(z.object({ q: z.string().max(200).optional(), page: z.number().int().min(1).max(MAX_PAGE).default(1) })) .handler(async ({ data, context }) => { const { clause, viewerId } = visibleReposClause(context.viewer, { adminSeesAll: false }); const offset = (data.page - 1) * PAGE_SIZE; const query = data.q?.trim(); const rows = query ? await all( `SELECT r.*, a.name AS owner_name FROM repos_fts f JOIN repos r ON r.id = f.repo_id JOIN accounts a ON a.id = r.owner_id WHERE repos_fts MATCH ?2 AND r.status = 'ready' AND a.disabled_at IS NULL AND ${clause} ORDER BY rank LIMIT ?3 OFFSET ?4`, viewerId, query .replace(/["*]/g, " ") .split(/\s+/) .filter(Boolean) .map((term) => `"${term}"*`) .join(" "), PAGE_SIZE, offset, ) : await all( `SELECT r.*, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.status = 'ready' AND a.disabled_at IS NULL AND ${clause} ORDER BY COALESCE(r.pushed_at, r.created_at) DESC LIMIT ?2 OFFSET ?3`, viewerId, PAGE_SIZE, offset, ); return { repos: await toRepoSummaries(rows), hasMore: rows.length === PAGE_SIZE }; }); export const getForks = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const { clause, viewerId } = visibleReposClause(context.viewer); const rows = await all( `SELECT r.*, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.fork_of_id = ?2 AND ${clause} ORDER BY r.created_at DESC LIMIT 100`, viewerId, repo.id, ); return toRepoSummaries(rows); }); export const transferableOwners = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .handler(async ({ context }) => { const viewer = requireViewer(context.viewer); const orgs = await all<{ name: string }>( `SELECT a.name FROM org_members m JOIN accounts a ON a.id = m.org_id WHERE m.user_id = ? ORDER BY a.name`, viewer.id, ); return [viewer.name, ...orgs.map((org) => org.name)]; });