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 html.WithClasses(true),
353 )
354
355 iterator, err := lexer.Tokenise(nil, string(file))
356 if err != nil {
357 return err
358 }
359
360 var code bytes.Buffer
361 err = formatter.Format(&code, style, iterator)
362 if err != nil {
363 return err
364 }
365
366 gitList := &templates.GitItemPage{
367 Name: name,
368 Ref: ref,
369 GitItemBase: &templates.GitItemBlobPage{
370 Path: paths,
371 Content: code.Bytes(),
372 },
373 }
374 templates.WritePageTemplate(w, gitList, r.Context())
375 return nil
376}
377
378func (g *GitHandler) Log(w http.ResponseWriter, r *ext.Request) error {
379 ext.SetHTML(w)
380 name := r.PathValue("name")
381 ref := r.PathValue("ref")
382 from := r.URL.Query().Get("from")
383
384 commits, next, err := g.gitService.ListCommits(name, ref, from, 100)
385 if err != nil {
386 return err
387 }
388
389 gitList := &templates.GitItemPage{
390 Name: name,
391 Ref: ref,
392 GitItemBase: &templates.GitItemLogPage{
393 Commits: commits,
394 Next: next,
395 },
396 }
397 templates.WritePageTemplate(w, gitList, r.Context())
398 return nil
399}
400
401func (g *GitHandler) Ref(w http.ResponseWriter, r *ext.Request) error {
402 ext.SetHTML(w)
403 name := r.PathValue("name")
404 ref := r.PathValue("ref")
405
406 commit, tag, err := g.gitService.GetTag(ref, name)
407 if err != nil {
408 return err
409 }
410
411 gitList := &templates.GitItemPage{
412 Name: name,
413 Ref: ref,
414 GitItemBase: &templates.GitItemRefPage{
415 Commit: commit,
416 Reference: tag,
417 },
418 }
419 templates.WritePageTemplate(w, gitList, r.Context())
420 return nil
421}
422
423func (g *GitHandler) Commit(w http.ResponseWriter, r *ext.Request) error {
424 ext.SetHTML(w)
425 name := r.PathValue("name")
426 ref := r.PathValue("ref")
427
428 commit, err := g.gitService.LastCommit(name, ref)
429 if err != nil {
430 return err
431 }
432
433 diff, err := g.gitService.Diff(name, ref)
434 if err != nil {
435 return err
436 }
437
438 lexer := lexers.Get("diff")
439 style := styles.Get(g.config.GetSyntaxHighlight())
440
441 formatter := html.New(
442 html.WithLineNumbers(true),
443 html.WithLinkableLineNumbers(true, "L"),
444 html.WithClasses(true),
445 )
446
447 iterator, err := lexer.Tokenise(nil, diff)
448 if err != nil {
449 return err
450 }
451
452 var code bytes.Buffer
453 err = formatter.Format(&code, style, iterator)
454 if err != nil {
455 return err
456 }
457
458 gitList := &templates.GitItemPage{
459 Name: name,
460 Ref: ref,
461 GitItemBase: &templates.GitItemCommitPage{
462 Commit: commit,
463 Diff: code.Bytes(),
464 },
465 }
466 templates.WritePageTemplate(w, gitList, r.Context())
467 return nil
468}
469
470func GetLexers(filename string) chroma.Lexer {
471 if filename == "APKBUILD" {
472 return lexers.Get("sh")
473 }
474
475 if strings.HasSuffix(filename, ".qtpl") {
476 return lexers.Get("html")
477 }
478
479 lexer := lexers.Get(filename)
480
481 if lexer == nil {
482 lexer = lexers.Get("txt")
483 }
484 return lexer
485}
486
487func isPublic(r *service.Repository) bool {
488 return r.Public
489}
490
491func orderBy(repos []*service.Repository, order config.OrderBy) []*service.Repository {
492 switch order {
493 case config.AlphabeticalAsc:
494 sort.Slice(repos, func(i, j int) bool {
495 return repos[i].Name < repos[j].Name
496 })
497 case config.AlphabeticalDesc:
498 sort.Slice(repos, func(i, j int) bool {
499 return repos[i].Name > repos[j].Name
500 })
501 case config.LastCommitAsc:
502 sort.Slice(repos, func(i, j int) bool {
503 return repos[i].LastCommit.Commit().Committer.When.Before(repos[j].LastCommit.Commit().Committer.When)
504 })
505 case config.LastCommitDesc:
506 sort.Slice(repos, func(i, j int) bool {
507 return repos[i].LastCommit.Commit().Committer.When.After(repos[j].LastCommit.Commit().Committer.When)
508 })
509 }
510
511 return repos
512}