Commit f92774345d
Verified · cmc
Layout: unified · split
cmd/gitbay/main.go +5
| @@ -31,6 +31,10 @@ func main() { | ||
| 31 | 31 | mrCmd(), |
| 32 | 32 | webCmd(), |
| 33 | 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 | 38 | webhookCmd(), |
| 35 | 39 | remoteCmd(), |
| 36 | 40 | initCmd(), |
| @@ -314,6 +318,7 @@ func orgCmd() *cobra.Command { | ||
| 314 | 318 | pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}), |
| 315 | 319 | pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}), |
| 316 | 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 | 322 | group("members", "manage members", |
| 318 | 323 | pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}), |
| 319 | 324 | pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}), |
e2e/profile_test.go added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | "strings" | |
| 8 | ||
| 9 | "gitbay.org/gitbay/internal/protocol" | |
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | ) | |
| 12 | ||
| 13 | func 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. | |
| 24 | func 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 | ||
| 45 | func 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 | ||
| 52 | func 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 | ||
| 70 | type 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 | ||
| 77 | func 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 | ||
| 89 | func 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 | ||
| 111 | func 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 | ||
| 133 | func 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 | 181 | http.NotFound(w, r) |
| 182 | 182 | return |
| 183 | 183 | } |
| 184 | profile, _ := s.st.OwnerProfile(kind, ownerID) | |
| 184 | 185 | |
| 185 | 186 | all, err := s.st.ListReposForOwner(kind, ownerID) |
| 186 | 187 | if err != nil { |
| @@ -202,10 +203,11 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 202 | 203 | Viewer string |
| 203 | 204 | Owner string |
| 204 | 205 | Kind string |
| 206 | Profile store.Profile | |
| 205 | 207 | Repos []describedRepo |
| 206 | 208 | Members []store.OrgMember |
| 207 | 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 | |
| 211 | 213 | func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { |
internal/store/migrations/0007_profiles.down.sql added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | ALTER TABLE users DROP COLUMN description; | |
| 2 | ALTER TABLE users DROP COLUMN website; | |
| 3 | ALTER TABLE orgs DROP COLUMN description; | |
| 4 | ALTER TABLE orgs DROP COLUMN website; | |
internal/store/migrations/0007_profiles.up.sql added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | ALTER TABLE users ADD COLUMN description TEXT NOT NULL DEFAULT ''; | |
| 2 | ALTER TABLE users ADD COLUMN website TEXT NOT NULL DEFAULT ''; | |
| 3 | ALTER TABLE orgs ADD COLUMN description TEXT NOT NULL DEFAULT ''; | |
| 4 | ALTER 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 | 216 | return tx.Commit() |
| 217 | 217 | } |
| 218 | ||
| 219 | // Profile is the presentational half of a user or org. | |
| 220 | type Profile struct { | |
| 221 | Description string | |
| 222 | Website string | |
| 223 | } | |
| 224 | ||
| 225 | // OwnerProfile reads the profile for kind "user" or "org". | |
| 226 | func (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". | |
| 236 | func (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 | 1 | {{define "title"}}{{.Owner}} · {{.Site}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 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 | 6 | {{if .Orgs}}<p class="crumbs">member of: {{range .Orgs}}<a href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}} |
| 5 | 7 | {{if .Members}}<p class="crumbs">members: {{range .Members}}<a href="/{{.Username}}">{{.Username}}</a> ({{.Role}}) {{end}}</p>{{end}} |
| 6 | 8 | <table> |