From 22947bad962876a11fbd153bd4b151ef5129cda2 Mon Sep 17 00:00:00 2001 From: William Le Roux Date: Thu, 9 Oct 2025 19:35:17 +0000 Subject: [PATCH 1/3] feat: agnostic git support --- .../page/dashboard/CreateProjectModal.tsx | 8 +- .../components/page/dashboard/ProjectCard.tsx | 12 +- .../components/page/project/CommitHistory.jsx | 12 +- .../page/project/ContributionMetrics.tsx | 19 +- .../components/page/project/LatestCommit.tsx | 23 +- .../components/page/project/ProjectInfo.astro | 21 +- dapp/src/pages/api/git.ts | 304 ++++++++++++++++++ dapp/src/pages/project/index.astro | 8 +- dapp/src/schemas/validation.ts | 22 +- .../src/service/ContributionMetricsService.ts | 10 +- dapp/src/service/GithubService.ts | 229 +++++++------ dapp/src/service/StateService.ts | 34 +- dapp/src/service/walletService.ts | 1 + dapp/src/utils/editLinkFunctions.ts | 58 ++-- dapp/tests/anonymous-execute-flow.spec.ts | 20 +- dapp/tests/helpers/mock.ts | 54 ++-- website/docs/developers/user_flows.mdx | 9 +- website/docs/using_the_dapp.mdx | 2 +- 18 files changed, 549 insertions(+), 297 deletions(-) create mode 100644 dapp/src/pages/api/git.ts diff --git a/dapp/src/components/page/dashboard/CreateProjectModal.tsx b/dapp/src/components/page/dashboard/CreateProjectModal.tsx index aed0f04a..d6accb42 100644 --- a/dapp/src/components/page/dashboard/CreateProjectModal.tsx +++ b/dapp/src/components/page/dashboard/CreateProjectModal.tsx @@ -3,7 +3,7 @@ import { loadedPublicKey } from "@service/walletService"; import { setConfigData, setProject, - setProjectRepoInfo, + setProjectRepoUrl, } from "@service/StateService"; import { navigate } from "astro:transitions/client"; import Button from "components/utils/Button.tsx"; @@ -13,7 +13,6 @@ import FlowProgressModal from "components/utils/FlowProgressModal.tsx"; import Step from "components/utils/Step.tsx"; import Title from "components/utils/Title.tsx"; import { useState, type FC, useCallback, useEffect } from "react"; -import { getAuthorRepo } from "utils/editLinkFunctions"; import { extractConfigData, toast } from "utils/utils"; import { validateProjectName as validateProjectNameUtil, @@ -345,9 +344,8 @@ ${maintainerGithubs.map((gh) => `[[PRINCIPALS]]\ngithub="${gh}"`).join("\n\n")} if (project && project.name && project.config && project.maintainers) { setProject(project); - const { username, repoName } = getAuthorRepo(project.config.url); - if (username && repoName) { - setProjectRepoInfo(username, repoName); + if (project.config.url) { + setProjectRepoUrl(project.config.url); } const tomlData = await fetchTomlFromCid(project.config.ipfs); diff --git a/dapp/src/components/page/dashboard/ProjectCard.tsx b/dapp/src/components/page/dashboard/ProjectCard.tsx index d4364a8f..d3ee9eb4 100644 --- a/dapp/src/components/page/dashboard/ProjectCard.tsx +++ b/dapp/src/components/page/dashboard/ProjectCard.tsx @@ -9,12 +9,9 @@ import { setProject, setProjectId, setProjectLatestSha, - setProjectRepoInfo, + setProjectRepoUrl, } from "../../../service/StateService"; -import { - convertGitHubLink, - getAuthorRepo, -} from "../../../utils/editLinkFunctions"; +import { convertGitHubLink } from "../../../utils/editLinkFunctions"; import { projectCardModalOpen } from "../../../utils/store"; import { extractConfigData, toast } from "../../../utils/utils"; @@ -38,9 +35,8 @@ const ProjectCard = ({ config }: { config: ProjectConfig }) => { const project = await getProjectFromName(config.projectName); if (project && project.name && project.config && project.maintainers) { setProject(project); - const { username, repoName } = getAuthorRepo(project.config.url); - if (username && repoName) { - setProjectRepoInfo(username, repoName); + if (project.config.url) { + setProjectRepoUrl(project.config.url); } const tomlData = await fetchTomlFromCid(project.config.ipfs); if (tomlData) { diff --git a/dapp/src/components/page/project/CommitHistory.jsx b/dapp/src/components/page/project/CommitHistory.jsx index 220c4799..d8d167e5 100644 --- a/dapp/src/components/page/project/CommitHistory.jsx +++ b/dapp/src/components/page/project/CommitHistory.jsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { getCommitHistory } from "../../../service/GithubService.ts"; import { loadConfigData, - loadProjectRepoInfo, + loadProjectRepoUrl, } from "../../../service/StateService.ts"; import { formatDate } from "../../../utils/formatTimeFunctions.ts"; import { latestCommit, projectInfoLoaded } from "../../../utils/store.ts"; @@ -17,13 +17,9 @@ const CommitHistory = () => { const [currentPage, setCurrentPage] = useState(1); const fetchCommitHistory = async (page = 1) => { - const projectRepoInfo = loadProjectRepoInfo(); - if (projectRepoInfo?.author && projectRepoInfo?.repository) { - const history = await getCommitHistory( - projectRepoInfo.author, - projectRepoInfo.repository, - page, - ); + const repoUrl = loadProjectRepoUrl(); + if (repoUrl) { + const history = await getCommitHistory(repoUrl, page); if (history) { setCommitHistory(history); diff --git a/dapp/src/components/page/project/ContributionMetrics.tsx b/dapp/src/components/page/project/ContributionMetrics.tsx index d878d1ca..6382f1ba 100644 --- a/dapp/src/components/page/project/ContributionMetrics.tsx +++ b/dapp/src/components/page/project/ContributionMetrics.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import type { ContributionMetrics as ContributionMetricsType } from "../../../types/contributionMetrics"; +import type { ContributionMetrics as ContributionMetricsData } from "../../../types/contributionMetrics"; import { ContributionMetricsService } from "../../../service/ContributionMetricsService"; import { loadConfigData } from "../../../service/StateService"; import PonyFactorCard from "./PonyFactorCard"; @@ -8,16 +8,14 @@ import MonthlyActivityChart from "./MonthlyActivityChart"; interface ContributionMetricsProps { projectName: string; - owner: string; - repo: string; + repoUrl: string; } const ContributionMetrics = ({ projectName, - owner, - repo, + repoUrl, }: ContributionMetricsProps) => { - const [metrics, setMetrics] = useState(null); + const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [maintainers, setMaintainers] = useState([]); @@ -28,10 +26,7 @@ const ContributionMetrics = ({ setLoading(true); setError(null); - const metrics = await ContributionMetricsService.fetchMetrics( - owner, - repo, - ); + const metrics = await ContributionMetricsService.fetchMetrics(repoUrl); setMetrics(metrics); const configData = loadConfigData(); @@ -54,10 +49,10 @@ const ContributionMetrics = ({ } }; - if (projectName && owner && repo) { + if (projectName && repoUrl) { fetchMetrics(); } - }, [projectName, owner, repo]); + }, [projectName, repoUrl]); if (loading) { return ( diff --git a/dapp/src/components/page/project/LatestCommit.tsx b/dapp/src/components/page/project/LatestCommit.tsx index 9044b30b..8d17fd4a 100644 --- a/dapp/src/components/page/project/LatestCommit.tsx +++ b/dapp/src/components/page/project/LatestCommit.tsx @@ -72,15 +72,20 @@ const LatestCommit = () => { {commitData?.sha.slice(0, 9)}

- - - Open link - + + {commitData?.html_url ? ( + + Open link + + ) : null}
)} diff --git a/dapp/src/components/page/project/ProjectInfo.astro b/dapp/src/components/page/project/ProjectInfo.astro index 9073d4e8..902f64da 100644 --- a/dapp/src/components/page/project/ProjectInfo.astro +++ b/dapp/src/components/page/project/ProjectInfo.astro @@ -119,10 +119,7 @@ import Button from "components/utils/Button"; loadProjectLatestSha, loadProjectName, } from "../../../service/StateService"; - import { - convertGitHubLink, - getAuthorRepo, - } from "../../../utils/editLinkFunctions"; + import { convertGitHubLink } from "../../../utils/editLinkFunctions"; import { projectInfoLoaded } from "../../../utils/store"; import { getBadges, getMember } from "@service/ReadContractService"; @@ -579,16 +576,12 @@ import Button from "components/utils/Button"; } if (projectInfo?.name && projectInfo?.config?.url) { - const { username, repoName } = getAuthorRepo(projectInfo.config.url); - if (username && repoName) { - contributionMetricsRoot.render( - React.createElement(ContributionMetrics, { - projectName: projectInfo.name, - owner: username, - repo: repoName, - }), - ); - } + contributionMetricsRoot.render( + React.createElement(ContributionMetrics, { + projectName: projectInfo.name, + repoUrl: projectInfo.config.url, + }), + ); } // Re-render modals with the updated data diff --git a/dapp/src/pages/api/git.ts b/dapp/src/pages/api/git.ts new file mode 100644 index 00000000..47126736 --- /dev/null +++ b/dapp/src/pages/api/git.ts @@ -0,0 +1,304 @@ +import type { APIRoute } from "astro"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +interface GitCloneOptions { + depth?: number; +} + +async function cloneRepository( + repoUrl: string, + options: GitCloneOptions = {}, +): Promise<{ baseDir: string; repoDir: string }> { + const baseDir = await mkdtemp(join(tmpdir(), "tansu-git-")); + const repoDir = join(baseDir, "repo.git"); + + const args = ["clone", "--bare", "--no-tags"]; + if (options.depth && options.depth > 0) { + args.push("--depth", String(options.depth)); + } + args.push(repoUrl, repoDir); + + try { + await execFileAsync("git", args, { + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + }); + } catch (error) { + await rm(baseDir, { recursive: true, force: true }); + throw error; + } + + return { baseDir, repoDir }; +} + +async function runGitCommand(repoDir: string, args: string[]): Promise { + const { stdout } = await execFileAsync( + "git", + ["--git-dir", repoDir, ...args], + { + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + maxBuffer: 10 * 1024 * 1024, + }, + ); + return stdout; +} + +function normalizeRepoUrl(repoUrl: string): string { + let normalized = repoUrl.trim(); + if (normalized.endsWith(".git")) { + normalized = normalized.slice(0, -4); + } + if (normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +function buildCommitUrl(repoUrl: string, sha: string): string | undefined { + const normalized = normalizeRepoUrl(repoUrl); + + try { + const parsed = new URL(normalized); + const host = parsed.hostname.toLowerCase(); + + if (host.includes("github.")) { + return `${normalized}/commit/${sha}`; + } + if (host.includes("gitlab.")) { + return `${normalized}/-/commit/${sha}`; + } + if (host.includes("bitbucket.")) { + return `${normalized}/commits/${sha}`; + } + } catch { + // Non-HTTP(S) URL (e.g., SSH) – we cannot build a web URL + return undefined; + } + + return undefined; +} + +async function getCommitHistory( + repoUrl: string, + page: number, + perPage: number, +) { + const depth = Math.max(page * perPage, perPage); + const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth }); + + try { + const skip = Math.max(0, (page - 1) * perPage); + const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B%x1e"; + const rawLog = await runGitCommand(repoDir, [ + "log", + "--date=iso-strict", + `--skip=${skip}`, + "-n", + String(perPage), + `--pretty=format:${format}`, + ]); + + const commits = rawLog + .split("\x1e") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [ + sha, + authorName, + authorDate, + authorEmail, + committerName, + committerDate, + committerEmail, + message, + ] = entry.split("\x1f"); + + return { + sha, + authorName, + authorDate, + authorEmail, + committerName, + committerDate, + committerEmail, + message: message?.trimEnd() ?? "", + commitUrl: buildCommitUrl(repoUrl, sha || "") ?? "", + }; + }); + + return commits; + } finally { + await rm(baseDir, { recursive: true, force: true }); + } +} + +async function getCommitDetails(repoUrl: string, sha: string) { + const { baseDir, repoDir } = await cloneRepository(repoUrl); + + try { + const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B"; + const raw = await runGitCommand(repoDir, [ + "show", + sha, + "--quiet", + "--date=iso-strict", + `--pretty=format:${format}`, + ]); + + const [ + commitSha, + authorName, + authorDate, + authorEmail, + committerName, + committerDate, + committerEmail, + message, + ] = raw.trim().split("\x1f"); + + if (!commitSha) { + return undefined; + } + + return { + sha: commitSha, + html_url: buildCommitUrl(repoUrl, commitSha) ?? "", + commit: { + message: message?.trimEnd() ?? "", + author: { + name: authorName || "", + email: authorEmail || "", + date: authorDate || "", + }, + committer: { + name: committerName || "", + email: committerEmail || "", + date: committerDate || "", + }, + }, + }; + } finally { + await rm(baseDir, { recursive: true, force: true }); + } +} + +async function getLatestCommitHash(repoUrl: string) { + const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth: 1 }); + + try { + const sha = await runGitCommand(repoDir, ["rev-parse", "HEAD"]); + return sha.trim(); + } finally { + await rm(baseDir, { recursive: true, force: true }); + } +} + +async function getReadmeContent(repoUrl: string) { + const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth: 1 }); + const candidates = [ + "README.md", + "README.MD", + "README", + "Readme.md", + "readme.md", + ]; + + try { + for (const candidate of candidates) { + try { + const content = await runGitCommand(repoDir, [ + "show", + `HEAD:${candidate}`, + ]); + if (content) { + return content; + } + } catch (_error) { + // Try next candidate + continue; + } + } + return undefined; + } finally { + await rm(baseDir, { recursive: true, force: true }); + } +} + +export const POST: APIRoute = async ({ request }) => { + try { + // Safely parse JSON body; handle empty or invalid JSON without throwing + let body: any = {}; + try { + const text = await request.text(); + body = text ? JSON.parse(text) : {}; + } catch { + body = {}; + } + + const { action, repoUrl, page = 1, perPage = 30, sha } = body ?? {}; + + if (!repoUrl || typeof repoUrl !== "string") { + return new Response( + JSON.stringify({ error: "Repository URL is required" }), + { status: 400 }, + ); + } + + switch (action) { + case "history": { + const commits = await getCommitHistory(repoUrl, page, perPage); + return new Response(JSON.stringify({ commits }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + case "commit": { + if (!sha || typeof sha !== "string") { + return new Response( + JSON.stringify({ error: "Commit SHA is required" }), + { status: 400 }, + ); + } + const commit = await getCommitDetails(repoUrl, sha); + if (!commit) { + return new Response(null, { status: 404 }); + } + return new Response(JSON.stringify(commit), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + case "latest-hash": { + const latestSha = await getLatestCommitHash(repoUrl); + return new Response(JSON.stringify({ sha: latestSha }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + case "readme": { + const content = await getReadmeContent(repoUrl); + return new Response(JSON.stringify({ content }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + default: + return new Response(JSON.stringify({ error: "Unknown action" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + } catch (error: any) { + console.error("Git API error", error); + return new Response( + JSON.stringify({ error: "Failed to process git request" }), + { status: 500 }, + ); + } +}; diff --git a/dapp/src/pages/project/index.astro b/dapp/src/pages/project/index.astro index 7ba8aee3..11969c50 100644 --- a/dapp/src/pages/project/index.astro +++ b/dapp/src/pages/project/index.astro @@ -28,9 +28,8 @@ import ProjectInfoTitle from "../../components/page/project/ProjectInfoTitle.ast setProject, setProjectId, setProjectLatestSha, - setProjectRepoInfo, + setProjectRepoUrl, } from "../../service/StateService"; - import { getAuthorRepo } from "../../utils/editLinkFunctions"; import { projectInfoLoaded } from "../../utils/store"; import { extractConfigData, toast } from "../../utils/utils"; @@ -43,10 +42,7 @@ import ProjectInfoTitle from "../../components/page/project/ProjectInfoTitle.ast const project = await getProjectFromName(projectName); if (project && project.name && project.config && project.maintainers) { setProject(project); - const { username, repoName } = getAuthorRepo(project.config.url); - if (username && repoName) { - setProjectRepoInfo(username, repoName); - } + setProjectRepoUrl(project.config.url); const tomlData = await fetchTomlFromCid(project.config.ipfs); if (tomlData) { const configData = extractConfigData(tomlData, project); diff --git a/dapp/src/schemas/validation.ts b/dapp/src/schemas/validation.ts index a9fd291a..9811e6a7 100644 --- a/dapp/src/schemas/validation.ts +++ b/dapp/src/schemas/validation.ts @@ -24,19 +24,29 @@ export const projectNameSchema = z export const githubUrlSchema = z .string() - .min(1, "GitHub URL is required") - .refine((url) => { + .min(1, "Repository URL is required") + .refine((value) => { try { - const parsedUrl = new URL(url); - if (parsedUrl.hostname !== "github.com") { + if (value.startsWith("git@")) { + const [, path] = value.split(":"); + if (!path) return false; + const segments = path + .replace(/\.git$/, "") + .split("/") + .filter(Boolean); + return segments.length >= 2; + } + + const parsedUrl = new URL(value); + if (parsedUrl.protocol !== "https:") { return false; } const pathParts = parsedUrl.pathname.split("/").filter(Boolean); - return pathParts.length === 2; + return pathParts.length >= 2; } catch { return false; } - }, "URL must be in format: https://github.com/username/repository"); + }, "URL must be a valid git repository (e.g., https://host/owner/repo)"); export const githubHandleSchema = z .string() diff --git a/dapp/src/service/ContributionMetricsService.ts b/dapp/src/service/ContributionMetricsService.ts index 779a1109..573d95a4 100644 --- a/dapp/src/service/ContributionMetricsService.ts +++ b/dapp/src/service/ContributionMetricsService.ts @@ -12,16 +12,16 @@ export class ContributionMetricsService { * Fetch contribution metrics by analyzing GitHub API commit data * Uses the same getCommitHistory() function as CommitHistory component */ - static async fetchMetrics( - owner: string, - repo: string, - ): Promise { + static async fetchMetrics(repoUrl: string): Promise { try { + if (!repoUrl) { + throw new Error("Repository URL is required"); + } const allCommits: FormattedCommit[] = []; const maxPages = 34; for (let page = 1; page <= maxPages; page++) { - const history = await getCommitHistory(owner, repo, page, 30); + const history = await getCommitHistory(repoUrl, page, 30); if (!history || history.length === 0) break; for (const dayGroup of history) { diff --git a/dapp/src/service/GithubService.ts b/dapp/src/service/GithubService.ts index 66b42963..fd31b29b 100644 --- a/dapp/src/service/GithubService.ts +++ b/dapp/src/service/GithubService.ts @@ -1,156 +1,149 @@ -import axios from "axios"; - import type { FormattedCommit } from "../types/github"; -import { - getAuthorRepo, - getGithubContentUrlFromReadmeUrl, -} from "../utils/editLinkFunctions"; + +interface GitHistoryCommit { + sha: string; + authorName: string; + authorDate: string; + message: string; + commitUrl?: string; +} + +interface GitCommitDetails { + sha: string; + html_url?: string; + commit: { + message: string; + author: { name: string; email?: string; date?: string }; + committer: { name: string; email?: string; date?: string }; + }; +} + +async function callGitApi(body: Record): Promise { + const response = await fetch("/api/git", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Git API request failed with status ${response.status}`); + } + + return (await response.json()) as T; +} + +function groupCommitsByDate(commits: FormattedCommit[]) { + const grouped = commits.reduce( + (acc: Record, commit) => { + const date = new Date(commit.commit_date).toISOString().split("T")[0]; + if (!date) { + return acc; + } + if (!acc[date]) { + acc[date] = []; + } + acc[date].push(commit); + return acc; + }, + {}, + ); + + return Object.entries(grouped).map(([date, groupedCommits]) => ({ + date, + commits: groupedCommits as FormattedCommit[], + })); +} async function getCommitHistory( - username: string, - repo: string, + repoUrl: string, page: number = 1, perPage: number = 30, ): Promise<{ date: string; commits: FormattedCommit[] }[] | null> { - try { - const response = await axios.get( - `https://api.github.com/repos/${username}/${repo}/commits`, - { - params: { per_page: perPage, page: page }, - headers: { Accept: "application/vnd.github.v3+json" }, - }, - ); + if (!repoUrl) { + return null; + } - const formattedCommits = response.data.map((commit: any) => ({ - message: commit.commit.message, + try { + const data = await callGitApi<{ commits: GitHistoryCommit[] }>({ + action: "history", + repoUrl, + page, + perPage, + }); + + const formatted = data.commits.map((commit) => ({ + message: commit.message, author: { - name: commit.commit.author.name, - html_url: commit.author ? commit.author.html_url : "", + name: commit.authorName, + html_url: "", }, - commit_date: commit.commit.author.date, - html_url: commit.html_url, + commit_date: commit.authorDate, + html_url: commit.commitUrl || "", sha: commit.sha, })); - // Group commits by date - const groupedCommits = formattedCommits.reduce( - (acc: Record, commit: FormattedCommit) => { - const date = new Date(commit.commit_date).toISOString().split("T")[0]; - if (!date) { - return acc; - } - if (!acc[date]) { - acc[date] = []; - } - acc[date].push(commit); - return acc; - }, - {}, - ); - - // Convert grouped commits to array format - return Object.entries(groupedCommits).map(([date, commits]) => ({ - date, - commits: commits as FormattedCommit[], - })); - } catch { - // Don't show error toast as this may be an expected condition - // (e.g., repository is private or doesn't exist) + return groupCommitsByDate(formatted); + } catch (error) { + console.error("Failed to load commit history", error); return null; } } -async function getCommitDataFromSha( - owner: string, - repo: string, +async function getLatestCommitData( + repoUrl: string, sha: string, -): Promise { - try { - const url = `https://api.github.com/repos/${owner}/${repo}/commits/${sha}`; - const response = await fetch(url); - - if (!response.ok) { - // Expected condition (commit may not exist) - return undefined; - } - - return await response.json(); - } catch { - // Don't show error toast as this may be an expected condition +): Promise { + if (!repoUrl || !sha) { return undefined; } -} -async function getLatestCommitData( - configUrl: string, - sha: string, -): Promise { - const { username, repoName } = getAuthorRepo(configUrl); - if (!username || !repoName) { - // Expected condition (URL may be malformed) + try { + const commit = await callGitApi({ + action: "commit", + repoUrl, + sha, + }); + return commit; + } catch (error) { + console.error("Failed to load commit data", error); return undefined; } - - return await getCommitDataFromSha(username, repoName, sha); } async function getLatestCommitHash( - configUrl: string, + repoUrl: string, ): Promise { - const { username, repoName } = getAuthorRepo(configUrl); - if (!username || !repoName) { - // Expected condition (URL may be malformed) + if (!repoUrl) { return undefined; } try { - const repoRes = await fetch( - `https://api.github.com/repos/${username}/${repoName}`, - ); - if (!repoRes.ok) { - // Expected condition (repo may not exist or be private) - return undefined; - } - - const repoData = await repoRes.json(); - const defaultBranch = repoData.default_branch; - - const commitRes = await fetch( - `https://api.github.com/repos/${username}/${repoName}/commits/${defaultBranch}`, - ); - if (!commitRes.ok) { - // Expected condition (branch may not exist) - return undefined; - } - - const latestCommit = await commitRes.json(); - const latestSha = latestCommit.sha; - return latestSha; - } catch { - // Don't show error toast as these are often expected conditions + const { sha } = await callGitApi<{ sha: string }>({ + action: "latest-hash", + repoUrl, + }); + return sha; + } catch (error) { + console.error("Failed to load latest commit hash", error); return undefined; } } -async function fetchReadmeContentFromConfigUrl(configUrl: string) { - try { - const url = getGithubContentUrlFromReadmeUrl(configUrl); - - if (url) { - const response = await fetch(url); - - if (!response.ok) { - // Expected condition (readme may not exist) - return undefined; - } - - const tomlText = await response.text(); - return tomlText; - } - +async function fetchReadmeContentFromConfigUrl( + repoUrl: string, +): Promise { + if (!repoUrl) { return undefined; - } catch { - // Don't show error toast as this may be an expected condition + } + + try { + const { content } = await callGitApi<{ content?: string | null }>({ + action: "readme", + repoUrl, + }); + return content ?? undefined; + } catch (error) { + console.error("Failed to load repository README", error); return undefined; } } diff --git a/dapp/src/service/StateService.ts b/dapp/src/service/StateService.ts index e9ab1380..80882a85 100644 --- a/dapp/src/service/StateService.ts +++ b/dapp/src/service/StateService.ts @@ -28,12 +28,10 @@ const projectInfo: { project_config_ipfs: undefined, }; -const projectRepoInfo: { - project_author: string | undefined; - project_repository: string | undefined; +const projectRepo: { + project_url: string | undefined; } = { - project_author: undefined, - project_repository: undefined, + project_url: undefined, }; const projectLatestSha: { @@ -54,8 +52,7 @@ function refreshLocalStorage(): void { projectInfo.project_maintainers = undefined; projectInfo.project_config_url = undefined; projectInfo.project_config_ipfs = undefined; - projectRepoInfo.project_author = undefined; - projectRepoInfo.project_repository = undefined; + projectRepo.project_url = undefined; projectLatestSha.sha = undefined; configData = undefined; } @@ -87,11 +84,10 @@ function setProject(project: Project): void { } } -function setProjectRepoInfo(author: string, repository: string): void { - projectRepoInfo.project_author = author; - projectRepoInfo.project_repository = repository; +function setProjectRepoUrl(url: string): void { + projectRepo.project_url = url; if (typeof window !== "undefined") { - projectRepoInfoStore.set(projectRepoInfo); + projectRepoInfoStore.set(projectRepo); } } @@ -143,16 +139,8 @@ function loadProjectInfo(): Project | undefined { }; } -function loadProjectRepoInfo(): - | { author: string; repository: string } - | undefined { - if (!projectRepoInfo.project_author || !projectRepoInfo.project_repository) { - return undefined; - } - return { - author: projectRepoInfo.project_author, - repository: projectRepoInfo.project_repository, - }; +function loadProjectRepoUrl(): string | undefined { + return projectRepo.project_url; } function loadProjectLatestSha(): string | undefined { @@ -166,10 +154,10 @@ function loadConfigData(): ConfigData | undefined { export { setProjectId, setProject, - setProjectRepoInfo, + setProjectRepoUrl, loadedProjectId, loadProjectInfo, - loadProjectRepoInfo, + loadProjectRepoUrl, setProjectLatestSha, loadProjectLatestSha, loadProjectName, diff --git a/dapp/src/service/walletService.ts b/dapp/src/service/walletService.ts index f361e779..a59f5513 100644 --- a/dapp/src/service/walletService.ts +++ b/dapp/src/service/walletService.ts @@ -127,6 +127,7 @@ export { loadedPublicKey, loadedProvider, setConnection, + setConnection as setPublicKey, disconnect, initializeConnection, getWalletHealth, diff --git a/dapp/src/utils/editLinkFunctions.ts b/dapp/src/utils/editLinkFunctions.ts index cc4d1271..47f71fbd 100644 --- a/dapp/src/utils/editLinkFunctions.ts +++ b/dapp/src/utils/editLinkFunctions.ts @@ -12,42 +12,38 @@ export function convertGitHubLink(link: string): string { } } -export function getGithubContentUrl( - username: string, - repoName: string, - filePath: string, -): string { - return `https://raw.githubusercontent.com/${username}/${repoName}/${filePath}`; -} - export function getAuthorRepo(repoUrl: string): { username: string | undefined; repoName: string | undefined; } { - const match = repoUrl.match(/https\:\/\/github\.com\/([^\/]+)\/([^\/]+)/); - if (!match || !match[1] || !match[2]) - return { username: undefined, repoName: undefined }; - return { username: match[1], repoName: match[2] }; -} + try { + if (repoUrl.startsWith("git@")) { + const [, path] = repoUrl.split(":"); + if (!path) { + return { username: undefined, repoName: undefined }; + } + const segments = path + .replace(/\.git$/, "") + .split("/") + .filter(Boolean); + if (segments.length < 2) { + return { username: undefined, repoName: undefined }; + } + const repoName = segments.pop(); + const username = segments.pop(); + return { username, repoName }; + } -export function getGithubContentUrlFromConfigUrl( - configUrl: string, -): string | undefined { - const { username, repoName } = getAuthorRepo(configUrl); - if (username && repoName) { - // use master as GitHub will do an automatic redirection to main - return getGithubContentUrl(username, repoName, "master/tansu.toml"); - } - return undefined; -} + const url = new URL(repoUrl); + const segments = url.pathname.split("/").filter(Boolean); + if (segments.length < 2) { + return { username: undefined, repoName: undefined }; + } -export function getGithubContentUrlFromReadmeUrl( - configUrl: string, -): string | undefined { - const { username, repoName } = getAuthorRepo(configUrl); - if (username && repoName) { - // use master as GitHub will do an automatic redirection to main - return getGithubContentUrl(username, repoName, "master/README.md"); + const repoName = segments.pop()?.replace(/\.git$/, ""); + const username = segments.pop(); + return { username, repoName }; + } catch { + return { username: undefined, repoName: undefined }; } - return undefined; } diff --git a/dapp/tests/anonymous-execute-flow.spec.ts b/dapp/tests/anonymous-execute-flow.spec.ts index a0c96de2..2044f9c0 100644 --- a/dapp/tests/anonymous-execute-flow.spec.ts +++ b/dapp/tests/anonymous-execute-flow.spec.ts @@ -92,19 +92,13 @@ test("execute() receives weighted tallies/seeds for anonymous proposal", async ( // Stub walletService to provide a connected Mock wallet await page.route("**/src/service/walletService.ts", (route) => { const body = ` - export function loadedPublicKey() { - return 'G'.padEnd(56,'A'); - } - - export function loadedProvider() { - // Simulate a Mock wallet object - return { id: 'mockWallet', name: 'Mock Wallet', connected: true }; - } - - export function setConnection() {} - export function disconnect() {} - export function initializeConnection() {} - `; + export function loadedPublicKey(){ return 'G'.padEnd(56,'A'); } + export function loadedProvider(){ return 'freighter'; } + export function setConnection(){} + export function setPublicKey(){} + export function disconnect(){} + export function initializeConnection(){} + `; route.fulfill({ status: 200, headers: { "content-type": "application/javascript" }, diff --git a/dapp/tests/helpers/mock.ts b/dapp/tests/helpers/mock.ts index 87d97584..7c0cc35f 100644 --- a/dapp/tests/helpers/mock.ts +++ b/dapp/tests/helpers/mock.ts @@ -390,10 +390,7 @@ export async function applyAllMocks(page) { signAuthEntry: async () => ({ signedAuthEntry: 'mock', signerAddress: '${WALLET_PK}' }), signMessage: async () => ({ signature: 'mock', signerAddress: '${WALLET_PK}' }), getNetwork: async () => ({ network: 'testnet' }), - setWallet: async (walletId) => { - console.log('Mock setWallet called with:', walletId); - return true; - } + setWallet: (id) => { /* no-op for tests */ } };`; route.fulfill({ status: 200, @@ -485,10 +482,7 @@ export async function applyAllMocks(page) { signAuthEntry: async () => ({ signedAuthEntry: 'mock', signerAddress: '${WALLET_PK}' }), signMessage: async () => ({ signature: 'mock', signerAddress: '${WALLET_PK}' }), getNetwork: async () => ({ network: 'testnet' }), - setWallet: async (walletId) => { - console.log('Mock setWallet called with:', walletId); - return true; - } + setWallet: (id) => { /* no-op for tests */ } };`; route.fulfill({ status: 200, @@ -528,19 +522,15 @@ export async function applyAllMocks(page) { // Apply walletService mock before navigating to provide authenticated user for tests await page.route("**/src/service/walletService.ts", (route) => { const body = ` - export function loadedPublicKey() { return '${WALLET_PK}'; } - export function loadedProvider() { return { id: 'mockWallet', name: 'Mock Wallet', connected: true }; } - export function setPublicKey() {} - export function setConnection() {} - export function disconnect() {} - export function initializeConnection() { return { success: true }; } - export async function checkAndNotifyFunding() { - console.log('🧪 Mocked checkAndNotifyFunding called'); - } - export async function getWalletHealth() { - return { exists: true, balance: 100 }; - } - `; + export function loadedPublicKey(){ return '${WALLET_PK}'; } + export function loadedProvider(){ return 'freighter'; } + export function setConnection(){ } + export function setPublicKey(){} + export function disconnect(){} + export function initializeConnection(){} + export async function checkAndNotifyFunding() {} + export async function getWalletHealth() { return { exists: true, balance: 100 }; } + `; route.fulfill({ status: 200, headers: { "content-type": "application/javascript" }, @@ -551,19 +541,15 @@ export async function applyAllMocks(page) { // Also mock the wallet service with the @service alias pattern await page.route("**/@service/walletService*", (route) => { const body = ` - export function loadedPublicKey() { return '${WALLET_PK}'; } - export function loadedProvider() { return { id: 'mockWallet', name: 'Mock Wallet', connected: true }; } - export function setPublicKey() {} - export function setConnection() {} - export function disconnect() {} - export function initializeConnection() { return { success: true }; } - export async function checkAndNotifyFunding() { - console.log('🧪 Mocked checkAndNotifyFunding called'); - } - export async function getWalletHealth() { - return { exists: true, balance: 100 }; - } - `; + export function loadedPublicKey(){ return '${WALLET_PK}'; } + export function loadedProvider(){ return 'freighter'; } + export function setConnection(){ } + export function setPublicKey(){} + export function disconnect(){} + export function initializeConnection(){} + export async function checkAndNotifyFunding() {} + export async function getWalletHealth() { return { exists: true, balance: 100 }; } + `; route.fulfill({ status: 200, headers: { "content-type": "application/javascript" }, diff --git a/website/docs/developers/user_flows.mdx b/website/docs/developers/user_flows.mdx index 49ff81ec..95dafc3d 100644 --- a/website/docs/developers/user_flows.mdx +++ b/website/docs/developers/user_flows.mdx @@ -279,13 +279,13 @@ Maintainers can update project commit hashes to track code versions on-chain. sequenceDiagram participant M as Maintainer participant D as dApp - participant GH as GitHub + participant LG as Local Git Mirror participant SC as SorobanContract participant DB as Database M->>D: Update Commit Hash - D->>GH: Verify Commit Exists - GH-->>D: Commit Verification + D->>LG: Clone & Verify Commit + LG-->>D: Commit Verification M->>SC: Submit commit() SC-->>D: Commit Updated SC->>DB: Emit Commit Event @@ -294,7 +294,8 @@ sequenceDiagram ### Commit Verification -- **GitHub API Integration**: Verify commit hashes exist in specified repository +- **Local Git Clone**: The dApp clones the repository server-side to verify the commit exists, supporting any git hosting provider +- **Generic Metadata Fetching**: Commit history, README content, and latest hashes are resolved through the local clone instead of external platform APIs - **Hash Format Validation**: Ensure 40-character hexadecimal format - **Authorization Check**: Only project maintainers can update commits diff --git a/website/docs/using_the_dapp.mdx b/website/docs/using_the_dapp.mdx index 2878370f..48ecdd37 100644 --- a/website/docs/using_the_dapp.mdx +++ b/website/docs/using_the_dapp.mdx @@ -30,7 +30,7 @@ The dApp shows a **pending** status until the transaction is confirmed. Once don ## 3. Update your commit hash -Inside a project page click **Set commit hash**, paste the 40-character Git commit SHA and sign. The commit is stored on-chain and displayed alongside a link to the corresponding GitHub commit. +Inside a project page click **Set commit hash**, paste the 40-character Git commit SHA and sign. The commit is stored on-chain and displayed alongside a link to the corresponding commit in your repository host when the URL can be inferred. For an automated workflow, install the pre-push hook described in the [Git Hooks](developers/on_chain.mdx#git-hooks) section. From c4ac9cb7ba163506aba7d8fe52e645a7585d6feb Mon Sep 17 00:00:00 2001 From: William Le Roux Date: Thu, 9 Oct 2025 21:34:46 +0000 Subject: [PATCH 2/3] perf: make initial load faster and cache repo 5min --- dapp/src/pages/api/git.ts | 364 +++++++++++++++++++++++++------------- 1 file changed, 239 insertions(+), 125 deletions(-) diff --git a/dapp/src/pages/api/git.ts b/dapp/src/pages/api/git.ts index 47126736..19a0120e 100644 --- a/dapp/src/pages/api/git.ts +++ b/dapp/src/pages/api/git.ts @@ -1,4 +1,6 @@ import type { APIRoute } from "astro"; +// This endpoint must run on the server to handle POST requests +export const prerender = false; import { execFile } from "node:child_process"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -7,33 +9,162 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -interface GitCloneOptions { - depth?: number; +interface RepoCacheEntry { + baseDir: string; + repoDir: string; + depth: number; + lastAccess: number; } -async function cloneRepository( - repoUrl: string, - options: GitCloneOptions = {}, -): Promise<{ baseDir: string; repoDir: string }> { +const REPO_CACHE = new Map(); +const REPO_PENDING = new Map>(); +const REPO_TTL_MS = 5 * 60 * 1000; + +function now(): number { + return Date.now(); +} + +function cacheKeyFor(repoUrl: string): string { + return normalizeRepoUrl(repoUrl); +} + +async function cloneBareRepo(repoUrl: string, depth: number): Promise { const baseDir = await mkdtemp(join(tmpdir(), "tansu-git-")); const repoDir = join(baseDir, "repo.git"); - const args = ["clone", "--bare", "--no-tags"]; - if (options.depth && options.depth > 0) { - args.push("--depth", String(options.depth)); + const args = [ + "-c", + "protocol.version=2", + "clone", + "--bare", + "--no-tags", + "--filter=blob:none", + ]; + if (depth > 0) { + args.push("--depth", String(depth)); } args.push(repoUrl, repoDir); try { await execFileAsync("git", args, { - env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" }, }); + return { baseDir, repoDir, depth, lastAccess: now() }; } catch (error) { await rm(baseDir, { recursive: true, force: true }); throw error; } +} + +async function deepenRepo(repoDir: string, depth: number): Promise { + await execFileAsync( + "git", + [ + "--git-dir", + repoDir, + "-c", + "protocol.version=2", + "fetch", + "--no-tags", + "--filter=blob:none", + "--depth", + String(depth), + "origin", + ], + { env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" } }, + ); +} + +async function getRepo(repoUrl: string, depth: number): Promise { + const key = cacheKeyFor(repoUrl); + const requestedDepth = Math.max(depth, 1); + let entry = REPO_CACHE.get(key); + + if (!entry) { + // Deduplicate concurrent clones + let pending = REPO_PENDING.get(key); + if (!pending) { + pending = cloneBareRepo(repoUrl, requestedDepth).then((e) => { + REPO_CACHE.set(key, e); + REPO_PENDING.delete(key); + return e; + }).catch((err) => { + REPO_PENDING.delete(key); + throw err; + }); + REPO_PENDING.set(key, pending); + } + entry = await pending; + return entry; + } + + if (requestedDepth > entry.depth) { + const deepenKey = `${key}@${requestedDepth}`; + let pending = REPO_PENDING.get(deepenKey); + if (!pending) { + pending = deepenRepo(entry.repoDir, requestedDepth).then(() => { + entry!.depth = requestedDepth; + entry!.lastAccess = now(); + REPO_PENDING.delete(deepenKey); + return entry!; + }).catch((err) => { + REPO_PENDING.delete(deepenKey); + throw err; + }); + REPO_PENDING.set(deepenKey, pending); + } + entry = await pending; + } - return { baseDir, repoDir }; + entry.lastAccess = now(); + return entry; +} + +async function ensureCommitAvailable(repoDir: string, sha: string): Promise { + try { + await runGitCommand(repoDir, ["cat-file", "-e", `${sha}^{commit}`]); + } catch { + await execFileAsync( + "git", + [ + "--git-dir", + repoDir, + "-c", + "protocol.version=2", + "fetch", + "--no-tags", + "--filter=blob:none", + "--depth", + "1", + "origin", + sha, + ], + { env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" } }, + ); + } +} + +setInterval(async () => { + const cutoff = now() - REPO_TTL_MS; + for (const [key, entry] of REPO_CACHE.entries()) { + if (entry.lastAccess < cutoff) { + REPO_CACHE.delete(key); + await rm(entry.baseDir, { recursive: true, force: true }).catch(() => undefined); + } + } +}, 30_000).unref(); + +interface GitCloneOptions { + depth?: number; +} + +async function cloneRepository( + repoUrl: string, + options: GitCloneOptions = {}, +): Promise<{ baseDir: string; repoDir: string }> { + const depth = options.depth && options.depth > 0 ? options.depth : 1; + const entry = await getRepo(repoUrl, depth); + return { baseDir: entry.baseDir, repoDir: entry.repoDir }; } async function runGitCommand(repoDir: string, args: string[]): Promise { @@ -89,118 +220,105 @@ async function getCommitHistory( perPage: number, ) { const depth = Math.max(page * perPage, perPage); - const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth }); + const { repoDir } = await cloneRepository(repoUrl, { depth }); + const skip = Math.max(0, (page - 1) * perPage); + const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B%x1e"; + const rawLog = await runGitCommand(repoDir, [ + "log", + "--date=iso-strict", + `--skip=${skip}`, + "-n", + String(perPage), + `--pretty=format:${format}`, + ]); - try { - const skip = Math.max(0, (page - 1) * perPage); - const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B%x1e"; - const rawLog = await runGitCommand(repoDir, [ - "log", - "--date=iso-strict", - `--skip=${skip}`, - "-n", - String(perPage), - `--pretty=format:${format}`, - ]); - - const commits = rawLog - .split("\x1e") - .map((entry) => entry.trim()) - .filter(Boolean) - .map((entry) => { - const [ - sha, - authorName, - authorDate, - authorEmail, - committerName, - committerDate, - committerEmail, - message, - ] = entry.split("\x1f"); - - return { - sha, - authorName, - authorDate, - authorEmail, - committerName, - committerDate, - committerEmail, - message: message?.trimEnd() ?? "", - commitUrl: buildCommitUrl(repoUrl, sha || "") ?? "", - }; - }); + const commits = rawLog + .split("\x1e") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [ + sha, + authorName, + authorDate, + authorEmail, + committerName, + committerDate, + committerEmail, + message, + ] = entry.split("\x1f"); - return commits; - } finally { - await rm(baseDir, { recursive: true, force: true }); - } + return { + sha, + authorName, + authorDate, + authorEmail, + committerName, + committerDate, + committerEmail, + message: message?.trimEnd() ?? "", + commitUrl: buildCommitUrl(repoUrl, sha || "") ?? "", + }; + }); + + return commits; } async function getCommitDetails(repoUrl: string, sha: string) { - const { baseDir, repoDir } = await cloneRepository(repoUrl); + const { repoDir } = await cloneRepository(repoUrl, { depth: 1 }); + await ensureCommitAvailable(repoDir, sha); - try { - const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B"; - const raw = await runGitCommand(repoDir, [ - "show", - sha, - "--quiet", - "--date=iso-strict", - `--pretty=format:${format}`, - ]); - - const [ - commitSha, - authorName, - authorDate, - authorEmail, - committerName, - committerDate, - committerEmail, - message, - ] = raw.trim().split("\x1f"); - - if (!commitSha) { - return undefined; - } + const format = "%H%x1f%an%x1f%aI%x1f%ae%x1f%cn%x1f%cI%x1f%ce%x1f%B"; + const raw = await runGitCommand(repoDir, [ + "show", + sha, + "--quiet", + "--date=iso-strict", + `--pretty=format:${format}`, + ]); - return { - sha: commitSha, - html_url: buildCommitUrl(repoUrl, commitSha) ?? "", - commit: { - message: message?.trimEnd() ?? "", - author: { - name: authorName || "", - email: authorEmail || "", - date: authorDate || "", - }, - committer: { - name: committerName || "", - email: committerEmail || "", - date: committerDate || "", - }, - }, - }; - } finally { - await rm(baseDir, { recursive: true, force: true }); + const [ + commitSha, + authorName, + authorDate, + authorEmail, + committerName, + committerDate, + committerEmail, + message, + ] = raw.trim().split("\x1f"); + + if (!commitSha) { + return undefined; } + + return { + sha: commitSha, + html_url: buildCommitUrl(repoUrl, commitSha) ?? "", + commit: { + message: message?.trimEnd() ?? "", + author: { + name: authorName || "", + email: authorEmail || "", + date: authorDate || "", + }, + committer: { + name: committerName || "", + email: committerEmail || "", + date: committerDate || "", + }, + }, + }; } async function getLatestCommitHash(repoUrl: string) { - const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth: 1 }); - - try { - const sha = await runGitCommand(repoDir, ["rev-parse", "HEAD"]); - return sha.trim(); - } finally { - await rm(baseDir, { recursive: true, force: true }); - } + const { repoDir } = await cloneRepository(repoUrl, { depth: 1 }); + const sha = await runGitCommand(repoDir, ["rev-parse", "HEAD"]); + return sha.trim(); } async function getReadmeContent(repoUrl: string) { - const { baseDir, repoDir } = await cloneRepository(repoUrl, { depth: 1 }); + const { repoDir } = await cloneRepository(repoUrl, { depth: 1 }); const candidates = [ "README.md", "README.MD", @@ -209,25 +327,21 @@ async function getReadmeContent(repoUrl: string) { "readme.md", ]; - try { - for (const candidate of candidates) { - try { - const content = await runGitCommand(repoDir, [ - "show", - `HEAD:${candidate}`, - ]); - if (content) { - return content; - } - } catch (_error) { - // Try next candidate - continue; + for (const candidate of candidates) { + try { + const content = await runGitCommand(repoDir, [ + "show", + `HEAD:${candidate}`, + ]); + if (content) { + return content; } + } catch (_error) { + continue; } - return undefined; - } finally { - await rm(baseDir, { recursive: true, force: true }); } + + return undefined; } export const POST: APIRoute = async ({ request }) => { From a0e1879a96c18f89911e72245cf56b102eba056e Mon Sep 17 00:00:00 2001 From: William Le Roux Date: Tue, 13 Jan 2026 15:46:53 +0000 Subject: [PATCH 3/3] feat: add agnostic git support with allow list and Cloudflare proxy - Add ALLOWED_GIT_HOSTS allow list to local git API (github, gitlab, bitbucket, codeberg, etc.) - Support additional hosts via GIT_ALLOWED_HOSTS environment variable - Create git-proxy Cloudflare Worker as alternative to local API - Add PUBLIC_GIT_PROXY_URL environment variable to switch between local API and Cloudflare proxy - Worker supports GitHub, GitLab, Bitbucket, Codeberg APIs with unified interface - Add CORS and security controls to both local and proxy implementations --- dapp/src/env.d.ts | 14 + dapp/src/pages/api/git.ts | 78 +++ dapp/src/service/GithubService.ts | 22 +- dapp/workers/git-proxy/.gitignore | 3 + dapp/workers/git-proxy/README.md | 104 ++++ dapp/workers/git-proxy/package.json | 16 + dapp/workers/git-proxy/src/index.ts | 731 +++++++++++++++++++++++++++ dapp/workers/git-proxy/tsconfig.json | 17 + dapp/workers/git-proxy/wrangler.toml | 17 + 9 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 dapp/workers/git-proxy/.gitignore create mode 100644 dapp/workers/git-proxy/README.md create mode 100644 dapp/workers/git-proxy/package.json create mode 100644 dapp/workers/git-proxy/src/index.ts create mode 100644 dapp/workers/git-proxy/tsconfig.json create mode 100644 dapp/workers/git-proxy/wrangler.toml diff --git a/dapp/src/env.d.ts b/dapp/src/env.d.ts index 88bf36ed..f7748211 100644 --- a/dapp/src/env.d.ts +++ b/dapp/src/env.d.ts @@ -11,6 +11,20 @@ interface ImportMetaEnv { readonly PUBLIC_DEFAULT_FEE: string; readonly PUBLIC_DEFAULT_TIMEOUT: number; readonly PUBLIC_DELEGATION_API_URL: string; + /** + * Optional URL for the Git proxy Cloudflare Worker. + * When set, git operations will use this proxy instead of the local /api/git endpoint. + * Examples: + * - "https://git.tansu.dev" (production) + * - "https://git-testnet.tansu.dev" (testnet) + */ + readonly PUBLIC_GIT_PROXY_URL?: string; + /** + * Additional allowed Git hosts (comma-separated). + * Used by the local /api/git endpoint to allow custom self-hosted Git servers. + * Example: "gitlab.mycompany.com,git.example.org" + */ + readonly GIT_ALLOWED_HOSTS?: string; } interface ImportMeta { diff --git a/dapp/src/pages/api/git.ts b/dapp/src/pages/api/git.ts index 19a0120e..9adbe0ef 100644 --- a/dapp/src/pages/api/git.ts +++ b/dapp/src/pages/api/git.ts @@ -9,6 +9,74 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +/** + * Allow list of trusted git hosting domains. + * Only repositories from these domains will be cloned. + * Add new domains here to support additional git hosts. + */ +const ALLOWED_GIT_HOSTS = new Set([ + "github.com", + "gitlab.com", + "bitbucket.org", + "codeberg.org", + "sr.ht", + "gitea.com", + "git.sr.ht", + // Self-hosted GitLab instances can be added via environment variable +]); + +/** + * Additional allowed hosts from environment variable. + * Format: comma-separated list of domains (e.g., "gitlab.mycompany.com,git.example.org") + */ +const EXTRA_ALLOWED_HOSTS = ( + import.meta.env.GIT_ALLOWED_HOSTS || "" +) + .split(",") + .map((h: string) => h.trim().toLowerCase()) + .filter(Boolean); + +EXTRA_ALLOWED_HOSTS.forEach((host: string) => ALLOWED_GIT_HOSTS.add(host)); + +/** + * Validate that a repository URL is from an allowed host. + * Returns the normalized hostname if allowed, throws an error otherwise. + */ +function validateRepoUrl(repoUrl: string): string { + const trimmed = repoUrl.trim(); + + // Handle SSH URLs (git@host:path) + if (trimmed.startsWith("git@")) { + const match = trimmed.match(/^git@([^:]+):/); + if (match) { + const host = match[1].toLowerCase(); + if (ALLOWED_GIT_HOSTS.has(host)) { + return host; + } + } + throw new Error( + `Repository host not allowed. Allowed hosts: ${Array.from(ALLOWED_GIT_HOSTS).join(", ")}`, + ); + } + + // Handle HTTPS URLs + try { + const url = new URL(trimmed); + const host = url.hostname.toLowerCase(); + if (ALLOWED_GIT_HOSTS.has(host)) { + return host; + } + throw new Error( + `Repository host "${host}" not allowed. Allowed hosts: ${Array.from(ALLOWED_GIT_HOSTS).join(", ")}`, + ); + } catch (e) { + if (e instanceof Error && e.message.includes("not allowed")) { + throw e; + } + throw new Error("Invalid repository URL format"); + } +} + interface RepoCacheEntry { baseDir: string; repoDir: string; @@ -364,6 +432,16 @@ export const POST: APIRoute = async ({ request }) => { ); } + // Validate that the repository is from an allowed host + try { + validateRepoUrl(repoUrl); + } catch (validationError: any) { + return new Response( + JSON.stringify({ error: validationError.message }), + { status: 403 }, + ); + } + switch (action) { case "history": { const commits = await getCommitHistory(repoUrl, page, perPage); diff --git a/dapp/src/service/GithubService.ts b/dapp/src/service/GithubService.ts index fd31b29b..8113bf1c 100644 --- a/dapp/src/service/GithubService.ts +++ b/dapp/src/service/GithubService.ts @@ -18,8 +18,28 @@ interface GitCommitDetails { }; } +/** + * Get the Git API endpoint URL. + * Uses Cloudflare proxy if configured, otherwise uses local API. + * + * Environment variable GIT_PROXY_URL can be set to: + * - "https://git.tansu.dev" (production Cloudflare Worker) + * - "https://git-testnet.tansu.dev" (testnet Cloudflare Worker) + * - "" or undefined (use local /api/git endpoint) + */ +function getGitApiUrl(): string { + // Check for Cloudflare proxy URL from environment + const proxyUrl = import.meta.env.PUBLIC_GIT_PROXY_URL; + if (proxyUrl && typeof proxyUrl === "string" && proxyUrl.trim()) { + return proxyUrl.trim(); + } + // Default to local API endpoint + return "/api/git"; +} + async function callGitApi(body: Record): Promise { - const response = await fetch("/api/git", { + const apiUrl = getGitApiUrl(); + const response = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/dapp/workers/git-proxy/.gitignore b/dapp/workers/git-proxy/.gitignore new file mode 100644 index 00000000..d391312c --- /dev/null +++ b/dapp/workers/git-proxy/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.wrangler/ diff --git a/dapp/workers/git-proxy/README.md b/dapp/workers/git-proxy/README.md new file mode 100644 index 00000000..2c2797b4 --- /dev/null +++ b/dapp/workers/git-proxy/README.md @@ -0,0 +1,104 @@ +# Git Proxy Cloudflare Worker + +A Cloudflare Worker that provides a unified API for fetching git commit data from multiple git hosting providers. This worker acts as a proxy to avoid CORS issues and rate limiting when fetching git data from the browser. + +## Supported Git Hosts + +- **GitHub** (github.com) +- **GitLab** (gitlab.com) +- **Bitbucket** (bitbucket.org) +- **Codeberg** (codeberg.org) +- **Gitea** (gitea.com) +- **SourceHut** (sr.ht, git.sr.ht) + +Additional hosts can be added via the `EXTRA_GIT_HOSTS` environment variable. + +## Security + +### Allow List + +The worker only proxies requests to repositories hosted on allowed domains. This prevents abuse and ensures the proxy is only used for legitimate git hosting services. + +### CORS + +Only requests from allowed origins are accepted: +- `http://localhost:4321` (development) +- `https://testnet.tansu.dev` +- `https://app.tansu.dev` +- `https://tansu.xlm.sh` +- Netlify deploy previews + +## API + +All requests are POST requests with a JSON body. + +### Get Commit History + +```json +{ + "action": "history", + "repoUrl": "https://github.com/owner/repo", + "page": 1, + "perPage": 30 +} +``` + +### Get Commit Details + +```json +{ + "action": "commit", + "repoUrl": "https://github.com/owner/repo", + "sha": "abc123..." +} +``` + +### Get Latest Commit Hash + +```json +{ + "action": "latest-hash", + "repoUrl": "https://github.com/owner/repo" +} +``` + +### Get README Content + +```json +{ + "action": "readme", + "repoUrl": "https://github.com/owner/repo" +} +``` + +## Configuration + +### Environment Variables + +- `GITHUB_TOKEN` (optional): GitHub personal access token for higher rate limits +- `GITLAB_TOKEN` (optional): GitLab token for private repositories +- `EXTRA_GIT_HOSTS` (optional): Comma-separated list of additional allowed hosts + +### Deployment + +```bash +# Development +npm run dev + +# Deploy to testnet +npm run deploy:testnet + +# Deploy to production +npm run deploy:production +``` + +## Using with the dApp + +Set the `PUBLIC_GIT_PROXY_URL` environment variable in your dApp: + +```env +# Use Cloudflare proxy instead of local /api/git +PUBLIC_GIT_PROXY_URL=https://git.tansu.dev +``` + +When this variable is set, the GithubService will use the Cloudflare Worker instead of the local API endpoint. diff --git a/dapp/workers/git-proxy/package.json b/dapp/workers/git-proxy/package.json new file mode 100644 index 00000000..8bced331 --- /dev/null +++ b/dapp/workers/git-proxy/package.json @@ -0,0 +1,16 @@ +{ + "name": "git-proxy", + "version": "1.0.0", + "private": true, + "scripts": { + "deploy:testnet": "wrangler deploy --env testnet", + "deploy:production": "wrangler deploy --env production", + "dev": "wrangler dev", + "tail": "wrangler tail" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241127.0", + "typescript": "^5.7.0", + "wrangler": "^3.91.0" + } +} diff --git a/dapp/workers/git-proxy/src/index.ts b/dapp/workers/git-proxy/src/index.ts new file mode 100644 index 00000000..d863c2d0 --- /dev/null +++ b/dapp/workers/git-proxy/src/index.ts @@ -0,0 +1,731 @@ +/** + * Cloudflare Worker for Git Proxy + * + * This worker proxies requests to various Git hosting providers' APIs. + * It provides a unified interface for fetching commit data from + * GitHub, GitLab, Bitbucket, Codeberg, and other Git hosts. + * + * SECURITY: Uses an allow list for Git hosts and CORS for origins. + */ + +export interface Env { + // Optional: GitHub token for higher rate limits + GITHUB_TOKEN?: string; + // Optional: GitLab token for private repos + GITLAB_TOKEN?: string; + // Optional: Additional allowed hosts (comma-separated) + EXTRA_GIT_HOSTS?: string; +} + +/** + * Allow list of trusted git hosting domains. + * Only repositories from these domains will be proxied. + */ +const ALLOWED_GIT_HOSTS = new Set([ + "github.com", + "gitlab.com", + "bitbucket.org", + "codeberg.org", + "sr.ht", + "gitea.com", + "git.sr.ht", +]); + +const ALLOWED_ORIGINS = [ + "http://localhost:4321", + "https://testnet.tansu.dev", + "https://app.tansu.dev", + "https://tansu.xlm.sh", + "https://deploy-preview-*--staging-tansu.netlify.app", +]; + +function getCorsHeaders(origin: string | null): Record { + if (!origin) return {}; + + const isAllowed = ALLOWED_ORIGINS.some( + (allowed) => + allowed === origin || + (allowed.includes("*") && + new RegExp(`^${allowed.replace(/\*/g, ".*")}$`).test(origin)), + ); + + if (!isAllowed) return {}; + + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", + }; +} + +function getAllowedHosts(env: Env): Set { + const hosts = new Set(ALLOWED_GIT_HOSTS); + if (env.EXTRA_GIT_HOSTS) { + env.EXTRA_GIT_HOSTS.split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean) + .forEach((h) => hosts.add(h)); + } + return hosts; +} + +function validateRepoUrl(repoUrl: string, allowedHosts: Set): URL { + const trimmed = repoUrl.trim(); + + try { + const url = new URL(trimmed); + const host = url.hostname.toLowerCase(); + if (allowedHosts.has(host)) { + return url; + } + throw new Error( + `Repository host "${host}" not allowed. Allowed hosts: ${Array.from(allowedHosts).join(", ")}`, + ); + } catch (e) { + if (e instanceof Error && e.message.includes("not allowed")) { + throw e; + } + throw new Error("Invalid repository URL format"); + } +} + +function normalizeRepoUrl(repoUrl: string): string { + let normalized = repoUrl.trim(); + if (normalized.endsWith(".git")) { + normalized = normalized.slice(0, -4); + } + if (normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +function parseRepoPath(url: URL): { owner: string; repo: string } { + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length < 2) { + throw new Error("Invalid repository path"); + } + return { owner: parts[0], repo: parts[1].replace(".git", "") }; +} + +interface GitCommit { + sha: string; + authorName: string; + authorDate: string; + authorEmail: string; + committerName: string; + committerDate: string; + committerEmail: string; + message: string; + commitUrl: string; +} + +interface GitHostHandler { + getCommitHistory( + owner: string, + repo: string, + page: number, + perPage: number, + env: Env, + ): Promise; + getCommitDetails( + owner: string, + repo: string, + sha: string, + env: Env, + ): Promise; + getLatestCommitHash(owner: string, repo: string, env: Env): Promise; + getReadmeContent( + owner: string, + repo: string, + env: Env, + ): Promise; +} + +// GitHub API handler +const githubHandler: GitHostHandler = { + async getCommitHistory(owner, repo, page, perPage, env) { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Tansu-Git-Proxy/1.0", + }; + if (env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`; + } + + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/commits?page=${page}&per_page=${perPage}`, + { headers }, + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const data = (await response.json()) as any[]; + return data.map((commit: any) => ({ + sha: commit.sha, + authorName: commit.commit.author.name, + authorDate: commit.commit.author.date, + authorEmail: commit.commit.author.email, + committerName: commit.commit.committer.name, + committerDate: commit.commit.committer.date, + committerEmail: commit.commit.committer.email, + message: commit.commit.message, + commitUrl: commit.html_url, + })); + }, + + async getCommitDetails(owner, repo, sha, env) { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Tansu-Git-Proxy/1.0", + }; + if (env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`; + } + + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/commits/${sha}`, + { headers }, + ); + + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(`GitHub API error: ${response.status}`); + } + + const commit = (await response.json()) as any; + return { + sha: commit.sha, + authorName: commit.commit.author.name, + authorDate: commit.commit.author.date, + authorEmail: commit.commit.author.email, + committerName: commit.commit.committer.name, + committerDate: commit.commit.committer.date, + committerEmail: commit.commit.committer.email, + message: commit.commit.message, + commitUrl: commit.html_url, + }; + }, + + async getLatestCommitHash(owner, repo, env) { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Tansu-Git-Proxy/1.0", + }; + if (env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`; + } + + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/commits?per_page=1`, + { headers }, + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const data = (await response.json()) as any[]; + if (data.length === 0) { + throw new Error("No commits found"); + } + return data[0].sha; + }, + + async getReadmeContent(owner, repo, env) { + const headers: Record = { + Accept: "application/vnd.github.v3.raw", + "User-Agent": "Tansu-Git-Proxy/1.0", + }; + if (env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${env.GITHUB_TOKEN}`; + } + + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/readme`, + { headers }, + ); + + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(`GitHub API error: ${response.status}`); + } + + return response.text(); + }, +}; + +// GitLab API handler +const gitlabHandler: GitHostHandler = { + async getCommitHistory(owner, repo, page, perPage, env) { + const headers: Record = { + "User-Agent": "Tansu-Git-Proxy/1.0", + }; + if (env.GITLAB_TOKEN) { + headers["PRIVATE-TOKEN"] = env.GITLAB_TOKEN; + } + + const projectPath = encodeURIComponent(`${owner}/${repo}`); + const response = await fetch( + `https://gitlab.com/api/v4/projects/${projectPath}/repository/commits?page=${page}&per_page=${perPage}`, + { headers }, + ); + + if (!response.ok) { + throw new Error(`GitLab API error: ${response.status}`); + } + + const data = (await response.json()) as any[]; + return data.map((commit: any) => ({ + sha: commit.id, + authorName: commit.author_name, + authorDate: commit.authored_date, + authorEmail: commit.author_email, + committerName: commit.committer_name, + committerDate: commit.committed_date, + committerEmail: commit.committer_email, + message: commit.message, + commitUrl: commit.web_url, + })); + }, + + async getCommitDetails(owner, repo, sha, env) { + const headers: Record = { + "User-Agent": "Tansu-Git-Proxy/1.0", + }; + if (env.GITLAB_TOKEN) { + headers["PRIVATE-TOKEN"] = env.GITLAB_TOKEN; + } + + const projectPath = encodeURIComponent(`${owner}/${repo}`); + const response = await fetch( + `https://gitlab.com/api/v4/projects/${projectPath}/repository/commits/${sha}`, + { headers }, + ); + + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(`GitLab API error: ${response.status}`); + } + + const commit = (await response.json()) as any; + return { + sha: commit.id, + authorName: commit.author_name, + authorDate: commit.authored_date, + authorEmail: commit.author_email, + committerName: commit.committer_name, + committerDate: commit.committed_date, + committerEmail: commit.committer_email, + message: commit.message, + commitUrl: commit.web_url, + }; + }, + + async getLatestCommitHash(owner, repo, env) { + const commits = await gitlabHandler.getCommitHistory( + owner, + repo, + 1, + 1, + env, + ); + if (commits.length === 0) { + throw new Error("No commits found"); + } + return commits[0].sha; + }, + + async getReadmeContent(owner, repo, env) { + const headers: Record = { + "User-Agent": "Tansu-Git-Proxy/1.0", + }; + if (env.GITLAB_TOKEN) { + headers["PRIVATE-TOKEN"] = env.GITLAB_TOKEN; + } + + const projectPath = encodeURIComponent(`${owner}/${repo}`); + + // Try common README filenames + const candidates = ["README.md", "README", "readme.md"]; + for (const candidate of candidates) { + const response = await fetch( + `https://gitlab.com/api/v4/projects/${projectPath}/repository/files/${encodeURIComponent(candidate)}/raw?ref=HEAD`, + { headers }, + ); + + if (response.ok) { + return response.text(); + } + } + + return null; + }, +}; + +// Codeberg/Gitea API handler (Forgejo-compatible) +const codebergHandler: GitHostHandler = { + async getCommitHistory(owner, repo, page, perPage, _env) { + const response = await fetch( + `https://codeberg.org/api/v1/repos/${owner}/${repo}/commits?page=${page}&limit=${perPage}`, + { + headers: { "User-Agent": "Tansu-Git-Proxy/1.0" }, + }, + ); + + if (!response.ok) { + throw new Error(`Codeberg API error: ${response.status}`); + } + + const data = (await response.json()) as any[]; + return data.map((commit: any) => ({ + sha: commit.sha, + authorName: commit.commit.author.name, + authorDate: commit.commit.author.date, + authorEmail: commit.commit.author.email, + committerName: commit.commit.committer.name, + committerDate: commit.commit.committer.date, + committerEmail: commit.commit.committer.email, + message: commit.commit.message, + commitUrl: commit.html_url, + })); + }, + + async getCommitDetails(owner, repo, sha, _env) { + const response = await fetch( + `https://codeberg.org/api/v1/repos/${owner}/${repo}/git/commits/${sha}`, + { + headers: { "User-Agent": "Tansu-Git-Proxy/1.0" }, + }, + ); + + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(`Codeberg API error: ${response.status}`); + } + + const commit = (await response.json()) as any; + return { + sha: commit.sha, + authorName: commit.author?.name || "", + authorDate: commit.author?.date || "", + authorEmail: commit.author?.email || "", + committerName: commit.committer?.name || "", + committerDate: commit.committer?.date || "", + committerEmail: commit.committer?.email || "", + message: commit.message, + commitUrl: `https://codeberg.org/${owner}/${repo}/commit/${sha}`, + }; + }, + + async getLatestCommitHash(owner, repo, env) { + const commits = await codebergHandler.getCommitHistory( + owner, + repo, + 1, + 1, + env, + ); + if (commits.length === 0) { + throw new Error("No commits found"); + } + return commits[0].sha; + }, + + async getReadmeContent(owner, repo, _env) { + const response = await fetch( + `https://codeberg.org/api/v1/repos/${owner}/${repo}/raw/README.md`, + { + headers: { "User-Agent": "Tansu-Git-Proxy/1.0" }, + }, + ); + + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(`Codeberg API error: ${response.status}`); + } + + return response.text(); + }, +}; + +// Bitbucket API handler +const bitbucketHandler: GitHostHandler = { + async getCommitHistory(owner, repo, page, perPage, _env) { + const response = await fetch( + `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/commits?page=${page}&pagelen=${perPage}`, + { + headers: { "User-Agent": "Tansu-Git-Proxy/1.0" }, + }, + ); + + if (!response.ok) { + throw new Error(`Bitbucket API error: ${response.status}`); + } + + const data = (await response.json()) as any; + return (data.values || []).map((commit: any) => ({ + sha: commit.hash, + authorName: commit.author?.user?.display_name || commit.author?.raw || "", + authorDate: commit.date, + authorEmail: "", + committerName: commit.author?.user?.display_name || "", + committerDate: commit.date, + committerEmail: "", + message: commit.message, + commitUrl: commit.links?.html?.href || "", + })); + }, + + async getCommitDetails(owner, repo, sha, _env) { + const response = await fetch( + `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/commit/${sha}`, + { + headers: { "User-Agent": "Tansu-Git-Proxy/1.0" }, + }, + ); + + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(`Bitbucket API error: ${response.status}`); + } + + const commit = (await response.json()) as any; + return { + sha: commit.hash, + authorName: commit.author?.user?.display_name || commit.author?.raw || "", + authorDate: commit.date, + authorEmail: "", + committerName: commit.author?.user?.display_name || "", + committerDate: commit.date, + committerEmail: "", + message: commit.message, + commitUrl: commit.links?.html?.href || "", + }; + }, + + async getLatestCommitHash(owner, repo, env) { + const commits = await bitbucketHandler.getCommitHistory( + owner, + repo, + 1, + 1, + env, + ); + if (commits.length === 0) { + throw new Error("No commits found"); + } + return commits[0].sha; + }, + + async getReadmeContent(owner, repo, _env) { + const response = await fetch( + `https://api.bitbucket.org/2.0/repositories/${owner}/${repo}/src/HEAD/README.md`, + { + headers: { "User-Agent": "Tansu-Git-Proxy/1.0" }, + }, + ); + + if (!response.ok) { + if (response.status === 404) return null; + throw new Error(`Bitbucket API error: ${response.status}`); + } + + return response.text(); + }, +}; + +function getHandlerForHost(host: string): GitHostHandler { + const normalizedHost = host.toLowerCase(); + + if (normalizedHost.includes("github")) { + return githubHandler; + } + if (normalizedHost.includes("gitlab")) { + return gitlabHandler; + } + if (normalizedHost.includes("codeberg") || normalizedHost.includes("gitea")) { + return codebergHandler; + } + if (normalizedHost.includes("bitbucket")) { + return bitbucketHandler; + } + + // Default to Gitea/Forgejo API for unknown hosts (common for self-hosted) + return codebergHandler; +} + +interface RequestBody { + action: "history" | "commit" | "latest-hash" | "readme"; + repoUrl: string; + page?: number; + perPage?: number; + sha?: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const origin = request.headers.get("Origin"); + const corsHeaders = getCorsHeaders(origin); + + // Handle OPTIONS preflight request + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: corsHeaders, + }); + } + + if (request.method !== "POST") { + return new Response(JSON.stringify({ error: "Method not allowed" }), { + status: 405, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + try { + const body = (await request.json()) as RequestBody; + const { action, repoUrl, page = 1, perPage = 30, sha } = body; + + if (!repoUrl || typeof repoUrl !== "string") { + return new Response( + JSON.stringify({ error: "Repository URL is required" }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + // Validate and parse the repository URL + const allowedHosts = getAllowedHosts(env); + let url: URL; + try { + url = validateRepoUrl(repoUrl, allowedHosts); + } catch (validationError: any) { + return new Response( + JSON.stringify({ error: validationError.message }), + { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + const { owner, repo } = parseRepoPath(url); + const handler = getHandlerForHost(url.hostname); + + switch (action) { + case "history": { + const commits = await handler.getCommitHistory( + owner, + repo, + page, + perPage, + env, + ); + return new Response(JSON.stringify({ commits }), { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + "Cache-Control": "public, max-age=60", + }, + }); + } + + case "commit": { + if (!sha || typeof sha !== "string") { + return new Response( + JSON.stringify({ error: "Commit SHA is required" }), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + const commit = await handler.getCommitDetails(owner, repo, sha, env); + if (!commit) { + return new Response(JSON.stringify({ error: "Commit not found" }), { + status: 404, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + return new Response( + JSON.stringify({ + sha: commit.sha, + html_url: commit.commitUrl, + commit: { + message: commit.message, + author: { + name: commit.authorName, + email: commit.authorEmail, + date: commit.authorDate, + }, + committer: { + name: commit.committerName, + email: commit.committerEmail, + date: commit.committerDate, + }, + }, + }), + { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600", + }, + }, + ); + } + + case "latest-hash": { + const latestSha = await handler.getLatestCommitHash( + owner, + repo, + env, + ); + return new Response(JSON.stringify({ sha: latestSha }), { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + "Cache-Control": "public, max-age=60", + }, + }); + } + + case "readme": { + const content = await handler.getReadmeContent(owner, repo, env); + return new Response(JSON.stringify({ content }), { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + "Cache-Control": "public, max-age=300", + }, + }); + } + + default: + return new Response(JSON.stringify({ error: "Unknown action" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + } catch (error: any) { + console.error("Git proxy error:", error); + return new Response( + JSON.stringify({ error: "Failed to process git request" }), + { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + }, +}; diff --git a/dapp/workers/git-proxy/tsconfig.json b/dapp/workers/git-proxy/tsconfig.json new file mode 100644 index 00000000..b25d9af5 --- /dev/null +++ b/dapp/workers/git-proxy/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "WebWorker"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/dapp/workers/git-proxy/wrangler.toml b/dapp/workers/git-proxy/wrangler.toml new file mode 100644 index 00000000..0f3a25a6 --- /dev/null +++ b/dapp/workers/git-proxy/wrangler.toml @@ -0,0 +1,17 @@ +name = "git-proxy" +main = "src/index.ts" +compatibility_date = "2024-01-01" +compatibility_flags = ["nodejs_compat"] + +[env.testnet] +[[env.testnet.routes]] +pattern = "git-testnet.tansu.dev" +custom_domain = true + +[env.production] +[[env.production.routes]] +pattern = "git.tansu.dev" +custom_domain = true + +[observability] +enabled = true