feat(web): order repos by last push, link authors, serve media and render readme video
10 files changed, +54 −28
- static/style.css modified +0 −2
- store.go modified +0 −8
- templates/commit.html modified +1 −1
- templates/compare.html modified +1 −1
- templates/index.html modified +0 −4
- templates/log.html modified +1 −1
- templates/meta.html modified +1 −0
- templates/pull.html modified +1 −1
- templates/repo.html modified +1 −1
- web.go modified +48 −9
static/style.css modified
| @@ -67,8 +67,6 @@ body.fluid main.file{padding-top:.9rem} | ||
| 67 | 67 | .descbar .inner{padding:.3rem 1rem} |
| 68 | 68 | .repohead+main{margin-top:0} |
| 69 | 69 | main{padding-bottom:3rem} |
| 70 | -.welcome{max-width:50rem;margin:0 auto 1.5rem;padding:0 1rem} | |
| 71 | -.welcome p{margin:.3rem 0 0} | |
| 72 | 70 | .cards{display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem;margin-bottom:1.5rem} |
| 73 | 71 | .panel{background:var(--soft);padding:.8rem 1rem;margin-bottom:1.5rem} |
| 74 | 72 | .panel.narrow{max-width:34rem;margin:1rem auto} |
store.go modified
| @@ -605,14 +605,6 @@ func (s *Store) ReposByOwner(owner int64, includePrivate bool) ([]Repo, error) { | ||
| 605 | 605 | return scanRepos(rows) |
| 606 | 606 | } |
| 607 | 607 | |
| 608 | -func (s *Store) RecentRepos(n int) ([]Repo, error) { | |
| 609 | - rows, err := s.db.Query(`select `+repoColumns+` where r.private = 0 order by r.created desc limit ?`, n) | |
| 610 | - if err != nil { | |
| 611 | - return nil, err | |
| 612 | - } | |
| 613 | - return scanRepos(rows) | |
| 614 | -} | |
| 615 | - | |
| 616 | 608 | func (s *Store) UpdateRepo(id int64, description string, visibility int, rawTags string) error { |
| 617 | 609 | tags, err := parseTags(rawTags) |
| 618 | 610 | if err != nil { |
templates/commit.html modified
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | <dl class="meta"> |
| 7 | 7 | <dt>commit</dt><dd><code>{{$c.Hash}}</code> <a href="/{{$p.Repo.Path}}/tree/{{$c.Hash}}/">browse tree</a></dd> |
| 8 | 8 | {{range $c.Parents}}<dt>parent</dt><dd><a href="/{{$p.Repo.Path}}/commit/{{.}}"><code>{{.}}</code></a></dd>{{end}} |
| 9 | -<dt>author</dt><dd>{{$c.Author}} <{{$c.Email}}></dd> | |
| 9 | +<dt>author</dt><dd>{{template "who" $c}} <{{$c.Email}}></dd> | |
| 10 | 10 | <dt>signature</dt><dd>{{with signer $c}}<span class="verified">Verified ~{{.}}</span> signed with a key registered by <a href="/~{{.}}">~{{.}}</a>{{else}}{{if and $c.Signature (ne $c.Signature "N")}}<span class="verified unknown">Unverified</span> signed with an unregistered key{{else}}none{{end}}{{end}}</dd> |
| 11 | 11 | <dt>date</dt><dd>{{date $c.Time}} UTC</dd> |
| 12 | 12 | </dl> |
templates/compare.html modified
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | {{if .Data.Unrelated}}<p class="info">These refs have no common history.</p>{{end}} |
| 12 | 12 | {{if .Data.Commits}} |
| 13 | 13 | <h3>Commits <span class="muted">{{len .Data.Commits}}</span></h3> |
| 14 | -<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></div><div class="muted small">{{.Author}} · {{ago .Time}}</div></div>{{end}}</div> | |
| 14 | +<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a></div><div class="muted small">{{template "who" .}} · {{ago .Time}}</div></div>{{end}}</div> | |
| 15 | 15 | {{else if and .Data.Base .Data.Head (not .Data.Unrelated)}}<p class="info">{{.Data.Head}} has nothing that {{.Data.Base}} does not.</p>{{end}} |
| 16 | 16 | {{if .Data.Files}} |
| 17 | 17 | <h3>Changes <span class="muted">{{len .Data.Files}} files, <span class="add">+{{.Data.Added}}</span> <span class="del">−{{.Data.Removed}}</span></span></h3> |
templates/index.html modified
| @@ -9,10 +9,6 @@ | ||
| 9 | 9 | <section class="panel"><h3>Keys and tokens</h3><p>Register SSH public keys for pushing and create access tokens for HTTPS pushes or scripts.</p><a class="btn" href="/settings/keys">Manage keys ▸</a></section> |
| 10 | 10 | </div> |
| 11 | 11 | {{else}} |
| 12 | -<div class="welcome"> | |
| 13 | -<strong>Welcome to {{.Host}}!</strong> | |
| 14 | -<p>{{.Host}} is a small, self hosted git forge. It serves repositories over SSH and HTTPS and renders trees, logs, diffs and READMEs as plain pages, with Markdown and LaTeX where it helps and no scripts where it doesn't.</p> | |
| 15 | -</div> | |
| 16 | 12 | <div class="cards"> |
| 17 | 13 | <section class="panel"><h3>Host a repository</h3><p>Registration is by invitation. Once you have an account, push any repository and it gets a page with its history, refs and README.</p><a class="btn primary" href="/register">Register ▸</a></section> |
| 18 | 14 | <section class="panel"><h3>Browse the code</h3><p>Every public repository can be cloned anonymously over HTTPS and read in the browser with syntax highlighting.</p><a class="btn" href="/projects">Browse public projects ▸</a></section> |
templates/log.html modified
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{$p := .Data.Page}} |
| 3 | 3 | {{if .Data.Path}}<p class="muted">History of <a href="/{{$p.Repo.Path}}/tree/{{$p.Ref}}/{{.Data.Path}}">{{.Data.Path}}</a> on {{$p.Ref}}</p>{{end}} |
| 4 | 4 | {{if $p.Empty}}<p class="muted">No commits.</p>{{else}} |
| 5 | -<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a>{{template "badge" .}}</div><div class="muted small">{{.Author}} · <span title="{{date .Time}}">{{ago .Time}}</span></div>{{if .Body}}<pre class="body">{{.Body}}</pre>{{end}}</div>{{end}}</div> | |
| 5 | +<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a>{{template "badge" .}}</div><div class="muted small">{{template "who" .}} · <span title="{{date .Time}}">{{ago .Time}}</span></div>{{if .Body}}<pre class="body">{{.Body}}</pre>{{end}}</div>{{end}}</div> | |
| 6 | 6 | {{with .Data.Next}}<p><a class="btn" href="?from={{.}}{{if $.Data.Path}}&path={{$.Data.Path}}{{end}}">Older ▸</a></p>{{end}} |
| 7 | 7 | {{end}} |
| 8 | 8 | {{end}} |
templates/meta.html modified
| @@ -8,3 +8,4 @@ | ||
| 8 | 8 | {{else if .Assignees}}{{range .Assignees}}<a href="/~{{.Name}}">~{{.Name}}</a> {{end}}{{else}}<p class="muted small">Nobody</p>{{end}} |
| 9 | 9 | </div> |
| 10 | 10 | {{end}} |
| 11 | +{{define "who"}}{{with person .Email}}<a href="/~{{.}}">{{$.Author}}</a>{{else}}{{.Author}}{{end}}{{end}} | |
templates/pull.html modified
| @@ -55,7 +55,7 @@ | ||
| 55 | 55 | </div> |
| 56 | 56 | {{if .Data.Commits}} |
| 57 | 57 | <h3>Commits</h3> |
| 58 | -<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a>{{with signer .}}<span class="verified" title="Signed with a key registered by ~{{.}}">Verified ~{{.}}</span>{{end}}</div><div class="muted small">{{.Author}} · {{ago .Time}}</div></div>{{end}}</div> | |
| 58 | +<div class="log">{{range .Data.Commits}}<div class="event"><div><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> <a class="subject" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{.Subject}}</a>{{with signer .}}<span class="verified" title="Signed with a key registered by ~{{.}}">Verified ~{{.}}</span>{{end}}</div><div class="muted small">{{template "who" .}} · {{ago .Time}}</div></div>{{end}}</div> | |
| 59 | 59 | {{end}} |
| 60 | 60 | {{if .Data.Files}} |
| 61 | 61 | <h3 id="diff">Changes <span class="muted">{{len .Data.Files}} files, <span class="add">+{{.Data.Added}}</span> <span class="del">−{{.Data.Removed}}</span>{{if .Data.ReviewCount}}, {{.Data.ReviewCount}} review comments{{end}}</span></h3> |
templates/repo.html modified
| @@ -7,7 +7,7 @@ git push -u origin {{$p.Ref}}</pre> | ||
| 7 | 7 | {{else}} |
| 8 | 8 | <div class="summary"> |
| 9 | 9 | <div class="commits"> |
| 10 | -{{range .Data.Commits}}<div class="event"><div class="row"><span><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{.Author}}{{with signer .}}<span class="verified" title="Signed with a key registered by ~{{.}}">Verified</span>{{end}}</span><a class="when" href="/{{$p.Repo.Path}}/commit/{{.Hash}}" title="{{date .Time}}">{{ago .Time}}</a></div><pre class="subject">{{.Subject}}</pre></div>{{end}} | |
| 10 | +{{range .Data.Commits}}<div class="event"><div class="row"><span><a class="hash" href="/{{$p.Repo.Path}}/commit/{{.Hash}}">{{short .Hash}}</a> — {{template "who" .}}{{with signer .}}<span class="verified" title="Signed with a key registered by ~{{.}}">Verified</span>{{end}}</span><a class="when" href="/{{$p.Repo.Path}}/commit/{{.Hash}}" title="{{date .Time}}">{{ago .Time}}</a></div><pre class="subject">{{.Subject}}</pre></div>{{end}} | |
| 11 | 11 | </div> |
| 12 | 12 | <div class="refs"> |
| 13 | 13 | <h3>refs</h3> |
web.go modified
| @@ -28,6 +28,7 @@ import ( | ||
| 28 | 28 | "github.com/yuin/goldmark" |
| 29 | 29 | highlighting "github.com/yuin/goldmark-highlighting/v2" |
| 30 | 30 | "github.com/yuin/goldmark/extension" |
| 31 | + gmhtml "github.com/yuin/goldmark/renderer/html" | |
| 31 | 32 | ) |
| 32 | 33 | |
| 33 | 34 | //go:embed templates/*.html |
| @@ -81,7 +82,13 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv | ||
| 81 | 82 | "inc": func(i int) int { return i + 1 }, |
| 82 | 83 | "perm": perm, |
| 83 | 84 | "signer": s.signer, |
| 84 | - "mark": mark, | |
| 85 | + "person": func(email string) string { | |
| 86 | + if u, err := s.store.UserByEmail(email); err == nil { | |
| 87 | + return u.Name | |
| 88 | + } | |
| 89 | + return "" | |
| 90 | + }, | |
| 91 | + "mark": mark, | |
| 85 | 92 | "dict": func(kv ...any) map[string]any { |
| 86 | 93 | m := map[string]any{} |
| 87 | 94 | for i := 0; i+1 < len(kv); i += 2 { |
| @@ -106,9 +113,13 @@ func newServer(store *Store, root, host string, open bool, sshUser string) *Serv | ||
| 106 | 113 | s.pages[strings.TrimSuffix(base, ".html")] = template.Must(template.New("layout.html").Funcs(funcs).ParseFS(templateFS, "templates/layout.html", "templates/meta.html", name)) |
| 107 | 114 | } |
| 108 | 115 | s.markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, mathjax.MathJax, |
| 109 | - highlighting.NewHighlighting(highlighting.WithFormatOptions(chromahtml.WithClasses(true))))) | |
| 116 | + highlighting.NewHighlighting(highlighting.WithGuessLanguage(true), highlighting.WithFormatOptions(chromahtml.WithClasses(true)))), | |
| 117 | + goldmark.WithRendererOptions(gmhtml.WithUnsafe())) | |
| 110 | 118 | s.policy = bluemonday.UGCPolicy() |
| 111 | 119 | s.policy.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-z]{1,8}( [a-z]{1,8})*$`)).OnElements("span", "pre", "code") |
| 120 | + s.policy.AllowElements("video", "source") | |
| 121 | + s.policy.AllowAttrs("src", "type").OnElements("video", "source") | |
| 122 | + s.policy.AllowAttrs("controls", "muted", "loop", "playsinline", "poster", "width", "height").OnElements("video") | |
| 112 | 123 | s.writeSigners() |
| 113 | 124 | s.writeKeyring() |
| 114 | 125 | s.installHooks() |
| @@ -215,15 +226,17 @@ func (s *Server) handler() http.Handler { | ||
| 215 | 226 | |
| 216 | 227 | func (s *Server) index(w http.ResponseWriter, r *http.Request) { |
| 217 | 228 | user := s.current(r) |
| 218 | - recent, err := s.store.RecentRepos(20) | |
| 229 | + recent, err := s.store.PublicRepos("", "") | |
| 219 | 230 | if err != nil { |
| 220 | 231 | s.fail(w, r, 500, err.Error()) |
| 221 | 232 | return |
| 222 | 233 | } |
| 234 | + recent = s.byUpdate(recent)[:min(len(recent), 20)] | |
| 223 | 235 | var repos, shared []Repo |
| 224 | 236 | if user != nil { |
| 225 | 237 | repos, _ = s.store.ReposByOwner(user.ID, true) |
| 226 | 238 | shared, _ = s.store.SharedRepos(user.ID) |
| 239 | + repos, shared = s.byUpdate(repos), s.byUpdate(shared) | |
| 227 | 240 | } |
| 228 | 241 | users, public := s.store.Stats() |
| 229 | 242 | s.render(w, r, "index", page{Title: s.host, User: user, Data: map[string]any{"Repos": repos, "Shared": shared, "Recent": recent, "Users": users, "Public": public}}) |
| @@ -446,16 +459,21 @@ func (s *Server) projects(w http.ResponseWriter, r *http.Request) { | ||
| 446 | 459 | s.fail(w, r, 500, err.Error()) |
| 447 | 460 | return |
| 448 | 461 | } |
| 449 | - for i := range repos { | |
| 450 | - repos[i].Updated = repoUpdated(s.repoDir(repos[i])) | |
| 451 | - } | |
| 452 | 462 | if sort != "created" { |
| 453 | - slices.SortStableFunc(repos, func(a, b Repo) int { return b.Updated.Compare(a.Updated) }) | |
| 463 | + repos = s.byUpdate(repos) | |
| 454 | 464 | } |
| 455 | 465 | users, public := s.store.Stats() |
| 456 | 466 | s.render(w, r, "projects", page{Title: "Browse public projects", Data: map[string]any{"Repos": repos, "Query": query, "Tag": tag, "Sort": sort, "Users": users, "Public": public}}) |
| 457 | 467 | } |
| 458 | 468 | |
| 469 | +func (s *Server) byUpdate(repos []Repo) []Repo { | |
| 470 | + for i := range repos { | |
| 471 | + repos[i].Updated = repoUpdated(s.repoDir(repos[i])) | |
| 472 | + } | |
| 473 | + slices.SortStableFunc(repos, func(a, b Repo) int { return b.Updated.Compare(a.Updated) }) | |
| 474 | + return repos | |
| 475 | +} | |
| 476 | + | |
| 459 | 477 | func repoUpdated(dir string) time.Time { |
| 460 | 478 | var latest time.Time |
| 461 | 479 | consider := func(p string) { |
| @@ -727,6 +745,7 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 727 | 745 | s.fail(w, r, 500, err.Error()) |
| 728 | 746 | return |
| 729 | 747 | } |
| 748 | + repos = s.byUpdate(repos) | |
| 730 | 749 | profile := s.store.Profile(owner.ID) |
| 731 | 750 | var bio template.HTML |
| 732 | 751 | if profile.Bio != "" { |
| @@ -1062,6 +1081,14 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st | ||
| 1062 | 1081 | } |
| 1063 | 1082 | } |
| 1064 | 1083 | |
| 1084 | +const maxMedia = 64 << 20 | |
| 1085 | + | |
| 1086 | +var mediaTypes = map[string]string{ | |
| 1087 | + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", | |
| 1088 | + ".mp4": "video/mp4", ".webm": "video/webm", ".ogv": "video/ogg", ".mov": "video/quicktime", | |
| 1089 | + ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".wav": "audio/wav", ".flac": "audio/flac", | |
| 1090 | +} | |
| 1091 | + | |
| 1065 | 1092 | func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec string) { |
| 1066 | 1093 | g := Git{Dir: s.repoDir(repo)} |
| 1067 | 1094 | ref, filePath, ok := s.splitRef(g, spec) |
| @@ -1083,10 +1110,22 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec str | ||
| 1083 | 1110 | s.fail(w, r, 404, "No such path") |
| 1084 | 1111 | return |
| 1085 | 1112 | } |
| 1086 | - w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 1087 | 1113 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 1088 | - w.Header().Set("Content-Length", strconv.FormatInt(g.Size(commit, filePath), 10)) | |
| 1089 | 1114 | w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filepath.Base(filePath)})) |
| 1115 | + size := g.Size(commit, filePath) | |
| 1116 | + if kind, ok := mediaTypes[strings.ToLower(filepath.Ext(filePath))]; ok && size <= maxMedia { | |
| 1117 | + data, err := g.Blob(commit, filePath) | |
| 1118 | + if err != nil { | |
| 1119 | + s.fail(w, r, 500, err.Error()) | |
| 1120 | + return | |
| 1121 | + } | |
| 1122 | + w.Header().Set("Content-Type", kind) | |
| 1123 | + w.Header().Set("Cache-Control", "public, max-age=300") | |
| 1124 | + http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) | |
| 1125 | + return | |
| 1126 | + } | |
| 1127 | + w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 1128 | + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) | |
| 1090 | 1129 | g.Stream(w, commit, filePath) |
| 1091 | 1130 | } |
| 1092 | 1131 | |