Commit 08edfb55e9
Verified · cmc
Layout: unified · split
cmd/gitbay/main.go +15
| @@ -30,6 +30,7 @@ func main() { | |||
| 30 | issueCmd(), | 30 | issueCmd(), |
| 31 | mrCmd(), | 31 | mrCmd(), |
| 32 | webCmd(), | 32 | webCmd(), |
| 33 | orgCmd(), | ||
| 33 | remoteCmd(), | 34 | remoteCmd(), |
| 34 | initCmd(), | 35 | initCmd(), |
| 35 | pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", | 36 | pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", |
| @@ -287,6 +288,20 @@ func webCmd() *cobra.Command { | |||
| 287 | ) | 288 | ) |
| 288 | } | 289 | } |
| 289 | 290 | ||
| 291 | func orgCmd() *cobra.Command { | ||
| 292 | return group("org", "organizations", | ||
| 293 | pass("create", "create an organization", passOpts{server: []string{"org", "create"}}), | ||
| 294 | pass("list", "list organizations you belong to", passOpts{server: []string{"org", "list"}}), | ||
| 295 | pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}), | ||
| 296 | pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}), | ||
| 297 | group("members", "manage members", | ||
| 298 | pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}), | ||
| 299 | pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}), | ||
| 300 | pass("list", "list members: <org>", passOpts{server: []string{"org", "members", "list"}}), | ||
| 301 | ), | ||
| 302 | ) | ||
| 303 | } | ||
| 304 | |||
| 290 | func remoteCmd() *cobra.Command { | 305 | func remoteCmd() *cobra.Command { |
| 291 | return group("remote", "local instance profiles (no server contact)", | 306 | return group("remote", "local instance profiles (no server contact)", |
| 292 | local("add", "add a named gitbay instance: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]", | 307 | local("add", "add a named gitbay instance: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]", |
e2e/org_test.go added +130
| @@ -0,0 +1,130 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func TestOrganizations(t *testing.T) { | ||
| 11 | inst := startInstance(t) | ||
| 12 | aliceKey := inst.newKey(t, "alice") | ||
| 13 | bobKey := inst.newKey(t, "bob") | ||
| 14 | eveKey := inst.newKey(t, "eve") | ||
| 15 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 16 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 17 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 18 | inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub") | ||
| 19 | |||
| 20 | // Alice creates an org; the namespace is shared with users. | ||
| 21 | if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "krz"); code != 0 { | ||
| 22 | t.Fatalf("org create: %s", errOut) | ||
| 23 | } | ||
| 24 | if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "bob"); code == 0 { | ||
| 25 | t.Fatal("org created with a user's name") | ||
| 26 | } | ||
| 27 | if out := inst.admin(t, "admin", "user", "create", "krz2", "--key", inst.newKey(t, "krz2")+".pub"); out == "" { | ||
| 28 | t.Fatal("control user create failed") | ||
| 29 | } | ||
| 30 | // A user cannot claim an org's name either. | ||
| 31 | cmd := inst.forgedAdminErr(t, "admin", "user", "create", "krz") | ||
| 32 | if !strings.Contains(cmd, "taken") { | ||
| 33 | t.Fatalf("user with org name: %s", cmd) | ||
| 34 | } | ||
| 35 | |||
| 36 | // Only org admins create repos under the org. | ||
| 37 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "create", "krz/lib"); code != 4 { | ||
| 38 | t.Fatalf("non-member org repo create: %d %s", code, errOut) | ||
| 39 | } | ||
| 40 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "krz/lib", "--private"); code != 0 { | ||
| 41 | t.Fatalf("org repo create: %s", errOut) | ||
| 42 | } | ||
| 43 | |||
| 44 | // Membership-derived access: bob (member) gets write, eve (outsider) | ||
| 45 | // sees nothing on the private repo. | ||
| 46 | if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "members", "add", "krz", "bob"); code != 0 { | ||
| 47 | t.Fatalf("members add: %s", errOut) | ||
| 48 | } | ||
| 49 | work := t.TempDir() | ||
| 50 | bobEnv := inst.gitEnv(bobKey) | ||
| 51 | mustGit(t, work, bobEnv, "clone", inst.sshURL("krz/lib"), "w") | ||
| 52 | dir := filepath.Join(work, "w") | ||
| 53 | os.WriteFile(filepath.Join(dir, "f.txt"), []byte("org work\n"), 0o644) | ||
| 54 | mustGit(t, dir, bobEnv, "checkout", "-q", "-b", "main") | ||
| 55 | mustGit(t, dir, bobEnv, "add", ".") | ||
| 56 | mustGit(t, dir, bobEnv, "commit", "-q", "-m", "bob pushes to org repo") | ||
| 57 | mustGit(t, dir, bobEnv, "push", "-q", "origin", "main") | ||
| 58 | |||
| 59 | if out, code := gitRun(t, t.TempDir(), inst.gitEnv(eveKey), "clone", inst.sshURL("krz/lib")); code == 0 || !strings.Contains(out, "repository not found") { | ||
| 60 | t.Fatalf("outsider on private org repo: %d\n%s", code, out) | ||
| 61 | } | ||
| 62 | |||
| 63 | // Members are not repo admins: bob cannot change settings or grant | ||
| 64 | // access; an org admin can. | ||
| 65 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "settings", "protect", "krz/lib", "main"); code != 4 { | ||
| 66 | t.Fatal("member changed org repo settings") | ||
| 67 | } | ||
| 68 | if _, _, code := inst.ssh(t, bobKey, "", "org", "members", "add", "krz", "eve"); code != 4 { | ||
| 69 | t.Fatal("member managed org membership") | ||
| 70 | } | ||
| 71 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "krz/lib", "main"); code != 0 { | ||
| 72 | t.Fatalf("org admin protect: %s", errOut) | ||
| 73 | } | ||
| 74 | |||
| 75 | // Explicit per-repo grants still work alongside membership: eve gets | ||
| 76 | // read on the private org repo. | ||
| 77 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "krz/lib", "eve", "read"); code != 0 { | ||
| 78 | t.Fatalf("grant: %s", errOut) | ||
| 79 | } | ||
| 80 | mustGit(t, t.TempDir(), inst.gitEnv(eveKey), "clone", inst.sshURL("krz/lib")) | ||
| 81 | |||
| 82 | // Org repos list for members; org shows in org list. | ||
| 83 | out, _, _ := inst.ssh(t, bobKey, "", "repo", "list") | ||
| 84 | if !strings.Contains(out, "krz/lib") { | ||
| 85 | t.Fatalf("member repo list missing org repo:\n%s", out) | ||
| 86 | } | ||
| 87 | out, _, _ = inst.ssh(t, bobKey, "", "org", "list") | ||
| 88 | if !strings.Contains(out, "krz\tmember") { | ||
| 89 | t.Fatalf("org list: %s", out) | ||
| 90 | } | ||
| 91 | |||
| 92 | // Promotion works; the last admin is protected. | ||
| 93 | if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "members", "add", "krz", "bob", "--role", "admin"); code != 0 { | ||
| 94 | t.Fatalf("promote: %s", errOut) | ||
| 95 | } | ||
| 96 | if _, errOut, code := inst.ssh(t, bobKey, "", "org", "members", "remove", "krz", "alice"); code != 0 { | ||
| 97 | t.Fatalf("bob (now admin) removing alice: %s", errOut) | ||
| 98 | } | ||
| 99 | _, errOut, code := inst.ssh(t, bobKey, "", "org", "members", "remove", "krz", "bob") | ||
| 100 | if code != 2 || !strings.Contains(errOut, "at least one admin") { | ||
| 101 | t.Fatalf("last admin removal: %d %s", code, errOut) | ||
| 102 | } | ||
| 103 | |||
| 104 | // Org deletion refuses while repos exist, then succeeds. | ||
| 105 | _, errOut, code = inst.ssh(t, bobKey, "", "org", "delete", "krz", "--yes") | ||
| 106 | if code != 1 || !strings.Contains(errOut, "still owns") { | ||
| 107 | t.Fatalf("delete with repos: %d %s", code, errOut) | ||
| 108 | } | ||
| 109 | if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "delete", "krz/lib", "--yes"); code != 0 { | ||
| 110 | t.Fatalf("org repo delete: %s", errOut) | ||
| 111 | } | ||
| 112 | if _, errOut, code = inst.ssh(t, bobKey, "", "org", "delete", "krz", "--yes"); code != 0 { | ||
| 113 | t.Fatalf("org delete: %s", errOut) | ||
| 114 | } | ||
| 115 | |||
| 116 | // Public org repos appear on the anonymous web index. | ||
| 117 | if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "puborg"); code != 0 { | ||
| 118 | t.Fatal("org create failed") | ||
| 119 | } | ||
| 120 | if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "puborg/site"); code != 0 { | ||
| 121 | t.Fatal("org public repo failed") | ||
| 122 | } | ||
| 123 | status, body := inst.get(t, "/") | ||
| 124 | if status != 200 || !strings.Contains(body, "puborg/site") { | ||
| 125 | t.Fatalf("org repo missing from index: %d", status) | ||
| 126 | } | ||
| 127 | if status, _ := inst.get(t, "/puborg/site"); status != 200 { | ||
| 128 | t.Fatalf("org repo page: %d", status) | ||
| 129 | } | ||
| 130 | } | ||
e2e/ssh_test.go +11
| @@ -115,6 +115,17 @@ func (i *instance) admin(t *testing.T, args ...string) string { | |||
| 115 | return string(out) | 115 | return string(out) |
| 116 | } | 116 | } |
| 117 | 117 | ||
| 118 | // forgedAdminErr runs an admin command expected to fail, returning output. | ||
| 119 | func (i *instance) forgedAdminErr(t *testing.T, args ...string) string { | ||
| 120 | t.Helper() | ||
| 121 | cmd := exec.Command(i.gitbayd, append([]string{"--config", i.config}, args...)...) | ||
| 122 | out, err := cmd.CombinedOutput() | ||
| 123 | if err == nil { | ||
| 124 | t.Fatalf("gitbayd %v unexpectedly succeeded:\n%s", args, out) | ||
| 125 | } | ||
| 126 | return string(out) | ||
| 127 | } | ||
| 128 | |||
| 118 | // newKey generates a client keypair and returns the private key path. | 129 | // newKey generates a client keypair and returns the private key path. |
| 119 | func (i *instance) newKey(t *testing.T, name string) string { | 130 | func (i *instance) newKey(t *testing.T, name string) string { |
| 120 | t.Helper() | 131 | t.Helper() |
internal/control/org.go added +216
| @@ -0,0 +1,216 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/policy" | ||
| 9 | "gitbay.org/gitbay/internal/protocol" | ||
| 10 | "gitbay.org/gitbay/internal/store" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func init() { | ||
| 14 | register(Command{Path: []string{"org", "create"}, | ||
| 15 | Summary: "create an organization (you become its first admin): org create <name>", Run: runOrgCreate}) | ||
| 16 | register(Command{Path: []string{"org", "list"}, | ||
| 17 | Summary: "list organizations you belong to", Run: runOrgList}) | ||
| 18 | register(Command{Path: []string{"org", "show"}, | ||
| 19 | Summary: "show an organization and its members: org show <name>", Run: runOrgShow}) | ||
| 20 | register(Command{Path: []string{"org", "delete"}, | ||
| 21 | Summary: "delete an empty organization: org delete <name> --yes", Run: runOrgDelete}) | ||
| 22 | register(Command{Path: []string{"org", "members", "add"}, | ||
| 23 | Summary: "add or update a member: org members add <org> <user> [--role member|admin]", Run: runOrgMembersAdd}) | ||
| 24 | register(Command{Path: []string{"org", "members", "remove"}, | ||
| 25 | Summary: "remove a member: org members remove <org> <user>", Run: runOrgMembersRemove}) | ||
| 26 | register(Command{Path: []string{"org", "members", "list"}, | ||
| 27 | Summary: "list members: org members list <org>", Run: runOrgMembersList}) | ||
| 28 | } | ||
| 29 | |||
| 30 | // orgAdmin loads an org and requires the caller to be one of its admins. | ||
| 31 | func orgAdmin(c *Ctx, name string) (store.Org, int) { | ||
| 32 | org, err := c.Store.OrgByName(name) | ||
| 33 | if errors.Is(err, store.ErrNotFound) { | ||
| 34 | return org, c.fail(protocol.ExitNotFound, "no organization %q", name) | ||
| 35 | } | ||
| 36 | if err != nil { | ||
| 37 | return org, c.fail(protocol.ExitFailure, "%v", err) | ||
| 38 | } | ||
| 39 | role, err := c.Store.OrgRole(org.ID, c.User.ID) | ||
| 40 | if err != nil { | ||
| 41 | return org, c.fail(protocol.ExitFailure, "%v", err) | ||
| 42 | } | ||
| 43 | if role != "admin" { | ||
| 44 | return org, c.fail(protocol.ExitDenied, "only admins of %s can do that", name) | ||
| 45 | } | ||
| 46 | return org, -1 | ||
| 47 | } | ||
| 48 | |||
| 49 | func runOrgCreate(c *Ctx, args []string) int { | ||
| 50 | if len(args) != 1 { | ||
| 51 | return c.fail(protocol.ExitUsage, "usage: org create <name>") | ||
| 52 | } | ||
| 53 | if err := policy.ValidateOwnerName(args[0]); err != nil { | ||
| 54 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 55 | } | ||
| 56 | if _, err := c.Store.CreateOrg(args[0], c.User.ID); err != nil { | ||
| 57 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 58 | } | ||
| 59 | return c.emit(map[string]string{"org": args[0], "role": "admin"}, func(w io.Writer) { | ||
| 60 | fmt.Fprintf(w, "created organization %s; you are its admin\n", args[0]) | ||
| 61 | }) | ||
| 62 | } | ||
| 63 | |||
| 64 | func runOrgList(c *Ctx, args []string) int { | ||
| 65 | orgs, err := c.Store.ListOrgsForUser(c.User.ID) | ||
| 66 | if err != nil { | ||
| 67 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 68 | } | ||
| 69 | type out struct { | ||
| 70 | Org string `json:"org"` | ||
| 71 | Role string `json:"role"` | ||
| 72 | } | ||
| 73 | var ds []out | ||
| 74 | for _, o := range orgs { | ||
| 75 | ds = append(ds, out{o.Username, o.Role}) | ||
| 76 | } | ||
| 77 | return c.emit(ds, func(w io.Writer) { | ||
| 78 | for _, d := range ds { | ||
| 79 | fmt.Fprintf(w, "%s\t%s\n", d.Org, d.Role) | ||
| 80 | } | ||
| 81 | }) | ||
| 82 | } | ||
| 83 | |||
| 84 | func runOrgShow(c *Ctx, args []string) int { | ||
| 85 | if len(args) != 1 { | ||
| 86 | return c.fail(protocol.ExitUsage, "usage: org show <name>") | ||
| 87 | } | ||
| 88 | org, err := c.Store.OrgByName(args[0]) | ||
| 89 | if errors.Is(err, store.ErrNotFound) { | ||
| 90 | return c.fail(protocol.ExitNotFound, "no organization %q", args[0]) | ||
| 91 | } | ||
| 92 | if err != nil { | ||
| 93 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 94 | } | ||
| 95 | members, err := c.Store.OrgMembers(org.ID) | ||
| 96 | if err != nil { | ||
| 97 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 98 | } | ||
| 99 | type memberOut struct { | ||
| 100 | User string `json:"user"` | ||
| 101 | Role string `json:"role"` | ||
| 102 | } | ||
| 103 | var ms []memberOut | ||
| 104 | for _, m := range members { | ||
| 105 | ms = append(ms, memberOut{m.Username, m.Role}) | ||
| 106 | } | ||
| 107 | d := struct { | ||
| 108 | Org string `json:"org"` | ||
| 109 | Members []memberOut `json:"members"` | ||
| 110 | }{org.Name, ms} | ||
| 111 | return c.emit(d, func(w io.Writer) { | ||
| 112 | fmt.Fprintf(w, "%s\n", d.Org) | ||
| 113 | for _, m := range ms { | ||
| 114 | fmt.Fprintf(w, " %s\t%s\n", m.User, m.Role) | ||
| 115 | } | ||
| 116 | }) | ||
| 117 | } | ||
| 118 | |||
| 119 | func runOrgDelete(c *Ctx, args []string) int { | ||
| 120 | var name string | ||
| 121 | yes := false | ||
| 122 | for _, a := range args { | ||
| 123 | if a == "--yes" { | ||
| 124 | yes = true | ||
| 125 | } else if name == "" { | ||
| 126 | name = a | ||
| 127 | } else { | ||
| 128 | return c.fail(protocol.ExitUsage, "usage: org delete <name> --yes") | ||
| 129 | } | ||
| 130 | } | ||
| 131 | if name == "" { | ||
| 132 | return c.fail(protocol.ExitUsage, "usage: org delete <name> --yes") | ||
| 133 | } | ||
| 134 | org, code := orgAdmin(c, name) | ||
| 135 | if code >= 0 { | ||
| 136 | return code | ||
| 137 | } | ||
| 138 | if !yes { | ||
| 139 | return c.fail(protocol.ExitUsage, "org delete is permanent; re-run with --yes") | ||
| 140 | } | ||
| 141 | if err := c.Store.DeleteOrg(org.ID); err != nil { | ||
| 142 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 143 | } | ||
| 144 | return c.emit(map[string]string{"deleted": name}, func(w io.Writer) { | ||
| 145 | fmt.Fprintf(w, "deleted organization %s\n", name) | ||
| 146 | }) | ||
| 147 | } | ||
| 148 | |||
| 149 | func runOrgMembersAdd(c *Ctx, args []string) int { | ||
| 150 | role := "member" | ||
| 151 | var rest []string | ||
| 152 | for i := 0; i < len(args); i++ { | ||
| 153 | if args[i] == "--role" { | ||
| 154 | if i+1 >= len(args) { | ||
| 155 | return c.fail(protocol.ExitUsage, "--role requires member|admin") | ||
| 156 | } | ||
| 157 | role = args[i+1] | ||
| 158 | i++ | ||
| 159 | continue | ||
| 160 | } | ||
| 161 | rest = append(rest, args[i]) | ||
| 162 | } | ||
| 163 | if len(rest) != 2 || (role != "member" && role != "admin") { | ||
| 164 | return c.fail(protocol.ExitUsage, "usage: org members add <org> <user> [--role member|admin]") | ||
| 165 | } | ||
| 166 | org, code := orgAdmin(c, rest[0]) | ||
| 167 | if code >= 0 { | ||
| 168 | return code | ||
| 169 | } | ||
| 170 | target, err := c.Store.UserByUsername(rest[1]) | ||
| 171 | if errors.Is(err, store.ErrNotFound) { | ||
| 172 | return c.fail(protocol.ExitNotFound, "no such user %q", rest[1]) | ||
| 173 | } | ||
| 174 | if err != nil { | ||
| 175 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 176 | } | ||
| 177 | if err := c.Store.SetOrgMember(org.ID, target.ID, role); err != nil { | ||
| 178 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 179 | } | ||
| 180 | return c.emit(map[string]string{"org": org.Name, "user": target.Username, "role": role}, func(w io.Writer) { | ||
| 181 | fmt.Fprintf(w, "%s is now a %s of %s\n", target.Username, role, org.Name) | ||
| 182 | }) | ||
| 183 | } | ||
| 184 | |||
| 185 | func runOrgMembersRemove(c *Ctx, args []string) int { | ||
| 186 | if len(args) != 2 { | ||
| 187 | return c.fail(protocol.ExitUsage, "usage: org members remove <org> <user>") | ||
| 188 | } | ||
| 189 | org, code := orgAdmin(c, args[0]) | ||
| 190 | if code >= 0 { | ||
| 191 | return code | ||
| 192 | } | ||
| 193 | target, err := c.Store.UserByUsername(args[1]) | ||
| 194 | if errors.Is(err, store.ErrNotFound) { | ||
| 195 | return c.fail(protocol.ExitNotFound, "no such user %q", args[1]) | ||
| 196 | } | ||
| 197 | if err != nil { | ||
| 198 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 199 | } | ||
| 200 | if err := c.Store.RemoveOrgMember(org.ID, target.ID); err != nil { | ||
| 201 | if errors.Is(err, store.ErrNotFound) { | ||
| 202 | return c.fail(protocol.ExitNotFound, "%s is not a member of %s", target.Username, org.Name) | ||
| 203 | } | ||
| 204 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 205 | } | ||
| 206 | return c.emit(map[string]string{"org": org.Name, "removed": target.Username}, func(w io.Writer) { | ||
| 207 | fmt.Fprintf(w, "removed %s from %s\n", target.Username, org.Name) | ||
| 208 | }) | ||
| 209 | } | ||
| 210 | |||
| 211 | func runOrgMembersList(c *Ctx, args []string) int { | ||
| 212 | if len(args) != 1 { | ||
| 213 | return c.fail(protocol.ExitUsage, "usage: org members list <org>") | ||
| 214 | } | ||
| 215 | return runOrgShow(c, args) | ||
| 216 | } | ||
internal/control/repo.go +16 −4
| @@ -90,13 +90,25 @@ func runRepoCreate(c *Ctx, args []string) int { | |||
| 90 | if !ok { | 90 | if !ok { |
| 91 | return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]") | 91 | return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]") |
| 92 | } | 92 | } |
| 93 | if owner != c.User.Username { | ||
| 94 | return c.fail(protocol.ExitDenied, "cannot create repositories under %q (orgs not yet supported)", owner) | ||
| 95 | } | ||
| 96 | if err := policyValidateRepoName(name); err != nil { | 93 | if err := policyValidateRepoName(name); err != nil { |
| 97 | return c.fail(protocol.ExitUsage, "%v", err) | 94 | return c.fail(protocol.ExitUsage, "%v", err) |
| 98 | } | 95 | } |
| 99 | id, err := c.Store.CreateRepo("user", c.User.ID, name, visibility) | 96 | ownerKind, ownerID := "user", c.User.ID |
| 97 | if owner != c.User.Username { | ||
| 98 | org, err := c.Store.OrgByName(owner) | ||
| 99 | if err != nil { | ||
| 100 | return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner) | ||
| 101 | } | ||
| 102 | role, err := c.Store.OrgRole(org.ID, c.User.ID) | ||
| 103 | if err != nil { | ||
| 104 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 105 | } | ||
| 106 | if role != "admin" { | ||
| 107 | return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner) | ||
| 108 | } | ||
| 109 | ownerKind, ownerID = "org", org.ID | ||
| 110 | } | ||
| 111 | id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) | ||
| 100 | if err != nil { | 112 | if err != nil { |
| 101 | return c.fail(protocol.ExitFailure, "%v", err) | 113 | return c.fail(protocol.ExitFailure, "%v", err) |
| 102 | } | 114 | } |
internal/store/orgs.go added +192
| @@ -0,0 +1,192 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | ) | ||
| 8 | |||
| 9 | type Org struct { | ||
| 10 | ID int64 | ||
| 11 | Name string | ||
| 12 | } | ||
| 13 | |||
| 14 | type OrgMember struct { | ||
| 15 | Username string | ||
| 16 | Role string // member | admin | ||
| 17 | } | ||
| 18 | |||
| 19 | // ownerNameTaken reports whether name is claimed by any user or org. Users | ||
| 20 | // and orgs share one namespace: /<owner>/<repo> must be unambiguous. | ||
| 21 | func ownerNameTaken(q interface { | ||
| 22 | QueryRow(string, ...any) *sql.Row | ||
| 23 | }, name string) (bool, error) { | ||
| 24 | var n int | ||
| 25 | err := q.QueryRow( | ||
| 26 | "SELECT (SELECT COUNT(*) FROM users WHERE username = ?) + (SELECT COUNT(*) FROM orgs WHERE name = ?)", | ||
| 27 | name, name).Scan(&n) | ||
| 28 | return n > 0, err | ||
| 29 | } | ||
| 30 | |||
| 31 | // CreateOrg makes an organization with the creator as its first admin. | ||
| 32 | func (s *Store) CreateOrg(name string, creatorID int64) (int64, error) { | ||
| 33 | tx, err := s.DB.Begin() | ||
| 34 | if err != nil { | ||
| 35 | return 0, err | ||
| 36 | } | ||
| 37 | defer tx.Rollback() | ||
| 38 | taken, err := ownerNameTaken(tx, name) | ||
| 39 | if err != nil { | ||
| 40 | return 0, err | ||
| 41 | } | ||
| 42 | if taken { | ||
| 43 | return 0, fmt.Errorf("the name %q is taken", name) | ||
| 44 | } | ||
| 45 | res, err := tx.Exec("INSERT INTO orgs (name) VALUES (?)", name) | ||
| 46 | if err != nil { | ||
| 47 | return 0, err | ||
| 48 | } | ||
| 49 | id, err := res.LastInsertId() | ||
| 50 | if err != nil { | ||
| 51 | return 0, err | ||
| 52 | } | ||
| 53 | if _, err := tx.Exec( | ||
| 54 | "INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, 'admin')", id, creatorID); err != nil { | ||
| 55 | return 0, err | ||
| 56 | } | ||
| 57 | return id, tx.Commit() | ||
| 58 | } | ||
| 59 | |||
| 60 | func (s *Store) OrgByName(name string) (Org, error) { | ||
| 61 | var o Org | ||
| 62 | err := s.DB.QueryRow("SELECT id, name FROM orgs WHERE name = ?", name).Scan(&o.ID, &o.Name) | ||
| 63 | if errors.Is(err, sql.ErrNoRows) { | ||
| 64 | return o, ErrNotFound | ||
| 65 | } | ||
| 66 | return o, err | ||
| 67 | } | ||
| 68 | |||
| 69 | // OrgRole returns the user's role in the org ("" for non-members). | ||
| 70 | func (s *Store) OrgRole(orgID, userID int64) (string, error) { | ||
| 71 | var role string | ||
| 72 | err := s.DB.QueryRow( | ||
| 73 | "SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID).Scan(&role) | ||
| 74 | if errors.Is(err, sql.ErrNoRows) { | ||
| 75 | return "", nil | ||
| 76 | } | ||
| 77 | return role, err | ||
| 78 | } | ||
| 79 | |||
| 80 | func (s *Store) OrgMembers(orgID int64) ([]OrgMember, error) { | ||
| 81 | rows, err := s.DB.Query(` | ||
| 82 | SELECT u.username, m.role FROM org_members m | ||
| 83 | JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY u.username`, orgID) | ||
| 84 | if err != nil { | ||
| 85 | return nil, err | ||
| 86 | } | ||
| 87 | defer rows.Close() | ||
| 88 | var out []OrgMember | ||
| 89 | for rows.Next() { | ||
| 90 | var m OrgMember | ||
| 91 | if err := rows.Scan(&m.Username, &m.Role); err != nil { | ||
| 92 | return nil, err | ||
| 93 | } | ||
| 94 | out = append(out, m) | ||
| 95 | } | ||
| 96 | return out, rows.Err() | ||
| 97 | } | ||
| 98 | |||
| 99 | // ListOrgsForUser returns the orgs the user belongs to, with their role. | ||
| 100 | func (s *Store) ListOrgsForUser(userID int64) ([]OrgMember, error) { | ||
| 101 | rows, err := s.DB.Query(` | ||
| 102 | SELECT o.name, m.role FROM org_members m | ||
| 103 | JOIN orgs o ON o.id = m.org_id WHERE m.user_id = ? ORDER BY o.name`, userID) | ||
| 104 | if err != nil { | ||
| 105 | return nil, err | ||
| 106 | } | ||
| 107 | defer rows.Close() | ||
| 108 | var out []OrgMember | ||
| 109 | for rows.Next() { | ||
| 110 | var m OrgMember | ||
| 111 | if err := rows.Scan(&m.Username, &m.Role); err != nil { | ||
| 112 | return nil, err | ||
| 113 | } | ||
| 114 | out = append(out, m) | ||
| 115 | } | ||
| 116 | return out, rows.Err() | ||
| 117 | } | ||
| 118 | |||
| 119 | // SetOrgMember adds a member or updates their role. Demoting the last admin | ||
| 120 | // is refused: an org must always have one. | ||
| 121 | func (s *Store) SetOrgMember(orgID, userID int64, role string) error { | ||
| 122 | tx, err := s.DB.Begin() | ||
| 123 | if err != nil { | ||
| 124 | return err | ||
| 125 | } | ||
| 126 | defer tx.Rollback() | ||
| 127 | if role == "member" { | ||
| 128 | ok, err := wouldKeepAdmin(tx, orgID, userID) | ||
| 129 | if err != nil { | ||
| 130 | return err | ||
| 131 | } | ||
| 132 | if !ok { | ||
| 133 | return errors.New("an organization needs at least one admin") | ||
| 134 | } | ||
| 135 | } | ||
| 136 | if _, err := tx.Exec(` | ||
| 137 | INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, ?) | ||
| 138 | ON CONFLICT (org_id, user_id) DO UPDATE SET role = excluded.role`, | ||
| 139 | orgID, userID, role); err != nil { | ||
| 140 | return err | ||
| 141 | } | ||
| 142 | return tx.Commit() | ||
| 143 | } | ||
| 144 | |||
| 145 | // RemoveOrgMember drops a member, refusing to remove the last admin. | ||
| 146 | func (s *Store) RemoveOrgMember(orgID, userID int64) error { | ||
| 147 | tx, err := s.DB.Begin() | ||
| 148 | if err != nil { | ||
| 149 | return err | ||
| 150 | } | ||
| 151 | defer tx.Rollback() | ||
| 152 | ok, err := wouldKeepAdmin(tx, orgID, userID) | ||
| 153 | if err != nil { | ||
| 154 | return err | ||
| 155 | } | ||
| 156 | if !ok { | ||
| 157 | return errors.New("an organization needs at least one admin") | ||
| 158 | } | ||
| 159 | res, err := tx.Exec("DELETE FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID) | ||
| 160 | if err != nil { | ||
| 161 | return err | ||
| 162 | } | ||
| 163 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 164 | return ErrNotFound | ||
| 165 | } | ||
| 166 | return tx.Commit() | ||
| 167 | } | ||
| 168 | |||
| 169 | // wouldKeepAdmin reports whether the org keeps at least one admin after | ||
| 170 | // userID stops being one. | ||
| 171 | func wouldKeepAdmin(tx *sql.Tx, orgID, userID int64) (bool, error) { | ||
| 172 | var n int | ||
| 173 | err := tx.QueryRow( | ||
| 174 | "SELECT COUNT(*) FROM org_members WHERE org_id = ? AND role = 'admin' AND user_id <> ?", | ||
| 175 | orgID, userID).Scan(&n) | ||
| 176 | return n > 0, err | ||
| 177 | } | ||
| 178 | |||
| 179 | // DeleteOrg removes an empty organization; orgs still owning repositories | ||
| 180 | // are refused. | ||
| 181 | func (s *Store) DeleteOrg(orgID int64) error { | ||
| 182 | var n int | ||
| 183 | if err := s.DB.QueryRow( | ||
| 184 | "SELECT COUNT(*) FROM repos WHERE owner_kind = 'org' AND owner_id = ?", orgID).Scan(&n); err != nil { | ||
| 185 | return err | ||
| 186 | } | ||
| 187 | if n > 0 { | ||
| 188 | return fmt.Errorf("the organization still owns %d repositories; delete or transfer them first", n) | ||
| 189 | } | ||
| 190 | _, err := s.DB.Exec("DELETE FROM orgs WHERE id = ?", orgID) | ||
| 191 | return err | ||
| 192 | } | ||
internal/store/registration.go +5
| @@ -56,6 +56,11 @@ func (s *Store) ConsumeEmailToken(userID int64, tokenHash string) (string, error | |||
| 56 | // CreateRegisteredUser makes a self-registered account, pending until its | 56 | // CreateRegisteredUser makes a self-registered account, pending until its |
| 57 | // email is verified. | 57 | // email is verified. |
| 58 | func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { | 58 | func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { |
| 59 | if taken, err := ownerNameTaken(s.DB, username); err != nil { | ||
| 60 | return 0, err | ||
| 61 | } else if taken { | ||
| 62 | return 0, errors.New("that username is taken") | ||
| 63 | } | ||
| 59 | res, err := s.DB.Exec("INSERT INTO users (username, pending) VALUES (?, ?)", username, boolInt(pending)) | 64 | res, err := s.DB.Exec("INSERT INTO users (username, pending) VALUES (?, ?)", username, boolInt(pending)) |
| 60 | if err != nil { | 65 | if err != nil { |
| 61 | if isUniqueErr(err) { | 66 | if isUniqueErr(err) { |
internal/store/repos.go +66 −55
| @@ -42,29 +42,39 @@ func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility str | |||
| 42 | return res.LastInsertId() | 42 | return res.LastInsertId() |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | // RepoByPath resolves "owner/name". Only user owners exist until orgs land. | 45 | // repoSelect resolves the owner name from whichever table owns the repo. |
| 46 | const repoSelect = ` | ||
| 47 | SELECT r.id, r.owner_kind, r.owner_id, COALESCE(u.username, o.name), | ||
| 48 | r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json | ||
| 49 | FROM repos r | ||
| 50 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | ||
| 51 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id` | ||
| 52 | |||
| 53 | func scanRepo(row interface{ Scan(...any) error }) (Repo, error) { | ||
| 54 | var r Repo | ||
| 55 | var settingsJSON string | ||
| 56 | err := row.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON) | ||
| 57 | if err != nil { | ||
| 58 | return r, err | ||
| 59 | } | ||
| 60 | if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { | ||
| 61 | return r, fmt.Errorf("repo %d settings: %w", r.ID, err) | ||
| 62 | } | ||
| 63 | return r, nil | ||
| 64 | } | ||
| 65 | |||
| 66 | // RepoByPath resolves "owner/name"; the owner may be a user or an org. | ||
| 46 | func (s *Store) RepoByPath(path string) (Repo, error) { | 67 | func (s *Store) RepoByPath(path string) (Repo, error) { |
| 47 | owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/") | 68 | owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/") |
| 48 | if !ok || owner == "" || name == "" || strings.Contains(name, "/") { | 69 | if !ok || owner == "" || name == "" || strings.Contains(name, "/") { |
| 49 | return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound) | 70 | return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound) |
| 50 | } | 71 | } |
| 51 | var r Repo | 72 | r, err := scanRepo(s.DB.QueryRow( |
| 52 | var settingsJSON string | 73 | repoSelect+" WHERE COALESCE(u.username, o.name) = ? AND r.name = ?", owner, name)) |
| 53 | err := s.DB.QueryRow(` | ||
| 54 | SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json | ||
| 55 | FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | ||
| 56 | WHERE u.username = ? AND r.name = ?`, owner, name). | ||
| 57 | Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON) | ||
| 58 | if errors.Is(err, sql.ErrNoRows) { | 74 | if errors.Is(err, sql.ErrNoRows) { |
| 59 | return Repo{}, ErrNotFound | 75 | return Repo{}, ErrNotFound |
| 60 | } | 76 | } |
| 61 | if err != nil { | 77 | return r, err |
| 62 | return Repo{}, err | ||
| 63 | } | ||
| 64 | if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { | ||
| 65 | return Repo{}, fmt.Errorf("repo %d settings: %w", r.ID, err) | ||
| 66 | } | ||
| 67 | return r, nil | ||
| 68 | } | 78 | } |
| 69 | 79 | ||
| 70 | func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error { | 80 | func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error { |
| @@ -92,27 +102,23 @@ func (s *Store) DeleteRepo(repoID int64) error { | |||
| 92 | return nil | 102 | return nil |
| 93 | } | 103 | } |
| 94 | 104 | ||
| 95 | // ListReposForUser returns repos the user owns or has an explicit grant on. | 105 | // ListReposForUser returns repos the user owns, belongs to through an org, |
| 106 | // or has an explicit grant on. | ||
| 96 | func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { | 107 | func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { |
| 97 | rows, err := s.DB.Query(` | 108 | rows, err := s.DB.Query(repoSelect+` |
| 98 | SELECT DISTINCT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json | ||
| 99 | FROM repos r | ||
| 100 | JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | ||
| 101 | LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ? | 109 | LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ? |
| 102 | WHERE r.owner_id = ? OR a.subject_id IS NOT NULL | 110 | LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ? |
| 103 | ORDER BY u.username, r.name`, userID, userID) | 111 | WHERE (r.owner_kind = 'user' AND r.owner_id = ?) OR a.subject_id IS NOT NULL OR m.user_id IS NOT NULL |
| 112 | GROUP BY r.id | ||
| 113 | ORDER BY 4, r.name`, userID, userID, userID) | ||
| 104 | if err != nil { | 114 | if err != nil { |
| 105 | return nil, err | 115 | return nil, err |
| 106 | } | 116 | } |
| 107 | defer rows.Close() | 117 | defer rows.Close() |
| 108 | var out []Repo | 118 | var out []Repo |
| 109 | for rows.Next() { | 119 | for rows.Next() { |
| 110 | var r Repo | 120 | r, err := scanRepo(rows) |
| 111 | var settingsJSON string | 121 | if err != nil { |
| 112 | if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil { | ||
| 113 | return nil, err | ||
| 114 | } | ||
| 115 | if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { | ||
| 116 | return nil, err | 122 | return nil, err |
| 117 | } | 123 | } |
| 118 | out = append(out, r) | 124 | out = append(out, r) |
| @@ -120,16 +126,37 @@ func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { | |||
| 120 | return out, rows.Err() | 126 | return out, rows.Err() |
| 121 | } | 127 | } |
| 122 | 128 | ||
| 123 | // AccessRole returns the explicit grant for userID on repoID ("" if none). | 129 | // AccessRole returns the user's effective role on the repo ("" if none): |
| 130 | // the strongest of any explicit grant and, for org-owned repos, the role | ||
| 131 | // derived from org membership (org admin -> admin, org member -> write). | ||
| 124 | func (s *Store) AccessRole(repoID, userID int64) (string, error) { | 132 | func (s *Store) AccessRole(repoID, userID int64) (string, error) { |
| 125 | var role string | 133 | rank := map[string]int{"": 0, "read": 1, "write": 2, "admin": 3} |
| 134 | best := "" | ||
| 135 | |||
| 136 | var explicit string | ||
| 126 | err := s.DB.QueryRow( | 137 | err := s.DB.QueryRow( |
| 127 | "SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?", | 138 | "SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?", |
| 128 | repoID, userID).Scan(&role) | 139 | repoID, userID).Scan(&explicit) |
| 129 | if errors.Is(err, sql.ErrNoRows) { | 140 | if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| 130 | return "", nil | 141 | return "", err |
| 142 | } | ||
| 143 | if rank[explicit] > rank[best] { | ||
| 144 | best = explicit | ||
| 145 | } | ||
| 146 | |||
| 147 | var orgRole string | ||
| 148 | err = s.DB.QueryRow(` | ||
| 149 | SELECT m.role FROM repos r | ||
| 150 | JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ? | ||
| 151 | WHERE r.id = ?`, userID, repoID).Scan(&orgRole) | ||
| 152 | if err != nil && !errors.Is(err, sql.ErrNoRows) { | ||
| 153 | return "", err | ||
| 154 | } | ||
| 155 | derived := map[string]string{"admin": "admin", "member": "write"}[orgRole] | ||
| 156 | if rank[derived] > rank[best] { | ||
| 157 | best = derived | ||
| 131 | } | 158 | } |
| 132 | return role, err | 159 | return best, nil |
| 133 | } | 160 | } |
| 134 | 161 | ||
| 135 | func (s *Store) GrantAccess(repoID, userID int64, role string) error { | 162 | func (s *Store) GrantAccess(repoID, userID int64, role string) error { |
| @@ -179,40 +206,24 @@ func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) { | |||
| 179 | } | 206 | } |
| 180 | 207 | ||
| 181 | func (s *Store) RepoByID(id int64) (Repo, error) { | 208 | func (s *Store) RepoByID(id int64) (Repo, error) { |
| 182 | var r Repo | 209 | r, err := scanRepo(s.DB.QueryRow(repoSelect+" WHERE r.id = ?", id)) |
| 183 | var settingsJSON string | ||
| 184 | err := s.DB.QueryRow(` | ||
| 185 | SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json | ||
| 186 | FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | ||
| 187 | WHERE r.id = ?`, id). | ||
| 188 | Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON) | ||
| 189 | if errors.Is(err, sql.ErrNoRows) { | 210 | if errors.Is(err, sql.ErrNoRows) { |
| 190 | return Repo{}, ErrNotFound | 211 | return Repo{}, ErrNotFound |
| 191 | } | 212 | } |
| 192 | if err != nil { | 213 | return r, err |
| 193 | return Repo{}, err | ||
| 194 | } | ||
| 195 | if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { | ||
| 196 | return Repo{}, err | ||
| 197 | } | ||
| 198 | return r, nil | ||
| 199 | } | 214 | } |
| 200 | 215 | ||
| 201 | // ListPublicRepos returns all public repositories, for the anonymous index. | 216 | // ListPublicRepos returns all public repositories, for the anonymous index. |
| 202 | func (s *Store) ListPublicRepos() ([]Repo, error) { | 217 | func (s *Store) ListPublicRepos() ([]Repo, error) { |
| 203 | rows, err := s.DB.Query(` | 218 | rows, err := s.DB.Query(repoSelect + " WHERE r.visibility = 'public' ORDER BY 4, r.name") |
| 204 | SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json | ||
| 205 | FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | ||
| 206 | WHERE r.visibility = 'public' ORDER BY u.username, r.name`) | ||
| 207 | if err != nil { | 219 | if err != nil { |
| 208 | return nil, err | 220 | return nil, err |
| 209 | } | 221 | } |
| 210 | defer rows.Close() | 222 | defer rows.Close() |
| 211 | var out []Repo | 223 | var out []Repo |
| 212 | for rows.Next() { | 224 | for rows.Next() { |
| 213 | var r Repo | 225 | r, err := scanRepo(rows) |
| 214 | var settingsJSON string | 226 | if err != nil { |
| 215 | if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil { | ||
| 216 | return nil, err | 227 | return nil, err |
| 217 | } | 228 | } |
| 218 | out = append(out, r) | 229 | out = append(out, r) |
internal/store/users.go +5
| @@ -30,6 +30,11 @@ var ErrDuplicateKey = errors.New("that key is already registered to another acco | |||
| 30 | var ErrNotFound = errors.New("not found") | 30 | var ErrNotFound = errors.New("not found") |
| 31 | 31 | ||
| 32 | func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { | 32 | func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { |
| 33 | if taken, err := ownerNameTaken(s.DB, username); err != nil { | ||
| 34 | return 0, err | ||
| 35 | } else if taken { | ||
| 36 | return 0, fmt.Errorf("username %q is taken", username) | ||
| 37 | } | ||
| 33 | res, err := s.DB.Exec("INSERT INTO users (username, is_admin) VALUES (?, ?)", username, boolInt(isAdmin)) | 38 | res, err := s.DB.Exec("INSERT INTO users (username, is_admin) VALUES (?, ?)", username, boolInt(isAdmin)) |
| 34 | if err != nil { | 39 | if err != nil { |
| 35 | if isUniqueErr(err) { | 40 | if isUniqueErr(err) { |