frieren

~ / frieren

a self-hosted git server in one binary — everyone reads, only the owner writes

feat: read-only JSON API under /api

fa23b71a5e3de013eb30edc317e2c252ecfb70c9

justin06lee · Aug 18, 2026 16:13 (5h ago)

 README.md   |  17 +++++
 api.go      | 219 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 api_test.go |  87 ++++++++++++++++++++++++
 gitcmd.go   |  42 ++++++------
 server.go   |  10 +++
 5 files changed, 354 insertions(+), 21 deletions(-)

diff --git a/README.md b/README.md
index 85e03aa..67e9be0 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,23 @@ Since this machine becomes the source of truth, back the repo root up somewhere
 rsync -a /srv/frieren/repos/ backup-host:frieren-repos/
 ```
 
+## JSON API
+
+Everything the web UI shows is also served as JSON under `/api`, so external frontends can build their own experience on top. Same access model: world-readable, nothing writes. Refs and paths travel as query parameters, so branch names with slashes just work.
+
+```
+GET /api/repos                          all repositories
+GET /api/repos/{name}                   one repository's info
+GET /api/repos/{name}/tree?ref=&path=   directory listing
+GET /api/repos/{name}/blob?ref=&path=   file content (text inline, binary flagged)
+GET /api/repos/{name}/readme?ref=       root README, if any
+GET /api/repos/{name}/commits?ref=&n=   commit log
+GET /api/repos/{name}/commit/{hash}     one commit with its patch
+GET /api/repos/{name}/refs              branches and tags
+```
+
+Omitting `ref` uses the repository's default branch.
+
 ## What it deliberately isn't
 
 No issues, no pull requests, no user accounts, no markdown rendering yet — it hosts and shows git repositories, and stops there. The single-writer model is the point: if you need collaborators with write access, you want a full forge like Forgejo.
diff --git a/api.go b/api.go
new file mode 100644
index 0000000..7a3c261
--- /dev/null
+++ b/api.go
@@ -0,0 +1,219 @@
+package main
+
+import (
+	"encoding/json"
+	"log"
+	"net/http"
+	"strconv"
+)
+
+// Read-only JSON API for external frontends. Same access model as the rest
+// of the server: everything here is world-readable, nothing writes.
+
+func writeJSON(w http.ResponseWriter, v any) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	if err := json.NewEncoder(w).Encode(v); err != nil {
+		log.Printf("encode json: %v", err)
+	}
+}
+
+func apiError(w http.ResponseWriter, code int, msg string) {
+	w.Header().Set("Content-Type", "application/json; charset=utf-8")
+	w.WriteHeader(code)
+	json.NewEncoder(w).Encode(map[string]string{"error": msg})
+}
+
+func (srv *Server) apiRepo(w http.ResponseWriter, r *http.Request) *RepoInfo {
+	info, err := srv.Store.open(repoParam(r))
+	if err != nil {
+		apiError(w, http.StatusNotFound, "no such repository")
+		return nil
+	}
+	return info
+}
+
+// refParam returns the requested ref, falling back to the repo default,
+// or writes a 400 and returns "" when the ref is malformed.
+func refParam(w http.ResponseWriter, r *http.Request, repo *RepoInfo) string {
+	ref := r.URL.Query().Get("ref")
+	if ref == "" {
+		ref = repo.Default
+	}
+	if !validRef(ref) {
+		apiError(w, http.StatusBadRequest, "invalid ref")
+		return ""
+	}
+	return ref
+}
+
+func (srv *Server) apiRepos(w http.ResponseWriter, r *http.Request) {
+	repos := srv.Store.list()
+	if repos == nil {
+		repos = []*RepoInfo{}
+	}
+	writeJSON(w, repos)
+}
+
+func (srv *Server) apiRepoInfo(w http.ResponseWriter, r *http.Request) {
+	repo := srv.apiRepo(w, r)
+	if repo == nil {
+		return
+	}
+	writeJSON(w, repo)
+}
+
+func (srv *Server) apiTree(w http.ResponseWriter, r *http.Request) {
+	repo := srv.apiRepo(w, r)
+	if repo == nil {
+		return
+	}
+	ref := refParam(w, r, repo)
+	if ref == "" {
+		return
+	}
+	path := r.URL.Query().Get("path")
+	if !validPath(path) {
+		apiError(w, http.StatusBadRequest, "invalid path")
+		return
+	}
+	entries, err := srv.Store.lsTree(r.Context(), repo.Name, ref, path)
+	if err != nil {
+		apiError(w, http.StatusNotFound, "no such tree")
+		return
+	}
+	writeJSON(w, entries)
+}
+
+type blobResponse struct {
+	Path      string `json:"path"`
+	Size      int64  `json:"size"`
+	Binary    bool   `json:"binary"`
+	Truncated bool   `json:"truncated"`
+	Content   string `json:"content"`
+}
+
+func (srv *Server) apiBlob(w http.ResponseWriter, r *http.Request) {
+	repo := srv.apiRepo(w, r)
+	if repo == nil {
+		return
+	}
+	ref := refParam(w, r, repo)
+	if ref == "" {
+		return
+	}
+	path := r.URL.Query().Get("path")
+	if !validPath(path) || path == "" {
+		apiError(w, http.StatusBadRequest, "invalid path")
+		return
+	}
+	blob, err := srv.Store.catBlob(r.Context(), repo.Name, ref, path)
+	if err != nil {
+		apiError(w, http.StatusNotFound, "no such blob")
+		return
+	}
+	resp := blobResponse{Path: path, Size: int64(len(blob))}
+	switch {
+	case isBinary(blob):
+		resp.Binary = true
+	case len(blob) > maxBlobBytes:
+		resp.Truncated = true
+	default:
+		resp.Content = string(blob)
+	}
+	writeJSON(w, resp)
+}
+
+type readmeResponse struct {
+	Name    string `json:"name"`
+	Content string `json:"content"`
+}
+
+func (srv *Server) apiReadme(w http.ResponseWriter, r *http.Request) {
+	repo := srv.apiRepo(w, r)
+	if repo == nil {
+		return
+	}
+	ref := refParam(w, r, repo)
+	if ref == "" {
+		return
+	}
+	name, body := srv.Store.readme(r.Context(), repo.Name, ref)
+	if name == "" || isBinary(body) {
+		apiError(w, http.StatusNotFound, "no readme")
+		return
+	}
+	writeJSON(w, readmeResponse{Name: name, Content: string(body)})
+}
+
+func (srv *Server) apiCommits(w http.ResponseWriter, r *http.Request) {
+	repo := srv.apiRepo(w, r)
+	if repo == nil {
+		return
+	}
+	if repo.Empty {
+		writeJSON(w, []Commit{})
+		return
+	}
+	ref := refParam(w, r, repo)
+	if ref == "" {
+		return
+	}
+	limit := 100
+	if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n > 0 && n <= 500 {
+		limit = n
+	}
+	commits, err := srv.Store.log(r.Context(), repo.Name, ref, limit)
+	if err != nil {
+		apiError(w, http.StatusNotFound, "no such ref")
+		return
+	}
+	if commits == nil {
+		commits = []Commit{}
+	}
+	writeJSON(w, commits)
+}
+
+type commitResponse struct {
+	Commit
+	Patch     string `json:"patch"`
+	Truncated bool   `json:"truncated"`
+}
+
+func (srv *Server) apiCommit(w http.ResponseWriter, r *http.Request) {
+	repo := srv.apiRepo(w, r)
+	if repo == nil {
+		return
+	}
+	hash := r.PathValue("hash")
+	if !validRef(hash) {
+		apiError(w, http.StatusBadRequest, "invalid hash")
+		return
+	}
+	commit, err := srv.Store.commit(r.Context(), repo.Name, hash)
+	if err != nil {
+		apiError(w, http.StatusNotFound, "no such commit")
+		return
+	}
+	patch, truncated, err := srv.Store.patch(r.Context(), repo.Name, commit.Hash)
+	if err != nil {
+		apiError(w, http.StatusInternalServerError, "patch failed")
+		return
+	}
+	writeJSON(w, commitResponse{Commit: *commit, Patch: patch, Truncated: truncated})
+}
+
+func (srv *Server) apiRefs(w http.ResponseWriter, r *http.Request) {
+	repo := srv.apiRepo(w, r)
+	if repo == nil {
+		return
+	}
+	branches, _ := srv.Store.refs(r.Context(), repo.Name, "heads")
+	tags, _ := srv.Store.refs(r.Context(), repo.Name, "tags")
+	if branches == nil {
+		branches = []Ref{}
+	}
+	if tags == nil {
+		tags = []Ref{}
+	}
+	writeJSON(w, map[string][]Ref{"branches": branches, "tags": tags})
+}
diff --git a/api_test.go b/api_test.go
new file mode 100644
index 0000000..33a8a22
--- /dev/null
+++ b/api_test.go
@@ -0,0 +1,87 @@
+package main
+
+import (
+	"encoding/json"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func getJSON(t *testing.T, url string, v any) int {
+	t.Helper()
+	resp, err := http.Get(url)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+	if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
+		t.Fatalf("GET %s: bad json: %v", url, err)
+	}
+	return resp.StatusCode
+}
+
+func TestJSONAPI(t *testing.T) {
+	ts, store := newTestServer(t)
+	if err := store.create("apidemo", "api smoke test"); err != nil {
+		t.Fatal(err)
+	}
+
+	work := t.TempDir()
+	mustGit(t, work, "clone", ts.URL+"/apidemo.git", "w")
+	dir := filepath.Join(work, "w")
+	os.WriteFile(filepath.Join(dir, "README.md"), []byte("# apidemo\nhello api\n"), 0o644)
+	os.MkdirAll(filepath.Join(dir, "src"), 0o755)
+	os.WriteFile(filepath.Join(dir, "src", "main.go"), []byte("package main\n"), 0o644)
+	mustGit(t, dir, "add", ".")
+	mustGit(t, dir, "commit", "-m", "seed api test")
+	mustGit(t, dir, "push", withToken(t, ts.URL+"/apidemo.git"), "master")
+
+	var repos []RepoInfo
+	if code := getJSON(t, ts.URL+"/api/repos", &repos); code != 200 || len(repos) != 1 || repos[0].Name != "apidemo" {
+		t.Fatalf("repos: code %d, %+v", code, repos)
+	}
+	if repos[0].Empty || repos[0].Description != "api smoke test" {
+		t.Errorf("repo info wrong: %+v", repos[0])
+	}
+
+	var entries []TreeEntry
+	if code := getJSON(t, ts.URL+"/api/repos/apidemo/tree?path=src", &entries); code != 200 || len(entries) != 1 || entries[0].Name != "main.go" {
+		t.Fatalf("tree: code %d, %+v", code, entries)
+	}
+
+	var blob blobResponse
+	if code := getJSON(t, ts.URL+"/api/repos/apidemo/blob?path=src/main.go", &blob); code != 200 || blob.Content != "package main\n" {
+		t.Fatalf("blob: code %d, %+v", code, blob)
+	}
+
+	var readme readmeResponse
+	if code := getJSON(t, ts.URL+"/api/repos/apidemo/readme", &readme); code != 200 || !strings.Contains(readme.Content, "hello api") {
+		t.Fatalf("readme: code %d, %+v", code, readme)
+	}
+
+	var commits []Commit
+	if code := getJSON(t, ts.URL+"/api/repos/apidemo/commits", &commits); code != 200 || len(commits) != 1 || commits[0].Subject != "seed api test" {
+		t.Fatalf("commits: code %d, %+v", code, commits)
+	}
+
+	var full commitResponse
+	if code := getJSON(t, ts.URL+"/api/repos/apidemo/commit/"+commits[0].Hash, &full); code != 200 || !strings.Contains(full.Patch, "package main") {
+		t.Fatalf("commit: code %d", code)
+	}
+
+	var refs map[string][]Ref
+	if code := getJSON(t, ts.URL+"/api/repos/apidemo/refs", &refs); code != 200 || len(refs["branches"]) != 1 {
+		t.Fatalf("refs: code %d, %+v", code, refs)
+	}
+
+	// Errors are JSON with proper codes.
+	var e map[string]string
+	if code := getJSON(t, ts.URL+"/api/repos/ghost", &e); code != 404 || e["error"] == "" {
+		t.Errorf("missing repo: code %d, %+v", code, e)
+	}
+	if code := getJSON(t, ts.URL+"/api/repos/apidemo/blob?path=../secret", &e); code != 400 {
+		t.Errorf("traversal blob: code %d", code)
+	}
+}
diff --git a/gitcmd.go b/gitcmd.go
index faaab78..3bcaa35 100644
--- a/gitcmd.go
+++ b/gitcmd.go
@@ -19,8 +19,8 @@ import (
 
 var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
 
-// reservedNames are path roots the web UI claims for itself.
-var reservedNames = map[string]bool{"static": true}
+// reservedNames are path roots the web UI and JSON API claim for themselves.
+var reservedNames = map[string]bool{"static": true, "api": true}
 
 func validRepoName(name string) bool {
 	return repoNameRe.MatchString(name) && !reservedNames[name] && len(name) <= 100
@@ -103,11 +103,11 @@ func runGit(ctx context.Context, dir string, stdin []byte, args ...string) ([]by
 }
 
 type RepoInfo struct {
-	Name        string
-	Description string
-	Default     string
-	LastCommit  time.Time
-	Empty       bool
+	Name        string    `json:"name"`
+	Description string    `json:"description"`
+	Default     string    `json:"default"`
+	LastCommit  time.Time `json:"lastCommit"`
+	Empty       bool      `json:"empty"`
 }
 
 func (s *Store) open(name string) (*RepoInfo, error) {
@@ -157,11 +157,11 @@ func (s *Store) list() []*RepoInfo {
 }
 
 type TreeEntry struct {
-	Mode string
-	Type string // blob, tree, commit (submodule)
-	Hash string
-	Size int64 // -1 for trees
-	Name string
+	Mode string `json:"mode"`
+	Type string `json:"type"` // blob, tree, commit (submodule)
+	Hash string `json:"hash"`
+	Size int64  `json:"size"` // -1 for trees
+	Name string `json:"name"`
 }
 
 func (s *Store) lsTree(ctx context.Context, repo, ref, path string) ([]TreeEntry, error) {
@@ -208,11 +208,11 @@ func (s *Store) catBlob(ctx context.Context, repo, ref, path string) ([]byte, er
 }
 
 type Commit struct {
-	Hash    string
-	Short   string
-	Author  string
-	When    time.Time
-	Subject string
+	Hash    string    `json:"hash"`
+	Short   string    `json:"short"`
+	Author  string    `json:"author"`
+	When    time.Time `json:"when"`
+	Subject string    `json:"subject"`
 }
 
 const logFormat = "%H%x1f%h%x1f%an%x1f%at%x1f%s%x1e"
@@ -270,10 +270,10 @@ func (s *Store) patch(ctx context.Context, repo, hash string) (string, bool, err
 }
 
 type Ref struct {
-	Name    string
-	Short   string
-	When    time.Time
-	Subject string
+	Name    string    `json:"name"`
+	Short   string    `json:"short"`
+	When    time.Time `json:"when"`
+	Subject string    `json:"subject"`
 }
 
 func (s *Store) refs(ctx context.Context, repo, kind string) ([]Ref, error) {
diff --git a/server.go b/server.go
index fc2f5bf..7eb2152 100644
--- a/server.go
+++ b/server.go
@@ -24,6 +24,16 @@ func (srv *Server) handler() http.Handler {
 	mux.HandleFunc("POST /{repo}/git-upload-pack", srv.uploadPack)
 	mux.HandleFunc("POST /{repo}/git-receive-pack", srv.receivePack)
 
+	// JSON API (external frontends, read-only)
+	mux.HandleFunc("GET /api/repos", srv.apiRepos)
+	mux.HandleFunc("GET /api/repos/{repo}", srv.apiRepoInfo)
+	mux.HandleFunc("GET /api/repos/{repo}/tree", srv.apiTree)
+	mux.HandleFunc("GET /api/repos/{repo}/blob", srv.apiBlob)
+	mux.HandleFunc("GET /api/repos/{repo}/readme", srv.apiReadme)
+	mux.HandleFunc("GET /api/repos/{repo}/commits", srv.apiCommits)
+	mux.HandleFunc("GET /api/repos/{repo}/commit/{hash}", srv.apiCommit)
+	mux.HandleFunc("GET /api/repos/{repo}/refs", srv.apiRefs)
+
 	// Web UI (browsers, read-only)
 	mux.HandleFunc("GET /{$}", srv.indexPage)
 	mux.HandleFunc("GET /static/style.css", srv.styleCSS)