Commit f96e6712b1

f96e6712b1c2ee11705aed6c06b7d28a7b2dfd33

parent: 5f3b0a7e1a

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 00:10 UTC

profile: read the about text from <owner>/.gitbay

profile/README.{md,org,markdown} on the default branch, with the
parent repository's access. about_path says which file it came from.

Ref #236
e2e/profileabout_test.go added +89
@@ -0,0 +1,89 @@
1package e2e
2
3import (
4 "strings"
5 "testing"
6)
7
8// The about text is a file in <owner>/.gitbay, read on every surface with
9// the reader's own access.
10func TestProfileAboutFromRepo(t *testing.T) {
11 inst := startInstance(t)
12 aliceKey := inst.newKey(t, "alice")
13 inst.admin(t, "admin", "user", "create", "alice",
14 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
15 bobKey := inst.newKey(t, "bob")
16 inst.admin(t, "admin", "user", "create", "bob",
17 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
18
19 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay"); code != 0 {
20 t.Fatal("creating alice/.gitbay failed")
21 }
22 if _, _, code := inst.ssh(t, aliceKey, "# alice\n\nhello from a file\n",
23 "repo", "commit-file", "alice/.gitbay", "profile/README.md",
24 "--ref", "main", "--file", "-"); code != 0 {
25 t.Fatal("committing the about failed")
26 }
27
28 out, _, code := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
29 if code != 0 {
30 t.Fatalf("profile show: %d", code)
31 }
32 if !strings.Contains(out, "hello from a file") {
33 t.Errorf("about not read from the repository: %s", out)
34 }
35 if !strings.Contains(out, `"about_format":"md"`) {
36 t.Errorf("about_format not md: %s", out)
37 }
38 if !strings.Contains(out, `"about_path":"profile/README.md"`) {
39 t.Errorf("about_path missing: %s", out)
40 }
41
42 _, body := inst.get(t, "/alice")
43 if !strings.Contains(body, "hello from a file") {
44 t.Error("web profile does not render the about")
45 }
46}
47
48// The extension picks the format, .md wins the resolution order, and a
49// private .gitbay keeps the about to the people who can read it.
50func TestProfileAboutFormatAndPrivacy(t *testing.T) {
51 inst := startInstance(t)
52 aliceKey := inst.newKey(t, "alice")
53 inst.admin(t, "admin", "user", "create", "alice",
54 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
55 bobKey := inst.newKey(t, "bob")
56 inst.admin(t, "admin", "user", "create", "bob",
57 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
58
59 inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay", "--private")
60 if _, _, code := inst.ssh(t, aliceKey, "* heading\n\norg text here\n",
61 "repo", "commit-file", "alice/.gitbay", "profile/README.org",
62 "--ref", "main", "--file", "-"); code != 0 {
63 t.Fatal("committing the org about failed")
64 }
65
66 out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
67 if !strings.Contains(out, "org text here") || !strings.Contains(out, `"about_format":"org"`) {
68 t.Errorf("owner cannot read their own private about: %s", out)
69 }
70
71 out, _, code := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
72 if code != 0 {
73 t.Fatalf("profile show for an outsider should succeed: %d", code)
74 }
75 if strings.Contains(out, "org text here") {
76 t.Errorf("private about leaked to an outsider: %s", out)
77 }
78
79 // A .md beside the .org wins: it is first in the resolution order.
80 if _, _, code := inst.ssh(t, aliceKey, "markdown wins\n",
81 "repo", "commit-file", "alice/.gitbay", "profile/README.md",
82 "--ref", "main", "--file", "-"); code != 0 {
83 t.Fatal("committing the md about failed")
84 }
85 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
86 if !strings.Contains(out, "markdown wins") {
87 t.Errorf(".md did not win resolution: %s", out)
88 }
89}
internal/control/profile.go +52 −4
@@ -30,6 +30,49 @@ func init() {
3030// not a linktree.
3131const maxProfileLinks = 5
3232
33// ProfileRepoName is the repository that holds an owner's profile
34// content. A dot-repo because it is infrastructure rather than a
35// project: later per-owner configuration goes beside the about text,
36// and the leading dot keeps it out of the listings.
37const ProfileRepoName = ".gitbay"
38
39// AboutBase is the about file's path in that repository, without its
40// extension.
41const AboutBase = "profile/README"
42
43// aboutExts are the formats the about is read from, in resolution order
44// — the wiki's order, for the same reason.
45var aboutExts = []string{".md", ".org", ".markdown"}
46
47// ownerAbout reads an owner's about text from <owner>/.gitbay. Anything
48// missing — the repository, the branch, the file — is an empty about,
49// and so is a repository this caller cannot read: a profile must not
50// confirm a private namespace. path is the file it came from, so a
51// client can link to it instead of guessing the extension.
52func ownerAbout(c *Ctx, owner string) (text, format, path string) {
53 repo, err := c.Store.RepoByPath(owner + "/" + ProfileRepoName)
54 if err != nil {
55 return "", "", ""
56 }
57 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
58 if err != nil || !policy.CanRead(c.User, repo, grant) {
59 return "", "", ""
60 }
61 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
62 for _, ext := range aboutExts {
63 raw, err := gitutil.ReadBlob(dir, repo.DefaultBranch, AboutBase+ext, maxCommitFileBytes)
64 if err != nil || len(raw) == 0 {
65 continue
66 }
67 f := "md"
68 if ext == ".org" {
69 f = "org"
70 }
71 return string(raw), f, AboutBase + ext
72 }
73 return "", "", ""
74}
75
3376// profileEdit is the set of profile fields a command may change. A nil
3477// field is left alone; an empty value clears it.
3578type profileEdit struct {
@@ -169,9 +212,12 @@ type ProfileOut struct {
169212 Website string `json:"website,omitempty"`
170213 // About is long-form markdown, rendered by the web between the
171214 // header and the activity graph.
172 About string `json:"about,omitempty"`
173 AboutFormat string `json:"about_format,omitempty"`
174 Links []store.ProfileLink `json:"links,omitempty"`
215 About string `json:"about,omitempty"`
216 AboutFormat string `json:"about_format,omitempty"`
217 // AboutPath is where the about was read from in <owner>/.gitbay, so a
218 // client can link to the file rather than guess its extension.
219 AboutPath string `json:"about_path,omitempty"`
220 Links []store.ProfileLink `json:"links,omitempty"`
175221 // The rest is what a profile page shows: who they work with, what
176222 // they own that you can see, and how active they have been. The web
177223 // read these straight out of the store, which kept them off every
@@ -271,8 +317,10 @@ func runProfileShow(c *Ctx, args []string) int {
271317 if err != nil {
272318 return c.fail(protocol.ExitFailure, "%v", err)
273319 }
320 about, aboutFormat, aboutPath := ownerAbout(c, name)
274321 d := ProfileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website,
275 About: p.About, AboutFormat: p.AboutFormat, Links: p.Links, Repos: []ProfileRepo{}}
322 About: about, AboutFormat: aboutFormat, AboutPath: aboutPath,
323 Links: p.Links, Repos: []ProfileRepo{}}
276324
277325 // Who they work with. Both lists are public on a profile — the web
278326 // has always shown them — and neither exposes anything a member
internal/httpd/web.go +8 −10
@@ -442,8 +442,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
442442 weeks, activityTotal := activityGrid(counts)
443443
444444 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
445 profile := store.Profile{Description: d.Description, Website: d.Website,
446 About: d.About, AboutFormat: d.AboutFormat, Links: d.Links}
445 profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links}
447446 s.render(w, "owner.html", struct {
448447 basePage
449448 Owner string
@@ -461,7 +460,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
461460 Snippets int
462461 Notice string
463462 Feed string
464 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
463 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(d.About, d.AboutFormat),
465464 d.Repos, d.Members, d.Orgs,
466465 weeks, activityTotal, teams, canAdmin,
467466 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
@@ -1124,18 +1123,17 @@ func mdHTML(raw string) template.HTML {
11241123 return focusableBlocks(template.HTML(buf.String()))
11251124}
11261125
1127// aboutHTML renders a profile's about text. It has no filename to
1128// dispatch on, so the stored format picks the extension; anything other
1129// than org is markdown.
1130func aboutHTML(p store.Profile) template.HTML {
1131 if strings.TrimSpace(p.About) == "" {
1126// aboutHTML renders a profile's about text. The format comes from the
1127// file it was read from: org is org, anything else markdown.
1128func aboutHTML(text, format string) template.HTML {
1129 if strings.TrimSpace(text) == "" {
11321130 return ""
11331131 }
11341132 name := "about.md"
1135 if p.AboutFormat == "org" {
1133 if format == "org" {
11361134 name = "about.org"
11371135 }
1138 return renderReadme(name, []byte(p.About))
1136 return renderReadme(name, []byte(text))
11391137}
11401138
11411139// webResolver answers autolink lookups for one viewer. Cross-repo