cerrado @ v0.3

  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}