all repos

mugit @ 3e015509254c8504f2b923989eb731fe1ea6be4d

🐮 git server that your cow will love

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
feat: pagination for log page, 3 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
	"github.com/yuin/goldmark"
19
	emoji "github.com/yuin/goldmark-emoji"
20
	"github.com/yuin/goldmark/extension"
21
	"github.com/yuin/goldmark/renderer/html"
22
	"olexsmir.xyz/mugit/internal/git"
23
	"olexsmir.xyz/mugit/internal/mdx"
24
)
25
26
type Meta struct {
27
	Title       string
28
	Description string
29
	Host        string
30
	IsEmpty     bool
31
	GoMod       bool
32
	SSHEnabled  bool
33
}
34
35
type RepoBase struct {
36
	Ref  string
37
	Desc string
38
}
39
40
type PageData[T any] struct {
41
	Meta     Meta
42
	RepoName string // empty for non-repo pages, needed for _head.html to  compile
43
	P        T
44
}
45
46
func (h *handlers) indexHandler(w http.ResponseWriter, r *http.Request) {
47
	repos, err := h.listPublicRepos()
48
	if err != nil {
49
		h.write500(w, err)
50
		return
51
	}
52
	h.templ(w, "index", h.pageData(nil, repos))
53
}
54
55
type RepoIndex struct {
56
	Desc           string
57
	IsEmpty        bool
58
	Readme         template.HTML
59
	Ref            string
60
	Commits        []*git.Commit
61
	IsMirror       bool
62
	MirrorURL      string
63
	MirrorLastSync time.Time
64
}
65
66
func (h *handlers) repoIndex(w http.ResponseWriter, r *http.Request) {
67
	repo, err := h.openPublicRepo(r.PathValue("name"), "")
68
	if err != nil {
69
		h.write404(w, err)
70
		return
71
	}
72
73
	desc, err := repo.Description()
74
	if err != nil {
75
		h.write500(w, err)
76
		return
77
	}
78
79
	p := RepoIndex{Desc: desc, IsEmpty: repo.IsEmpty()}
80
	if p.IsEmpty {
81
		h.templ(w, "repo_index", h.pageData(repo, p))
82
		return
83
	}
84
85
	p.Ref, err = repo.FindMasterBranch(h.c.Repo.Masters)
86
	if err != nil {
87
		h.write500(w, err)
88
		return
89
	}
90
91
	p.Readme, err = h.renderReadme(repo, p.Ref, "")
92
	if err != nil {
93
		h.write500(w, err)
94
		return
95
	}
96
97
	p.Commits, err = repo.Commits("")
98
	if err != nil {
99
		h.write500(w, err)
100
		return
101
	}
102
103
	if len(p.Commits) >= 3 {
104
		p.Commits = p.Commits[:3:3]
105
	}
106
107
	if isMirror, err := repo.IsMirror(); isMirror && err == nil {
108
		p.IsMirror = true
109
		p.MirrorURL, _ = repo.RemoteURL()
110
		p.MirrorLastSync, _ = repo.LastSync()
111
	}
112
113
	h.templ(w, "repo_index", h.pageData(repo, p))
114
}
115
116
type RepoTree struct {
117
	Desc       string
118
	Ref        string
119
	Tree       []git.NiceTree
120
	ParentPath string
121
	DotDot     string
122
	Readme     template.HTML
123
}
124
125
func (h *handlers) repoTreeHandler(w http.ResponseWriter, r *http.Request) {
126
	name := r.PathValue("name")
127
	ref := h.parseRef(r.PathValue("ref"))
128
	treePath := r.PathValue("rest")
129
130
	repo, err := h.openPublicRepo(name, ref)
131
	if err != nil {
132
		h.write404(w, err)
133
		return
134
	}
135
136
	desc, err := repo.Description()
137
	if err != nil {
138
		h.write500(w, err)
139
		return
140
	}
141
142
	tree, err := repo.FileTree(treePath)
143
	if err != nil {
144
		h.write500(w, err)
145
		return
146
	}
147
148
	readme, err := h.renderReadme(repo, ref, treePath)
149
	if err != nil {
150
		h.write500(w, err)
151
		return
152
	}
153
154
	h.templ(w, "repo_tree", h.pageData(repo, RepoTree{
155
		Desc:       desc,
156
		Ref:        ref,
157
		Tree:       tree,
158
		ParentPath: treePath,
159
		DotDot:     filepath.Dir(treePath),
160
		Readme:     readme,
161
	}))
162
}
163
164
type RepoFile struct {
165
	Ref       string
166
	Desc      string
167
	LineCount []int
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
	var raw bool
182
	if rawParam, err := strconv.ParseBool(r.URL.Query().Get("raw")); err == nil {
183
		raw = rawParam
184
	}
185
186
	repo, err := h.openPublicRepo(name, ref)
187
	if err != nil {
188
		h.write404(w, 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, err)
196
			return
197
		}
198
		h.write500(w, err)
199
		return
200
	}
201
202
	if raw {
203
		w.Header().Set("Content-Type", fc.Mime)
204
		w.WriteHeader(http.StatusOK)
205
		w.Write(fc.Content)
206
		return
207
	}
208
209
	p := RepoFile{
210
		Ref:      ref,
211
		Path:     treePath,
212
		IsImage:  fc.IsImage(),
213
		IsBinary: fc.IsBinary,
214
		Mime:     fc.Mime,
215
		Size:     fc.Size,
216
	}
217
218
	p.Desc, err = repo.Description()
219
	if err != nil {
220
		h.write500(w, err)
221
		return
222
	}
223
224
	if !fc.IsImage() && !fc.IsBinary {
225
		contentStr := fc.String()
226
		lc, err := countLines(strings.NewReader(contentStr))
227
		if err != nil {
228
			slog.Error("failed to count line numbers", "err", err)
229
		}
230
		lines := make([]int, lc)
231
		for i := range lines {
232
			lines[i] = i + 1
233
		}
234
		p.Content = contentStr
235
		p.LineCount = lines
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 edge case, when last page has 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
var markdown = goldmark.New(
463
	goldmark.WithRendererOptions(html.WithUnsafe()),
464
	goldmark.WithExtensions(
465
		extension.GFM,
466
		extension.Linkify,
467
		emoji.Emoji,
468
		mdx.RelativeLink,
469
	))
470
471
func (h *handlers) renderReadme(r *git.Repo, ref, treePath string) (template.HTML, error) {
472
	name := r.Name()
473
	cacheKey := fmt.Sprintf("%s:%s:%s", name, ref, treePath)
474
	if v, found := h.readmeCache.Get(cacheKey); found {
475
		return v, nil
476
	}
477
478
	var readmeContents template.HTML
479
	for _, readme := range h.c.Repo.Readmes {
480
		fullPath := filepath.Join(treePath, readme)
481
		fc, ferr := r.FileContent(fullPath)
482
		if ferr != nil {
483
			continue
484
		}
485
486
		if fc.IsBinary {
487
			continue
488
		}
489
490
		ext := filepath.Ext(readme)
491
		content := fc.String()
492
		if len(content) > 0 {
493
			switch ext {
494
			case ".md", ".markdown", ".mkd":
495
				var buf bytes.Buffer
496
				if cerr := markdown.Convert([]byte(content), &buf,
497
					mdx.NewRelativeLinkCtx(name, fullPath)); cerr != nil {
498
					return "", cerr
499
				}
500
				readmeContents = template.HTML(buf.String())
501
			default:
502
				readmeContents = template.HTML(fmt.Sprintf(`<pre>%s</pre>`, content))
503
			}
504
			break
505
		}
506
	}
507
508
	h.readmeCache.Set(cacheKey, readmeContents)
509
	return readmeContents, nil
510
}
511
512
func (h handlers) pageData(repo *git.Repo, p any) PageData[any] {
513
	var name string
514
	var gomod, empty bool
515
	if repo != nil {
516
		gomod = repo.IsGoMod()
517
		empty = repo.IsEmpty()
518
		name = repo.Name()
519
	}
520
521
	return PageData[any]{
522
		P:        p,
523
		RepoName: name,
524
		Meta: Meta{
525
			Title:       h.c.Meta.Title,
526
			Description: h.c.Meta.Description,
527
			Host:        h.c.Meta.Host,
528
			GoMod:       gomod,
529
			SSHEnabled:  h.c.SSH.Enable,
530
			IsEmpty:     empty,
531
		},
532
	}
533
}