all repos

mugit @ b8831a71dc78c52a36344a697d792cf7a922ca81

🐮 git server that your cow will love

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

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