orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1// The read only JSON view of the repositories, for chat.bythewood.me's tools.
2//
3// The index page already assembles this, so this handler builds the same cards
4// and hands them over as data. Hidden repositories are included, unlike on the
5// index, because this is behind the session and hiding one is about the public
6// listing rather than about secrecy.
7package main
8
9import (
10 "encoding/json"
11 "log/slog"
12 "net/http"
13 "sort"
14 "strings"
15 "time"
16)
17
18type apiRepo struct {
19 Name string `json:"name"`
20 Description string `json:"description,omitempty"`
21 Mirror bool `json:"mirror"`
22 Hidden bool `json:"hidden"`
23 Empty bool `json:"empty"`
24 SizeBytes int64 `json:"size_bytes"`
25 Branches int `json:"branches"`
26 Tags int `json:"tags"`
27 LastPush time.Time `json:"last_push"`
28 PushPercent int `json:"push_percent_of_limit"`
29}
30
31func (s *site) apiRepos(w http.ResponseWriter, r *http.Request) {
32 ctx := r.Context()
33
34 repos, err := s.store.Discover()
35 if err != nil {
36 slog.Error("discover repos", slog.Any("err", err))
37 http.Error(w, "internal server error", http.StatusInternalServerError)
38 return
39 }
40 meta, err := s.db.AllRepos()
41 if err != nil {
42 slog.Error("read repo metadata", slog.Any("err", err))
43 meta = map[string]RepoMeta{}
44 }
45
46 out := make([]apiRepo, 0, len(repos))
47 var total int64
48 for _, repo := range repos {
49 m := meta[repo.Name]
50 o := s.store.Overview(ctx, repo)
51 total += o.Size
52 out = append(out, apiRepo{
53 Name: repo.Name,
54 Description: m.Description,
55 Mirror: m.Mirror,
56 Hidden: m.Hidden,
57 Empty: o.Empty,
58 SizeBytes: o.Size,
59 Branches: o.Branches,
60 Tags: o.Tags,
61 LastPush: o.LastPush,
62 PushPercent: percentOf(o.Size, cloudflareBodyLimit),
63 })
64 }
65 sort.SliceStable(out, func(i, j int) bool { return out[i].LastPush.After(out[j].LastPush) })
66
67 w.Header().Set("Content-Type", "application/json; charset=utf-8")
68 w.Header().Set("Cache-Control", "no-store")
69 _ = json.NewEncoder(w).Encode(map[string]any{
70 "repos": out,
71 "count": len(out),
72 "total_size_bytes": total,
73 })
74}
75
76// The tree and the file behind it, as data.
77//
78// chat.bythewood.me could list the repositories and read nothing inside them,
79// so every question about Isaac's own code ended with the model guessing raw
80// addresses, collecting 404s, and on one occasion writing a file it claimed to
81// have read. These two are the same git plumbing the HTML pages use, returned
82// as JSON so a tool can walk a repository the way a person does.
83
84type apiEntry struct {
85 Name string `json:"name"`
86 Path string `json:"path"`
87 Type string `json:"type"` // tree, blob or commit for a submodule
88 Size int64 `json:"size,omitempty"`
89}
90
91// apiTree lists one directory. It does not recurse: a repository the size of
92// orchard flattened into one response is most of a megabyte of paths, and the
93// model only ever needs the level it is looking at.
94func (s *site) apiTree(w http.ResponseWriter, r *http.Request) {
95 repo, rev, ok := s.apiResolve(w, r)
96 if !ok {
97 return
98 }
99 path := strings.Trim(r.PathValue("path"), "/")
100 entries, err := s.store.Tree(r.Context(), repo, rev, path)
101 if err != nil {
102 apiError(w, http.StatusNotFound, "no directory at that path")
103 return
104 }
105 out := make([]apiEntry, 0, len(entries))
106 for _, e := range entries {
107 out = append(out, apiEntry{Name: e.Name, Path: e.Path, Type: e.Type, Size: e.Size})
108 }
109 writeJSON(w, map[string]any{
110 "repo": repo.Name, "rev": rev, "path": path, "entries": out, "count": len(out),
111 })
112}
113
114// apiFile returns one file's text. Binary is refused rather than encoded, since
115// nothing that reads this can do anything with the bytes.
116func (s *site) apiFile(w http.ResponseWriter, r *http.Request) {
117 repo, rev, ok := s.apiResolve(w, r)
118 if !ok {
119 return
120 }
121 path := strings.Trim(r.PathValue("path"), "/")
122 if path == "" {
123 apiError(w, http.StatusNotFound, "no file at that path")
124 return
125 }
126 src, size, err := s.store.Blob(r.Context(), repo, rev, path)
127 switch {
128 case err == errTooLarge:
129 apiError(w, http.StatusRequestEntityTooLarge, "that file is too large to read")
130 return
131 case err != nil:
132 apiError(w, http.StatusNotFound, "no file at that path")
133 return
134 case IsBinary(src):
135 apiError(w, http.StatusUnsupportedMediaType, "that file is binary")
136 return
137 }
138 writeJSON(w, map[string]any{
139 "repo": repo.Name, "rev": rev, "path": path,
140 "size": size, "lines": strings.Count(string(src), "\n") + 1,
141 "language": languageOf(path), "text": string(src),
142 })
143}
144
145// apiResolve is resolveRepo without the page furniture, and it answers JSON on
146// the way out so a tool never has to read an HTML error.
147func (s *site) apiResolve(w http.ResponseWriter, r *http.Request) (Repo, string, bool) {
148 repo, ok := s.store.Open(r.PathValue("name"))
149 if !ok {
150 apiError(w, http.StatusNotFound, "no repository by that name")
151 return Repo{}, "", false
152 }
153 rev := r.PathValue("rev")
154 if rev == "" {
155 rev = s.store.Head(r.Context(), repo)
156 }
157 if _, err := s.store.Resolve(r.Context(), repo, rev); err != nil {
158 apiError(w, http.StatusNotFound, "no branch, tag or commit by that name")
159 return Repo{}, "", false
160 }
161 return repo, rev, true
162}
163
164func writeJSON(w http.ResponseWriter, v any) {
165 w.Header().Set("Content-Type", "application/json; charset=utf-8")
166 w.Header().Set("Cache-Control", "no-store")
167 _ = json.NewEncoder(w).Encode(v)
168}
169
170func apiError(w http.ResponseWriter, code int, msg string) {
171 w.Header().Set("Content-Type", "application/json; charset=utf-8")
172 w.WriteHeader(code)
173 _ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
174}