1package git
2
3import (
4 "bytes"
5 "compress/gzip"
6 "errors"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/http"
11 "os"
12 "path/filepath"
13 "sort"
14 "strings"
15
16 "git.gabrielgio.me/cerrado/pkg/config"
17 "git.gabrielgio.me/cerrado/pkg/ext"
18 "git.gabrielgio.me/cerrado/pkg/service"
19 "git.gabrielgio.me/cerrado/pkg/u"
20 "git.gabrielgio.me/cerrado/templates"
21 "github.com/alecthomas/chroma/v2"
22 "github.com/alecthomas/chroma/v2/formatters/html"
23 "github.com/alecthomas/chroma/v2/lexers"
24 "github.com/alecthomas/chroma/v2/styles"
25 "github.com/go-git/go-git/v5/plumbing/object"
26 "github.com/gomarkdown/markdown"
27 markdownhtml "github.com/gomarkdown/markdown/html"
28 "github.com/gomarkdown/markdown/parser"
29)
30
31type (
32 GitHandler struct {
33 gitService *service.GitService
34 config configurationRepository
35 }
36
37 configurationRepository interface {
38 GetRootReadme() string
39 GetSyntaxHighlight() string
40 GetOrderBy() config.OrderBy
41 GetHostname() string
42 }
43)
44
45func NewGitHandler(gitService *service.GitService, confRepo configurationRepository) *GitHandler {
46 return &GitHandler{
47 gitService: gitService,
48 config: confRepo,
49 }
50}
51
52func (g *GitHandler) List(w http.ResponseWriter, r *ext.Request) error {
53 // this is the only handler that needs to handle authentication itself.
54 // everything else relay on name path parameter
55 logged := ext.IsLoggedIn(r.Context())
56
57 repos, err := g.gitService.ListRepositories()
58 if err != nil {
59 return err
60 }
61
62 if !logged {
63 repos = u.Filter(repos, isPublic)
64 }
65
66 f, err := os.Open(g.config.GetRootReadme())
67 if err != nil {
68 return err
69 }
70
71 bs, err := io.ReadAll(f)
72 if err != nil {
73 return err
74 }
75
76 extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
77 p := parser.NewWithExtensions(extensions)
78 doc := p.Parse(bs)
79
80 htmlFlag := markdownhtml.CommonFlags | markdownhtml.HrefTargetBlank
81 opts := markdownhtml.RendererOptions{Flags: htmlFlag}
82 renderer := markdownhtml.NewRenderer(opts)
83
84 bs = markdown.Render(doc, renderer)
85
86 gitList := &templates.GitListPage{
87 Respositories: orderBy(repos, g.config.GetOrderBy()),
88 About: bs,
89 }
90 templates.WritePageTemplate(w, gitList, r.Context())
91 return nil
92}
93
94func (g *GitHandler) Archive(w http.ResponseWriter, r *ext.Request) error {
95 ext.SetGZip(w)
96 name := r.PathValue("name")
97 file := r.PathValue("file")
98 ref := strings.TrimSuffix(file, ".tar.gz")
99
100 // TODO: remove it once we can support more than gzip
101 if !strings.HasSuffix(file, ".tar.gz") {
102 ext.NotFound(w, r)
103 return nil
104 }
105
106 filename := fmt.Sprintf("%s-%s.tar.gz", name, ref)
107 ext.SetFileName(w, filename)
108
109 prefix := fmt.Sprintf("%s-%s", name, ref)
110 err := g.gitService.WriteTarGZip(w, name, ref, prefix)
111 if err != nil {
112 // once we start writing to the body we can't report error anymore
113 // so we are only left with printing the error.
114 slog.Error("Error generating tar gzip file", "error", err)
115 }
116
117 return nil
118}
119
120func (g *GitHandler) Multiplex(w http.ResponseWriter, r *ext.Request) error {
121 path := r.PathValue("rest")
122 name := r.PathValue("name")
123
124 if r.URL.RawQuery == "service=git-receive-pack" {
125 ext.BadRequest(w, r, "no pushing allowed")
126 return nil
127 }
128
129 if path == "info/refs" && r.URL.RawQuery == "service=git-upload-pack" && r.Method == "GET" {
130 w.Header().Set("content-type", "application/x-git-upload-pack-advertisement")
131
132 err := g.gitService.WriteInfoRefs(r.Context(), name, w)
133 if err != nil {
134 slog.Error("Error WriteInfoRefs", "error", err)
135 }
136 } else if path == "git-upload-pack" && r.Method == "POST" {
137 w.Header().Set("content-type", "application/x-git-upload-pack-result")
138 w.Header().Set("Connection", "Keep-Alive")
139 w.Header().Set("Transfer-Encoding", "chunked")
140 w.WriteHeader(http.StatusOK)
141
142 reader := r.Body
143
144 if r.Header.Get("Content-Encoding") == "gzip" {
145 var err error
146 reader, err = gzip.NewReader(r.Body)
147 if err != nil {
148 return err
149 }
150 defer reader.Close()
151 }
152
153 err := g.gitService.WriteUploadPack(r.Context(), name, reader, w)
154 if err != nil {
155 slog.Error("Error WriteUploadPack", "error", err)
156 }
157 } else if r.Method == "GET" {
158 return g.Summary(w, r)
159 }
160
161 return nil
162}
163
164func (g *GitHandler) Summary(w http.ResponseWriter, r *ext.Request) error {
165 ext.SetHTML(w)
166 name := r.PathValue("name")
167 ref, err := g.gitService.GetHead(name)
168 if err != nil {
169 return err
170 }
171
172 tags, err := g.gitService.ListTags(name)
173 if err != nil {
174 return err
175 }
176
177 branches, err := g.gitService.ListBranches(name)
178 if err != nil {
179 return err
180 }
181
182 commits, _, err := g.gitService.ListCommits(name, "", "", 10)
183 if err != nil {
184 return err
185 }
186
187 if len(tags) > 3 {
188 tags = tags[:3]
189 }
190
191 gitList := &templates.GitItemPage{
192 Name: name,
193 Ref: ref.Name().Short(),
194 GitItemBase: &templates.GitItemSummaryPage{
195 Tags: tags,
196 Branches: branches,
197 Commits: commits,
198 Hostname: g.config.GetHostname(),
199 },
200 }
201 templates.WritePageTemplate(w, gitList, r.Context())
202 return nil
203}
204
205func (g *GitHandler) About(w http.ResponseWriter, r *ext.Request) error {
206 ext.SetHTML(w)
207 name := r.PathValue("name")
208 ref, err := g.gitService.GetHead(name)
209 if err != nil {
210 return err
211 }
212
213 file, err := g.gitService.GetAbout(name)
214 if errors.Is(err, object.ErrFileNotFound) {
215 templates.WritePageTemplate(w, &templates.GitItemPage{
216 Name: name,
217 Ref: ref.Name().Short(),
218 GitItemBase: &templates.GitItemAboutPage{
219 About: []byte("About file not configured properly"),
220 },
221 }, r.Context())
222 return nil
223 }
224 if err != nil {
225 return err
226 }
227
228 extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
229 p := parser.NewWithExtensions(extensions)
230 doc := p.Parse(file)
231
232 htmlFlag := markdownhtml.CommonFlags | markdownhtml.HrefTargetBlank
233 opts := markdownhtml.RendererOptions{Flags: htmlFlag}
234 renderer := markdownhtml.NewRenderer(opts)
235
236 bs := markdown.Render(doc, renderer)
237
238 gitList := &templates.GitItemPage{
239 Name: name,
240 Ref: ref.Name().Short(),
241 GitItemBase: &templates.GitItemAboutPage{
242 About: bs,
243 },
244 }
245 templates.WritePageTemplate(w, gitList, r.Context())
246 return nil
247}
248
249func (g *GitHandler) Refs(w http.ResponseWriter, r *ext.Request) error {
250 ext.SetHTML(w)
251 name := r.PathValue("name")
252
253 tags, err := g.gitService.ListTags(name)
254 if err != nil {
255 return err
256 }
257
258 branches, err := g.gitService.ListBranches(name)
259 if err != nil {
260 return err
261 }
262
263 ref, err := g.gitService.GetHead(name)
264 if err != nil {
265 return err
266 }
267
268 gitList := &templates.GitItemPage{
269 Name: name,
270 Ref: ref.Name().Short(),
271 GitItemBase: &templates.GitItemRefsPage{
272 Tags: tags,
273 Branches: branches,
274 },
275 }
276 templates.WritePageTemplate(w, gitList, r.Context())
277 return nil
278}
279
280func (g *GitHandler) Tree(w http.ResponseWriter, r *ext.Request) error {
281 ext.SetHTML(w)
282 name := r.PathValue("name")
283 ref := r.PathValue("ref")
284 rest := r.PathValue("rest")
285 paths := []string{}
286
287 // this is avoid Split from generating a len 1 array with empty string
288 if rest != "" {
289 paths = strings.Split(rest, "/")
290 }
291
292 tree, err := g.gitService.GetTree(name, ref, rest)
293 if err != nil {
294 return err
295 }
296
297 gitList := &templates.GitItemPage{
298 Name: name,
299 Ref: ref,
300 GitItemBase: &templates.GitItemTreePage{
301 Path: paths,
302 Tree: tree,
303 },
304 }
305 templates.WritePageTemplate(w, gitList, r.Context())
306 return nil
307}
308
309func (g *GitHandler) Blob(w http.ResponseWriter, r *ext.Request) error {
310 ext.SetHTML(w)
311 name := r.PathValue("name")
312 ref := r.PathValue("ref")
313 rest := r.PathValue("rest")
314 paths := []string{}
315
316 // this is avoid Split from generating a len 1 array with empty string
317 if rest != "" {
318 paths = strings.Split(rest, "/")
319 }
320
321 isBin, err := g.gitService.IsBinary(name, ref, rest)
322 if err != nil {
323 return err
324 }
325
326 // if it is binary no need to over all the chroma process
327 if isBin {
328 gitList := &templates.GitItemPage{
329 Name: name,
330 Ref: ref,
331 GitItemBase: &templates.GitItemBlobPage{
332 Path: paths,
333 Content: []byte("Binary file"),
334 },
335 }
336 templates.WritePageTemplate(w, gitList, r.Context())
337 return nil
338 }
339
340 file, err := g.gitService.GetFileContent(name, ref, rest)
341 if err != nil {
342 return err
343 }
344
345 filename := filepath.Base(rest)
346 lexer := GetLexers(filename)
347 style := styles.Get(g.config.GetSyntaxHighlight())
348
349 formatter := html.New(
350 html.WithLineNumbers(true),
351 html.WithLinkableLineNumbers(true, "L"),
352 )
353
354 iterator, err := lexer.Tokenise(nil, string(file))
355 if err != nil {
356 return err
357 }
358
359 var code bytes.Buffer
360 err = formatter.Format(&code, style, iterator)
361 if err != nil {
362 return err
363 }
364
365 gitList := &templates.GitItemPage{
366 Name: name,
367 Ref: ref,
368 GitItemBase: &templates.GitItemBlobPage{
369 Path: paths,
370 Content: code.Bytes(),
371 },
372 }
373 templates.WritePageTemplate(w, gitList, r.Context())
374 return nil
375}
376
377func (g *GitHandler) Log(w http.ResponseWriter, r *ext.Request) error {
378 ext.SetHTML(w)
379 name := r.PathValue("name")
380 ref := r.PathValue("ref")
381 from := r.URL.Query().Get("from")
382
383 commits, next, err := g.gitService.ListCommits(name, ref, from, 100)
384 if err != nil {
385 return err
386 }
387
388 gitList := &templates.GitItemPage{
389 Name: name,
390 Ref: ref,
391 GitItemBase: &templates.GitItemLogPage{
392 Commits: commits,
393 Next: next,
394 },
395 }
396 templates.WritePageTemplate(w, gitList, r.Context())
397 return nil
398}
399
400func (g *GitHandler) Ref(w http.ResponseWriter, r *ext.Request) error {
401 ext.SetHTML(w)
402 name := r.PathValue("name")
403 ref := r.PathValue("ref")
404
405 commit, tag, err := g.gitService.GetTag(ref, name)
406 if err != nil {
407 return err
408 }
409
410 gitList := &templates.GitItemPage{
411 Name: name,
412 Ref: ref,
413 GitItemBase: &templates.GitItemRefPage{
414 Commit: commit,
415 Reference: tag,
416 },
417 }
418 templates.WritePageTemplate(w, gitList, r.Context())
419 return nil
420}
421
422func (g *GitHandler) Commit(w http.ResponseWriter, r *ext.Request) error {
423 ext.SetHTML(w)
424 name := r.PathValue("name")
425 ref := r.PathValue("ref")
426
427 commit, err := g.gitService.LastCommit(name, ref)
428 if err != nil {
429 return err
430 }
431
432 diff, err := g.gitService.Diff(name, ref)
433 if err != nil {
434 return err
435 }
436
437 lexer := lexers.Get("diff")
438 style := styles.Get(g.config.GetSyntaxHighlight())
439
440 formatter := html.New(
441 html.WithLineNumbers(true),
442 html.WithLinkableLineNumbers(true, "L"),
443 )
444
445 iterator, err := lexer.Tokenise(nil, diff)
446 if err != nil {
447 return err
448 }
449
450 var code bytes.Buffer
451 err = formatter.Format(&code, style, iterator)
452 if err != nil {
453 return err
454 }
455
456 gitList := &templates.GitItemPage{
457 Name: name,
458 Ref: ref,
459 GitItemBase: &templates.GitItemCommitPage{
460 Commit: commit,
461 Diff: code.Bytes(),
462 },
463 }
464 templates.WritePageTemplate(w, gitList, r.Context())
465 return nil
466}
467
468func GetLexers(filename string) chroma.Lexer {
469 if filename == "APKBUILD" {
470 return lexers.Get("sh")
471 }
472
473 if strings.HasSuffix(filename, ".qtpl") {
474 return lexers.Get("html")
475 }
476
477 lexer := lexers.Get(filename)
478
479 if lexer == nil {
480 lexer = lexers.Get("txt")
481 }
482 return lexer
483}
484
485func isPublic(r *service.Repository) bool {
486 return r.Public
487}
488
489func orderBy(repos []*service.Repository, order config.OrderBy) []*service.Repository {
490 switch order {
491 case config.AlphabeticalAsc:
492 sort.Slice(repos, func(i, j int) bool {
493 return repos[i].Name < repos[j].Name
494 })
495 case config.AlphabeticalDesc:
496 sort.Slice(repos, func(i, j int) bool {
497 return repos[i].Name > repos[j].Name
498 })
499 case config.LastCommitAsc:
500 sort.Slice(repos, func(i, j int) bool {
501 return repos[i].LastCommit.Commit().Committer.When.Before(repos[j].LastCommit.Commit().Committer.When)
502 })
503 case config.LastCommitDesc:
504 sort.Slice(repos, func(i, j int) bool {
505 return repos[i].LastCommit.Commit().Committer.When.After(repos[j].LastCommit.Commit().Committer.When)
506 })
507 }
508
509 return repos
510}