all repos

mugit @ d1bac8e2130c9a627e4238a702a2821f90e48170

🐮 git server that your cow will love

mugit/internal/handlers/repo.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
git: remove context.TODO from makeNiceTree, 2 months ago
1
package handlers
2
3
import (
4
	"bytes"
5
	"errors"
6
	"fmt"
7
	"html/template"
8
	"io"
9
	"log/slog"
10
	"net/http"
11
	"os"
12
	"path/filepath"
13
	"sort"
14
	"strconv"
15
	"strings"
16
	"time"
17
18
	"olexsmir.xyz/mugit/internal/git"
19
	"olexsmir.xyz/mugit/internal/markdown"
20
)
21
22
type Meta struct {
23
	Title       string
24
	Description string
25
	Host        string
26
	IsEmpty     bool
27
	GoMod       bool
28
	SSHEnabled  bool
29
}
30
31
type RepoBase struct {
32
	Ref  string
33
	Desc string
34
}
35
36
type PageData[T any] struct {
37
	Meta     Meta
38
	RepoName string // empty for non-repo pages, needed for _head.html to  compile
39
	P        T
40
}
41
42
func (h *handlers) indexHandler(w http.ResponseWriter, r *http.Request) {
43
	repos, err := h.listPublicRepos()
44
	if err != nil {
45
		h.write500(w, err)
46
		return
47
	}
48
	h.templ(w, "index", h.pageData(nil, repos))
49
}
50
51
type RepoIndex struct {
52
	Desc           string
53
	SSHUser        string
54
	IsEmpty        bool
55
	Readme         template.HTML
56
	Ref            string
57
	Commits        []*git.Commit
58
	IsMirror       bool
59
	MirrorURL      string
60
	MirrorLastSync time.Time
61
}
62
63
func (h *handlers) repoIndexHandler(w http.ResponseWriter, r *http.Request) {
64
	repo, err := h.openPublicRepo(r.PathValue("name"), "")
65
	if err != nil {
66
		h.write404(w, err)
67
		return
68
	}
69
70
	desc, err := repo.Description()
71
	if err != nil {
72
		h.write500(w, err)
73
		return
74
	}
75
76
	p := RepoIndex{Desc: desc, IsEmpty: repo.IsEmpty()}
77
	if p.IsEmpty {
78
		h.templ(w, "repo_index", h.pageData(repo, p))
79
		return
80
	}
81
82
	p.Ref, err = repo.FindMasterBranch(h.c.Repo.Masters)
83
	if err != nil {
84
		h.write500(w, err)
85
		return
86
	}
87
88
	p.Readme, err = h.renderReadme(repo, p.Ref, "")
89
	if err != nil {
90
		h.write500(w, err)
91
		return
92
	}
93
94
	p.Commits, err = repo.Commits("")
95
	if err != nil {
96
		h.write500(w, err)
97
		return
98
	}
99
100
	if len(p.Commits) >= 3 {
101
		p.Commits = p.Commits[:3:3]
102
	}
103
104
	if isMirror, err := repo.IsMirror(); isMirror && err == nil {
105
		p.IsMirror = true
106
		p.MirrorURL, _ = repo.RemoteURL()
107
		p.MirrorLastSync, _ = repo.LastSync()
108
	}
109
110
	p.SSHUser = h.c.SSH.User
111
	h.templ(w, "repo_index", h.pageData(repo, p))
112
}
113
114
type RepoTree struct {
115
	Desc       string
116
	Ref        string
117
	Tree       []git.NiceTree
118
	ParentPath string
119
	DotDot     string
120
	Readme     template.HTML
121
}
122
123
func (h *handlers) repoTreeHandler(w http.ResponseWriter, r *http.Request) {
124
	name := r.PathValue("name")
125
	ref := h.parseRef(r.PathValue("ref"))
126
	treePath := r.PathValue("rest")
127
128
	repo, err := h.openPublicRepo(name, ref)
129
	if err != nil {
130
		h.write404(w, err)
131
		return
132
	}
133
134
	desc, err := repo.Description()
135
	if err != nil {
136
		h.write500(w, err)
137
		return
138
	}
139
140
	tree, err := repo.FileTree(r.Context(), treePath)
141
	if err != nil {
142
		h.write500(w, err)
143
		return
144
	}
145
146
	readme, err := h.renderReadme(repo, ref, treePath)
147
	if err != nil {
148
		h.write500(w, err)
149
		return
150
	}
151
152
	h.templ(w, "repo_tree", h.pageData(repo, RepoTree{
153
		Desc:       desc,
154
		Ref:        ref,
155
		Tree:       tree,
156
		ParentPath: treePath,
157
		DotDot:     filepath.Dir(treePath),
158
		Readme:     readme,
159
	}))
160
}
161
162
type RepoFile struct {
163
	Ref         string
164
	Desc        string
165
	LineCount   []int
166
	Breadcrumbs []Breadcrumb
167
	Path        string
168
	IsImage     bool
169
	IsBinary    bool
170
	Content     string
171
	Mime        string
172
	Size        int64
173
}
174
175
func (h *handlers) fileContentsHandler(w http.ResponseWriter, r *http.Request) {
176
	name := r.PathValue("name")
177
	ref := h.parseRef(r.PathValue("ref"))
178
	treePath := r.PathValue("rest")
179
180
	var raw bool
181
	if rawParam, err := strconv.ParseBool(r.URL.Query().Get("raw")); err == nil {
182
		raw = rawParam
183
	}
184
185
	repo, err := h.openPublicRepo(name, ref)
186
	if err != nil {
187
		h.write404(w, err)
188
		return
189
	}
190
191
	fc, err := repo.FileContent(treePath)
192
	if err != nil {
193
		if errors.Is(err, git.ErrFileNotFound) {
194
			h.write404(w, err)
195
			return
196
		}
197
		h.write500(w, err)
198
		return
199
	}
200
201
	if raw {
202
		w.Header().Set("Content-Type", fc.Mime)
203
		w.WriteHeader(http.StatusOK)
204
		w.Write(fc.Content)
205
		return
206
	}
207
208
	p := RepoFile{
209
		Ref:      ref,
210
		Path:     treePath,
211
		IsImage:  fc.IsImage,
212
		IsBinary: fc.IsBinary,
213
		Mime:     fc.Mime,
214
		Size:     fc.Size,
215
	}
216
217
	p.Desc, err = repo.Description()
218
	if err != nil {
219
		h.write500(w, err)
220
		return
221
	}
222
223
	if !fc.IsImage && !fc.IsBinary {
224
		contentStr := fc.String()
225
		lc, err := countLines(strings.NewReader(contentStr))
226
		if err != nil {
227
			slog.Error("failed to count line numbers", "err", err)
228
		}
229
		lines := make([]int, lc)
230
		for i := range lines {
231
			lines[i] = i + 1
232
		}
233
		p.Content = contentStr
234
		p.LineCount = lines // TODO: replace with strings.Count(, "\n")
235
		p.Breadcrumbs = Breadcrumbs(treePath)
236
	}
237
238
	h.templ(w, "repo_file", h.pageData(repo, p))
239
}
240
241
type RepoLog struct {
242
	Desc      string
243
	Commits   []*git.Commit
244
	Ref       string
245
	NextAfter string
246
}
247
248
func (h *handlers) logHandler(w http.ResponseWriter, r *http.Request) {
249
	name := r.PathValue("name")
250
	ref := h.parseRef(r.PathValue("ref"))
251
	after := r.URL.Query().Get("after")
252
253
	repo, err := h.openPublicRepo(name, ref)
254
	if err != nil {
255
		h.write404(w, err)
256
		return
257
	}
258
259
	desc, err := repo.Description()
260
	if err != nil {
261
		h.write500(w, err)
262
		return
263
	}
264
265
	commits, err := repo.Commits(after)
266
	if err != nil {
267
		h.write500(w, err)
268
		return
269
	}
270
271
	// if we got full page of commits, we probably have more.
272
	// NOTE: this has an edge case, when last page is len(git.CommitsPage), "load more" would be shown
273
	nextAfter := ""
274
	if len(commits) == git.CommitsPage && len(commits) > 0 {
275
		nextAfter = commits[len(commits)-1].HashShort
276
	}
277
278
	h.templ(w, "repo_log", h.pageData(repo, RepoLog{
279
		Desc:      desc,
280
		Ref:       ref,
281
		Commits:   commits,
282
		NextAfter: nextAfter,
283
	}))
284
}
285
286
type RepoCommit struct {
287
	Diff *git.NiceDiff
288
	Ref  string
289
	Desc string
290
}
291
292
func (h *handlers) commitHandler(w http.ResponseWriter, r *http.Request) {
293
	name := r.PathValue("name")
294
	ref := h.parseRef(r.PathValue("ref"))
295
296
	repo, err := h.openPublicRepo(name, ref)
297
	if err != nil {
298
		h.write404(w, err)
299
		return
300
	}
301
302
	diff, err := h.getDiff(repo, ref)
303
	if err != nil {
304
		h.write500(w, err)
305
		return
306
	}
307
308
	desc, err := repo.Description()
309
	if err != nil {
310
		h.write500(w, err)
311
		return
312
	}
313
314
	h.templ(w, "repo_commit", h.pageData(repo, RepoCommit{
315
		Desc: desc,
316
		Ref:  ref,
317
		Diff: diff,
318
	}))
319
}
320
321
type RepoRefs struct {
322
	Desc     string
323
	Ref      string
324
	Branches []*git.Branch
325
	Tags     []*git.TagReference
326
}
327
328
func (h *handlers) refsHandler(w http.ResponseWriter, r *http.Request) {
329
	repo, err := h.openPublicRepo(r.PathValue("name"), "")
330
	if err != nil {
331
		h.write404(w, err)
332
		return
333
	}
334
335
	desc, err := repo.Description()
336
	if err != nil {
337
		h.write500(w, err)
338
		return
339
	}
340
341
	master, err := repo.FindMasterBranch(h.c.Repo.Masters)
342
	if err != nil {
343
		h.write500(w, err)
344
		return
345
	}
346
347
	branches, err := repo.Branches()
348
	if err != nil {
349
		h.write500(w, err)
350
		return
351
	}
352
353
	// repo should have at least one branch, tags are *optional*
354
	tags, _ := repo.Tags()
355
356
	h.templ(w, "repo_refs", h.pageData(repo, RepoRefs{
357
		Desc:     desc,
358
		Ref:      master,
359
		Tags:     tags,
360
		Branches: branches,
361
	}))
362
}
363
364
func countLines(r io.Reader) (int, error) {
365
	buf := make([]byte, 32*1024)
366
	bufLen := 0
367
	count := 0
368
	nl := []byte{'\n'}
369
370
	for {
371
		c, err := r.Read(buf)
372
		if c > 0 {
373
			bufLen += c
374
		}
375
		count += bytes.Count(buf[:c], nl)
376
377
		switch {
378
		case err == io.EOF:
379
			// handle last line not having a newline at the end
380
			if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
381
				count++
382
			}
383
			return count, nil
384
		case err != nil:
385
			return 0, err
386
		}
387
	}
388
}
389
390
type repoList struct {
391
	Name       string
392
	Desc       string
393
	LastCommit time.Time
394
}
395
396
func (h *handlers) listPublicRepos() ([]repoList, error) {
397
	if v, found := h.repoListCache.Get("repo_list"); found {
398
		return v, nil
399
	}
400
401
	dirs, err := os.ReadDir(h.c.Repo.Dir)
402
	if err != nil {
403
		return nil, err
404
	}
405
406
	var repos []repoList
407
	var errs []error
408
	for _, dir := range dirs {
409
		if !dir.IsDir() {
410
			continue
411
		}
412
413
		name := dir.Name()
414
		repo, err := h.openPublicRepo(name, "")
415
		if err != nil {
416
			// if it's not git repo, just ignore it
417
			continue
418
		}
419
420
		desc, err := repo.Description()
421
		if err != nil {
422
			errs = append(errs, err)
423
			continue
424
		}
425
426
		lastCommit, err := repo.LastCommit()
427
		if err != nil {
428
			errs = append(errs, err)
429
			continue
430
		}
431
432
		repos = append(repos, repoList{
433
			Name:       repo.Name(),
434
			Desc:       desc,
435
			LastCommit: lastCommit.Committed,
436
		})
437
	}
438
439
	sort.Slice(repos, func(i, j int) bool {
440
		return repos[j].LastCommit.Before(repos[i].LastCommit)
441
	})
442
443
	h.repoListCache.Set("repo_list", repos)
444
	return repos, errors.Join(errs...)
445
}
446
447
func (h handlers) getDiff(r *git.Repo, ref string) (*git.NiceDiff, error) {
448
	cacheKey := fmt.Sprintf("%s:%s", r.Name(), ref)
449
	if v, found := h.diffCache.Get(cacheKey); found {
450
		return v, nil
451
	}
452
453
	diff, err := r.Diff()
454
	if err != nil {
455
		return nil, err
456
	}
457
458
	h.diffCache.Set(cacheKey, diff)
459
	return diff, nil
460
}
461
462
func (h *handlers) renderReadme(r *git.Repo, ref, treePath string) (template.HTML, error) {
463
	name := r.Name()
464
	cacheKey := fmt.Sprintf("%s:%s:%s", name, ref, treePath)
465
	if v, found := h.readmeCache.Get(cacheKey); found {
466
		return v, nil
467
	}
468
469
	var readmeContents template.HTML
470
	for _, readme := range h.c.Repo.Readmes {
471
		fullPath := filepath.Join(treePath, readme)
472
		fc, ferr := r.FileContent(fullPath)
473
		if ferr != nil {
474
			continue
475
		}
476
477
		if fc.IsBinary && fc.IsImage {
478
			continue
479
		}
480
481
		ext := filepath.Ext(readme)
482
		content := fc.String()
483
		if len(content) > 0 {
484
			switch ext {
485
			case ".md", ".markdown", ".mkd":
486
				readme, err := markdown.Render(name, ref, fullPath, content)
487
				if err != nil {
488
					return "", err
489
				}
490
				return template.HTML(readme), nil
491
492
			default:
493
				readmeContents = template.HTML(fmt.Sprintf(`<pre class="raw">%s</pre>`, content))
494
			}
495
			break
496
		}
497
	}
498
499
	h.readmeCache.Set(cacheKey, readmeContents)
500
	return readmeContents, nil
501
}
502
503
func (h handlers) pageData(repo *git.Repo, p any) PageData[any] {
504
	var name string
505
	var gomod, empty bool
506
	if repo != nil {
507
		gomod = repo.IsGoMod()
508
		empty = repo.IsEmpty()
509
		name = repo.Name()
510
	}
511
512
	return PageData[any]{
513
		P:        p,
514
		RepoName: name,
515
		Meta: Meta{
516
			Title:       h.c.Meta.Title,
517
			Description: h.c.Meta.Description,
518
			Host:        h.c.Meta.Host,
519
			GoMod:       gomod,
520
			SSHEnabled:  h.c.SSH.Enable,
521
			IsEmpty:     empty,
522
		},
523
	}
524
}
525
526
type Breadcrumb struct {
527
	Name   string
528
	Path   string
529
	IsLast bool
530
}
531
532
func Breadcrumbs(path string) []Breadcrumb {
533
	if path == "" {
534
		return nil
535
	}
536
	parts := strings.Split(path, "/")
537
	crumbs := make([]Breadcrumb, len(parts))
538
	for i, part := range parts {
539
		crumbs[i] = Breadcrumb{
540
			Name:   part,
541
			Path:   strings.Join(parts[:i+1], "/"),
542
			IsLast: i == len(parts)-1,
543
		}
544
	}
545
	return crumbs
546
}