all repos

mugit @ aaf0e5f796a25976779c0da320ef9087bfc51ab0

🐮 git server that your cow will love

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
mirror: distinguish between sync and check time, 2 months 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
	Breadcrumbs []Breadcrumb
173
	Path        string
174
	IsImage     bool
175
	IsBinary    bool
176
	Mime        string
177
	Size        int64
178
}
179
180
func (h *handlers) fileContentsHandler(w http.ResponseWriter, r *http.Request) {
181
	name := r.PathValue("name")
182
	ref := h.parseRef(r.PathValue("ref"))
183
	treePath := r.PathValue("rest")
184
185
	repo, err := h.openPublicRepo(name, ref)
186
	if err != nil {
187
		h.write404(w, r.URL.Path, 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, r.URL.Path, err)
195
			return
196
		}
197
		h.write500(w, err)
198
		return
199
	}
200
201
	p := RepoFile{
202
		Ref:      ref,
203
		Path:     treePath,
204
		IsImage:  fc.IsImage,
205
		IsBinary: fc.IsBinary,
206
		Mime:     fc.Mime,
207
		Size:     fc.Size,
208
	}
209
210
	p.Desc, err = repo.Description()
211
	if err != nil {
212
		h.write500(w, err)
213
		return
214
	}
215
216
	p.Breadcrumbs = Breadcrumbs(treePath)
217
	if !fc.IsImage && !fc.IsBinary {
218
		p.Lines = strings.Split(strings.TrimRight(fc.String(), "\n"), "\n")
219
	}
220
221
	h.templ(w, "repo_file", h.pageData(repo, p))
222
}
223
224
func (h *handlers) rawFileContentsHandler(w http.ResponseWriter, r *http.Request) {
225
	name := r.PathValue("name")
226
	ref := h.parseRef(r.PathValue("ref"))
227
	treePath := r.PathValue("rest")
228
229
	repo, err := h.openPublicRepo(name, ref)
230
	if err != nil {
231
		w.WriteHeader(http.StatusNotFound)
232
		slog.Info("404", "err", err)
233
		return
234
	}
235
236
	fc, err := repo.FileContent(treePath)
237
	if err != nil {
238
		if errors.Is(err, git.ErrFileNotFound) {
239
			w.WriteHeader(http.StatusNotFound)
240
			slog.Info("404", "err", err)
241
			return
242
		}
243
244
		w.WriteHeader(http.StatusInternalServerError)
245
		slog.Info("500", "err", err)
246
		return
247
	}
248
249
	w.Header().Set("Content-Type", fc.Mime)
250
	w.WriteHeader(http.StatusOK)
251
	w.Write(fc.Content)
252
}
253
254
type RepoLog struct {
255
	Desc      string
256
	Commits   []*git.Commit
257
	Ref       string
258
	NextAfter string
259
}
260
261
func (h *handlers) logHandler(w http.ResponseWriter, r *http.Request) {
262
	name := r.PathValue("name")
263
	ref := h.parseRef(r.PathValue("ref"))
264
	after := r.URL.Query().Get("after")
265
266
	repo, err := h.openPublicRepo(name, ref)
267
	if err != nil {
268
		h.write404(w, r.URL.Path, err)
269
		return
270
	}
271
272
	desc, err := repo.Description()
273
	if err != nil {
274
		h.write500(w, err)
275
		return
276
	}
277
278
	commits, err := repo.Commits(after)
279
	if err != nil {
280
		h.write500(w, err)
281
		return
282
	}
283
284
	// if we got full page of commits, we probably have more.
285
	// NOTE: this has an edge case, when last page is len(git.CommitsPage), "load more" would be shown
286
	nextAfter := ""
287
	if len(commits) == git.CommitsPage && len(commits) > 0 {
288
		nextAfter = commits[len(commits)-1].HashShort
289
	}
290
291
	h.templ(w, "repo_log", h.pageData(repo, RepoLog{
292
		Desc:      desc,
293
		Ref:       ref,
294
		Commits:   commits,
295
		NextAfter: nextAfter,
296
	}))
297
}
298
299
type RepoCommit struct {
300
	Diff *git.NiceDiff
301
	Ref  string
302
	Desc string
303
}
304
305
func (h *handlers) commitHandler(w http.ResponseWriter, r *http.Request) {
306
	name := r.PathValue("name")
307
	ref := h.parseRef(r.PathValue("ref"))
308
309
	repo, err := h.openPublicRepo(name, ref)
310
	if err != nil {
311
		h.write404(w, r.URL.Path, err)
312
		return
313
	}
314
315
	diff, err := h.getDiff(repo, ref)
316
	if err != nil {
317
		h.write500(w, err)
318
		return
319
	}
320
321
	desc, err := repo.Description()
322
	if err != nil {
323
		h.write500(w, err)
324
		return
325
	}
326
327
	h.templ(w, "repo_commit", h.pageData(repo, RepoCommit{
328
		Desc: desc,
329
		Ref:  ref,
330
		Diff: diff,
331
	}))
332
}
333
334
type RepoRefs struct {
335
	Desc     string
336
	Ref      string
337
	Branches []*git.Branch
338
	Tags     []*git.TagReference
339
}
340
341
func (h *handlers) refsHandler(w http.ResponseWriter, r *http.Request) {
342
	repo, err := h.openPublicRepo(r.PathValue("name"), "")
343
	if err != nil {
344
		h.write404(w, r.URL.Path, err)
345
		return
346
	}
347
348
	desc, err := repo.Description()
349
	if err != nil {
350
		h.write500(w, err)
351
		return
352
	}
353
354
	master, err := repo.DefaultBranch()
355
	if err != nil {
356
		h.write500(w, err)
357
		return
358
	}
359
360
	branches, err := repo.Branches()
361
	if err != nil {
362
		h.write500(w, err)
363
		return
364
	}
365
366
	// repo should have at least one branch, tags are *optional*
367
	tags, _ := repo.Tags()
368
369
	h.templ(w, "repo_refs", h.pageData(repo, RepoRefs{
370
		Desc:     desc,
371
		Ref:      master,
372
		Tags:     tags,
373
		Branches: branches,
374
	}))
375
}
376
377
type repoList struct {
378
	Name       string
379
	Desc       string
380
	LastCommit time.Time
381
}
382
383
func (h *handlers) listPublicRepos() ([]repoList, error) {
384
	if v, found := h.repoListCache.Get("repo_list"); found {
385
		return v, nil
386
	}
387
388
	dirs, err := os.ReadDir(h.c.Repo.Dir)
389
	if err != nil {
390
		return nil, err
391
	}
392
393
	var repos []repoList
394
	var errs []error
395
	for _, dir := range dirs {
396
		if !dir.IsDir() {
397
			continue
398
		}
399
400
		name := dir.Name()
401
		repo, err := h.openPublicRepo(name, "")
402
		if err != nil {
403
			// if it's not git repo, just ignore it
404
			continue
405
		}
406
407
		desc, err := repo.Description()
408
		if err != nil {
409
			errs = append(errs, err)
410
			continue
411
		}
412
413
		lastCommit, err := repo.LastCommit()
414
		if err != nil {
415
			errs = append(errs, err)
416
			continue
417
		}
418
419
		repos = append(repos, repoList{
420
			Name:       repo.Name(),
421
			Desc:       desc,
422
			LastCommit: lastCommit.Committed,
423
		})
424
	}
425
426
	sort.Slice(repos, func(i, j int) bool {
427
		return repos[j].LastCommit.Before(repos[i].LastCommit)
428
	})
429
430
	h.repoListCache.Set("repo_list", repos)
431
	return repos, errors.Join(errs...)
432
}
433
434
func (h handlers) getDiff(r *git.Repo, ref string) (*git.NiceDiff, error) {
435
	cacheKey := fmt.Sprintf("%s:%s", r.Name(), ref)
436
	if v, found := h.diffCache.Get(cacheKey); found {
437
		return v, nil
438
	}
439
440
	diff, err := r.Diff()
441
	if err != nil {
442
		return nil, err
443
	}
444
445
	h.diffCache.Set(cacheKey, diff)
446
	return diff, nil
447
}
448
449
func (h *handlers) renderReadme(r *git.Repo, ref, treePath string) (template.HTML, error) {
450
	name := r.Name()
451
	cacheKey := fmt.Sprintf("%s:%s:%s", name, ref, treePath)
452
	if v, found := h.readmeCache.Get(cacheKey); found {
453
		return v, nil
454
	}
455
456
	var readmeContents template.HTML
457
	for _, readme := range h.c.Repo.Readmes {
458
		fullPath := filepath.Join(treePath, readme)
459
		fc, ferr := r.FileContent(fullPath)
460
		if ferr != nil {
461
			continue
462
		}
463
464
		if fc.IsBinary && fc.IsImage {
465
			continue
466
		}
467
468
		ext := filepath.Ext(readme)
469
		content := fc.String()
470
		if len(content) > 0 {
471
			switch ext {
472
			case ".md", ".markdown", ".mkd":
473
				readme, err := markdown.Render(name, ref, fullPath, content)
474
				if err != nil {
475
					return "", err
476
				}
477
				return template.HTML(readme), nil
478
479
			default:
480
				readmeContents = template.HTML(fmt.Sprintf(
481
					`<pre class="raw">%s</pre>`, html.EscapeString(content)))
482
			}
483
			break
484
		}
485
	}
486
487
	h.readmeCache.Set(cacheKey, readmeContents)
488
	return readmeContents, nil
489
}
490
491
func (h handlers) pageData(repo *git.Repo, p any) PageData[any] {
492
	var name string
493
	var gomod, empty bool
494
	if repo != nil {
495
		gomod = repo.IsGoMod()
496
		empty = repo.IsEmpty()
497
		name = repo.Name()
498
	}
499
500
	return PageData[any]{
501
		P:        p,
502
		RepoName: name,
503
		Meta: Meta{
504
			Title:       h.c.Meta.Title,
505
			Description: h.c.Meta.Description,
506
			Host:        h.c.Meta.Host,
507
			GoMod:       gomod,
508
			SSHEnabled:  h.c.SSH.Enable,
509
			IsEmpty:     empty,
510
		},
511
	}
512
}
513
514
type Breadcrumb struct {
515
	Name   string
516
	Path   string
517
	IsLast bool
518
}
519
520
func Breadcrumbs(path string) []Breadcrumb {
521
	if path == "" {
522
		return nil
523
	}
524
	parts := strings.Split(path, "/")
525
	crumbs := make([]Breadcrumb, len(parts))
526
	for i, part := range parts {
527
		crumbs[i] = Breadcrumb{
528
			Name:   part,
529
			Path:   strings.Join(parts[:i+1], "/"),
530
			IsLast: i == len(parts)-1,
531
		}
532
	}
533
	return crumbs
534
}