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(-)
| @@ -75,6 +75,23 @@ Since this machine becomes the source of truth, back the repo root up somewhere | |||
| 75 | 75 | rsync -a /srv/frieren/repos/ backup-host:frieren-repos/ | |
| 76 | 76 | ``` | |
| 77 | 77 | ||
| 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 | + | ||
| 78 | 95 | ## What it deliberately isn't | |
| 79 | 96 | ||
| 80 | 97 | 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. |
| @@ -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 | + | } |
| @@ -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 | + | } |
| @@ -19,8 +19,8 @@ import ( | |||
| 19 | 19 | ||
| 20 | 20 | var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) | |
| 21 | 21 | ||
| 22 | − | // reservedNames are path roots the web UI claims for itself. | |
| 23 | − | var 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} | |
| 24 | 24 | ||
| 25 | 25 | func validRepoName(name string) bool { | |
| 26 | 26 | 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 | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | type 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"` | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | 113 | func (s *Store) open(name string) (*RepoInfo, error) { | |
| @@ -157,11 +157,11 @@ func (s *Store) list() []*RepoInfo { | |||
| 157 | 157 | } | |
| 158 | 158 | ||
| 159 | 159 | type 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"` | |
| 165 | 165 | } | |
| 166 | 166 | ||
| 167 | 167 | 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 | |||
| 208 | 208 | } | |
| 209 | 209 | ||
| 210 | 210 | type 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"` | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 218 | 218 | 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 | |||
| 270 | 270 | } | |
| 271 | 271 | ||
| 272 | 272 | type 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"` | |
| 277 | 277 | } | |
| 278 | 278 | ||
| 279 | 279 | func (s *Store) refs(ctx context.Context, repo, kind string) ([]Ref, error) { |
| @@ -24,6 +24,16 @@ func (srv *Server) handler() http.Handler { | |||
| 24 | 24 | mux.HandleFunc("POST /{repo}/git-upload-pack", srv.uploadPack) | |
| 25 | 25 | mux.HandleFunc("POST /{repo}/git-receive-pack", srv.receivePack) | |
| 26 | 26 | ||
| 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 | + | ||
| 27 | 37 | // Web UI (browsers, read-only) | |
| 28 | 38 | mux.HandleFunc("GET /{$}", srv.indexPage) | |
| 29 | 39 | mux.HandleFunc("GET /static/style.css", srv.styleCSS) | |
| 30 | 40 |