all repos

mugit @ a744b78

🐮 git server that your cow will love

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
ui: fix breadcrumbs for non text files, 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
	ParentPath string
118
	DotDot     string
119
	Readme     template.HTML
120
}
121
122
func (h *handlers) repoTreeHandler(w http.ResponseWriter, r *http.Request) {
123
	name := r.PathValue("name")
124
	ref := h.parseRef(r.PathValue("ref"))
125
	treePath := r.PathValue("rest")
126
127
	repo, err := h.openPublicRepo(name, ref)
128
	if err != nil {
129
		h.write404(w, err)
130
		return
131
	}
132
133
	desc, err := repo.Description()
134
	if err != nil {
135
		h.write500(w, err)
136
		return
137
	}
138
139
	tree, err := repo.FileTree(r.Context(), treePath)
140
	if err != nil {
141
		h.write500(w, err)
142
		return
143
	}
144
145
	readme, err := h.renderReadme(repo, ref, treePath)
146
	if err != nil {
147
		h.write500(w, err)
148
		return
149
	}
150
151
	h.templ(w, "repo_tree", h.pageData(repo, RepoTree{
152
		Desc:       desc,
153
		Ref:        ref,
154
		Tree:       tree,
155
		ParentPath: treePath,
156
		DotDot:     filepath.Dir(treePath),
157
		Readme:     readme,
158
	}))
159
}
160
161
type RepoFile struct {
162
	Ref         string
163
	Desc        string
164
	LineCount   []int
165
	Breadcrumbs []Breadcrumb
166
	Path        string
167
	IsImage     bool
168
	IsBinary    bool
169
	Content     string
170
	Mime        string
171
	Size        int64
172
}
173
174
func (h *handlers) fileContentsHandler(w http.ResponseWriter, r *http.Request) {
175
	name := r.PathValue("name")
176
	ref := h.parseRef(r.PathValue("ref"))
177
	treePath := r.PathValue("rest")
178
179
	repo, err := h.openPublicRepo(name, ref)
180
	if err != nil {
181
		h.write404(w, err)
182
		return
183
	}
184
185
	fc, err := repo.FileContent(treePath)
186
	if err != nil {
187
		if errors.Is(err, git.ErrFileNotFound) {
188
			h.write404(w, err)
189
			return
190
		}
191
		h.write500(w, err)
192
		return
193
	}
194
195
	p := RepoFile{
196
		Ref:      ref,
197
		Path:     treePath,
198
		IsImage:  fc.IsImage,
199
		IsBinary: fc.IsBinary,
200
		Mime:     fc.Mime,
201
		Size:     fc.Size,
202
	}
203
204
	p.Desc, err = repo.Description()
205
	if err != nil {
206
		h.write500(w, err)
207
		return
208
	}
209
210
	p.Breadcrumbs = Breadcrumbs(treePath)
211
	if !fc.IsImage && !fc.IsBinary {
212
		contentStr := fc.String()
213
		lc, err := countLines(strings.NewReader(contentStr))
214
		if err != nil {
215
			slog.Error("failed to count line numbers", "err", err)
216
		}
217
		lines := make([]int, lc)
218
		for i := range lines {
219
			lines[i] = i + 1
220
		}
221
		p.Content = contentStr
222
		p.LineCount = lines // TODO: replace with strings.Count(, "\n")
223
	}
224
225
	h.templ(w, "repo_file", h.pageData(repo, p))
226
}
227
228
func (h *handlers) rawFileContentsHandler(w http.ResponseWriter, r *http.Request) {
229
	name := r.PathValue("name")
230
	ref := h.parseRef(r.PathValue("ref"))
231
	treePath := r.PathValue("rest")
232
233
	repo, err := h.openPublicRepo(name, ref)
234
	if err != nil {
235
		w.WriteHeader(http.StatusNotFound)
236
		slog.Info("404", "err", err)
237
		return
238
	}
239
240
	fc, err := repo.FileContent(treePath)
241
	if err != nil {
242
		if errors.Is(err, git.ErrFileNotFound) {
243
			w.WriteHeader(http.StatusNotFound)
244
			slog.Info("404", "err", err)
245
			return
246
		}
247
248
		w.WriteHeader(http.StatusInternalServerError)
249
		slog.Info("500", "err", err)
250
		return
251
	}
252
253
	w.Header().Set("Content-Type", fc.Mime)
254
	w.WriteHeader(http.StatusOK)
255
	w.Write(fc.Content)
256
}
257
258
type RepoLog struct {
259
	Desc      string
260
	Commits   []*git.Commit
261
	Ref       string
262
	NextAfter string
263
}
264
265
func (h *handlers) logHandler(w http.ResponseWriter, r *http.Request) {
266
	name := r.PathValue("name")
267
	ref := h.parseRef(r.PathValue("ref"))
268
	after := r.URL.Query().Get("after")
269
270
	repo, err := h.openPublicRepo(name, ref)
271
	if err != nil {
272
		h.write404(w, err)
273
		return
274
	}
275
276
	desc, err := repo.Description()
277
	if err != nil {
278
		h.write500(w, err)
279
		return
280
	}
281
282
	commits, err := repo.Commits(after)
283
	if err != nil {
284
		h.write500(w, err)
285
		return
286
	}
287
288
	// if we got full page of commits, we probably have more.
289
	// NOTE: this has an edge case, when last page is len(git.CommitsPage), "load more" would be shown
290
	nextAfter := ""
291
	if len(commits) == git.CommitsPage && len(commits) > 0 {
292
		nextAfter = commits[len(commits)-1].HashShort
293
	}
294
295
	h.templ(w, "repo_log", h.pageData(repo, RepoLog{
296
		Desc:      desc,
297
		Ref:       ref,
298
		Commits:   commits,
299
		NextAfter: nextAfter,
300
	}))
301
}
302
303
type RepoCommit struct {
304
	Diff *git.NiceDiff
305
	Ref  string
306
	Desc string
307
}
308
309
func (h *handlers) commitHandler(w http.ResponseWriter, r *http.Request) {
310
	name := r.PathValue("name")
311
	ref := h.parseRef(r.PathValue("ref"))
312
313
	repo, err := h.openPublicRepo(name, ref)
314
	if err != nil {
315
		h.write404(w, err)
316
		return
317
	}
318
319
	diff, err := h.getDiff(repo, ref)
320
	if err != nil {
321
		h.write500(w, err)
322
		return
323
	}
324
325
	desc, err := repo.Description()
326
	if err != nil {
327
		h.write500(w, err)
328
		return
329
	}
330
331
	h.templ(w, "repo_commit", h.pageData(repo, RepoCommit{
332
		Desc: desc,
333
		Ref:  ref,
334
		Diff: diff,
335
	}))
336
}
337
338
type RepoRefs struct {
339
	Desc     string
340
	Ref      string
341
	Branches []*git.Branch
342
	Tags     []*git.TagReference
343
}
344
345
func (h *handlers) refsHandler(w http.ResponseWriter, r *http.Request) {
346
	repo, err := h.openPublicRepo(r.PathValue("name"), "")
347
	if err != nil {
348
		h.write404(w, err)
349
		return
350
	}
351
352
	desc, err := repo.Description()
353
	if err != nil {
354
		h.write500(w, err)
355
		return
356
	}
357
358
	master, err := repo.FindMasterBranch(h.c.Repo.Masters)
359
	if err != nil {
360
		h.write500(w, err)
361
		return
362
	}
363
364
	branches, err := repo.Branches()
365
	if err != nil {
366
		h.write500(w, err)
367
		return
368
	}
369
370
	// repo should have at least one branch, tags are *optional*
371
	tags, _ := repo.Tags()
372
373
	h.templ(w, "repo_refs", h.pageData(repo, RepoRefs{
374
		Desc:     desc,
375
		Ref:      master,
376
		Tags:     tags,
377
		Branches: branches,
378
	}))
379
}
380
381
func countLines(r io.Reader) (int, error) {
382
	buf := make([]byte, 32*1024)
383
	bufLen := 0
384
	count := 0
385
	nl := []byte{'\n'}
386
387
	for {
388
		c, err := r.Read(buf)
389
		if c > 0 {
390
			bufLen += c
391
		}
392
		count += bytes.Count(buf[:c], nl)
393
394
		switch {
395
		case err == io.EOF:
396
			// handle last line not having a newline at the end
397
			if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' {
398
				count++
399
			}
400
			return count, nil
401
		case err != nil:
402
			return 0, err
403
		}
404
	}
405
}
406
407
type repoList struct {
408
	Name       string
409
	Desc       string
410
	LastCommit time.Time
411
}
412
413
func (h *handlers) listPublicRepos() ([]repoList, error) {
414
	if v, found := h.repoListCache.Get("repo_list"); found {
415
		return v, nil
416
	}
417
418
	dirs, err := os.ReadDir(h.c.Repo.Dir)
419
	if err != nil {
420
		return nil, err
421
	}
422
423
	var repos []repoList
424
	var errs []error
425
	for _, dir := range dirs {
426
		if !dir.IsDir() {
427
			continue
428
		}
429
430
		name := dir.Name()
431
		repo, err := h.openPublicRepo(name, "")
432
		if err != nil {
433
			// if it's not git repo, just ignore it
434
			continue
435
		}
436
437
		desc, err := repo.Description()
438
		if err != nil {
439
			errs = append(errs, err)
440
			continue
441
		}
442
443
		lastCommit, err := repo.LastCommit()
444
		if err != nil {
445
			errs = append(errs, err)
446
			continue
447
		}
448
449
		repos = append(repos, repoList{
450
			Name:       repo.Name(),
451
			Desc:       desc,
452
			LastCommit: lastCommit.Committed,
453
		})
454
	}
455
456
	sort.Slice(repos, func(i, j int) bool {
457
		return repos[j].LastCommit.Before(repos[i].LastCommit)
458
	})
459
460
	h.repoListCache.Set("repo_list", repos)
461
	return repos, errors.Join(errs...)
462
}
463
464
func (h handlers) getDiff(r *git.Repo, ref string) (*git.NiceDiff, error) {
465
	cacheKey := fmt.Sprintf("%s:%s", r.Name(), ref)
466
	if v, found := h.diffCache.Get(cacheKey); found {
467
		return v, nil
468
	}
469
470
	diff, err := r.Diff()
471
	if err != nil {
472
		return nil, err
473
	}
474
475
	h.diffCache.Set(cacheKey, diff)
476
	return diff, nil
477
}
478
479
func (h *handlers) renderReadme(r *git.Repo, ref, treePath string) (template.HTML, error) {
480
	name := r.Name()
481
	cacheKey := fmt.Sprintf("%s:%s:%s", name, ref, treePath)
482
	if v, found := h.readmeCache.Get(cacheKey); found {
483
		return v, nil
484
	}
485
486
	var readmeContents template.HTML
487
	for _, readme := range h.c.Repo.Readmes {
488
		fullPath := filepath.Join(treePath, readme)
489
		fc, ferr := r.FileContent(fullPath)
490
		if ferr != nil {
491
			continue
492
		}
493
494
		if fc.IsBinary && fc.IsImage {
495
			continue
496
		}
497
498
		ext := filepath.Ext(readme)
499
		content := fc.String()
500
		if len(content) > 0 {
501
			switch ext {
502
			case ".md", ".markdown", ".mkd":
503
				readme, err := markdown.Render(name, ref, fullPath, content)
504
				if err != nil {
505
					return "", err
506
				}
507
				return template.HTML(readme), nil
508
509
			default:
510
				readmeContents = template.HTML(fmt.Sprintf(`<pre class="raw">%s</pre>`, content))
511
			}
512
			break
513
		}
514
	}
515
516
	h.readmeCache.Set(cacheKey, readmeContents)
517
	return readmeContents, nil
518
}
519
520
func (h handlers) pageData(repo *git.Repo, p any) PageData[any] {
521
	var name string
522
	var gomod, empty bool
523
	if repo != nil {
524
		gomod = repo.IsGoMod()
525
		empty = repo.IsEmpty()
526
		name = repo.Name()
527
	}
528
529
	return PageData[any]{
530
		P:        p,
531
		RepoName: name,
532
		Meta: Meta{
533
			Title:       h.c.Meta.Title,
534
			Description: h.c.Meta.Description,
535
			Host:        h.c.Meta.Host,
536
			GoMod:       gomod,
537
			SSHEnabled:  h.c.SSH.Enable,
538
			IsEmpty:     empty,
539
		},
540
	}
541
}
542
543
type Breadcrumb struct {
544
	Name   string
545
	Path   string
546
	IsLast bool
547
}
548
549
func Breadcrumbs(path string) []Breadcrumb {
550
	if path == "" {
551
		return nil
552
	}
553
	parts := strings.Split(path, "/")
554
	crumbs := make([]Breadcrumb, len(parts))
555
	for i, part := range parts {
556
		crumbs[i] = Breadcrumb{
557
			Name:   part,
558
			Path:   strings.Join(parts[:i+1], "/"),
559
			IsLast: i == len(parts)-1,
560
		}
561
	}
562
	return crumbs
563
}