~gitpocket/gitpocket

1

feat(profile): show a profile readme

commit
7d9c64e97dcd0a31a25f8c2e91ca1c411ca8b250 browse tree
parent
01564b362d54401436f8eba3b7a051e787e450e2
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Verified ~james signed with a key registered by ~james
date
2026-10-28 20:44 UTC

6 files changed, +75 −4

README.md modified

@@ -7,7 +7,7 @@ Small and lightweight git server for self hosting. Repositories are served over
77 - 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
88 - 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
99 - 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
1111 - 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
1212 - 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
1313 - 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) {
302302 t.Error("repos page does not list every repo")
303303 }
304304 }
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}
436436 form.inline{display:inline-flex;align-items:center;gap:.4rem;margin:0 0 .6rem}
437437 table.keys form.inline{margin:0}
438438 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 @@
44 <h2>~{{$d.Owner.Name}}</h2>
55 <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>
66 </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}}
88 {{end}}

templates/user.html modified

@@ -20,6 +20,7 @@
2020 {{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}}
2121 </aside>
2222 <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}}
2324 {{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}}
2425 {{if .Data.Repos}}<div class="events">{{range .Data.Repos}}{{template "repoitem" .}}{{end}}</div>{{end}}
2526 </div>

web.go modified

@@ -22,6 +22,7 @@ import (
2222 "slices"
2323 "strconv"
2424 "strings"
25+ "sync"
2526 "time"
2627
2728 chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
@@ -836,12 +837,14 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
836837 orgs = append(orgs, userHit{User: o, Profile: s.store.Profile(o.ID)})
837838 }
838839 data["Orgs"] = orgs
840+ intro := s.profileReadme(owner)
841+ data["Intro"] = intro
839842 if name := strings.ToLower(strings.TrimLeft(strings.TrimSpace(profile.Workplace), "@~")); validName(name) && name != owner.Name {
840843 if place, err := s.store.UserByName(name); err == nil {
841844 data["Workplace"] = place.Name
842845 }
843846 }
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})
845848 return
846849 }
847850 name := parts[1]
@@ -1600,6 +1603,47 @@ func (s *Server) repos(w http.ResponseWriter, r *http.Request, owner User, user
16001603 return
16011604 }
16021605 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)}
16041607 s.render(w, r, "repos", page{Title: "~" + owner.Name + " repos", User: user, Data: data})
16051608 }
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+}