dgit.go in dgit

at main

1// See LICENSE file for copyright and license details
2
3// Package dgit provides the DGit [http.Handler] and its helpers.
4//
5// DGit is a fast, template-driven Git repository viewer
6// written in pure Go. Being written in pure Go, it is possible to
7// statically-link the resulting command-line interface with all of its
8// dependencies, including templates and static files. When this is
9// achieved, its only external requirements are the Git repositories
10// themselves. This makes DGit suitable for dropping into a chroot or
11// other restricted environment.
12//
13// To use, initialize DGit with a [config.Config] object specifying,
14// among other things, an [io/fs.FS] containing your HTML templates,
15// drop this Handler into your site's [http.ServeMux] and start viewing
16// Git repositories.
17//
18// The DGit handler supports both [Git HTTP transfer] protocols, so
19// read-only repository operations, such as cloning and fetching, are
20// supported.
21//
22// [Git HTTP transfer]: https://git-scm.com/docs/gitprotocol-http
23package dgit
24
25import (
26 "context"
27 "errors"
28 "fmt"
29 "html/template"
30 "io"
31 "log"
32 "net/http"
33 "path/filepath"
34 "sort"
35 "strings"
36
37 "djmo.ch/dgit/data"
38
39 "djmo.ch/dgit/config"
40 "djmo.ch/dgit/internal/convert"
41 "djmo.ch/dgit/internal/middleware"
42 "djmo.ch/dgit/internal/repo"
43 "djmo.ch/dgit/internal/request"
44 "djmo.ch/dgit/internal/smart"
45 "github.com/dustin/go-humanize"
46)
47
48var funcMap = template.FuncMap{"Humanize": humanize.Time}
49
50// DGit is an [http.Handler] and can therefore be dropped into an
51// [http.ServeMux]. It serves read-only pages with Git repository
52// information in the following manner, where / is the root of the
53// DGit [http.Handler]:
54//
55// - Navigating to / serves a list of Git repositories available for
56// viewing.
57// - Navigating to /{repo} serves the tree of the HEAD ref for the
58// of {repo}. If the repository contains a README file, it's raw
59// contents are displayed below the commit tree.
60// - Navigating to /{repo}/-/refs displays a list of branches and tags
61// for repository {repo}.
62// - Navigating to /{repo}/-/tree/{rev}/{path} displays
63// the tree for {rev} of {repo} at {path}. If not provided,
64// {path} defaults to the root of the repository.
65// - Navigating to /{repo}/-/blob/{rev}/{path} displays
66// the blob contents for {rev} of {repo} at {path}. If not
67// provided, {path} defaults to the root of the repository.
68// - Navigating to /{repo}/-/raw/{rev}/{path} displays
69// the raw contents for {rev} of {repo} at {path}. If not
70// provided, {path} defaults to the root of the repository.
71// - Navigating to /{repo}/-/commit/{commit} displays the commit
72// message and diff for commit {commit} of repository {repo}.
73// - Navigating to /{repo}/-/log/{branch} displays summary information
74// for each commit in the history of branch {branch} in repository
75// {repo}. When navigating to /{repo}/-/log, callers are redirected
76// to /{repo}/log/{default branch}.
77// - Navigating to /{repo}/-/diff/rev1..rev2 displays the diff from {rev1}
78// to {rev2} of {repo}.
79//
80// Where the variable {commit} is used above, it may refer to a commit
81// hash or ref. If the ref is a branch, the commit is the branch's
82// HEAD.
83type DGit struct {
84 // DGit configuration
85 Config config.Config
86}
87
88// ServeHTTP implements the [http.Handler] interface for DGit.
89func (d *DGit) ServeHTTP(w http.ResponseWriter, r *http.Request) {
90 dReq, err := request.Parse(r.URL)
91 if err != nil {
92 switch {
93 case errors.Is(err, request.ErrMalformed):
94 log.Println("ERROR: bad request:", err)
95 w.WriteHeader(http.StatusBadRequest)
96 fmt.Fprintf(w, "Bad Request: %v", err)
97 case errors.Is(err, request.ErrUnknownSection):
98 w.WriteHeader(http.StatusNotFound)
99 fmt.Fprintf(w, "Not Found: %v", err)
100 default:
101 log.Print("ERROR: unexpected error:", err)
102 w.WriteHeader(http.StatusInternalServerError)
103 fmt.Fprintf(w, "Internal Server Error")
104 }
105 return
106 }
107
108 ctx := context.WithValue(r.Context(), "dReq", dReq)
109 ctx = context.WithValue(ctx, "cfg", d.Config)
110 req := r.WithContext(ctx)
111 switch dReq.Section {
112 case "repo":
113 h := middleware.Get(middleware.Repos(d.rootHandler))
114 h(w, req)
115 case "head":
116 h := middleware.Get(middleware.Repo(middleware.ResolveHead(d.treeHandler)))
117 h(w, req)
118 case "tree":
119 h := middleware.Get(middleware.Repo(d.treeHandler))
120 h(w, req)
121 case "blob":
122 h := middleware.Get(middleware.Repo(d.blobHandler))
123 h(w, req)
124 case "raw":
125 h := middleware.Get(middleware.Repo(d.rawHandler))
126 h(w, req)
127 case "refs":
128 h := middleware.Get(middleware.Repo(d.refsHandler))
129 h(w, req)
130 case "log":
131 h := middleware.Get(middleware.Repo(d.logHandler))
132 h(w, req)
133 case "commit":
134 h := middleware.Get(middleware.Repo(d.commitHandler))
135 h(w, req)
136 case "diff":
137 h := middleware.Get(middleware.Repo(d.diffHandler))
138 h(w, req)
139 case "dumbClone":
140 h := middleware.Get(middleware.Repo(d.dumbCloneHandler))
141 h(w, req)
142 case "smartClone":
143 h := middleware.Repo(d.smartCloneHandler)
144 h(w, req)
145 default:
146 log.Println("ERROR: Request for unknown section:", dReq.Section)
147 w.WriteHeader(http.StatusBadRequest)
148 d.displayError(w, "Bad Request")
149 }
150}
151
152func (d *DGit) treeHandler(w http.ResponseWriter, r *http.Request) {
153 repo := getRepo(r)
154 if repo == nil {
155 w.WriteHeader(http.StatusNotFound)
156 d.displayError(w, "Repo not found")
157 return
158 }
159 dReq := r.Context().Value("dReq").(*request.Request)
160 if dReq.Revision == "" {
161 t := template.Must(template.New("templates").Funcs(funcMap).
162 ParseFS(d.Config.Templates, "templates/*.tmpl"))
163 if err := t.ExecuteTemplate(w, "tree.tmpl", data.TreeData{
164 RequestData: data.RequestData{
165 Repo: data.Repo{Slug: repo.Slug},
166 },
167 }); err != nil {
168 log.Printf("ERROR: failed to execute template: %v", err)
169 }
170 return
171 }
172 treeData, err := convert.ToTreeData(repo, dReq)
173 if err != nil {
174 if errors.Is(err, convert.ErrDirectoryNotFound) {
175 log.Println(err)
176 w.WriteHeader(http.StatusNotFound)
177 d.displayError(w, "Not found")
178 return
179 }
180 log.Printf("ERROR: failed to extract template data from %s: %v", repo.Slug, err)
181 w.WriteHeader(http.StatusInternalServerError)
182 d.displayError(w, "Internal server error")
183 return
184 }
185 t := template.Must(template.New("templates").Funcs(funcMap).
186 ParseFS(d.Config.Templates, "templates/*.tmpl"))
187 if err = t.ExecuteTemplate(w, "tree.tmpl", treeData); err != nil {
188 log.Printf("ERROR: failed to execute template: %v", err)
189 }
190}
191
192func (d *DGit) logHandler(w http.ResponseWriter, r *http.Request) {
193 repo := getRepo(r)
194 if repo == nil {
195 w.WriteHeader(http.StatusNotFound)
196 d.displayError(w, "Repo not found")
197 return
198 }
199 dReq := r.Context().Value("dReq").(*request.Request)
200 logData, err := convert.ToLogData(repo, dReq)
201 if err != nil {
202 log.Printf("ERROR: failed to extract template data from %s: %v", repo.Slug, err)
203 w.WriteHeader(http.StatusInternalServerError)
204 d.displayError(w, "Internal server error")
205 return
206 }
207 t := template.Must(template.New("templates").Funcs(funcMap).
208 ParseFS(d.Config.Templates, "templates/*.tmpl"))
209 if err = t.ExecuteTemplate(w, "log.tmpl", logData); err != nil {
210 log.Printf("ERROR: failed to execute template: %v", err)
211 }
212}
213
214func (d *DGit) rootHandler(w http.ResponseWriter, r *http.Request) {
215 repos := r.Context().Value("repos").([]*repo.Repo)
216 sort.Sort(sort.Reverse(repo.ByLastModified(repos)))
217 indexData := convert.ToIndexData(repos)
218 t := template.Must(template.New("templates").Funcs(funcMap).
219 ParseFS(d.Config.Templates, "templates/*.tmpl"))
220 if err := t.ExecuteTemplate(w, "index.tmpl", indexData); err != nil {
221 log.Printf("ERROR: failed to execute template: %v", err)
222 }
223}
224
225func (d *DGit) commitHandler(w http.ResponseWriter, r *http.Request) {
226 repo := getRepo(r)
227 if repo == nil {
228 w.WriteHeader(http.StatusNotFound)
229 d.displayError(w, "Repo not found")
230 return
231 }
232 dReq := r.Context().Value("dReq").(*request.Request)
233 commitData, err := convert.ToCommitData(repo, dReq)
234 if err != nil {
235 log.Printf("ERROR: failed to extract template data from %s: %v", repo.Slug, err)
236 w.WriteHeader(http.StatusInternalServerError)
237 d.displayError(w, "Internal server error")
238 return
239 }
240 t := template.Must(template.New("templates").Funcs(funcMap).
241 ParseFS(d.Config.Templates, "templates/*.tmpl"))
242 if err = t.ExecuteTemplate(w, "commit.tmpl", commitData); err != nil {
243 log.Printf("ERROR: failed to execute template: %v", err)
244 }
245}
246
247func (d *DGit) diffHandler(w http.ResponseWriter, r *http.Request) {
248 repo := getRepo(r)
249 if repo == nil {
250 w.WriteHeader(http.StatusNotFound)
251 d.displayError(w, "Repo not found")
252 return
253 }
254 dReq := r.Context().Value("dReq").(*request.Request)
255 diffData, err := convert.ToDiffData(repo, dReq)
256 if err != nil {
257 log.Printf("ERROR: failed to extract template data from %s: %v", repo.Slug, err)
258 w.WriteHeader(http.StatusInternalServerError)
259 d.displayError(w, "Internal server error")
260 return
261 }
262 t := template.Must(template.New("templates").Funcs(funcMap).
263 ParseFS(d.Config.Templates, "templates/*.tmpl"))
264 if err = t.ExecuteTemplate(w, "diff.tmpl", diffData); err != nil {
265 log.Printf("ERROR: failed to execute template: %v", err)
266 }
267}
268
269func (d *DGit) blobHandler(w http.ResponseWriter, r *http.Request) {
270 repo := getRepo(r)
271 if repo == nil {
272 w.WriteHeader(http.StatusNotFound)
273 d.displayError(w, "Repo not found")
274 return
275 }
276 dReq := r.Context().Value("dReq").(*request.Request)
277 treeData, err := convert.ToBlobData(repo, dReq)
278 if err != nil {
279 if errors.Is(err, convert.ErrFileNotFound) {
280 log.Println(err)
281 w.WriteHeader(http.StatusNotFound)
282 d.displayError(w, "Not found")
283 return
284 }
285 log.Printf("ERROR: failed to extract template data from %s: %v", repo.Slug, err)
286 w.WriteHeader(http.StatusInternalServerError)
287 d.displayError(w, "Internal server error")
288 return
289 }
290 t := template.Must(template.New("templates").Funcs(funcMap).
291 ParseFS(d.Config.Templates, "templates/*.tmpl"))
292 if err = t.ExecuteTemplate(w, "blob.tmpl", treeData); err != nil {
293 log.Printf("ERROR: failed to execute template: %v", err)
294 }
295}
296
297func (d *DGit) rawHandler(w http.ResponseWriter, r *http.Request) {
298 repo := getRepo(r)
299 if repo == nil {
300 w.WriteHeader(http.StatusNotFound)
301 d.displayError(w, "Repo not found")
302 return
303 }
304 dReq := r.Context().Value("dReq").(*request.Request)
305 blobData, err := convert.ToBlobData(repo, dReq)
306 if err != nil {
307 if errors.Is(err, convert.ErrFileNotFound) {
308 log.Println(err)
309 w.WriteHeader(http.StatusNotFound)
310 d.displayError(w, "Not found")
311 return
312 }
313 log.Printf("ERROR: failed to extract template data from %s: %v", repo.Slug, err)
314 w.WriteHeader(http.StatusInternalServerError)
315 d.displayError(w, "Internal server error")
316 return
317 }
318 for _, line := range blobData.Blob.Lines {
319 fmt.Fprintln(w, line.Content)
320 }
321}
322
323func (d *DGit) refsHandler(w http.ResponseWriter, r *http.Request) {
324 repo := getRepo(r)
325 if repo == nil {
326 w.WriteHeader(http.StatusNotFound)
327 d.displayError(w, "Repo not found")
328 return
329 }
330 refsData, err := convert.ToRefsData(repo)
331 sort.Sort(sort.Reverse(convert.ByAge(refsData.Branches)))
332 sort.Sort(sort.Reverse(convert.ByAge(refsData.Tags)))
333 if err != nil {
334 log.Printf("ERROR: failed to extract template data from %s: %v", repo.Slug, err)
335 w.WriteHeader(http.StatusInternalServerError)
336 d.displayError(w, "Internal server error")
337 return
338 }
339 t := template.Must(template.New("templates").Funcs(funcMap).
340 ParseFS(d.Config.Templates, "templates/*.tmpl"))
341 if err = t.ExecuteTemplate(w, "refs.tmpl", refsData); err != nil {
342 log.Printf("ERROR: failed to execute template: %v", err)
343 }
344}
345
346func (d *DGit) dumbCloneHandler(w http.ResponseWriter, r *http.Request) {
347 dReq := r.Context().Value("dReq").(*request.Request)
348 repo := getRepo(r)
349 if repo == nil {
350 w.WriteHeader(http.StatusNotFound)
351 fmt.Fprintln(w, "Repo not found")
352 return
353 }
354 cloneResponse, err := convert.ToCloneData(repo, dReq, d.Config)
355 if err != nil {
356 if strings.Contains(err.Error(), "no such file or directory") {
357 w.WriteHeader(http.StatusNotFound)
358 fmt.Fprintf(w, "not found")
359 return
360 }
361 log.Printf("ERROR: failed to extract clone data %s: %v", repo.Slug, err)
362 w.WriteHeader(http.StatusInternalServerError)
363 fmt.Fprint(w, "Internal server error")
364 return
365 }
366 w.Header().Set("content-type", cloneResponse.ContentType)
367 _, err = io.Copy(w, cloneResponse.Data)
368 if err != nil {
369 log.Printf("ERROR: failed to copy clone data %s: %v", repo.Slug, err)
370 w.WriteHeader(http.StatusInternalServerError)
371 fmt.Fprint(w, "Internal server error")
372 return
373 }
374}
375
376func (d *DGit) smartCloneHandler(w http.ResponseWriter, r *http.Request) {
377 dReq := r.Context().Value("dReq").(*request.Request)
378 repo := getRepo(r)
379 if repo == nil {
380 w.WriteHeader(http.StatusNotFound)
381 fmt.Fprintln(w, "Repo not found")
382 return
383 }
384 switch dReq.Path {
385 case "info/refs":
386 smart.HttpInfoRefs(filepath.Join(d.Config.RepoBasePath, repo.Path))(w, r)
387 case "git-upload-pack":
388 smart.HttpGitUploadPack(filepath.Join(d.Config.RepoBasePath, repo.Path))(w, r)
389 }
390}
391
392func (d *DGit) displayError(w http.ResponseWriter, msg string) {
393 t := template.Must(template.New("templates").Funcs(funcMap).
394 ParseFS(d.Config.Templates, "templates/*.tmpl"))
395 if err := t.ExecuteTemplate(w, "error.tmpl", struct{ Message string }{Message: msg}); err != nil {
396 log.Printf("ERROR: failed to execute template: %v", err)
397 }
398}
399
400func getRepo(r *http.Request) *repo.Repo {
401 ctxRepo := r.Context().Value("repo")
402 if ctxRepo == nil {
403 return nil
404 }
405 re := ctxRepo.(*repo.Repo)
406 return re
407}