Commit f96e6712b1
Verified · cmc
e2e/profileabout_test.go added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 10 | func 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. | |
| 50 | func 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() { | ||
| 30 | 30 | // not a linktree. |
| 31 | 31 | const maxProfileLinks = 5 |
| 32 | 32 | |
| 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. | |
| 37 | const ProfileRepoName = ".gitbay" | |
| 38 | ||
| 39 | // AboutBase is the about file's path in that repository, without its | |
| 40 | // extension. | |
| 41 | const 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. | |
| 45 | var 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. | |
| 52 | func 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 | ||
| 33 | 76 | // profileEdit is the set of profile fields a command may change. A nil |
| 34 | 77 | // field is left alone; an empty value clears it. |
| 35 | 78 | type profileEdit struct { |
| @@ -169,9 +212,12 @@ type ProfileOut struct { | ||
| 169 | 212 | Website string `json:"website,omitempty"` |
| 170 | 213 | // About is long-form markdown, rendered by the web between the |
| 171 | 214 | // 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"` | |
| 175 | 221 | // The rest is what a profile page shows: who they work with, what |
| 176 | 222 | // they own that you can see, and how active they have been. The web |
| 177 | 223 | // read these straight out of the store, which kept them off every |
| @@ -271,8 +317,10 @@ func runProfileShow(c *Ctx, args []string) int { | ||
| 271 | 317 | if err != nil { |
| 272 | 318 | return c.fail(protocol.ExitFailure, "%v", err) |
| 273 | 319 | } |
| 320 | about, aboutFormat, aboutPath := ownerAbout(c, name) | |
| 274 | 321 | 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{}} | |
| 276 | 324 | |
| 277 | 325 | // Who they work with. Both lists are public on a profile — the web |
| 278 | 326 | // 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) { | ||
| 442 | 442 | weeks, activityTotal := activityGrid(counts) |
| 443 | 443 | |
| 444 | 444 | 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} | |
| 447 | 446 | s.render(w, "owner.html", struct { |
| 448 | 447 | basePage |
| 449 | 448 | Owner string |
| @@ -461,7 +460,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 461 | 460 | Snippets int |
| 462 | 461 | Notice string |
| 463 | 462 | 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), | |
| 465 | 464 | d.Repos, d.Members, d.Orgs, |
| 466 | 465 | weeks, activityTotal, teams, canAdmin, |
| 467 | 466 | d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), |
| @@ -1124,18 +1123,17 @@ func mdHTML(raw string) template.HTML { | ||
| 1124 | 1123 | return focusableBlocks(template.HTML(buf.String())) |
| 1125 | 1124 | } |
| 1126 | 1125 | |
| 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. | |
| 1130 | func 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. | |
| 1128 | func aboutHTML(text, format string) template.HTML { | |
| 1129 | if strings.TrimSpace(text) == "" { | |
| 1132 | 1130 | return "" |
| 1133 | 1131 | } |
| 1134 | 1132 | name := "about.md" |
| 1135 | if p.AboutFormat == "org" { | |
| 1133 | if format == "org" { | |
| 1136 | 1134 | name = "about.org" |
| 1137 | 1135 | } |
| 1138 | return renderReadme(name, []byte(p.About)) | |
| 1136 | return renderReadme(name, []byte(text)) | |
| 1139 | 1137 | } |
| 1140 | 1138 | |
| 1141 | 1139 | // webResolver answers autolink lookups for one viewer. Cross-repo |