frierena personal git archive

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, 11:13 PM (4h ago)

README.md   |  17 +++++
 api.go      | 219 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 api_test.go |  87 ++++++++++++++++++++++++
 gitcmd.go   |  42 ++++++------
 server.go   |  10 +++
 5 files changed, 354 insertions(+), 21 deletions(-)
README.md+170
@@ -75,6 +75,23 @@ Since this machine becomes the source of truth, back the repo root up somewhere
7575rsync -a /srv/frieren/repos/ backup-host:frieren-repos/
7676```
7777
78+## JSON API
79+
80+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.
81+
82+```
83+GET /api/repos all repositories
84+GET /api/repos/{name} one repository's info
85+GET /api/repos/{name}/tree?ref=&path= directory listing
86+GET /api/repos/{name}/blob?ref=&path= file content (text inline, binary flagged)
87+GET /api/repos/{name}/readme?ref= root README, if any
88+GET /api/repos/{name}/commits?ref=&n= commit log
89+GET /api/repos/{name}/commit/{hash} one commit with its patch
90+GET /api/repos/{name}/refs branches and tags
91+```
92+
93+Omitting `ref` uses the repository's default branch.
94+
7895## What it deliberately isn't
7996
8097No 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.
api.go+2190
@@ -0,0 +1,219 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "log"
6+ "net/http"
7+ "strconv"
8+)
9+
10+// Read-only JSON API for external frontends. Same access model as the rest
11+// of the server: everything here is world-readable, nothing writes.
12+
13+func writeJSON(w http.ResponseWriter, v any) {
14+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
15+ if err := json.NewEncoder(w).Encode(v); err != nil {
16+ log.Printf("encode json: %v", err)
17+ }
18+}
19+
20+func apiError(w http.ResponseWriter, code int, msg string) {
21+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
22+ w.WriteHeader(code)
23+ json.NewEncoder(w).Encode(map[string]string{"error": msg})
24+}
25+
26+func (srv *Server) apiRepo(w http.ResponseWriter, r *http.Request) *RepoInfo {
27+ info, err := srv.Store.open(repoParam(r))
28+ if err != nil {
29+ apiError(w, http.StatusNotFound, "no such repository")
30+ return nil
31+ }
32+ return info
33+}
34+
35+// refParam returns the requested ref, falling back to the repo default,
36+// or writes a 400 and returns "" when the ref is malformed.
37+func refParam(w http.ResponseWriter, r *http.Request, repo *RepoInfo) string {
38+ ref := r.URL.Query().Get("ref")
39+ if ref == "" {
40+ ref = repo.Default
41+ }
42+ if !validRef(ref) {
43+ apiError(w, http.StatusBadRequest, "invalid ref")
44+ return ""
45+ }
46+ return ref
47+}
48+
49+func (srv *Server) apiRepos(w http.ResponseWriter, r *http.Request) {
50+ repos := srv.Store.list()
51+ if repos == nil {
52+ repos = []*RepoInfo{}
53+ }
54+ writeJSON(w, repos)
55+}
56+
57+func (srv *Server) apiRepoInfo(w http.ResponseWriter, r *http.Request) {
58+ repo := srv.apiRepo(w, r)
59+ if repo == nil {
60+ return
61+ }
62+ writeJSON(w, repo)
63+}
64+
65+func (srv *Server) apiTree(w http.ResponseWriter, r *http.Request) {
66+ repo := srv.apiRepo(w, r)
67+ if repo == nil {
68+ return
69+ }
70+ ref := refParam(w, r, repo)
71+ if ref == "" {
72+ return
73+ }
74+ path := r.URL.Query().Get("path")
75+ if !validPath(path) {
76+ apiError(w, http.StatusBadRequest, "invalid path")
77+ return
78+ }
79+ entries, err := srv.Store.lsTree(r.Context(), repo.Name, ref, path)
80+ if err != nil {
81+ apiError(w, http.StatusNotFound, "no such tree")
82+ return
83+ }
84+ writeJSON(w, entries)
85+}
86+
87+type blobResponse struct {
88+ Path string `json:"path"`
89+ Size int64 `json:"size"`
90+ Binary bool `json:"binary"`
91+ Truncated bool `json:"truncated"`
92+ Content string `json:"content"`
93+}
94+
95+func (srv *Server) apiBlob(w http.ResponseWriter, r *http.Request) {
96+ repo := srv.apiRepo(w, r)
97+ if repo == nil {
98+ return
99+ }
100+ ref := refParam(w, r, repo)
101+ if ref == "" {
102+ return
103+ }
104+ path := r.URL.Query().Get("path")
105+ if !validPath(path) || path == "" {
106+ apiError(w, http.StatusBadRequest, "invalid path")
107+ return
108+ }
109+ blob, err := srv.Store.catBlob(r.Context(), repo.Name, ref, path)
110+ if err != nil {
111+ apiError(w, http.StatusNotFound, "no such blob")
112+ return
113+ }
114+ resp := blobResponse{Path: path, Size: int64(len(blob))}
115+ switch {
116+ case isBinary(blob):
117+ resp.Binary = true
118+ case len(blob) > maxBlobBytes:
119+ resp.Truncated = true
120+ default:
121+ resp.Content = string(blob)
122+ }
123+ writeJSON(w, resp)
124+}
125+
126+type readmeResponse struct {
127+ Name string `json:"name"`
128+ Content string `json:"content"`
129+}
130+
131+func (srv *Server) apiReadme(w http.ResponseWriter, r *http.Request) {
132+ repo := srv.apiRepo(w, r)
133+ if repo == nil {
134+ return
135+ }
136+ ref := refParam(w, r, repo)
137+ if ref == "" {
138+ return
139+ }
140+ name, body := srv.Store.readme(r.Context(), repo.Name, ref)
141+ if name == "" || isBinary(body) {
142+ apiError(w, http.StatusNotFound, "no readme")
143+ return
144+ }
145+ writeJSON(w, readmeResponse{Name: name, Content: string(body)})
146+}
147+
148+func (srv *Server) apiCommits(w http.ResponseWriter, r *http.Request) {
149+ repo := srv.apiRepo(w, r)
150+ if repo == nil {
151+ return
152+ }
153+ if repo.Empty {
154+ writeJSON(w, []Commit{})
155+ return
156+ }
157+ ref := refParam(w, r, repo)
158+ if ref == "" {
159+ return
160+ }
161+ limit := 100
162+ if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n > 0 && n <= 500 {
163+ limit = n
164+ }
165+ commits, err := srv.Store.log(r.Context(), repo.Name, ref, limit)
166+ if err != nil {
167+ apiError(w, http.StatusNotFound, "no such ref")
168+ return
169+ }
170+ if commits == nil {
171+ commits = []Commit{}
172+ }
173+ writeJSON(w, commits)
174+}
175+
176+type commitResponse struct {
177+ Commit
178+ Patch string `json:"patch"`
179+ Truncated bool `json:"truncated"`
180+}
181+
182+func (srv *Server) apiCommit(w http.ResponseWriter, r *http.Request) {
183+ repo := srv.apiRepo(w, r)
184+ if repo == nil {
185+ return
186+ }
187+ hash := r.PathValue("hash")
188+ if !validRef(hash) {
189+ apiError(w, http.StatusBadRequest, "invalid hash")
190+ return
191+ }
192+ commit, err := srv.Store.commit(r.Context(), repo.Name, hash)
193+ if err != nil {
194+ apiError(w, http.StatusNotFound, "no such commit")
195+ return
196+ }
197+ patch, truncated, err := srv.Store.patch(r.Context(), repo.Name, commit.Hash)
198+ if err != nil {
199+ apiError(w, http.StatusInternalServerError, "patch failed")
200+ return
201+ }
202+ writeJSON(w, commitResponse{Commit: *commit, Patch: patch, Truncated: truncated})
203+}
204+
205+func (srv *Server) apiRefs(w http.ResponseWriter, r *http.Request) {
206+ repo := srv.apiRepo(w, r)
207+ if repo == nil {
208+ return
209+ }
210+ branches, _ := srv.Store.refs(r.Context(), repo.Name, "heads")
211+ tags, _ := srv.Store.refs(r.Context(), repo.Name, "tags")
212+ if branches == nil {
213+ branches = []Ref{}
214+ }
215+ if tags == nil {
216+ tags = []Ref{}
217+ }
218+ writeJSON(w, map[string][]Ref{"branches": branches, "tags": tags})
219+}
api_test.go+870
@@ -0,0 +1,87 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func getJSON(t *testing.T, url string, v any) int {
13+ t.Helper()
14+ resp, err := http.Get(url)
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+ defer resp.Body.Close()
19+ if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
20+ t.Fatalf("GET %s: bad json: %v", url, err)
21+ }
22+ return resp.StatusCode
23+}
24+
25+func TestJSONAPI(t *testing.T) {
26+ ts, store := newTestServer(t)
27+ if err := store.create("apidemo", "api smoke test"); err != nil {
28+ t.Fatal(err)
29+ }
30+
31+ work := t.TempDir()
32+ mustGit(t, work, "clone", ts.URL+"/apidemo.git", "w")
33+ dir := filepath.Join(work, "w")
34+ os.WriteFile(filepath.Join(dir, "README.md"), []byte("# apidemo\nhello api\n"), 0o644)
35+ os.MkdirAll(filepath.Join(dir, "src"), 0o755)
36+ os.WriteFile(filepath.Join(dir, "src", "main.go"), []byte("package main\n"), 0o644)
37+ mustGit(t, dir, "add", ".")
38+ mustGit(t, dir, "commit", "-m", "seed api test")
39+ mustGit(t, dir, "push", withToken(t, ts.URL+"/apidemo.git"), "master")
40+
41+ var repos []RepoInfo
42+ if code := getJSON(t, ts.URL+"/api/repos", &repos); code != 200 || len(repos) != 1 || repos[0].Name != "apidemo" {
43+ t.Fatalf("repos: code %d, %+v", code, repos)
44+ }
45+ if repos[0].Empty || repos[0].Description != "api smoke test" {
46+ t.Errorf("repo info wrong: %+v", repos[0])
47+ }
48+
49+ var entries []TreeEntry
50+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/tree?path=src", &entries); code != 200 || len(entries) != 1 || entries[0].Name != "main.go" {
51+ t.Fatalf("tree: code %d, %+v", code, entries)
52+ }
53+
54+ var blob blobResponse
55+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/blob?path=src/main.go", &blob); code != 200 || blob.Content != "package main\n" {
56+ t.Fatalf("blob: code %d, %+v", code, blob)
57+ }
58+
59+ var readme readmeResponse
60+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/readme", &readme); code != 200 || !strings.Contains(readme.Content, "hello api") {
61+ t.Fatalf("readme: code %d, %+v", code, readme)
62+ }
63+
64+ var commits []Commit
65+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/commits", &commits); code != 200 || len(commits) != 1 || commits[0].Subject != "seed api test" {
66+ t.Fatalf("commits: code %d, %+v", code, commits)
67+ }
68+
69+ var full commitResponse
70+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/commit/"+commits[0].Hash, &full); code != 200 || !strings.Contains(full.Patch, "package main") {
71+ t.Fatalf("commit: code %d", code)
72+ }
73+
74+ var refs map[string][]Ref
75+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/refs", &refs); code != 200 || len(refs["branches"]) != 1 {
76+ t.Fatalf("refs: code %d, %+v", code, refs)
77+ }
78+
79+ // Errors are JSON with proper codes.
80+ var e map[string]string
81+ if code := getJSON(t, ts.URL+"/api/repos/ghost", &e); code != 404 || e["error"] == "" {
82+ t.Errorf("missing repo: code %d, %+v", code, e)
83+ }
84+ if code := getJSON(t, ts.URL+"/api/repos/apidemo/blob?path=../secret", &e); code != 400 {
85+ t.Errorf("traversal blob: code %d", code)
86+ }
87+}
gitcmd.go+2121
@@ -19,8 +19,8 @@ import (
1919
2020var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
2121
22// reservedNames are path roots the web UI claims for itself.
23var reservedNames = map[string]bool{"static": true}
22+// reservedNames are path roots the web UI and JSON API claim for themselves.
23+var reservedNames = map[string]bool{"static": true, "api": true}
2424
2525func validRepoName(name string) bool {
2626 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
103103}
104104
105105type RepoInfo struct {
106 Name string
107 Description string
108 Default string
109 LastCommit time.Time
110 Empty bool
106+ Name string `json:"name"`
107+ Description string `json:"description"`
108+ Default string `json:"default"`
109+ LastCommit time.Time `json:"lastCommit"`
110+ Empty bool `json:"empty"`
111111}
112112
113113func (s *Store) open(name string) (*RepoInfo, error) {
@@ -157,11 +157,11 @@ func (s *Store) list() []*RepoInfo {
157157}
158158
159159type TreeEntry struct {
160 Mode string
161 Type string // blob, tree, commit (submodule)
162 Hash string
163 Size int64 // -1 for trees
164 Name string
160+ Mode string `json:"mode"`
161+ Type string `json:"type"` // blob, tree, commit (submodule)
162+ Hash string `json:"hash"`
163+ Size int64 `json:"size"` // -1 for trees
164+ Name string `json:"name"`
165165}
166166
167167func (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
208208}
209209
210210type Commit struct {
211 Hash string
212 Short string
213 Author string
214 When time.Time
215 Subject string
211+ Hash string `json:"hash"`
212+ Short string `json:"short"`
213+ Author string `json:"author"`
214+ When time.Time `json:"when"`
215+ Subject string `json:"subject"`
216216}
217217
218218const 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
270270}
271271
272272type Ref struct {
273 Name string
274 Short string
275 When time.Time
276 Subject string
273+ Name string `json:"name"`
274+ Short string `json:"short"`
275+ When time.Time `json:"when"`
276+ Subject string `json:"subject"`
277277}
278278
279279func (s *Store) refs(ctx context.Context, repo, kind string) ([]Ref, error) {
server.go+100
@@ -24,6 +24,16 @@ func (srv *Server) handler() http.Handler {
2424 mux.HandleFunc("POST /{repo}/git-upload-pack", srv.uploadPack)
2525 mux.HandleFunc("POST /{repo}/git-receive-pack", srv.receivePack)
2626
27+ // JSON API (external frontends, read-only)
28+ mux.HandleFunc("GET /api/repos", srv.apiRepos)
29+ mux.HandleFunc("GET /api/repos/{repo}", srv.apiRepoInfo)
30+ mux.HandleFunc("GET /api/repos/{repo}/tree", srv.apiTree)
31+ mux.HandleFunc("GET /api/repos/{repo}/blob", srv.apiBlob)
32+ mux.HandleFunc("GET /api/repos/{repo}/readme", srv.apiReadme)
33+ mux.HandleFunc("GET /api/repos/{repo}/commits", srv.apiCommits)
34+ mux.HandleFunc("GET /api/repos/{repo}/commit/{hash}", srv.apiCommit)
35+ mux.HandleFunc("GET /api/repos/{repo}/refs", srv.apiRefs)
36+
2737 // Web UI (browsers, read-only)
2838 mux.HandleFunc("GET /{$}", srv.indexPage)
2939 mux.HandleFunc("GET /static/style.css", srv.styleCSS)
3040