all repos

mugit @ afcecfcfa4a38ad7a1008ab2f44cc2e978081f71

🐮 git server that your cow will love

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

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