Commit 4744c629ed

4744c629ed4f0158bdea7d0f1d207fdccb1f7f99

parent: 68ae423bf6

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-28 18:21 UTC

profile: about text and free-form links

profile set gains --about (inline or --file -) with --about-format md|org,
and --link, repeatable up to five as "label|url" or a bare url. Both
fields live on users and orgs, read and written through store.Profile;
links are a JSON column.

About has no filename to dispatch on, so the stored format picks the
renderer. The web renders it between the profile header and the activity
graph, and links as chips under the website line.

Layout: unified · split

cmd/gitbay/main.go +4 −2
@@ -66,7 +66,8 @@ func newRoot() *cobra.Command {
66 orgCmd(), 66 orgCmd(),
67 group("profile", "user and org profiles", 67 group("profile", "user and org profiles",
68 pass("show", "show a profile: [name]", passOpts{server: []string{"profile", "show"}}), 68 pass("show", "show a profile: [name]", passOpts{server: []string{"profile", "show"}}),
69 pass("set", "set your profile: [--description d] [--website url]", passOpts{server: []string{"profile", "set"}}), 69 pass("set", "set your profile: [--description d] [--website url] [--about t|--file -] [--about-format md|org] [--link label|url]...",
70 passOpts{server: []string{"profile", "set"}, stdinOK: true}),
70 ), 71 ),
71 webhookCmd(), 72 webhookCmd(),
72 remoteCmd(), 73 remoteCmd(),
@@ -456,7 +457,8 @@ func orgCmd() *cobra.Command {
456 pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}), 457 pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}),
457 pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}), 458 pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}),
458 pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}), 459 pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}),
459 pass("profile", "show or set an org profile: <org> [--description d] [--website url]", passOpts{server: []string{"org", "profile"}}), 460 pass("profile", "show or set an org profile: <org> [--description d] [--website url] [--about t|--file -] [--about-format md|org] [--link label|url]...",
461 passOpts{server: []string{"org", "profile"}, stdinOK: true}),
460 group("members", "manage members", 462 group("members", "manage members",
461 pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}), 463 pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}),
462 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}), 464 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}),
e2e/profile_test.go +86 −1
@@ -96,11 +96,96 @@ func TestOwnerProfiles(t *testing.T) {
96 t.Fatalf("org profile show: %s", out) 96 t.Fatalf("org profile show: %s", out)
97 } 97 }
98 98
99 // Owner pages render description and website link. 99 // About: long-form markdown, set inline or piped, rendered on the page.
100 if _, errOut, code := inst.ssh(t, aliceKey, "# Hello\n\nI maintain *small tools*.\n",
101 "profile", "set", "--file", "-"); code != 0 {
102 t.Fatalf("about from stdin: %s", errOut)
103 }
104 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
105 if !strings.Contains(out, "I maintain *small tools*.") {
106 t.Fatalf("about not stored: %s", out)
107 }
108 if !strings.Contains(out, "tinkerer") {
109 t.Fatalf("about clobbered the description: %s", out)
110 }
111
112 // Org-mode about: the stored format picks the renderer.
113 if _, errOut, code := inst.ssh(t, aliceKey, "* Tools\n\nI maintain /small tools/.\n",
114 "profile", "set", "--file", "-", "--about-format", "org"); code != 0 {
115 t.Fatalf("org about: %s", errOut)
116 }
117 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
118 if !strings.Contains(out, `"about_format":"org"`) {
119 t.Fatalf("about format not stored: %s", out)
120 }
121 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about-format", "rst"); code != 2 {
122 t.Fatal("unknown about format accepted")
123 }
124 // Org emphasis parsed, not left as literal slashes the way the
125 // markdown renderer would.
100 status, body := inst.get(t, "/alice") 126 status, body := inst.get(t, "/alice")
127 if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") {
128 t.Fatalf("org about not rendered as org: %s", body)
129 }
130
131 // Back to markdown for the rest of the checks.
132 if _, _, code := inst.ssh(t, aliceKey, "# Hello\n\nI maintain *small tools*.\n",
133 "profile", "set", "--file", "-", "--about-format", "md"); code != 0 {
134 t.Fatal("markdown about")
135 }
136
137 // Links: free-form, labelled or bare, capped, cleared by an empty one.
138 if _, errOut, code := inst.ssh(t, aliceKey, "", "profile", "set",
139 "--link", "'Mastodon|https://fosstodon.example/@alice'",
140 "--link", "https://alice.example/now"); code != 0 {
141 t.Fatalf("links: %s", errOut)
142 }
143 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
144 if !strings.Contains(out, `"label":"Mastodon"`) ||
145 !strings.Contains(out, `"url":"https://fosstodon.example/@alice"`) ||
146 !strings.Contains(out, `"url":"https://alice.example/now"`) {
147 t.Fatalf("links not stored: %s", out)
148 }
149 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--link", "'x|gopher://nope'"); code != 2 {
150 t.Fatal("bad link scheme accepted")
151 }
152 sixth := []string{"profile", "set"}
153 for i := 0; i < 6; i++ {
154 sixth = append(sixth, "--link", "https://example.org/"+string(rune('a'+i)))
155 }
156 if _, _, code := inst.ssh(t, aliceKey, "", sixth...); code != 2 {
157 t.Fatal("more than five links accepted")
158 }
159
160 // Owner pages render description and website link.
161 status, body = inst.get(t, "/alice")
101 if status != 200 || !strings.Contains(body, "tinkerer") { 162 if status != 200 || !strings.Contains(body, "tinkerer") {
102 t.Fatalf("user page profile: %d", status) 163 t.Fatalf("user page profile: %d", status)
103 } 164 }
165 // About renders as markdown between the header and the activity graph;
166 // links render as chips.
167 if !strings.Contains(body, "<em>small tools</em>") {
168 t.Fatalf("about not rendered: %s", body)
169 }
170 if !strings.Contains(body, `href="https://fosstodon.example/@alice"`) ||
171 !strings.Contains(body, ">Mastodon<") {
172 t.Fatalf("links not rendered: %s", body)
173 }
174 if strings.Index(body, "<em>small tools</em>") > strings.Index(body, `class="activity"`) {
175 t.Error("about renders below the activity graph")
176 }
177
178 // Clearing works the same way as the other fields.
179 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--link", "''"); code != 0 {
180 t.Fatal("clear links failed")
181 }
182 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about", "''"); code != 0 {
183 t.Fatal("clear about failed")
184 }
185 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
186 if strings.Contains(out, "fosstodon") || strings.Contains(out, "small tools") {
187 t.Fatalf("about or links not cleared: %s", out)
188 }
104 status, body = inst.get(t, "/workshop") 189 status, body = inst.get(t, "/workshop")
105 if status != 200 || !strings.Contains(body, "where things get made") || 190 if status != 200 || !strings.Contains(body, "where things get made") ||
106 !strings.Contains(body, `href="https://workshop.example"`) { 191 !strings.Contains(body, `href="https://workshop.example"`) {
internal/control/migrate.go +2 −1
@@ -157,7 +157,8 @@ func runAccountImportBundle(c *Ctx, args []string) int {
157 return c.fail(protocol.ExitUsage, "unsupported bundle %q (want %s)", b.Bundle, bundleVersion) 157 return c.fail(protocol.ExitUsage, "unsupported bundle %q (want %s)", b.Bundle, bundleVersion)
158 } 158 }
159 159
160 if b.Profile.Description != "" || b.Profile.Website != "" { 160 if b.Profile.Description != "" || b.Profile.Website != "" ||
161 b.Profile.About != "" || len(b.Profile.Links) > 0 {
161 c.Store.SetOwnerProfile("user", c.User.ID, b.Profile) 162 c.Store.SetOwnerProfile("user", c.User.ID, b.Profile)
162 } 163 }
163 for _, addr := range b.Emails { 164 for _, addr := range b.Emails {
internal/control/profile.go +138 −31
@@ -18,32 +18,105 @@ func init() {
18 register(Command{Path: []string{"profile", "show"}, 18 register(Command{Path: []string{"profile", "show"},
19 Summary: "show a user's or org's profile: profile show [name]", ReadOnly: true, Run: runProfileShow}) 19 Summary: "show a user's or org's profile: profile show [name]", ReadOnly: true, Run: runProfileShow})
20 register(Command{Path: []string{"profile", "set"}, 20 register(Command{Path: []string{"profile", "set"},
21 Summary: "set your profile: profile set [--description <d>] [--website <url>] ('' clears)", Run: runProfileSet}) 21 Summary: "set your profile: profile set [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]... ('' clears)", ReadsStdin: true, Run: runProfileSet})
22 register(Command{Path: []string{"org", "profile"}, 22 register(Command{Path: []string{"org", "profile"},
23 Summary: "show or set an org's profile: org profile <org> [--description <d>] [--website <url>]", Run: runOrgProfile}) 23 Summary: "show or set an org's profile: org profile <org> [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...", ReadsStdin: true, Run: runOrgProfile})
24} 24}
25 25
26// parseProfileFlags pulls --description/--website out of args; a flag given 26// maxProfileLinks caps the free-form link list. A profile is a header,
27// with an empty value clears the field, an absent flag leaves it untouched. 27// not a linktree.
28func parseProfileFlags(args []string) (rest []string, desc, site *string, err error) { 28const maxProfileLinks = 5
29
30// profileEdit is the set of profile fields a command may change. A nil
31// field is left alone; an empty value clears it.
32type profileEdit struct {
33 Description *string
34 Website *string
35 About *string
36 AboutFormat *string
37 Links *[]store.ProfileLink
38}
39
40func (e profileEdit) empty() bool {
41 return e.Description == nil && e.Website == nil && e.About == nil &&
42 e.AboutFormat == nil && e.Links == nil
43}
44
45// parseProfileFlags pulls the profile flags out of args. --about takes
46// inline text or reads stdin via --file -; --link repeats, and a single
47// empty --link clears the list.
48func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err error) {
49 about, file := "", ""
50 sawAbout := false
51 var links []store.ProfileLink
29 for i := 0; i < len(args); i++ { 52 for i := 0; i < len(args); i++ {
30 switch args[i] { 53 switch args[i] {
31 case "--description", "--website": 54 case "--description", "--website", "--about", "--about-format", "--file", "--link":
32 if i+1 >= len(args) { 55 if i+1 >= len(args) {
33 return nil, nil, nil, fmt.Errorf("%s requires a value", args[i]) 56 return nil, e, fmt.Errorf("%s requires a value", args[i])
34 } 57 }
35 v := args[i+1] 58 v := args[i+1]
36 if args[i] == "--description" { 59 switch args[i] {
37 desc = &v 60 case "--description":
38 } else { 61 e.Description = &v
39 site = &v 62 case "--website":
63 e.Website = &v
64 case "--about":
65 about, sawAbout = v, true
66 case "--about-format":
67 e.AboutFormat = &v
68 case "--file":
69 file, sawAbout = v, true
70 case "--link":
71 if v == "" {
72 links = nil
73 e.Links = &links
74 break
75 }
76 l, lerr := parseProfileLink(v)
77 if lerr != nil {
78 return nil, e, lerr
79 }
80 links = append(links, l)
81 e.Links = &links
40 } 82 }
41 i++ 83 i++
42 default: 84 default:
43 rest = append(rest, args[i]) 85 rest = append(rest, args[i])
44 } 86 }
45 } 87 }
46 return rest, desc, site, nil 88 if sawAbout {
89 body, berr := bodyFrom(c, about, file)
90 if berr != nil {
91 return nil, e, berr
92 }
93 e.About = &body
94 }
95 if len(links) > maxProfileLinks {
96 return nil, e, fmt.Errorf("at most %d links", maxProfileLinks)
97 }
98 return rest, e, nil
99}
100
101// parseProfileLink splits "label|url"; without a separator the whole
102// value is the URL.
103func parseProfileLink(v string) (store.ProfileLink, error) {
104 label, url, ok := strings.Cut(v, "|")
105 if !ok {
106 label, url = "", v
107 }
108 label = strings.TrimSpace(label)
109 if len(label) > 32 {
110 label = label[:32]
111 }
112 url = strings.TrimSpace(url)
113 if url == "" {
114 return store.ProfileLink{}, errors.New("a link needs a url")
115 }
116 if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
117 return store.ProfileLink{}, errors.New("link url must start with https:// or http://")
118 }
119 return store.ProfileLink{Label: label, URL: url}, nil
47} 120}
48 121
49func validateWebsite(url string) error { 122func validateWebsite(url string) error {
@@ -53,21 +126,34 @@ func validateWebsite(url string) error {
53 return errors.New("website must start with https:// or http://") 126 return errors.New("website must start with https:// or http://")
54} 127}
55 128
56func applyProfile(p store.Profile, desc, site *string) (store.Profile, error) { 129func applyProfile(p store.Profile, e profileEdit) (store.Profile, error) {
57 if desc != nil { 130 if e.Description != nil {
58 d, _, _ := strings.Cut(strings.TrimSpace(*desc), "\n") 131 d, _, _ := strings.Cut(strings.TrimSpace(*e.Description), "\n")
59 if len(d) > 256 { 132 if len(d) > 256 {
60 d = d[:256] 133 d = d[:256]
61 } 134 }
62 p.Description = d 135 p.Description = d
63 } 136 }
64 if site != nil { 137 if e.Website != nil {
65 s := strings.TrimSpace(*site) 138 s := strings.TrimSpace(*e.Website)
66 if err := validateWebsite(s); err != nil { 139 if err := validateWebsite(s); err != nil {
67 return p, err 140 return p, err
68 } 141 }
69 p.Website = s 142 p.Website = s
70 } 143 }
144 if e.About != nil {
145 p.About = strings.TrimSpace(*e.About)
146 }
147 if e.AboutFormat != nil {
148 f := strings.TrimSpace(*e.AboutFormat)
149 if f != "md" && f != "org" {
150 return p, errors.New("about format must be md or org")
151 }
152 p.AboutFormat = f
153 }
154 if e.Links != nil {
155 p.Links = *e.Links
156 }
71 return p, nil 157 return p, nil
72} 158}
73 159
@@ -76,6 +162,11 @@ type profileOut struct {
76 Kind string `json:"kind"` 162 Kind string `json:"kind"`
77 Description string `json:"description,omitempty"` 163 Description string `json:"description,omitempty"`
78 Website string `json:"website,omitempty"` 164 Website string `json:"website,omitempty"`
165 // About is long-form markdown, rendered by the web between the
166 // header and the activity graph.
167 About string `json:"about,omitempty"`
168 AboutFormat string `json:"about_format,omitempty"`
169 Links []store.ProfileLink `json:"links,omitempty"`
79 // The rest is what a profile page shows: who they work with, what 170 // The rest is what a profile page shows: who they work with, what
80 // they own that you can see, and how active they have been. The web 171 // they own that you can see, and how active they have been. The web
81 // read these straight out of the store, which kept them off every 172 // read these straight out of the store, which kept them off every
@@ -124,6 +215,9 @@ func emitProfile(c *Ctx, d profileOut) int {
124 if d.Website != "" { 215 if d.Website != "" {
125 fmt.Fprintf(w, "%s\n", d.Website) 216 fmt.Fprintf(w, "%s\n", d.Website)
126 } 217 }
218 for _, l := range d.Links {
219 fmt.Fprintf(w, "link\t%s\t%s\n", l.Label, l.URL)
220 }
127 for _, m := range d.Orgs { 221 for _, m := range d.Orgs {
128 fmt.Fprintf(w, "org\t%s\t%s\n", m.Name, m.Role) 222 fmt.Fprintf(w, "org\t%s\t%s\n", m.Name, m.Role)
129 } 223 }
@@ -136,6 +230,9 @@ func emitProfile(c *Ctx, d profileOut) int {
136 if d.ActivityTotal > 0 { 230 if d.ActivityTotal > 0 {
137 fmt.Fprintf(w, "activity\t%d in the last year\n", d.ActivityTotal) 231 fmt.Fprintf(w, "activity\t%d in the last year\n", d.ActivityTotal)
138 } 232 }
233 if d.About != "" {
234 fmt.Fprintf(w, "\n%s\n", d.About)
235 }
139 }) 236 })
140} 237}
141 238
@@ -159,7 +256,7 @@ func runProfileShow(c *Ctx, args []string) int {
159 return c.fail(protocol.ExitFailure, "%v", err) 256 return c.fail(protocol.ExitFailure, "%v", err)
160 } 257 }
161 d := profileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website, 258 d := profileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website,
162 Repos: []profileRepo{}} 259 About: p.About, AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}}
163 260
164 // Who they work with. Both lists are public on a profile — the web 261 // Who they work with. Both lists are public on a profile — the web
165 // has always shown them — and neither exposes anything a member 262 // has always shown them — and neither exposes anything a member
@@ -226,18 +323,22 @@ func runProfileShow(c *Ctx, args []string) int {
226} 323}
227 324
228func runProfileSet(c *Ctx, args []string) int { 325func runProfileSet(c *Ctx, args []string) int {
229 rest, desc, site, err := parseProfileFlags(args) 326 rest, e, err := parseProfileFlags(c, args)
230 if err != nil || len(rest) != 0 { 327 if err != nil {
231 return c.fail(protocol.ExitUsage, "usage: profile set [--description <d>] [--website <url>]") 328 return c.fail(protocol.ExitUsage, "%v", err)
329 }
330 if len(rest) != 0 {
331 return c.fail(protocol.ExitUsage,
332 "usage: profile set [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...")
232 } 333 }
233 if desc == nil && site == nil { 334 if e.empty() {
234 return c.fail(protocol.ExitUsage, "nothing to set: pass --description and/or --website") 335 return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website, --about and/or --link")
235 } 336 }
236 p, err := c.Store.OwnerProfile("user", c.User.ID) 337 p, err := c.Store.OwnerProfile("user", c.User.ID)
237 if err != nil { 338 if err != nil {
238 return c.fail(protocol.ExitFailure, "%v", err) 339 return c.fail(protocol.ExitFailure, "%v", err)
239 } 340 }
240 p, err = applyProfile(p, desc, site) 341 p, err = applyProfile(p, e)
241 if err != nil { 342 if err != nil {
242 return c.fail(protocol.ExitUsage, "%v", err) 343 return c.fail(protocol.ExitUsage, "%v", err)
243 } 344 }
@@ -245,16 +346,21 @@ func runProfileSet(c *Ctx, args []string) int {
245 return c.fail(protocol.ExitFailure, "%v", err) 346 return c.fail(protocol.ExitFailure, "%v", err)
246 } 347 }
247 return emitProfile(c, profileOut{Name: c.User.Username, Kind: "user", 348 return emitProfile(c, profileOut{Name: c.User.Username, Kind: "user",
248 Description: p.Description, Website: p.Website, Repos: []profileRepo{}}) 349 Description: p.Description, Website: p.Website, About: p.About,
350 AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}})
249} 351}
250 352
251func runOrgProfile(c *Ctx, args []string) int { 353func runOrgProfile(c *Ctx, args []string) int {
252 rest, desc, site, err := parseProfileFlags(args) 354 rest, e, err := parseProfileFlags(c, args)
253 if err != nil || len(rest) != 1 { 355 if err != nil {
254 return c.fail(protocol.ExitUsage, "usage: org profile <org> [--description <d>] [--website <url>]") 356 return c.fail(protocol.ExitUsage, "%v", err)
357 }
358 if len(rest) != 1 {
359 return c.fail(protocol.ExitUsage,
360 "usage: org profile <org> [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...")
255 } 361 }
256 name := rest[0] 362 name := rest[0]
257 if desc == nil && site == nil { 363 if e.empty() {
258 return runProfileShow(c, []string{name}) 364 return runProfileShow(c, []string{name})
259 } 365 }
260 org, code := orgAdmin(c, name) 366 org, code := orgAdmin(c, name)
@@ -265,7 +371,7 @@ func runOrgProfile(c *Ctx, args []string) int {
265 if err != nil { 371 if err != nil {
266 return c.fail(protocol.ExitFailure, "%v", err) 372 return c.fail(protocol.ExitFailure, "%v", err)
267 } 373 }
268 p, err = applyProfile(p, desc, site) 374 p, err = applyProfile(p, e)
269 if err != nil { 375 if err != nil {
270 return c.fail(protocol.ExitUsage, "%v", err) 376 return c.fail(protocol.ExitUsage, "%v", err)
271 } 377 }
@@ -273,5 +379,6 @@ func runOrgProfile(c *Ctx, args []string) int {
273 return c.fail(protocol.ExitFailure, "%v", err) 379 return c.fail(protocol.ExitFailure, "%v", err)
274 } 380 }
275 return emitProfile(c, profileOut{Name: org.Name, Kind: "org", 381 return emitProfile(c, profileOut{Name: org.Name, Kind: "org",
276 Description: p.Description, Website: p.Website, Repos: []profileRepo{}}) 382 Description: p.Description, Website: p.Website, About: p.About,
383 AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}})
277} 384}
internal/httpd/web.go +17 −1
@@ -398,6 +398,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
398 Owner string 398 Owner string
399 Kind string 399 Kind string
400 Profile store.Profile 400 Profile store.Profile
401 AboutHTML template.HTML
401 Repos []describedRepo 402 Repos []describedRepo
402 Members []store.OrgMember 403 Members []store.OrgMember
403 Orgs []store.OrgMember 404 Orgs []store.OrgMember
@@ -406,7 +407,8 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
406 Teams []teamView 407 Teams []teamView
407 CanAdmin bool 408 CanAdmin bool
408 Notice string 409 Notice string
409 }{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs, 410 }{s.baseFor(viewer), name, kind, profile, aboutHTML(profile),
411 s.describeAll(visible), members, orgs,
410 weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")}) 412 weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")})
411} 413}
412 414
@@ -989,6 +991,20 @@ func mdHTML(raw string) template.HTML {
989 return template.HTML(buf.String()) 991 return template.HTML(buf.String())
990} 992}
991 993
994// aboutHTML renders a profile's about text. It has no filename to
995// dispatch on, so the stored format picks the extension; anything other
996// than org is markdown.
997func aboutHTML(p store.Profile) template.HTML {
998 if strings.TrimSpace(p.About) == "" {
999 return ""
1000 }
1001 name := "about.md"
1002 if p.AboutFormat == "org" {
1003 name = "about.org"
1004 }
1005 return renderReadme(name, []byte(p.About))
1006}
1007
992// webResolver answers autolink lookups for one viewer. Cross-repo 1008// webResolver answers autolink lookups for one viewer. Cross-repo
993// references to repositories the viewer cannot read stay plain text, per 1009// references to repositories the viewer cannot read stay plain text, per
994// the enumeration rule: a link would confirm the repo exists. 1010// the enumeration rule: a link would confirm the repo exists.
internal/store/migrations/0026_profile_about.down.sql added +6
@@ -0,0 +1,6 @@
1ALTER TABLE users DROP COLUMN about;
2ALTER TABLE users DROP COLUMN about_format;
3ALTER TABLE users DROP COLUMN links;
4ALTER TABLE orgs DROP COLUMN about;
5ALTER TABLE orgs DROP COLUMN about_format;
6ALTER TABLE orgs DROP COLUMN links;
internal/store/migrations/0026_profile_about.up.sql added +6
@@ -0,0 +1,6 @@
1ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT '';
2ALTER TABLE users ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
3ALTER TABLE users ADD COLUMN links TEXT NOT NULL DEFAULT '';
4ALTER TABLE orgs ADD COLUMN about TEXT NOT NULL DEFAULT '';
5ALTER TABLE orgs ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
6ALTER TABLE orgs ADD COLUMN links TEXT NOT NULL DEFAULT '';
internal/store/orgs.go +37 −5
@@ -2,6 +2,7 @@ package store
2 2
3import ( 3import (
4 "database/sql" 4 "database/sql"
5 "encoding/json"
5 "errors" 6 "errors"
6 "fmt" 7 "fmt"
7) 8)
@@ -220,23 +221,54 @@ func (s *Store) RenameOrg(orgID int64, newName string) error {
220type Profile struct { 221type Profile struct {
221 Description string `json:"description,omitempty"` 222 Description string `json:"description,omitempty"`
222 Website string `json:"website,omitempty"` 223 Website string `json:"website,omitempty"`
224 About string `json:"about,omitempty"`
225 // AboutFormat is "md" or "org"; About has no filename to dispatch on.
226 AboutFormat string `json:"about_format,omitempty"`
227 Links []ProfileLink `json:"links,omitempty"`
228}
229
230// ProfileLink is one free-form link. The label is optional; a link
231// without one renders as its URL.
232type ProfileLink struct {
233 Label string `json:"label,omitempty"`
234 URL string `json:"url"`
223} 235}
224 236
225// OwnerProfile reads the profile for kind "user" or "org". 237// OwnerProfile reads the profile for kind "user" or "org".
226func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) { 238func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) {
227 table := map[string]string{"user": "users", "org": "orgs"}[kind] 239 table := map[string]string{"user": "users", "org": "orgs"}[kind]
228 var p Profile 240 var p Profile
241 var linksJSON string
229 err := s.DB.QueryRow( 242 err := s.DB.QueryRow(
230 "SELECT description, website FROM "+table+" WHERE id = ?", id). 243 "SELECT description, website, about, about_format, links FROM "+table+" WHERE id = ?", id).
231 Scan(&p.Description, &p.Website) 244 Scan(&p.Description, &p.Website, &p.About, &p.AboutFormat, &linksJSON)
232 return p, err 245 if err != nil {
246 return p, err
247 }
248 if linksJSON != "" {
249 if err := json.Unmarshal([]byte(linksJSON), &p.Links); err != nil {
250 return p, fmt.Errorf("%s %d links: %w", kind, id, err)
251 }
252 }
253 return p, nil
233} 254}
234 255
235// SetOwnerProfile updates the profile for kind "user" or "org". 256// SetOwnerProfile updates the profile for kind "user" or "org".
236func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error { 257func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error {
237 table := map[string]string{"user": "users", "org": "orgs"}[kind] 258 table := map[string]string{"user": "users", "org": "orgs"}[kind]
259 if p.AboutFormat != "org" {
260 p.AboutFormat = "md"
261 }
262 links := ""
263 if len(p.Links) > 0 {
264 raw, err := json.Marshal(p.Links)
265 if err != nil {
266 return err
267 }
268 links = string(raw)
269 }
238 _, err := s.DB.Exec( 270 _, err := s.DB.Exec(
239 "UPDATE "+table+" SET description = ?, website = ? WHERE id = ?", 271 "UPDATE "+table+" SET description = ?, website = ?, about = ?, about_format = ?, links = ? WHERE id = ?",
240 p.Description, p.Website, id) 272 p.Description, p.Website, p.About, p.AboutFormat, links, id)
241 return err 273 return err
242} 274}
internal/web/static/style.css +3 −3
@@ -700,8 +700,8 @@ h2 .count { color: var(--muted); font-weight: 400; font-size: var(--fs-2); }
700} 700}
701.headrow .spacer { flex: 1; } 701.headrow .spacer { flex: 1; }
702 702
703/* member and org chips on owner pages */ 703/* member, org and profile link chips on owner pages */
704a.memberchip { 704a.memberchip, a.linkchip {
705 display: inline-block; 705 display: inline-block;
706 border: 1px solid var(--line); 706 border: 1px solid var(--line);
707 border-radius: var(--r-pill); 707 border-radius: var(--r-pill);
@@ -710,7 +710,7 @@ a.memberchip {
710 font-size: var(--fs-1); 710 font-size: var(--fs-1);
711 margin: 0 0.15rem 0.15rem 0; 711 margin: 0 0.15rem 0.15rem 0;
712} 712}
713a.memberchip:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); } 713a.memberchip:hover, a.linkchip:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); }
714a.memberchip .role { color: var(--muted); } 714a.memberchip .role { color: var(--muted); }
715 715
716/* landing page */ 716/* landing page */
internal/web/templates/owner.html +2
@@ -4,9 +4,11 @@
4<h1>{{.Owner}} <span class="chip chip-neutral">{{.Kind}}</span></h1> 4<h1>{{.Owner}} <span class="chip chip-neutral">{{.Kind}}</span></h1>
5{{if .Profile.Description}}<p class="desc lede">{{.Profile.Description}}</p>{{end}} 5{{if .Profile.Description}}<p class="desc lede">{{.Profile.Description}}</p>{{end}}
6{{if .Profile.Website}}<p class="meta"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}} 6{{if .Profile.Website}}<p class="meta"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}}
7{{if .Profile.Links}}<p class="meta">{{range .Profile.Links}}<a class="linkchip" href="{{.URL}}" rel="nofollow me">{{if .Label}}{{.Label}}{{else}}{{.URL}}{{end}}</a> {{end}}</p>{{end}}
7{{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}} 8{{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}}
8{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Username}}">{{.Username}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} 9{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Username}}">{{.Username}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
9</section> 10</section>
11{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}}
10<section class="activity"> 12<section class="activity">
11<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2> 13<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2>
12<div class="actgraph-scroll"> 14<div class="actgraph-scroll">