chore: added public API to kepler
dbf4ae9c
5 file(s) · +125 −5
| 15 | 15 | - Branches + tags page |
|
| 16 | 16 | - Archive download: `.tar.gz`, `.zip` |
|
| 17 | 17 | - Atom feed per repo |
|
| 18 | + | - Public read-only JSON API |
|
| 19 | + | ||
| 20 | + | ## API |
|
| 21 | + | ||
| 22 | + | `GET /api/repos` → JSON list of all repos (no auth, CORS `*`): |
|
| 23 | + | ||
| 24 | + | ```json |
|
| 25 | + | { |
|
| 26 | + | "site": "kepler", |
|
| 27 | + | "repos": [ |
|
| 28 | + | { |
|
| 29 | + | "name": "andromeda", |
|
| 30 | + | "description": "monorepo", |
|
| 31 | + | "default_ref": "main", |
|
| 32 | + | "last_commit": "2026-06-14T10:00:00Z", |
|
| 33 | + | "url": "https://git.example.com/andromeda", |
|
| 34 | + | "atom_url": "https://git.example.com/andromeda/atom.xml", |
|
| 35 | + | "clone_https": "https://git.example.com/andromeda.git", |
|
| 36 | + | "clone_ssh": "git@git.example.com:andromeda.git" |
|
| 37 | + | } |
|
| 38 | + | ] |
|
| 39 | + | } |
|
| 40 | + | ``` |
|
| 41 | + | ||
| 42 | + | `url` / `atom_url` use the request host (honors `X-Forwarded-Proto`); clone |
|
| 43 | + | fields appear only when `KEPLER_CLONE_BASE_URL` / `KEPLER_CLONE_SSH_HOST` are set. |
|
| 18 | 44 | ||
| 19 | 45 | ## Run |
|
| 20 | 46 |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "encoding/json" |
|
| 5 | + | "net/http" |
|
| 6 | + | "time" |
|
| 7 | + | ) |
|
| 8 | + | ||
| 9 | + | type apiRepo struct { |
|
| 10 | + | Name string `json:"name"` |
|
| 11 | + | Description string `json:"description,omitempty"` |
|
| 12 | + | DefaultRef string `json:"default_ref,omitempty"` |
|
| 13 | + | LastCommit time.Time `json:"last_commit,omitempty"` |
|
| 14 | + | URL string `json:"url"` |
|
| 15 | + | AtomURL string `json:"atom_url"` |
|
| 16 | + | CloneHTTPS string `json:"clone_https,omitempty"` |
|
| 17 | + | CloneSSH string `json:"clone_ssh,omitempty"` |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | type apiReposResponse struct { |
|
| 21 | + | Site string `json:"site"` |
|
| 22 | + | Repos []apiRepo `json:"repos"` |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | func (a *App) apiReposHandler(w http.ResponseWriter, r *http.Request) { |
|
| 26 | + | repos, err := a.listRepos() |
|
| 27 | + | if err != nil { |
|
| 28 | + | a.Log.Error("list repos failed", "err", err) |
|
| 29 | + | http.Error(w, "failed to list repos", http.StatusInternalServerError) |
|
| 30 | + | return |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | baseURL := a.requestBaseURL(r) |
|
| 34 | + | resp := apiReposResponse{Site: a.SiteName, Repos: make([]apiRepo, 0, len(repos))} |
|
| 35 | + | for _, s := range repos { |
|
| 36 | + | ar := apiRepo{ |
|
| 37 | + | Name: s.Name, |
|
| 38 | + | Description: s.Description, |
|
| 39 | + | DefaultRef: s.DefaultRef, |
|
| 40 | + | LastCommit: s.LastCommit, |
|
| 41 | + | URL: baseURL + "/" + s.Name, |
|
| 42 | + | AtomURL: baseURL + "/" + s.Name + "/atom.xml", |
|
| 43 | + | } |
|
| 44 | + | if a.CloneBaseURL != "" { |
|
| 45 | + | ar.CloneHTTPS = a.CloneBaseURL + "/" + s.Name + ".git" |
|
| 46 | + | } |
|
| 47 | + | if a.CloneSSHHost != "" { |
|
| 48 | + | ar.CloneSSH = a.CloneSSHHost + ":" + s.Name + ".git" |
|
| 49 | + | } |
|
| 50 | + | resp.Repos = append(resp.Repos, ar) |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | w.Header().Set("Content-Type", "application/json; charset=utf-8") |
|
| 54 | + | w.Header().Set("Access-Control-Allow-Origin", "*") |
|
| 55 | + | enc := json.NewEncoder(w) |
|
| 56 | + | enc.SetIndent("", " ") |
|
| 57 | + | if err := enc.Encode(resp); err != nil { |
|
| 58 | + | a.Log.Error("api repos encode failed", "err", err) |
|
| 59 | + | } |
|
| 60 | + | } |
| 14 | 14 | return pageBase{SiteName: a.SiteName, RepoName: repoName, BaseURL: a.BaseURL} |
|
| 15 | 15 | } |
|
| 16 | 16 | ||
| 17 | + | // requestBaseURL derives the externally-visible base URL (scheme://host) from |
|
| 18 | + | // the incoming request, honoring a reverse proxy's X-Forwarded-Proto. |
|
| 19 | + | func (a *App) requestBaseURL(r *http.Request) string { |
|
| 20 | + | scheme := "http" |
|
| 21 | + | if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { |
|
| 22 | + | scheme = "https" |
|
| 23 | + | } |
|
| 24 | + | return scheme + "://" + r.Host |
|
| 25 | + | } |
|
| 26 | + | ||
| 17 | 27 | func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) { |
|
| 18 | 28 | repos, err := a.listRepos() |
|
| 19 | 29 | if err != nil { |
|
| 268 | 278 | http.Error(w, "log failed", http.StatusInternalServerError) |
|
| 269 | 279 | return |
|
| 270 | 280 | } |
|
| 271 | - | scheme := "http" |
|
| 272 | - | if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { |
|
| 273 | - | scheme = "https" |
|
| 274 | - | } |
|
| 275 | - | baseURL := scheme + "://" + r.Host |
|
| 281 | + | baseURL := a.requestBaseURL(r) |
|
| 276 | 282 | feed, err := buildAtomFeed(a.SiteName, summary.Name, baseURL, commits) |
|
| 277 | 283 | if err != nil { |
|
| 278 | 284 | http.Error(w, "atom build failed", http.StatusInternalServerError) |
|
| 56 | 56 | mux.HandleFunc("GET /assets/"+name, staticAsset) |
|
| 57 | 57 | } |
|
| 58 | 58 | ||
| 59 | + | mux.HandleFunc("GET /api/repos", a.apiReposHandler) |
|
| 60 | + | ||
| 59 | 61 | mux.HandleFunc("GET /{$}", a.indexHandler) |
|
| 60 | 62 | mux.HandleFunc("GET /{repo}", a.repoHandler) |
|
| 61 | 63 | mux.HandleFunc("GET /{repo}/refs", a.refsHandler) |
| 13 | 13 | - Branches + tags page |
|
| 14 | 14 | - Archive download: `.tar.gz`, `.zip` |
|
| 15 | 15 | - Atom feed per repo |
|
| 16 | + | - Public read-only JSON API |
|
| 16 | 17 | - Dark themed UI with Commit Mono font |
|
| 17 | 18 | ||
| 18 | 19 | ## Configure |
|
| 87 | 88 | | `/{repo}/raw/{ref}/{path...}` | Raw file download | |
|
| 88 | 89 | | `/{repo}/archive/{name}` | Archive download (`.tar.gz`, `.zip`) | |
|
| 89 | 90 | | `/{repo}/atom.xml` | Per-repo Atom feed | |
|
| 91 | + | | `/api/repos` | Public JSON list of all repos | |
|
| 92 | + | ||
| 93 | + | ### API |
|
| 94 | + | ||
| 95 | + | `GET /api/repos` returns a JSON list of every repo. No auth, CORS open (`Access-Control-Allow-Origin: *`). |
|
| 96 | + | ||
| 97 | + | ```json |
|
| 98 | + | { |
|
| 99 | + | "site": "kepler", |
|
| 100 | + | "repos": [ |
|
| 101 | + | { |
|
| 102 | + | "name": "andromeda", |
|
| 103 | + | "description": "monorepo", |
|
| 104 | + | "default_ref": "main", |
|
| 105 | + | "last_commit": "2026-06-14T10:00:00Z", |
|
| 106 | + | "url": "https://git.example.com/andromeda", |
|
| 107 | + | "atom_url": "https://git.example.com/andromeda/atom.xml", |
|
| 108 | + | "clone_https": "https://git.example.com/andromeda.git", |
|
| 109 | + | "clone_ssh": "git@git.example.com:andromeda.git" |
|
| 110 | + | } |
|
| 111 | + | ] |
|
| 112 | + | } |
|
| 113 | + | ``` |
|
| 114 | + | ||
| 115 | + | `url` and `atom_url` are derived from the request host (honors `X-Forwarded-Proto` behind a proxy). The `clone_https` / `clone_ssh` fields appear only when `KEPLER_CLONE_BASE_URL` / `KEPLER_CLONE_SSH_HOST` are set. |
|