Commit 4744c629ed
Verified · cmc ci/build: success
cmd/gitbay/main.go +4 −2
| @@ -66,7 +66,8 @@ func newRoot() *cobra.Command { | ||
| 66 | 66 | orgCmd(), |
| 67 | 67 | group("profile", "user and org profiles", |
| 68 | 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 | 72 | webhookCmd(), |
| 72 | 73 | remoteCmd(), |
| @@ -456,7 +457,8 @@ func orgCmd() *cobra.Command { | ||
| 456 | 457 | pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}), |
| 457 | 458 | pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}), |
| 458 | 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 | 462 | group("members", "manage members", |
| 461 | 463 | pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}), |
| 462 | 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 | 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 | 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 | 162 | if status != 200 || !strings.Contains(body, "tinkerer") { |
| 102 | 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 | 189 | status, body = inst.get(t, "/workshop") |
| 105 | 190 | if status != 200 || !strings.Contains(body, "where things get made") || |
| 106 | 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 | 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 | 162 | c.Store.SetOwnerProfile("user", c.User.ID, b.Profile) |
| 162 | 163 | } |
| 163 | 164 | for _, addr := range b.Emails { |
internal/control/profile.go +138 −31
| @@ -18,32 +18,105 @@ func init() { | ||
| 18 | 18 | register(Command{Path: []string{"profile", "show"}, |
| 19 | 19 | Summary: "show a user's or org's profile: profile show [name]", ReadOnly: true, Run: runProfileShow}) |
| 20 | 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 | 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 | |
| 27 | // with an empty value clears the field, an absent flag leaves it untouched. | |
| 28 | func parseProfileFlags(args []string) (rest []string, desc, site *string, err error) { | |
| 26 | // maxProfileLinks caps the free-form link list. A profile is a header, | |
| 27 | // not a linktree. | |
| 28 | const 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. | |
| 32 | type profileEdit struct { | |
| 33 | Description *string | |
| 34 | Website *string | |
| 35 | About *string | |
| 36 | AboutFormat *string | |
| 37 | Links *[]store.ProfileLink | |
| 38 | } | |
| 39 | ||
| 40 | func (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. | |
| 48 | func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err error) { | |
| 49 | about, file := "", "" | |
| 50 | sawAbout := false | |
| 51 | var links []store.ProfileLink | |
| 29 | 52 | for i := 0; i < len(args); i++ { |
| 30 | 53 | switch args[i] { |
| 31 | case "--description", "--website": | |
| 54 | case "--description", "--website", "--about", "--about-format", "--file", "--link": | |
| 32 | 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 | 58 | v := args[i+1] |
| 36 | if args[i] == "--description" { | |
| 37 | desc = &v | |
| 38 | } else { | |
| 39 | site = &v | |
| 59 | switch args[i] { | |
| 60 | case "--description": | |
| 61 | e.Description = &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 | 83 | i++ |
| 42 | 84 | default: |
| 43 | 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. | |
| 103 | func 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 | |
| 49 | 122 | func validateWebsite(url string) error { |
| @@ -53,21 +126,34 @@ func validateWebsite(url string) error { | ||
| 53 | 126 | return errors.New("website must start with https:// or http://") |
| 54 | 127 | } |
| 55 | 128 | |
| 56 | func applyProfile(p store.Profile, desc, site *string) (store.Profile, error) { | |
| 57 | if desc != nil { | |
| 58 | d, _, _ := strings.Cut(strings.TrimSpace(*desc), "\n") | |
| 129 | func applyProfile(p store.Profile, e profileEdit) (store.Profile, error) { | |
| 130 | if e.Description != nil { | |
| 131 | d, _, _ := strings.Cut(strings.TrimSpace(*e.Description), "\n") | |
| 59 | 132 | if len(d) > 256 { |
| 60 | 133 | d = d[:256] |
| 61 | 134 | } |
| 62 | 135 | p.Description = d |
| 63 | 136 | } |
| 64 | if site != nil { | |
| 65 | s := strings.TrimSpace(*site) | |
| 137 | if e.Website != nil { | |
| 138 | s := strings.TrimSpace(*e.Website) | |
| 66 | 139 | if err := validateWebsite(s); err != nil { |
| 67 | 140 | return p, err |
| 68 | 141 | } |
| 69 | 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 | 157 | return p, nil |
| 72 | 158 | } |
| 73 | 159 | |
| @@ -76,6 +162,11 @@ type profileOut struct { | ||
| 76 | 162 | Kind string `json:"kind"` |
| 77 | 163 | Description string `json:"description,omitempty"` |
| 78 | 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 | 170 | // The rest is what a profile page shows: who they work with, what |
| 80 | 171 | // they own that you can see, and how active they have been. The web |
| 81 | 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 | 215 | if d.Website != "" { |
| 125 | 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 | 221 | for _, m := range d.Orgs { |
| 128 | 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 | 230 | if d.ActivityTotal > 0 { |
| 137 | 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 | 256 | return c.fail(protocol.ExitFailure, "%v", err) |
| 160 | 257 | } |
| 161 | 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 | 261 | // Who they work with. Both lists are public on a profile — the web |
| 165 | 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 | |
| 228 | 325 | func runProfileSet(c *Ctx, args []string) int { |
| 229 | rest, desc, site, err := parseProfileFlags(args) | |
| 230 | if err != nil || len(rest) != 0 { | |
| 231 | return c.fail(protocol.ExitUsage, "usage: profile set [--description <d>] [--website <url>]") | |
| 326 | rest, e, err := parseProfileFlags(c, args) | |
| 327 | if err != nil { | |
| 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 { | |
| 234 | return c.fail(protocol.ExitUsage, "nothing to set: pass --description and/or --website") | |
| 334 | if e.empty() { | |
| 335 | return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website, --about and/or --link") | |
| 235 | 336 | } |
| 236 | 337 | p, err := c.Store.OwnerProfile("user", c.User.ID) |
| 237 | 338 | if err != nil { |
| 238 | 339 | return c.fail(protocol.ExitFailure, "%v", err) |
| 239 | 340 | } |
| 240 | p, err = applyProfile(p, desc, site) | |
| 341 | p, err = applyProfile(p, e) | |
| 241 | 342 | if err != nil { |
| 242 | 343 | return c.fail(protocol.ExitUsage, "%v", err) |
| 243 | 344 | } |
| @@ -245,16 +346,21 @@ func runProfileSet(c *Ctx, args []string) int { | ||
| 245 | 346 | return c.fail(protocol.ExitFailure, "%v", err) |
| 246 | 347 | } |
| 247 | 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 | |
| 251 | 353 | func runOrgProfile(c *Ctx, args []string) int { |
| 252 | rest, desc, site, err := parseProfileFlags(args) | |
| 253 | if err != nil || len(rest) != 1 { | |
| 254 | return c.fail(protocol.ExitUsage, "usage: org profile <org> [--description <d>] [--website <url>]") | |
| 354 | rest, e, err := parseProfileFlags(c, args) | |
| 355 | if err != nil { | |
| 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 | 362 | name := rest[0] |
| 257 | if desc == nil && site == nil { | |
| 363 | if e.empty() { | |
| 258 | 364 | return runProfileShow(c, []string{name}) |
| 259 | 365 | } |
| 260 | 366 | org, code := orgAdmin(c, name) |
| @@ -265,7 +371,7 @@ func runOrgProfile(c *Ctx, args []string) int { | ||
| 265 | 371 | if err != nil { |
| 266 | 372 | return c.fail(protocol.ExitFailure, "%v", err) |
| 267 | 373 | } |
| 268 | p, err = applyProfile(p, desc, site) | |
| 374 | p, err = applyProfile(p, e) | |
| 269 | 375 | if err != nil { |
| 270 | 376 | return c.fail(protocol.ExitUsage, "%v", err) |
| 271 | 377 | } |
| @@ -273,5 +379,6 @@ func runOrgProfile(c *Ctx, args []string) int { | ||
| 273 | 379 | return c.fail(protocol.ExitFailure, "%v", err) |
| 274 | 380 | } |
| 275 | 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 | 398 | Owner string |
| 399 | 399 | Kind string |
| 400 | 400 | Profile store.Profile |
| 401 | AboutHTML template.HTML | |
| 401 | 402 | Repos []describedRepo |
| 402 | 403 | Members []store.OrgMember |
| 403 | 404 | Orgs []store.OrgMember |
| @@ -406,7 +407,8 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 406 | 407 | Teams []teamView |
| 407 | 408 | CanAdmin bool |
| 408 | 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 | 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 | 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. | |
| 997 | func 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 | 1008 | // webResolver answers autolink lookups for one viewer. Cross-repo |
| 993 | 1009 | // references to repositories the viewer cannot read stay plain text, per |
| 994 | 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 @@ | ||
| 1 | ALTER TABLE users DROP COLUMN about; | |
| 2 | ALTER TABLE users DROP COLUMN about_format; | |
| 3 | ALTER TABLE users DROP COLUMN links; | |
| 4 | ALTER TABLE orgs DROP COLUMN about; | |
| 5 | ALTER TABLE orgs DROP COLUMN about_format; | |
| 6 | ALTER TABLE orgs DROP COLUMN links; | |
internal/store/migrations/0026_profile_about.up.sql added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT ''; | |
| 2 | ALTER TABLE users ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md'; | |
| 3 | ALTER TABLE users ADD COLUMN links TEXT NOT NULL DEFAULT ''; | |
| 4 | ALTER TABLE orgs ADD COLUMN about TEXT NOT NULL DEFAULT ''; | |
| 5 | ALTER TABLE orgs ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md'; | |
| 6 | ALTER TABLE orgs ADD COLUMN links TEXT NOT NULL DEFAULT ''; | |
internal/store/orgs.go +37 −5
| @@ -2,6 +2,7 @@ package store | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "database/sql" |
| 5 | "encoding/json" | |
| 5 | 6 | "errors" |
| 6 | 7 | "fmt" |
| 7 | 8 | ) |
| @@ -220,23 +221,54 @@ func (s *Store) RenameOrg(orgID int64, newName string) error { | ||
| 220 | 221 | type Profile struct { |
| 221 | 222 | Description string `json:"description,omitempty"` |
| 222 | 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. | |
| 232 | type ProfileLink struct { | |
| 233 | Label string `json:"label,omitempty"` | |
| 234 | URL string `json:"url"` | |
| 223 | 235 | } |
| 224 | 236 | |
| 225 | 237 | // OwnerProfile reads the profile for kind "user" or "org". |
| 226 | 238 | func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) { |
| 227 | 239 | table := map[string]string{"user": "users", "org": "orgs"}[kind] |
| 228 | 240 | var p Profile |
| 241 | var linksJSON string | |
| 229 | 242 | err := s.DB.QueryRow( |
| 230 | "SELECT description, website FROM "+table+" WHERE id = ?", id). | |
| 231 | Scan(&p.Description, &p.Website) | |
| 232 | return p, err | |
| 243 | "SELECT description, website, about, about_format, links FROM "+table+" WHERE id = ?", id). | |
| 244 | Scan(&p.Description, &p.Website, &p.About, &p.AboutFormat, &linksJSON) | |
| 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 | 256 | // SetOwnerProfile updates the profile for kind "user" or "org". |
| 236 | 257 | func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error { |
| 237 | 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 | 270 | _, err := s.DB.Exec( |
| 239 | "UPDATE "+table+" SET description = ?, website = ? WHERE id = ?", | |
| 240 | p.Description, p.Website, id) | |
| 271 | "UPDATE "+table+" SET description = ?, website = ?, about = ?, about_format = ?, links = ? WHERE id = ?", | |
| 272 | p.Description, p.Website, p.About, p.AboutFormat, links, id) | |
| 241 | 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 | 701 | .headrow .spacer { flex: 1; } |
| 702 | 702 | |
| 703 | /* member and org chips on owner pages */ | |
| 704 | a.memberchip { | |
| 703 | /* member, org and profile link chips on owner pages */ | |
| 704 | a.memberchip, a.linkchip { | |
| 705 | 705 | display: inline-block; |
| 706 | 706 | border: 1px solid var(--line); |
| 707 | 707 | border-radius: var(--r-pill); |
| @@ -710,7 +710,7 @@ a.memberchip { | ||
| 710 | 710 | font-size: var(--fs-1); |
| 711 | 711 | margin: 0 0.15rem 0.15rem 0; |
| 712 | 712 | } |
| 713 | a.memberchip:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); } | |
| 713 | a.memberchip:hover, a.linkchip:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); } | |
| 714 | 714 | a.memberchip .role { color: var(--muted); } |
| 715 | 715 | |
| 716 | 716 | /* landing page */ |
internal/web/templates/owner.html +2
| @@ -4,9 +4,11 @@ | ||
| 4 | 4 | <h1>{{.Owner}} <span class="chip chip-neutral">{{.Kind}}</span></h1> |
| 5 | 5 | {{if .Profile.Description}}<p class="desc lede">{{.Profile.Description}}</p>{{end}} |
| 6 | 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 | 8 | {{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}} |
| 8 | 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 | 10 | </section> |
| 11 | {{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}} | |
| 10 | 12 | <section class="activity"> |
| 11 | 13 | <h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2> |
| 12 | 14 | <div class="actgraph-scroll"> |