Commit ca412c06a9

ca412c06a963d82272602f3b0654beff0c6186d5

parent: f96e6712b1

Verified · cmc

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

profile: the about text is written as a file, not a flag

profile set and org profile lose --about, --about-format and --file.
The settings page points at the file and offers to create the
repository that holds it; the repository's own editor does the editing.

Ref #236
e2e/profileabout_test.go +38
@@ -43,6 +43,19 @@ func TestProfileAboutFromRepo(t *testing.T) {
4343 if !strings.Contains(body, "hello from a file") {
4444 t.Error("web profile does not render the about")
4545 }
46
47 // The extension drives the renderer: an .org about renders as org.
48 if _, _, code := inst.ssh(t, bobKey, "", "repo", "create", "bob/.gitbay"); code != 0 {
49 t.Fatal("creating bob/.gitbay failed")
50 }
51 if _, errOut, code := inst.ssh(t, bobKey, "a /note/ in org\n",
52 "repo", "commit-file", "bob/.gitbay", "profile/README.org",
53 "--ref", "main", "--file", "-"); code != 0 {
54 t.Fatalf("committing bob's org about: %s", errOut)
55 }
56 if _, page := inst.get(t, "/bob"); !strings.Contains(page, "<em>note</em>") {
57 t.Errorf("about did not render as org:\n%s", page)
58 }
4659}
4760
4861// The extension picks the format, .md wins the resolution order, and a
@@ -87,3 +100,28 @@ func TestProfileAboutFormatAndPrivacy(t *testing.T) {
87100 t.Errorf(".md did not win resolution: %s", out)
88101 }
89102}
103
104// The about is not settable through profile set any more: it is a file.
105func TestProfileSetHasNoAbout(t *testing.T) {
106 inst := startInstance(t)
107 aliceKey := inst.newKey(t, "alice")
108 inst.admin(t, "admin", "user", "create", "alice",
109 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
110
111 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about", "'inline text'"); code == 0 {
112 t.Error("profile set --about still accepted")
113 }
114 if _, _, code := inst.ssh(t, aliceKey, "x", "profile", "set", "--file", "-"); code == 0 {
115 t.Error("profile set --file still accepted")
116 }
117
118 // The flags that stay still work.
119 if _, errOut, code := inst.ssh(t, aliceKey, "",
120 "profile", "set", "--description", "'a line'", "--link", "'site|https://example.org'"); code != 0 {
121 t.Fatalf("profile set --description --link: %s", errOut)
122 }
123 out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
124 if !strings.Contains(out, "a line") || !strings.Contains(out, "https://example.org") {
125 t.Errorf("description or link not saved: %s", out)
126 }
127}
e2e/profileweb_test.go +48 −22
@@ -7,8 +7,9 @@ import (
77)
88
99// TestProfileSettingsWeb covers profile set from the account settings page
10// (#161): description, website, links and about round-trip through the
11// form, and emptying a field actually clears it rather than being skipped.
10// (#161): description, website and links round-trip through the form, and
11// emptying a field actually clears it rather than being skipped. The about
12// text is not on this form — it is a file, covered below.
1213func TestProfileSettingsWeb(t *testing.T) {
1314 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
1415 aliceKey := inst.newKey(t, "alice")
@@ -21,7 +22,7 @@ func TestProfileSettingsWeb(t *testing.T) {
2122 _, body := browserGet(t, alice, settingsURL)
2223 for _, want := range []string{
2324 `<label for="p-description">`, `<label for="p-website">`,
24 `<label for="p-links">`, `<label for="p-about">`, `<label for="format">`,
25 `<label for="p-links">`,
2526 } {
2627 if !strings.Contains(body, want) {
2728 t.Fatalf("profile form missing %q:\n%s", want, body)
@@ -34,8 +35,6 @@ func TestProfileSettingsWeb(t *testing.T) {
3435 "description": {"builds small tools"},
3536 "website": {"https://alice.example"},
3637 "links": {"Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now"},
37 "about": {"hello there"},
38 "format": {"md"},
3938 })
4039 if status != 200 && status != 303 {
4140 t.Fatalf("profile post: %d", status)
@@ -44,7 +43,7 @@ func TestProfileSettingsWeb(t *testing.T) {
4443 for _, want := range []string{
4544 `"description":"builds small tools"`, `"website":"https://alice.example"`,
4645 `"label":"Mastodon"`, `"url":"https://fosstodon.example/@alice"`,
47 `"url":"https://alice.example/now"`, `"about":"hello there"`,
46 `"url":"https://alice.example/now"`,
4847 } {
4948 if !strings.Contains(out, want) {
5049 t.Fatalf("profile set missing %q: %s", want, out)
@@ -55,7 +54,7 @@ func TestProfileSettingsWeb(t *testing.T) {
5554 _, body = browserGet(t, alice, settingsURL)
5655 for _, want := range []string{
5756 "builds small tools", "https://alice.example", "Mastodon|https://fosstodon.example/@alice",
58 "https://alice.example/now", "hello there",
57 "https://alice.example/now",
5958 } {
6059 if !strings.Contains(body, want) {
6160 t.Fatalf("settings page did not round-trip %q:\n%s", want, body)
@@ -66,22 +65,10 @@ func TestProfileSettingsWeb(t *testing.T) {
6665 // each step below carries the fields already in place and changes one.
6766 links := "Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now"
6867
69 // The org choice is honoured on the profile page.
70 if status, _ := browserPost(t, alice, settingsURL, url.Values{
71 "field": {"profile"}, "description": {"builds small tools"},
72 "website": {"https://alice.example"}, "links": {links},
73 "about": {"a /note/ in org"}, "format": {"org"},
74 }); status != 200 && status != 303 {
75 t.Fatalf("profile post (org): %d", status)
76 }
77 if _, page := browserGet(t, alice, inst.base()+"/alice"); !strings.Contains(page, "<em>note</em>") {
78 t.Fatalf("about did not render as org:\n%s", page)
79 }
80
8168 // Emptying the website clears it, not leaves it alone.
8269 if status, _ := browserPost(t, alice, settingsURL, url.Values{
8370 "field": {"profile"}, "description": {"builds small tools"},
84 "website": {""}, "links": {links}, "about": {"a /note/ in org"}, "format": {"org"},
71 "website": {""}, "links": {links},
8572 }); status != 200 && status != 303 {
8673 t.Fatalf("profile post (clear website): %d", status)
8774 }
@@ -98,8 +85,7 @@ func TestProfileSettingsWeb(t *testing.T) {
9885
9986 // Emptying the links field clears the whole list.
10087 if status, _ := browserPost(t, alice, settingsURL, url.Values{
101 "field": {"profile"}, "description": {"builds small tools"},
102 "links": {""}, "about": {"a /note/ in org"}, "format": {"org"},
88 "field": {"profile"}, "description": {"builds small tools"}, "links": {""},
10389 }); status != 200 && status != 303 {
10490 t.Fatalf("profile post (clear links): %d", status)
10591 }
@@ -108,3 +94,43 @@ func TestProfileSettingsWeb(t *testing.T) {
10894 t.Fatalf("links not cleared: %s", out)
10995 }
11096}
97
98// The settings page does not edit the about text; it creates the
99// repository that holds it and points at the file editor.
100func TestProfileAboutRepoFromWeb(t *testing.T) {
101 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
102 aliceKey := inst.newKey(t, "alice")
103 inst.admin(t, "admin", "user", "create", "alice",
104 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
105 alice := inst.login(t, aliceKey)
106 settingsURL := inst.base() + "/settings"
107
108 // With no repository yet, the page offers to create one.
109 _, body := browserGet(t, alice, settingsURL)
110 if !strings.Contains(body, "Create alice/.gitbay") {
111 t.Fatalf("settings page does not offer the profile repository:\n%s", body)
112 }
113
114 if status, _ := browserPost(t, alice, settingsURL, url.Values{
115 "field": {"profile-repo"},
116 }); status != 200 && status != 303 {
117 t.Fatalf("profile-repo post: %d", status)
118 }
119
120 // The repository exists with a starter file, and the page now links to
121 // the editor instead of offering to create it again.
122 out, _, code := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
123 if code != 0 {
124 t.Fatalf("profile show: %d", code)
125 }
126 if !strings.Contains(out, `"about_path":"profile/README.md"`) {
127 t.Fatalf("starter about not committed: %s", out)
128 }
129 _, body = browserGet(t, alice, settingsURL)
130 if !strings.Contains(body, "/alice/.gitbay/edit/main/profile/README.md") {
131 t.Fatalf("settings page does not link to the about file:\n%s", body)
132 }
133 if strings.Contains(body, "Create alice/.gitbay") {
134 t.Error("settings page still offers to create an existing repository")
135 }
136}
internal/control/profile.go +18 −48
@@ -20,10 +20,10 @@ func init() {
2020 Usage: "profile show [name]", ReadOnly: true, Run: runProfileShow})
2121 register(Command{Path: []string{"profile", "set"},
2222 Summary: "set your profile",
23 Usage: "profile set [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]... ('' clears)", ReadsStdin: true, Run: runProfileSet})
23 Usage: "profile set [--description <d>] [--website <url>] [--link <label|url>]... ('' clears)", Run: runProfileSet})
2424 register(Command{Path: []string{"org", "profile"},
2525 Summary: "show or set an org's profile",
26 Usage: "org profile <org> [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...", ReadsStdin: true, Run: runOrgProfile})
26 Usage: "org profile <org> [--description <d>] [--website <url>] [--link <label|url>]...", Run: runOrgProfile})
2727}
2828
2929// maxProfileLinks caps the free-form link list. A profile is a header,
@@ -78,29 +78,24 @@ func ownerAbout(c *Ctx, owner string) (text, format, path string) {
7878type profileEdit struct {
7979 Description *string
8080 Website *string
81 About *string
82 AboutFormat *string
8381 Links *[]store.ProfileLink
8482}
8583
8684func (e profileEdit) empty() bool {
87 return e.Description == nil && e.Website == nil && e.About == nil &&
88 e.AboutFormat == nil && e.Links == nil
85 return e.Description == nil && e.Website == nil && e.Links == nil
8986}
9087
91// parseProfileFlags pulls the profile flags out of args. --about takes
92// inline text or reads stdin via --file -; --link repeats, and a single
93// empty --link clears the list.
94func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err error) {
95 about, file := "", ""
96 sawAbout := false
88// parseProfileFlags pulls the profile flags out of args. --link repeats,
89// and a single empty --link clears the list. The about text is not here:
90// it is a file in <owner>/.gitbay, written like any other file.
91func parseProfileFlags(args []string) (rest []string, e profileEdit, err error) {
9792 var links []store.ProfileLink
98 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--website", "--about", "--about-format", "--file"}, Multi: []string{"--link"}, MaxPos: -1})
93 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--website"}, Multi: []string{"--link"}, MaxPos: -1})
9994 if err != nil {
10095 return nil, e, err
10196 }
10297 rest = f.Pos
103 for _, name := range []string{"--description", "--website", "--about-format"} {
98 for _, name := range []string{"--description", "--website"} {
10499 if !f.Has(name) {
105100 continue
106101 }
@@ -110,16 +105,8 @@ func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err
110105 e.Description = &v
111106 case "--website":
112107 e.Website = &v
113 case "--about-format":
114 e.AboutFormat = &v
115108 }
116109 }
117 if f.Has("--about") {
118 about, sawAbout = f.Value("--about"), true
119 }
120 if f.Has("--file") {
121 file, sawAbout = f.Value("--file"), true
122 }
123110 for _, v := range f.List("--link") {
124111 if v == "" {
125112 links = nil
@@ -133,13 +120,6 @@ func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err
133120 links = append(links, l)
134121 e.Links = &links
135122 }
136 if sawAbout {
137 body, berr := bodyFrom(c, about, file)
138 if berr != nil {
139 return nil, e, berr
140 }
141 e.About = &body
142 }
143123 if len(links) > maxProfileLinks {
144124 return nil, e, fmt.Errorf("at most %d links", maxProfileLinks)
145125 }
@@ -189,16 +169,6 @@ func applyProfile(p store.Profile, e profileEdit) (store.Profile, error) {
189169 }
190170 p.Website = s
191171 }
192 if e.About != nil {
193 p.About = strings.TrimSpace(*e.About)
194 }
195 if e.AboutFormat != nil {
196 f := strings.TrimSpace(*e.AboutFormat)
197 if f != "md" && f != "org" {
198 return p, errors.New("about format must be md or org")
199 }
200 p.AboutFormat = f
201 }
202172 if e.Links != nil {
203173 p.Links = *e.Links
204174 }
@@ -210,8 +180,8 @@ type ProfileOut struct {
210180 Kind string `json:"kind"`
211181 Description string `json:"description,omitempty"`
212182 Website string `json:"website,omitempty"`
213 // About is long-form markdown, rendered by the web between the
214 // header and the activity graph.
183 // About is the long-form text from <owner>/.gitbay, rendered by the
184 // web between the header and the activity graph.
215185 About string `json:"about,omitempty"`
216186 AboutFormat string `json:"about_format,omitempty"`
217187 // AboutPath is where the about was read from in <owner>/.gitbay, so a
@@ -403,7 +373,7 @@ func runProfileShow(c *Ctx, args []string) int {
403373}
404374
405375func runProfileSet(c *Ctx, args []string) int {
406 rest, e, err := parseProfileFlags(c, args)
376 rest, e, err := parseProfileFlags(args)
407377 if err != nil {
408378 return c.failInput(err)
409379 }
@@ -411,7 +381,7 @@ func runProfileSet(c *Ctx, args []string) int {
411381 return c.usage()
412382 }
413383 if e.empty() {
414 return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website, --about and/or --link")
384 return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website and/or --link")
415385 }
416386 p, err := c.Store.OwnerProfile("user", c.User.ID)
417387 if err != nil {
@@ -425,12 +395,12 @@ func runProfileSet(c *Ctx, args []string) int {
425395 return c.fail(protocol.ExitFailure, "%v", err)
426396 }
427397 return emitProfile(c, ProfileOut{Name: c.User.Username, Kind: "user",
428 Description: p.Description, Website: p.Website, About: p.About,
429 AboutFormat: p.AboutFormat, Links: p.Links, Repos: []ProfileRepo{}})
398 Description: p.Description, Website: p.Website, Links: p.Links,
399 Repos: []ProfileRepo{}})
430400}
431401
432402func runOrgProfile(c *Ctx, args []string) int {
433 rest, e, err := parseProfileFlags(c, args)
403 rest, e, err := parseProfileFlags(args)
434404 if err != nil {
435405 return c.failInput(err)
436406 }
@@ -457,6 +427,6 @@ func runOrgProfile(c *Ctx, args []string) int {
457427 return c.fail(protocol.ExitFailure, "%v", err)
458428 }
459429 return emitProfile(c, ProfileOut{Name: org.Name, Kind: "org",
460 Description: p.Description, Website: p.Website, About: p.About,
461 AboutFormat: p.AboutFormat, Links: p.Links, Repos: []ProfileRepo{}})
430 Description: p.Description, Website: p.Website, Links: p.Links,
431 Repos: []ProfileRepo{}})
462432}
internal/httpd/account.go +33 −15
@@ -34,12 +34,11 @@ type accountPGP struct {
3434// accountForm renders the account's own settings: keys, addresses, and the
3535// commands for everything that stays on SSH.
3636func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
37 s.accountPage(w, r, u, nil)
37 s.accountPage(w, r, u)
3838}
3939
40// accountPage renders the settings page. d is non-nil when the profile
41// form asked to see its about text rather than save it (#235).
42func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User, d *draft) {
40// accountPage renders the settings page.
41func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User) {
4342 var keys []accountKey
4443 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
4544 for _, k := range list {
@@ -67,6 +66,14 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
6766 watchOn, _ := s.st.WatchEnabled(u.ID)
6867 theme, _ := s.st.Theme(u.ID)
6968
69 // The about text is a file. The page points at it rather than editing
70 // it: the repository's own editor already does that job.
71 aboutRepo := u.Username + "/" + control.ProfileRepoName
72 aboutEdit := ""
73 if profile.AboutPath != "" {
74 aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath
75 }
76
7077 s.render(w, "account.html", struct {
7178 basePage
7279 Tab string // marks the rail's Settings row as current
@@ -75,15 +82,17 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use
7582 Emails []store.Email
7683 Profile control.ProfileOut
7784 LinksText string
85 AboutRepo string // <user>/.gitbay, which holds the about text
86 AboutEdit string // the file editor's URL, empty when there is no file yet
7887 Host string
7988 Notice string
8089 Message string
8190 MailOn bool
8291 WatchOn bool
8392 ThemeSetting string // system, light or dark: the form's selected option
84 Draft *draft
85 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
86 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme, d})
93 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
94 aboutRepo, aboutEdit, s.cfg.SiteHost(),
95 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme})
8796}
8897
8998// accountExport hands the browser the same bundle `account export`
@@ -246,25 +255,34 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
246255 }
247256 back("", "notification preferences saved")
248257 case "profile":
249 format := bodyFormat(r)
250 if wantsPreview(r) {
251 s.accountPage(w, r, u, s.draftWith(r, "about", format, r.FormValue("about"), ugcHTML))
252 return
253 }
254258 argv := []string{"profile", "set",
255259 "--description", r.FormValue("description"),
256260 "--website", r.FormValue("website"),
257 "--about-format", format,
258 "--file", "-",
259261 }
260262 for _, link := range profileLinkArgs(r.FormValue("links")) {
261263 argv = append(argv, "--link", link)
262264 }
263 if msg, ok := s.runControlStdin(u, argv, r.FormValue("about")); !ok {
265 if _, msg, ok := s.runControl(u, argv); !ok {
264266 back(msg, "")
265267 return
266268 }
267269 back("", "profile updated")
270 case "profile-repo":
271 // The about text is a file. Create the repository that holds it and
272 // commit a starter README, so the file editor has a branch to open.
273 path := u.Username + "/" + control.ProfileRepoName
274 if _, msg, ok := s.runControl(u, []string{"repo", "create", path}); !ok {
275 back(msg, "")
276 return
277 }
278 starter := "# " + u.Username + "\n\nThis is the about text on your profile.\n"
279 if msg, ok := s.runControlStdin(u, []string{"repo", "commit-file", path,
280 control.AboutBase + ".md", "--ref", "main",
281 "--message", "add profile about", "--file", "-"}, starter); !ok {
282 back(msg, "")
283 return
284 }
285 back("", "profile repository created")
268286 default:
269287 back("unknown form", "")
270288 }
internal/web/templates/account.html +13 −8
@@ -22,20 +22,25 @@
2222<div class="colmain">
2323<section id="profile"><h2>Profile</h2>
2424<p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p>
25{{if .Draft.Is "about"}}{{template "previewblock" .Draft.HTML}}{{end}}
2625<form method="post" action="/settings" class="setform stack">
2726 <input type="hidden" name="field" value="profile">
2827 <label for="p-description">Description</label>
29 <input type="text" id="p-description" name="description" value="{{.Draft.Or "about" "description" .Profile.Description}}" placeholder="one line, shown in listings">
28 <input type="text" id="p-description" name="description" value="{{.Profile.Description}}" placeholder="one line, shown in listings">
3029 <label for="p-website">Website</label>
31 <input type="text" id="p-website" name="website" value="{{.Draft.Or "about" "website" .Profile.Website}}" placeholder="https://example.org">
30 <input type="text" id="p-website" name="website" value="{{.Profile.Website}}" placeholder="https://example.org">
3231 <label for="p-links">Links</label>
33 <textarea id="p-links" name="links" rows="3" placeholder="one per line: label|https://... or a bare https://... (at most 5)">{{.Draft.Or "about" "links" .LinksText}}</textarea>
34 <label for="p-about">About</label>
35 <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Draft.Or "about" "about" .Profile.About}}</textarea>
36 {{template "formatpicker" (.Draft.Or "about" "format" .Profile.AboutFormat)}}
37 <span class="btngroup"><button type="submit" class="btn">Save profile</button>{{template "previewbtn"}}</span>
32 <textarea id="p-links" name="links" rows="3" placeholder="one per line: label|https://... or a bare https://... (at most 5)">{{.LinksText}}</textarea>
33 <span class="btngroup"><button type="submit" class="btn">Save profile</button></span>
3834</form>
35<h3>About</h3>
36<p class="meta">The longer text below your repositories is a file:
37<code>profile/README.md</code> in <a href="/{{.AboutRepo}}">{{.AboutRepo}}</a>.
38Write it with a push, or edit it here.</p>
39{{if .AboutEdit}}<p><a class="btn" href="{{.AboutEdit}}">Edit {{.Profile.AboutPath}}</a></p>
40{{else}}<form method="post" action="/settings" class="setform">
41 <input type="hidden" name="field" value="profile-repo">
42 <button type="submit" class="btn">Create {{.AboutRepo}}</button>
43</form>{{end}}
3944</section>
4045
4146<section id="keys"><h2>SSH keys</h2>