Commit f92774345d

f92774345dbb1721f44285daac94369e864dfa85

parent: b6b09c54da

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 03:25 UTC

owner profiles: description and website for users and orgs

Migration 0007 adds both columns to users and orgs. profile show/set
(self-service; partial updates, '' clears, website must be http(s)),
org profile for org admins, rendered on owner pages with a nofollow
website link. CLI passthroughs included.

Layout: unified · split

cmd/gitbay/main.go +5
@@ -31,6 +31,10 @@ func main() {
31 mrCmd(), 31 mrCmd(),
32 webCmd(), 32 webCmd(),
33 orgCmd(), 33 orgCmd(),
34 group("profile", "user and org profiles",
35 pass("show", "show a profile: [name]", passOpts{server: []string{"profile", "show"}}),
36 pass("set", "set your profile: [--description d] [--website url]", passOpts{server: []string{"profile", "set"}}),
37 ),
34 webhookCmd(), 38 webhookCmd(),
35 remoteCmd(), 39 remoteCmd(),
36 initCmd(), 40 initCmd(),
@@ -314,6 +318,7 @@ func orgCmd() *cobra.Command {
314 pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}), 318 pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}),
315 pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}), 319 pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}),
316 pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}), 320 pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}),
321 pass("profile", "show or set an org profile: <org> [--description d] [--website url]", passOpts{server: []string{"org", "profile"}}),
317 group("members", "manage members", 322 group("members", "manage members",
318 pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}), 323 pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}),
319 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}), 324 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}),
e2e/profile_test.go added +73
@@ -0,0 +1,73 @@
1package e2e
2
3import (
4 "strings"
5 "testing"
6)
7
8func TestOwnerProfiles(t *testing.T) {
9 inst := startInstance(t)
10 aliceKey := inst.newKey(t, "alice")
11 bobKey := inst.newKey(t, "bob")
12 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
13 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
14
15 // Self-service user profile; website validated.
16 if _, errOut, code := inst.ssh(t, aliceKey, "",
17 "profile", "set", "--description", "'builds small tools'", "--website", "https://alice.example"); code != 0 {
18 t.Fatalf("profile set: %s", errOut)
19 }
20 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--website", "gopher://nope"); code != 2 {
21 t.Fatal("bad website scheme accepted")
22 }
23 out, _, _ := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
24 if !strings.Contains(out, `"description":"builds small tools"`) || !strings.Contains(out, `"website":"https://alice.example"`) {
25 t.Fatalf("profile show: %s", out)
26 }
27
28 // Partial update leaves the other field untouched; empty clears.
29 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--description", "'tinkerer'"); code != 0 {
30 t.Fatal("partial set failed")
31 }
32 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json")
33 if !strings.Contains(out, "tinkerer") || !strings.Contains(out, "alice.example") {
34 t.Fatalf("partial update clobbered website: %s", out)
35 }
36 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--website", "''"); code != 0 {
37 t.Fatal("clear failed")
38 }
39 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json")
40 if strings.Contains(out, "alice.example") {
41 t.Fatalf("website not cleared: %s", out)
42 }
43
44 // Org profile: admin sets, member cannot; no flags shows.
45 if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "workshop"); code != 0 {
46 t.Fatal("org create failed")
47 }
48 if _, _, code := inst.ssh(t, aliceKey, "", "org", "members", "add", "workshop", "bob"); code != 0 {
49 t.Fatal("member add failed")
50 }
51 if _, errOut, code := inst.ssh(t, aliceKey, "",
52 "org", "profile", "workshop", "--description", "'where things get made'", "--website", "https://workshop.example"); code != 0 {
53 t.Fatalf("org profile set: %s", errOut)
54 }
55 if _, _, code := inst.ssh(t, bobKey, "", "org", "profile", "workshop", "--description", "hax"); code != 4 {
56 t.Fatal("member set org profile")
57 }
58 out, _, _ = inst.ssh(t, bobKey, "", "org", "profile", "workshop")
59 if !strings.Contains(out, "where things get made") {
60 t.Fatalf("org profile show: %s", out)
61 }
62
63 // Owner pages render description and website link.
64 status, body := inst.get(t, "/alice")
65 if status != 200 || !strings.Contains(body, "tinkerer") {
66 t.Fatalf("user page profile: %d", status)
67 }
68 status, body = inst.get(t, "/workshop")
69 if status != 200 || !strings.Contains(body, "where things get made") ||
70 !strings.Contains(body, `href="https://workshop.example"`) {
71 t.Fatalf("org page profile: %d\n%s", status, body)
72 }
73}
internal/control/profile.go added +158
@@ -0,0 +1,158 @@
1package control
2
3import (
4 "errors"
5 "fmt"
6 "io"
7 "strings"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func init() {
14 register(Command{Path: []string{"profile", "show"},
15 Summary: "show a user's or org's profile: profile show [name]", ReadOnly: true, Run: runProfileShow})
16 register(Command{Path: []string{"profile", "set"},
17 Summary: "set your profile: profile set [--description <d>] [--website <url>] ('' clears)", Run: runProfileSet})
18 register(Command{Path: []string{"org", "profile"},
19 Summary: "show or set an org's profile: org profile <org> [--description <d>] [--website <url>]", Run: runOrgProfile})
20}
21
22// parseProfileFlags pulls --description/--website out of args; a flag given
23// with an empty value clears the field, an absent flag leaves it untouched.
24func parseProfileFlags(args []string) (rest []string, desc, site *string, err error) {
25 for i := 0; i < len(args); i++ {
26 switch args[i] {
27 case "--description", "--website":
28 if i+1 >= len(args) {
29 return nil, nil, nil, fmt.Errorf("%s requires a value", args[i])
30 }
31 v := args[i+1]
32 if args[i] == "--description" {
33 desc = &v
34 } else {
35 site = &v
36 }
37 i++
38 default:
39 rest = append(rest, args[i])
40 }
41 }
42 return rest, desc, site, nil
43}
44
45func validateWebsite(url string) error {
46 if url == "" || strings.HasPrefix(url, "https://") || strings.HasPrefix(url, "http://") {
47 return nil
48 }
49 return errors.New("website must start with https:// or http://")
50}
51
52func applyProfile(p store.Profile, desc, site *string) (store.Profile, error) {
53 if desc != nil {
54 d, _, _ := strings.Cut(strings.TrimSpace(*desc), "\n")
55 if len(d) > 256 {
56 d = d[:256]
57 }
58 p.Description = d
59 }
60 if site != nil {
61 s := strings.TrimSpace(*site)
62 if err := validateWebsite(s); err != nil {
63 return p, err
64 }
65 p.Website = s
66 }
67 return p, nil
68}
69
70type profileOut struct {
71 Name string `json:"name"`
72 Kind string `json:"kind"`
73 Description string `json:"description,omitempty"`
74 Website string `json:"website,omitempty"`
75}
76
77func emitProfile(c *Ctx, d profileOut) int {
78 return c.emit(d, func(w io.Writer) {
79 fmt.Fprintf(w, "%s (%s)\n", d.Name, d.Kind)
80 if d.Description != "" {
81 fmt.Fprintf(w, "%s\n", d.Description)
82 }
83 if d.Website != "" {
84 fmt.Fprintf(w, "%s\n", d.Website)
85 }
86 })
87}
88
89func runProfileShow(c *Ctx, args []string) int {
90 name := c.User.Username
91 if len(args) == 1 {
92 name = args[0]
93 } else if len(args) > 1 {
94 return c.fail(protocol.ExitUsage, "usage: profile show [name]")
95 }
96 kind, id := "", int64(0)
97 if u, err := c.Store.UserByUsername(name); err == nil {
98 kind, id = "user", u.ID
99 } else if o, err := c.Store.OrgByName(name); err == nil {
100 kind, id = "org", o.ID
101 } else {
102 return c.fail(protocol.ExitNotFound, "no user or organization %q", name)
103 }
104 p, err := c.Store.OwnerProfile(kind, id)
105 if err != nil {
106 return c.fail(protocol.ExitFailure, "%v", err)
107 }
108 return emitProfile(c, profileOut{name, kind, p.Description, p.Website})
109}
110
111func runProfileSet(c *Ctx, args []string) int {
112 rest, desc, site, err := parseProfileFlags(args)
113 if err != nil || len(rest) != 0 {
114 return c.fail(protocol.ExitUsage, "usage: profile set [--description <d>] [--website <url>]")
115 }
116 if desc == nil && site == nil {
117 return c.fail(protocol.ExitUsage, "nothing to set: pass --description and/or --website")
118 }
119 p, err := c.Store.OwnerProfile("user", c.User.ID)
120 if err != nil {
121 return c.fail(protocol.ExitFailure, "%v", err)
122 }
123 p, err = applyProfile(p, desc, site)
124 if err != nil {
125 return c.fail(protocol.ExitUsage, "%v", err)
126 }
127 if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil {
128 return c.fail(protocol.ExitFailure, "%v", err)
129 }
130 return emitProfile(c, profileOut{c.User.Username, "user", p.Description, p.Website})
131}
132
133func runOrgProfile(c *Ctx, args []string) int {
134 rest, desc, site, err := parseProfileFlags(args)
135 if err != nil || len(rest) != 1 {
136 return c.fail(protocol.ExitUsage, "usage: org profile <org> [--description <d>] [--website <url>]")
137 }
138 name := rest[0]
139 if desc == nil && site == nil {
140 return runProfileShow(c, []string{name})
141 }
142 org, code := orgAdmin(c, name)
143 if code >= 0 {
144 return code
145 }
146 p, err := c.Store.OwnerProfile("org", org.ID)
147 if err != nil {
148 return c.fail(protocol.ExitFailure, "%v", err)
149 }
150 p, err = applyProfile(p, desc, site)
151 if err != nil {
152 return c.fail(protocol.ExitUsage, "%v", err)
153 }
154 if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil {
155 return c.fail(protocol.ExitFailure, "%v", err)
156 }
157 return emitProfile(c, profileOut{org.Name, "org", p.Description, p.Website})
158}
internal/httpd/web.go +3 −1
@@ -181,6 +181,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
181 http.NotFound(w, r) 181 http.NotFound(w, r)
182 return 182 return
183 } 183 }
184 profile, _ := s.st.OwnerProfile(kind, ownerID)
184 185
185 all, err := s.st.ListReposForOwner(kind, ownerID) 186 all, err := s.st.ListReposForOwner(kind, ownerID)
186 if err != nil { 187 if err != nil {
@@ -202,10 +203,11 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
202 Viewer string 203 Viewer string
203 Owner string 204 Owner string
204 Kind string 205 Kind string
206 Profile store.Profile
205 Repos []describedRepo 207 Repos []describedRepo
206 Members []store.OrgMember 208 Members []store.OrgMember
207 Orgs []store.OrgMember 209 Orgs []store.OrgMember
208 }{s.siteName(), viewer.Username, name, kind, s.describeAll(visible), members, orgs}) 210 }{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs})
209} 211}
210 212
211func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { 213func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
internal/store/migrations/0007_profiles.down.sql added +4
@@ -0,0 +1,4 @@
1ALTER TABLE users DROP COLUMN description;
2ALTER TABLE users DROP COLUMN website;
3ALTER TABLE orgs DROP COLUMN description;
4ALTER TABLE orgs DROP COLUMN website;
internal/store/migrations/0007_profiles.up.sql added +4
@@ -0,0 +1,4 @@
1ALTER TABLE users ADD COLUMN description TEXT NOT NULL DEFAULT '';
2ALTER TABLE users ADD COLUMN website TEXT NOT NULL DEFAULT '';
3ALTER TABLE orgs ADD COLUMN description TEXT NOT NULL DEFAULT '';
4ALTER TABLE orgs ADD COLUMN website TEXT NOT NULL DEFAULT '';
internal/store/orgs.go +25
@@ -215,3 +215,28 @@ func (s *Store) RenameOrg(orgID int64, newName string) error {
215 } 215 }
216 return tx.Commit() 216 return tx.Commit()
217} 217}
218
219// Profile is the presentational half of a user or org.
220type Profile struct {
221 Description string
222 Website string
223}
224
225// OwnerProfile reads the profile for kind "user" or "org".
226func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) {
227 table := map[string]string{"user": "users", "org": "orgs"}[kind]
228 var p Profile
229 err := s.DB.QueryRow(
230 "SELECT description, website FROM "+table+" WHERE id = ?", id).
231 Scan(&p.Description, &p.Website)
232 return p, err
233}
234
235// SetOwnerProfile updates the profile for kind "user" or "org".
236func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error {
237 table := map[string]string{"user": "users", "org": "orgs"}[kind]
238 _, err := s.DB.Exec(
239 "UPDATE "+table+" SET description = ?, website = ? WHERE id = ?",
240 p.Description, p.Website, id)
241 return err
242}
internal/web/templates/owner.html +2
@@ -1,6 +1,8 @@
1{{define "title"}}{{.Owner}} · {{.Site}}{{end}} 1{{define "title"}}{{.Owner}} · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1>{{.Owner}} <span class="badge badge-unsigned">{{.Kind}}</span></h1> 3<h1>{{.Owner}} <span class="badge badge-unsigned">{{.Kind}}</span></h1>
4{{if .Profile.Description}}<p class="desc">{{.Profile.Description}}</p>{{end}}
5{{if .Profile.Website}}<p class="crumbs"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}}
4{{if .Orgs}}<p class="crumbs">member of: {{range .Orgs}}<a href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}} 6{{if .Orgs}}<p class="crumbs">member of: {{range .Orgs}}<a href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}}
5{{if .Members}}<p class="crumbs">members: {{range .Members}}<a href="/{{.Username}}">{{.Username}}</a> ({{.Role}}) {{end}}</p>{{end}} 7{{if .Members}}<p class="crumbs">members: {{range .Members}}<a href="/{{.Username}}">{{.Username}}</a> ({{.Role}}) {{end}}</p>{{end}}
6<table> 8<table>