repos
/ orchard main

orchard

mirror

Every 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

5.5 KB · 175 lines · Go Raw History
  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}