feat(profile): show a profile readme
6 files changed, +75 −4
- README.md modified +1 −1
- flow_test.go modified +22 −0
- static/style.css modified +4 −0
- templates/repos.html modified +1 −1
- templates/user.html modified +1 −0
- web.go modified +46 −2
README.md modified
| @@ -7,7 +7,7 @@ Small and lightweight git server for self hosting. Repositories are served over | ||
| 7 | 7 | - Repos sit under `~user/name` and can be public or unlisted or private. Each one takes up to three tags, and the old `/projects` address now redirects to the search page with the matching tag qualifier |
| 8 | 8 | - Tree and blob and log and commit and refs views, plus a case insensitive search across file contents at any ref backed by `git grep`. Chroma does the highlighting and goldmark rendering the Markdown before bluemonday cleans it. Anything in `$x$` or `$$x$$` gets typeset in the browser |
| 9 | 9 | - The last commit shown against every tree entry comes out of one `git log --name-only` walk per directory instead of a separate process per file |
| 10 | -- Profiles have a display name and a Markdown bio along with location and workplace and website, plus an avatar that you crop in the browser and the server then resamples down to a 256px PNG | |
| 10 | +- Profiles have a display name and a Markdown bio along with location and workplace and website, plus an avatar that you crop in the browser and the server then resamples down to a 256px PNG. Push a README.md to a public repository with your own name, `~you/you`, and it shows at the top of your profile | |
| 11 | 11 | - Forks and pull requests from either a branch or a fork. Discussion comments and inline review comments on any line of the diff are Markdown, review threads can be resolved and go to an outdated section when the line leaves the diff, and a request can be closed or reopened or brought up to date from its source at any point |
| 12 | 12 | - Three merge strategies, a merge commit with an editable message, a squash authored by the request author, or a rebase that replays each commit onto the target. All three are built from `merge-tree` and `commit-tree` without a working tree and fail cleanly on conflict |
| 13 | 13 | - Collaborators per repository who can push, read it when private and merge pull requests, while settings and deletion stay with the owner |
flow_test.go modified
| @@ -302,3 +302,25 @@ func TestPins(t *testing.T) { | ||
| 302 | 302 | t.Error("repos page does not list every repo") |
| 303 | 303 | } |
| 304 | 304 | } |
| 305 | + | |
| 306 | +func TestProfileReadme(t *testing.T) { | |
| 307 | + if _, err := exec.LookPath("git"); err != nil { | |
| 308 | + t.Skip("git not installed") | |
| 309 | + } | |
| 310 | + s, h := testServer(t) | |
| 311 | + alice, _ := s.store.CreateUser("alice", "correct horse") | |
| 312 | + repo, _ := s.store.CreateRepo(alice, "alice", "", Public, "") | |
| 313 | + g := Git{Dir: s.repoDir(repo)} | |
| 314 | + if err := initRepo(g.Dir); err != nil { | |
| 315 | + t.Fatal(err) | |
| 316 | + } | |
| 317 | + commitFile(t, g, g.Head(), "", "README.md", "# Hello\n\n<script>alert(1)</script>\n") | |
| 318 | + body := get(h, "/~alice", "").Body.String() | |
| 319 | + if !strings.Contains(body, `class="intro"`) || !strings.Contains(body, "Hello</h1>") || strings.Contains(body, "<script>alert") { | |
| 320 | + t.Error("profile README not rendered safely") | |
| 321 | + } | |
| 322 | + s.store.UpdateRepo(repo.ID, "", Private, "") | |
| 323 | + if strings.Contains(get(h, "/~alice", "").Body.String(), `class="intro"`) { | |
| 324 | + t.Error("private profile README shown") | |
| 325 | + } | |
| 326 | +} | |
static/style.css modified
| @@ -436,3 +436,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere} | ||
| 436 | 436 | form.inline{display:inline-flex;align-items:center;gap:.4rem;margin:0 0 .6rem} |
| 437 | 437 | table.keys form.inline{margin:0} |
| 438 | 438 | input.rank{width:4rem} |
| 439 | +.intro{border:1px solid var(--line);padding:.6rem 1.2rem .8rem;margin-bottom:1.2rem} | |
| 440 | +.introhead{font-family:var(--mono,monospace);font-size:.8rem;color:var(--muted);margin:0 0 .3rem} | |
| 441 | +.introhead a{color:var(--muted)} | |
| 442 | +.intro .readme>article>:last-child{margin-bottom:0} | |
templates/repos.html modified
| @@ -4,5 +4,5 @@ | ||
| 4 | 4 | <h2>~{{$d.Owner.Name}}</h2> |
| 5 | 5 | <nav class="tabs small"><a href="/~{{$d.Owner.Name}}">about</a><a class="active" href="/~{{$d.Owner.Name}}/repos">repos{{if $d.Repos}} <span class="count">{{len $d.Repos}}</span>{{end}}</a>{{if not $d.Org}}<a href="/~{{$d.Owner.Name}}/stars">stars{{if $d.Count}} <span class="count">{{$d.Count}}</span>{{end}}</a>{{end}}<a href="/~{{$d.Owner.Name}}/feed">feed</a></nav> |
| 6 | 6 | </div> |
| 7 | -{{if $d.Repos}}<div class="events">{{range $d.Repos}}{{$pin := index $d.Pins .ID}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}{{if $pin}} <span class="tag">pinned</span>{{end}}{{if .Stars}}<span class="starcount" title="{{.Stars}} stars">☆ {{.Stars}}</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">No repos yet.</p>{{end}} | |
| 7 | +{{if $d.Repos}}<div class="events">{{range $d.Repos}}<div class="event"><h4><a href="/{{.Path}}">{{.Name}}</a>{{if .Private}} <span class="tag">private</span>{{else if .Unlisted}} <span class="tag">unlisted</span>{{end}}{{if .Stars}}<span class="starcount" title="{{.Stars}} stars">☆ {{.Stars}}</span>{{end}}</h4>{{if .Description}}<p>{{.Description}}</p>{{end}}{{if .Tags}}<p class="tags">{{range .Tags}}<a href="/search?q=tag:{{.}}">#{{.}}</a> {{end}}</p>{{end}}</div>{{end}}</div>{{else}}<p class="info">No repos yet.</p>{{end}} | |
| 8 | 8 | {{end}} |
templates/user.html modified
| @@ -20,6 +20,7 @@ | ||
| 20 | 20 | {{if $own}}<div class="ownactions"><a class="btn primary block" href="/create">New repository</a><a class="btn block" href="/settings">Edit profile</a></div>{{end}} |
| 21 | 21 | </aside> |
| 22 | 22 | <div class="main"> |
| 23 | +{{with .Data.Intro.HTML}}<section class="intro"><p class="introhead"><a href="/{{$.Data.Intro.Path}}">{{$.Data.Intro.Path}}</a> / <a href="/{{$.Data.Intro.Path}}/tree/{{$.Data.Intro.Ref}}/{{$.Data.Intro.Name}}">{{$.Data.Intro.Name}}</a></p><div class="readme">{{.}}</div></section>{{end}} | |
| 23 | 24 | {{with .Data.Activity}}<section class="activity"><div class="heathead"><p class="muted small">{{.Total}} commit{{if ne .Total 1}}s{{end}} {{if .Year}}in {{.Year}}{{else}}in the past 12 months{{end}}</p><nav class="years"><a href="?"{{if not .Year}} class="active"{{end}}>past 12 months</a>{{range .Years}}<a href="?year={{.}}"{{if eq . $.Data.Activity.Year}} class="active"{{end}}>{{.}}</a>{{end}}</nav></div>{{.Grid}}<p class="legend muted small">less <i class="h0"></i><i class="h1"></i><i class="h2"></i><i class="h3"></i><i class="h4"></i> more</p></section>{{end}} |
| 24 | 25 | {{if .Data.Repos}}<div class="events">{{range .Data.Repos}}{{template "repoitem" .}}{{end}}</div>{{end}} |
| 25 | 26 | </div> |
web.go modified
| @@ -22,6 +22,7 @@ import ( | ||
| 22 | 22 | "slices" |
| 23 | 23 | "strconv" |
| 24 | 24 | "strings" |
| 25 | + "sync" | |
| 25 | 26 | "time" |
| 26 | 27 | |
| 27 | 28 | chromahtml "github.com/alecthomas/chroma/v2/formatters/html" |
| @@ -836,12 +837,14 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) { | ||
| 836 | 837 | orgs = append(orgs, userHit{User: o, Profile: s.store.Profile(o.ID)}) |
| 837 | 838 | } |
| 838 | 839 | data["Orgs"] = orgs |
| 840 | + intro := s.profileReadme(owner) | |
| 841 | + data["Intro"] = intro | |
| 839 | 842 | if name := strings.ToLower(strings.TrimLeft(strings.TrimSpace(profile.Workplace), "@~")); validName(name) && name != owner.Name { |
| 840 | 843 | if place, err := s.store.UserByName(name); err == nil { |
| 841 | 844 | data["Workplace"] = place.Name |
| 842 | 845 | } |
| 843 | 846 | } |
| 844 | - s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio), Data: data}) | |
| 847 | + s.render(w, r, "user", page{Title: "~" + owner.Name, User: user, Math: hasMath(bio) || hasMath(intro.HTML), Data: data}) | |
| 845 | 848 | return |
| 846 | 849 | } |
| 847 | 850 | name := parts[1] |
| @@ -1600,6 +1603,47 @@ func (s *Server) repos(w http.ResponseWriter, r *http.Request, owner User, user | ||
| 1600 | 1603 | return |
| 1601 | 1604 | } |
| 1602 | 1605 | starred, _ := s.store.StarredRepos(owner.ID) |
| 1603 | - data := map[string]any{"Owner": owner, "Repos": s.byUpdate(repos), "Pins": s.store.Pins(owner.ID), "Count": len(starred), "Org": s.store.IsOrg(owner.ID)} | |
| 1606 | + data := map[string]any{"Owner": owner, "Repos": s.byUpdate(repos), "Count": len(starred), "Org": s.store.IsOrg(owner.ID)} | |
| 1604 | 1607 | s.render(w, r, "repos", page{Title: "~" + owner.Name + " repos", User: user, Data: data}) |
| 1605 | 1608 | } |
| 1609 | + | |
| 1610 | +type intro struct { | |
| 1611 | + Path, Name, Ref string | |
| 1612 | + HTML template.HTML | |
| 1613 | +} | |
| 1614 | + | |
| 1615 | +type introEntry struct { | |
| 1616 | + stamp time.Time | |
| 1617 | + intro intro | |
| 1618 | +} | |
| 1619 | + | |
| 1620 | +var introCache = struct { | |
| 1621 | + sync.Mutex | |
| 1622 | + m map[int64]introEntry | |
| 1623 | +}{m: map[int64]introEntry{}} | |
| 1624 | + | |
| 1625 | +func (s *Server) profileReadme(owner User) intro { | |
| 1626 | + repo, err := s.store.RepoByPath(owner.Name, owner.Name) | |
| 1627 | + if err != nil || repo.Private || repo.Unlisted { | |
| 1628 | + return intro{} | |
| 1629 | + } | |
| 1630 | + g := Git{Dir: s.repoDir(repo)} | |
| 1631 | + stamp := repoUpdated(g.Dir) | |
| 1632 | + introCache.Lock() | |
| 1633 | + e, ok := introCache.m[repo.ID] | |
| 1634 | + introCache.Unlock() | |
| 1635 | + if ok && e.stamp.Equal(stamp) { | |
| 1636 | + return e.intro | |
| 1637 | + } | |
| 1638 | + var in intro | |
| 1639 | + ref := g.Head() | |
| 1640 | + if commit, err := g.Resolve(ref); err == nil { | |
| 1641 | + if name, content := g.Readme(commit); strings.HasSuffix(strings.ToLower(name), ".md") && len(content) <= 256<<10 { | |
| 1642 | + in = intro{Path: repo.Path(), Name: name, Ref: ref, HTML: s.renderMarkdownAt(content, "/"+repo.Path()+"/raw/"+ref+"/", "/"+repo.Path()+"/tree/"+ref+"/")} | |
| 1643 | + } | |
| 1644 | + } | |
| 1645 | + introCache.Lock() | |
| 1646 | + introCache.m[repo.ID] = introEntry{stamp, in} | |
| 1647 | + introCache.Unlock() | |
| 1648 | + return in | |
| 1649 | +} | |