Commit 8ff79f1044
Verified · cmc
Layout: unified · split
cmd/gitbay/main.go +6
| @@ -172,7 +172,13 @@ func authCmd() *cobra.Command { | |||
| 172 | return nil | 172 | return nil |
| 173 | }, | 173 | }, |
| 174 | } | 174 | } |
| 175 | tokens := group("token", "API tokens (minted over SSH, used with the JSON API)", | ||
| 176 | pass("create", "mint a token: --name <n> [--scope full|read] [--ttl 30d]", passOpts{server: []string{"token", "create"}}), | ||
| 177 | pass("list", "list API tokens", passOpts{server: []string{"token", "list"}}), | ||
| 178 | pass("revoke", "revoke a token by name", passOpts{server: []string{"token", "revoke"}}), | ||
| 179 | ) | ||
| 175 | return group("auth", "identity: whoami, SSH and PGP keys", | 180 | return group("auth", "identity: whoami, SSH and PGP keys", |
| 181 | tokens, | ||
| 176 | pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}), | 182 | pass("whoami", "show the authenticated account", passOpts{server: []string{"whoami"}}), |
| 177 | group("keys", "manage SSH keys", | 183 | group("keys", "manage SSH keys", |
| 178 | pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}), | 184 | pass("list", "list registered SSH keys", passOpts{server: []string{"keys", "list"}}), |
e2e/api_test.go added +163
| @@ -0,0 +1,163 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "encoding/json" | ||
| 6 | "fmt" | ||
| 7 | "io" | ||
| 8 | "net/http" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // apiCall posts one command to the JSON API. | ||
| 15 | func (i *instance) apiCall(t *testing.T, token string, argv []string, stdin string) (int, map[string]any) { | ||
| 16 | t.Helper() | ||
| 17 | body, _ := json.Marshal(map[string]any{"argv": argv, "stdin": stdin}) | ||
| 18 | req, err := http.NewRequest("POST", | ||
| 19 | fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", i.httpPort), bytes.NewReader(body)) | ||
| 20 | if err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | if token != "" { | ||
| 24 | req.Header.Set("Authorization", "Bearer "+token) | ||
| 25 | } | ||
| 26 | resp, err := http.DefaultClient.Do(req) | ||
| 27 | if err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | defer resp.Body.Close() | ||
| 31 | raw, _ := io.ReadAll(resp.Body) | ||
| 32 | var out map[string]any | ||
| 33 | if err := json.Unmarshal(raw, &out); err != nil { | ||
| 34 | t.Fatalf("API response not JSON (%d): %s", resp.StatusCode, raw) | ||
| 35 | } | ||
| 36 | return resp.StatusCode, out | ||
| 37 | } | ||
| 38 | |||
| 39 | func TestJSONAPI(t *testing.T) { | ||
| 40 | inst := startInstanceWith(t, "[api]\nenabled = true\n") | ||
| 41 | aliceKey := inst.newKey(t, "alice") | ||
| 42 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 43 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 44 | |||
| 45 | // Tokens are minted over SSH, shown once. | ||
| 46 | out, errOut, code := inst.ssh(t, aliceKey, "", "token", "create", "--name", "ci", "--json") | ||
| 47 | if code != 0 { | ||
| 48 | t.Fatalf("token create: %s", errOut) | ||
| 49 | } | ||
| 50 | var env struct { | ||
| 51 | Data struct { | ||
| 52 | Token string `json:"token"` | ||
| 53 | } `json:"data"` | ||
| 54 | } | ||
| 55 | if err := json.Unmarshal([]byte(out), &env); err != nil || !strings.HasPrefix(env.Data.Token, "gb_") { | ||
| 56 | t.Fatalf("token create output: %v %s", err, out) | ||
| 57 | } | ||
| 58 | token := env.Data.Token | ||
| 59 | |||
| 60 | // Auth failures are uniform 401s. | ||
| 61 | if status, _ := inst.apiCall(t, "", []string{"whoami"}, ""); status != 401 { | ||
| 62 | t.Fatalf("no token: %d", status) | ||
| 63 | } | ||
| 64 | if status, _ := inst.apiCall(t, "gb_wrong", []string{"whoami"}, ""); status != 401 { | ||
| 65 | t.Fatalf("bad token: %d", status) | ||
| 66 | } | ||
| 67 | |||
| 68 | // whoami through the API: same envelope, exit_code injected. | ||
| 69 | status, body := inst.apiCall(t, token, []string{"whoami"}, "") | ||
| 70 | if status != 200 || body["exit_code"].(float64) != 0 { | ||
| 71 | t.Fatalf("whoami: %d %v", status, body) | ||
| 72 | } | ||
| 73 | if data := body["data"].(map[string]any); data["username"] != "alice" { | ||
| 74 | t.Fatalf("whoami data: %v", body) | ||
| 75 | } | ||
| 76 | |||
| 77 | // Mutations work: create a repo and an issue, then read it back. | ||
| 78 | if status, body = inst.apiCall(t, token, []string{"repo", "create", "alice/proj", "--private"}, ""); status != 200 { | ||
| 79 | t.Fatalf("repo create: %d %v", status, body) | ||
| 80 | } | ||
| 81 | if status, _ = inst.apiCall(t, token, []string{"issue", "create", "alice/proj", "--title", "from the api", "--file", "-"}, "body via stdin\n"); status != 200 { | ||
| 82 | t.Fatal("issue create failed") | ||
| 83 | } | ||
| 84 | status, body = inst.apiCall(t, token, []string{"issue", "show", "alice/proj", "1"}, "") | ||
| 85 | data := body["data"].(map[string]any) | ||
| 86 | if status != 200 || data["title"] != "from the api" || data["body"] != "body via stdin\n" { | ||
| 87 | t.Fatalf("issue show: %d %v", status, body) | ||
| 88 | } | ||
| 89 | |||
| 90 | // Exit codes map to HTTP statuses. | ||
| 91 | if status, _ = inst.apiCall(t, token, []string{"issue", "show", "alice/proj", "99"}, ""); status != 404 { | ||
| 92 | t.Fatalf("missing issue: %d", status) | ||
| 93 | } | ||
| 94 | if status, _ = inst.apiCall(t, token, []string{"nonsense"}, ""); status != 400 { | ||
| 95 | t.Fatalf("unknown command: %d", status) | ||
| 96 | } | ||
| 97 | |||
| 98 | // Raw-output commands (no envelope) are wrapped. | ||
| 99 | status, body = inst.apiCall(t, token, []string{"help"}, "") | ||
| 100 | if status != 200 || !strings.Contains(body["output"].(string), "repo create") { | ||
| 101 | t.Fatalf("help via API: %d %v", status, body) | ||
| 102 | } | ||
| 103 | |||
| 104 | // Git transport is refused by name. | ||
| 105 | if status, _ = inst.apiCall(t, token, []string{"git-upload-pack", "alice/proj"}, ""); status != 400 { | ||
| 106 | t.Fatalf("git over API: %d", status) | ||
| 107 | } | ||
| 108 | |||
| 109 | // Token management never works over the API: no credential minting. | ||
| 110 | status, body = inst.apiCall(t, token, []string{"token", "create", "--name", "sneaky"}, "") | ||
| 111 | if status != 403 || !strings.Contains(body["error"].(string), "only available over SSH") { | ||
| 112 | t.Fatalf("token create via API: %d %v", status, body) | ||
| 113 | } | ||
| 114 | |||
| 115 | // Read-scoped tokens read but never write. | ||
| 116 | out, _, code = inst.ssh(t, aliceKey, "", "token", "create", "--name", "reader", "--scope", "read", "--json") | ||
| 117 | if code != 0 { | ||
| 118 | t.Fatal("read token create failed") | ||
| 119 | } | ||
| 120 | json.Unmarshal([]byte(out), &env) | ||
| 121 | readToken := env.Data.Token | ||
| 122 | if status, _ = inst.apiCall(t, readToken, []string{"issue", "list", "alice/proj"}, ""); status != 200 { | ||
| 123 | t.Fatalf("read token list: %d", status) | ||
| 124 | } | ||
| 125 | status, body = inst.apiCall(t, readToken, []string{"issue", "close", "alice/proj", "1"}, "") | ||
| 126 | if status != 403 || !strings.Contains(body["error"].(string), "read-only") { | ||
| 127 | t.Fatalf("read token write: %d %v", status, body) | ||
| 128 | } | ||
| 129 | |||
| 130 | // Expiry: a 1-second token dies. | ||
| 131 | out, _, _ = inst.ssh(t, aliceKey, "", "token", "create", "--name", "brief", "--ttl", "1s", "--json") | ||
| 132 | json.Unmarshal([]byte(out), &env) | ||
| 133 | brief := env.Data.Token | ||
| 134 | if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 { | ||
| 135 | t.Fatal("fresh short-ttl token rejected") | ||
| 136 | } | ||
| 137 | time.Sleep(1100 * time.Millisecond) | ||
| 138 | if status, _ = inst.apiCall(t, brief, []string{"whoami"}, ""); status != 401 { | ||
| 139 | t.Fatal("expired token accepted") | ||
| 140 | } | ||
| 141 | |||
| 142 | // Revocation kills a token immediately. | ||
| 143 | if _, _, code = inst.ssh(t, aliceKey, "", "token", "revoke", "ci"); code != 0 { | ||
| 144 | t.Fatal("revoke failed") | ||
| 145 | } | ||
| 146 | if status, _ = inst.apiCall(t, token, []string{"whoami"}, ""); status != 401 { | ||
| 147 | t.Fatal("revoked token accepted") | ||
| 148 | } | ||
| 149 | |||
| 150 | // With [api] disabled (the default), the endpoint does not exist. | ||
| 151 | inst2 := startInstance(t) | ||
| 152 | req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/v1/cmd", inst2.httpPort), | ||
| 153 | strings.NewReader(`{"argv":["whoami"]}`)) | ||
| 154 | req.Header.Set("Authorization", "Bearer gb_x") | ||
| 155 | resp, err := http.DefaultClient.Do(req) | ||
| 156 | if err != nil { | ||
| 157 | t.Fatal(err) | ||
| 158 | } | ||
| 159 | resp.Body.Close() | ||
| 160 | if resp.StatusCode != 404 { | ||
| 161 | t.Fatalf("API on disabled instance: %d, want 404", resp.StatusCode) | ||
| 162 | } | ||
| 163 | } | ||
internal/config/config.go +8
| @@ -19,6 +19,7 @@ type Config struct { | |||
| 19 | GitDaemon GitDaemon `toml:"git_daemon"` | 19 | GitDaemon GitDaemon `toml:"git_daemon"` |
| 20 | Web Web `toml:"web"` | 20 | Web Web `toml:"web"` |
| 21 | Registration Registration `toml:"registration"` | 21 | Registration Registration `toml:"registration"` |
| 22 | API API `toml:"api"` | ||
| 22 | Limits Limits `toml:"limits"` | 23 | Limits Limits `toml:"limits"` |
| 23 | Mail Mail `toml:"mail"` | 24 | Mail Mail `toml:"mail"` |
| 24 | } | 25 | } |
| @@ -61,6 +62,13 @@ type Registration struct { | |||
| 61 | Mode string `toml:"mode"` // closed | invite | open | 62 | Mode string `toml:"mode"` // closed | invite | open |
| 62 | } | 63 | } |
| 63 | 64 | ||
| 65 | // API controls the HTTPS/JSON control-plane API (bearer tokens minted over | ||
| 66 | // SSH). Off by default: an instance that never enables it has no | ||
| 67 | // credential-bearing HTTP surface at all. | ||
| 68 | type API struct { | ||
| 69 | Enabled bool `toml:"enabled"` | ||
| 70 | } | ||
| 71 | |||
| 64 | type Limits struct { | 72 | type Limits struct { |
| 65 | MaxPackBytes int64 `toml:"max_pack_bytes"` | 73 | MaxPackBytes int64 `toml:"max_pack_bytes"` |
| 66 | MaxBlobBytes int64 `toml:"max_blob_bytes"` | 74 | MaxBlobBytes int64 `toml:"max_blob_bytes"` |
internal/control/control.go +17 −2
| @@ -24,12 +24,20 @@ type Ctx struct { | |||
| 24 | Stdout io.Writer | 24 | Stdout io.Writer |
| 25 | Stderr io.Writer | 25 | Stderr io.Writer |
| 26 | JSON bool | 26 | JSON bool |
| 27 | // ViaAPI marks requests arriving over the HTTP token API. Some | ||
| 28 | // commands (token management) are SSH-only: an API token must never | ||
| 29 | // mint further credentials. | ||
| 30 | ViaAPI bool | ||
| 31 | // ReadOnly is set for read-scoped API tokens. | ||
| 32 | ReadOnly bool | ||
| 27 | } | 33 | } |
| 28 | 34 | ||
| 29 | type Command struct { | 35 | type Command struct { |
| 30 | Path []string // e.g. ["keys", "add"] | 36 | Path []string // e.g. ["keys", "add"] |
| 31 | Summary string | 37 | Summary string |
| 32 | ReadsStdin bool | 38 | ReadsStdin bool |
| 39 | ReadOnly bool // safe for read-scoped API tokens | ||
| 40 | SSHOnly bool // refused over the HTTP API (credential minting) | ||
| 33 | Run func(c *Ctx, args []string) int | 41 | Run func(c *Ctx, args []string) int |
| 34 | } | 42 | } |
| 35 | 43 | ||
| @@ -70,6 +78,12 @@ func Dispatch(c *Ctx, argv []string) int { | |||
| 70 | if c.Scope != "full" { | 78 | if c.Scope != "full" { |
| 71 | return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) | 79 | return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands", c.Scope) |
| 72 | } | 80 | } |
| 81 | if c.ViaAPI && cmd.SSHOnly { | ||
| 82 | return c.fail(protocol.ExitDenied, "%s is only available over SSH", joinPath(cmd.Path)) | ||
| 83 | } | ||
| 84 | if c.ReadOnly && !cmd.ReadOnly { | ||
| 85 | return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state", joinPath(cmd.Path)) | ||
| 86 | } | ||
| 73 | if c.User.Pending && !pendingAllowed(cmd.Path) { | 87 | if c.User.Pending && !pendingAllowed(cmd.Path) { |
| 74 | return c.fail(protocol.ExitDenied, | 88 | return c.fail(protocol.ExitDenied, |
| 75 | "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") | 89 | "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") |
| @@ -132,8 +146,9 @@ func (c *Ctx) fail(code int, format string, args ...any) int { | |||
| 132 | 146 | ||
| 133 | func init() { | 147 | func init() { |
| 134 | register(Command{ | 148 | register(Command{ |
| 135 | Path: []string{"help"}, | 149 | Path: []string{"help"}, |
| 136 | Summary: "list available commands", | 150 | Summary: "list available commands", |
| 151 | ReadOnly: true, | ||
| 137 | Run: func(c *Ctx, args []string) int { | 152 | Run: func(c *Ctx, args []string) int { |
| 138 | for _, cmd := range registry { | 153 | for _, cmd := range registry { |
| 139 | fmt.Fprintf(c.Stdout, "%-24s %s\n", joinPath(cmd.Path), cmd.Summary) | 154 | fmt.Fprintf(c.Stdout, "%-24s %s\n", joinPath(cmd.Path), cmd.Summary) |
internal/control/identity.go +4 −2
| @@ -15,12 +15,14 @@ func init() { | |||
| 15 | register(Command{ | 15 | register(Command{ |
| 16 | Path: []string{"whoami"}, | 16 | Path: []string{"whoami"}, |
| 17 | Summary: "show the authenticated account", | 17 | Summary: "show the authenticated account", |
| 18 | Run: runWhoami, | 18 | ReadOnly: true, |
| 19 | Run: runWhoami, | ||
| 19 | }) | 20 | }) |
| 20 | register(Command{ | 21 | register(Command{ |
| 21 | Path: []string{"keys", "list"}, | 22 | Path: []string{"keys", "list"}, |
| 22 | Summary: "list registered SSH keys", | 23 | Summary: "list registered SSH keys", |
| 23 | Run: runKeysList, | 24 | ReadOnly: true, |
| 25 | Run: runKeysList, | ||
| 24 | }) | 26 | }) |
| 25 | register(Command{ | 27 | register(Command{ |
| 26 | Path: []string{"keys", "add"}, | 28 | Path: []string{"keys", "add"}, |
internal/control/issue.go +2 −2
| @@ -19,9 +19,9 @@ func init() { | |||
| 19 | Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]", | 19 | Summary: "open an issue: issue create <owner/name> --title <t> [--body <b> | --file -]", |
| 20 | ReadsStdin: true, Run: runIssueCreate}) | 20 | ReadsStdin: true, Run: runIssueCreate}) |
| 21 | register(Command{Path: []string{"issue", "list"}, | 21 | register(Command{Path: []string{"issue", "list"}, |
| 22 | Summary: "list issues: issue list <owner/name> [--state open|closed|all]", Run: runIssueList}) | 22 | Summary: "list issues: issue list <owner/name> [--state open|closed|all]", ReadOnly: true, Run: runIssueList}) |
| 23 | register(Command{Path: []string{"issue", "show"}, | 23 | register(Command{Path: []string{"issue", "show"}, |
| 24 | Summary: "show an issue with comments: issue show <owner/name> <n>", Run: runIssueShow}) | 24 | Summary: "show an issue with comments: issue show <owner/name> <n>", ReadOnly: true, Run: runIssueShow}) |
| 25 | register(Command{Path: []string{"issue", "comment"}, | 25 | register(Command{Path: []string{"issue", "comment"}, |
| 26 | Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]", | 26 | Summary: "comment: issue comment <owner/name> <n> [--message <m> | --file -]", |
| 27 | ReadsStdin: true, Run: runIssueComment}) | 27 | ReadsStdin: true, Run: runIssueComment}) |
internal/control/mr.go +3 −3
| @@ -22,11 +22,11 @@ func init() { | |||
| 22 | Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]", | 22 | Summary: "open a merge request: mr create <target owner/name> --source [owner/name:]<branch> --target <branch> --title <t> [--body <b> | --file -]", |
| 23 | ReadsStdin: true, Run: runMRCreate}) | 23 | ReadsStdin: true, Run: runMRCreate}) |
| 24 | register(Command{Path: []string{"mr", "list"}, | 24 | register(Command{Path: []string{"mr", "list"}, |
| 25 | Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", Run: runMRList}) | 25 | Summary: "list merge requests: mr list <owner/name> [--state open|merged|closed|source_gone|all]", ReadOnly: true, Run: runMRList}) |
| 26 | register(Command{Path: []string{"mr", "show"}, | 26 | register(Command{Path: []string{"mr", "show"}, |
| 27 | Summary: "show a merge request: mr show <owner/name> <n>", Run: runMRShow}) | 27 | Summary: "show a merge request: mr show <owner/name> <n>", ReadOnly: true, Run: runMRShow}) |
| 28 | register(Command{Path: []string{"mr", "diff"}, | 28 | register(Command{Path: []string{"mr", "diff"}, |
| 29 | Summary: "show the diff: mr diff <owner/name> <n>", Run: runMRDiff}) | 29 | Summary: "show the diff: mr diff <owner/name> <n>", ReadOnly: true, Run: runMRDiff}) |
| 30 | register(Command{Path: []string{"mr", "comment"}, | 30 | register(Command{Path: []string{"mr", "comment"}, |
| 31 | Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]", | 31 | Summary: "comment: mr comment <owner/name> <n> [--message <m> | --file -]", |
| 32 | ReadsStdin: true, Run: runMRComment}) | 32 | ReadsStdin: true, Run: runMRComment}) |
internal/control/org.go +3 −3
| @@ -14,9 +14,9 @@ func init() { | |||
| 14 | register(Command{Path: []string{"org", "create"}, | 14 | register(Command{Path: []string{"org", "create"}, |
| 15 | Summary: "create an organization (you become its first admin): org create <name>", Run: runOrgCreate}) | 15 | Summary: "create an organization (you become its first admin): org create <name>", Run: runOrgCreate}) |
| 16 | register(Command{Path: []string{"org", "list"}, | 16 | register(Command{Path: []string{"org", "list"}, |
| 17 | Summary: "list organizations you belong to", Run: runOrgList}) | 17 | Summary: "list organizations you belong to", ReadOnly: true, Run: runOrgList}) |
| 18 | register(Command{Path: []string{"org", "show"}, | 18 | register(Command{Path: []string{"org", "show"}, |
| 19 | Summary: "show an organization and its members: org show <name>", Run: runOrgShow}) | 19 | Summary: "show an organization and its members: org show <name>", ReadOnly: true, Run: runOrgShow}) |
| 20 | register(Command{Path: []string{"org", "delete"}, | 20 | register(Command{Path: []string{"org", "delete"}, |
| 21 | Summary: "delete an empty organization: org delete <name> --yes", Run: runOrgDelete}) | 21 | Summary: "delete an empty organization: org delete <name> --yes", Run: runOrgDelete}) |
| 22 | register(Command{Path: []string{"org", "members", "add"}, | 22 | register(Command{Path: []string{"org", "members", "add"}, |
| @@ -24,7 +24,7 @@ func init() { | |||
| 24 | register(Command{Path: []string{"org", "members", "remove"}, | 24 | register(Command{Path: []string{"org", "members", "remove"}, |
| 25 | Summary: "remove a member: org members remove <org> <user>", Run: runOrgMembersRemove}) | 25 | Summary: "remove a member: org members remove <org> <user>", Run: runOrgMembersRemove}) |
| 26 | register(Command{Path: []string{"org", "members", "list"}, | 26 | register(Command{Path: []string{"org", "members", "list"}, |
| 27 | Summary: "list members: org members list <org>", Run: runOrgMembersList}) | 27 | Summary: "list members: org members list <org>", ReadOnly: true, Run: runOrgMembersList}) |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | // orgAdmin loads an org and requires the caller to be one of its admins. | 30 | // orgAdmin loads an org and requires the caller to be one of its admins. |
internal/control/repo.go +4 −4
| @@ -27,9 +27,9 @@ func init() { | |||
| 27 | register(Command{Path: []string{"repo", "create"}, | 27 | register(Command{Path: []string{"repo", "create"}, |
| 28 | Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate}) | 28 | Summary: "create a repository: repo create <owner/name> [--private]", Run: runRepoCreate}) |
| 29 | register(Command{Path: []string{"repo", "list"}, | 29 | register(Command{Path: []string{"repo", "list"}, |
| 30 | Summary: "list repositories you own or can access", Run: runRepoList}) | 30 | Summary: "list repositories you own or can access", ReadOnly: true, Run: runRepoList}) |
| 31 | register(Command{Path: []string{"repo", "show"}, | 31 | register(Command{Path: []string{"repo", "show"}, |
| 32 | Summary: "show repository details: repo show <owner/name>", Run: runRepoShow}) | 32 | Summary: "show repository details: repo show <owner/name>", ReadOnly: true, Run: runRepoShow}) |
| 33 | register(Command{Path: []string{"repo", "delete"}, | 33 | register(Command{Path: []string{"repo", "delete"}, |
| 34 | Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete}) | 34 | Summary: "delete a repository: repo delete <owner/name> --yes", Run: runRepoDelete}) |
| 35 | register(Command{Path: []string{"repo", "access", "grant"}, | 35 | register(Command{Path: []string{"repo", "access", "grant"}, |
| @@ -37,9 +37,9 @@ func init() { | |||
| 37 | register(Command{Path: []string{"repo", "access", "revoke"}, | 37 | register(Command{Path: []string{"repo", "access", "revoke"}, |
| 38 | Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke}) | 38 | Summary: "revoke access: repo access revoke <owner/name> <user>", Run: runAccessRevoke}) |
| 39 | register(Command{Path: []string{"repo", "access", "list"}, | 39 | register(Command{Path: []string{"repo", "access", "list"}, |
| 40 | Summary: "list access grants: repo access list <owner/name>", Run: runAccessList}) | 40 | Summary: "list access grants: repo access list <owner/name>", ReadOnly: true, Run: runAccessList}) |
| 41 | register(Command{Path: []string{"repo", "settings", "show"}, | 41 | register(Command{Path: []string{"repo", "settings", "show"}, |
| 42 | Summary: "show settings: repo settings show <owner/name>", Run: runSettingsShow}) | 42 | Summary: "show settings: repo settings show <owner/name>", ReadOnly: true, Run: runSettingsShow}) |
| 43 | register(Command{Path: []string{"repo", "settings", "protect"}, | 43 | register(Command{Path: []string{"repo", "settings", "protect"}, |
| 44 | Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect}) | 44 | Summary: "protect a branch: repo settings protect <owner/name> <branch>", Run: runProtect}) |
| 45 | register(Command{Path: []string{"repo", "settings", "unprotect"}, | 45 | register(Command{Path: []string{"repo", "settings", "unprotect"}, |
internal/control/sig.go +2 −2
| @@ -19,11 +19,11 @@ func init() { | |||
| 19 | register(Command{Path: []string{"pgp", "add"}, | 19 | register(Command{Path: []string{"pgp", "add"}, |
| 20 | Summary: "register an OpenPGP public key (armored, on stdin)", ReadsStdin: true, Run: runPGPAdd}) | 20 | Summary: "register an OpenPGP public key (armored, on stdin)", ReadsStdin: true, Run: runPGPAdd}) |
| 21 | register(Command{Path: []string{"pgp", "list"}, | 21 | register(Command{Path: []string{"pgp", "list"}, |
| 22 | Summary: "list registered OpenPGP keys", Run: runPGPList}) | 22 | Summary: "list registered OpenPGP keys", ReadOnly: true, Run: runPGPList}) |
| 23 | register(Command{Path: []string{"pgp", "remove"}, | 23 | register(Command{Path: []string{"pgp", "remove"}, |
| 24 | Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove}) | 24 | Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove}) |
| 25 | register(Command{Path: []string{"repo", "log"}, | 25 | register(Command{Path: []string{"repo", "log"}, |
| 26 | Summary: "commit log with signature states: repo log <owner/name> [--limit n]", Run: runRepoLog}) | 26 | Summary: "commit log with signature states: repo log <owner/name> [--limit n]", ReadOnly: true, Run: runRepoLog}) |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | func runPGPAdd(c *Ctx, args []string) int { | 29 | func runPGPAdd(c *Ctx, args []string) int { |
internal/control/token.go added +131
| @@ -0,0 +1,131 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "errors" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "strconv" | ||
| 8 | "strings" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "gitbay.org/gitbay/internal/protocol" | ||
| 12 | "gitbay.org/gitbay/internal/store" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func init() { | ||
| 16 | register(Command{Path: []string{"token", "create"}, | ||
| 17 | Summary: "mint an API token (shown once): token create --name <n> [--scope full|read] [--ttl 30d|720h]", | ||
| 18 | SSHOnly: true, Run: runTokenCreate}) | ||
| 19 | register(Command{Path: []string{"token", "list"}, | ||
| 20 | Summary: "list API tokens", ReadOnly: true, SSHOnly: true, Run: runTokenList}) | ||
| 21 | register(Command{Path: []string{"token", "revoke"}, | ||
| 22 | Summary: "revoke an API token by name: token revoke <name>", | ||
| 23 | SSHOnly: true, Run: runTokenRevoke}) | ||
| 24 | } | ||
| 25 | |||
| 26 | // parseTTL accepts Go durations plus a day suffix ("30d"). | ||
| 27 | func parseTTL(s string) (time.Duration, error) { | ||
| 28 | if days, ok := strings.CutSuffix(s, "d"); ok { | ||
| 29 | n, err := strconv.Atoi(days) | ||
| 30 | if err != nil || n < 1 { | ||
| 31 | return 0, fmt.Errorf("bad ttl %q", s) | ||
| 32 | } | ||
| 33 | return time.Duration(n) * 24 * time.Hour, nil | ||
| 34 | } | ||
| 35 | return time.ParseDuration(s) | ||
| 36 | } | ||
| 37 | |||
| 38 | func runTokenCreate(c *Ctx, args []string) int { | ||
| 39 | name, scope, ttl := "", "full", "" | ||
| 40 | for i := 0; i < len(args); i++ { | ||
| 41 | switch args[i] { | ||
| 42 | case "--name", "--scope", "--ttl": | ||
| 43 | if i+1 >= len(args) { | ||
| 44 | return c.fail(protocol.ExitUsage, "%s requires a value", args[i]) | ||
| 45 | } | ||
| 46 | switch args[i] { | ||
| 47 | case "--name": | ||
| 48 | name = args[i+1] | ||
| 49 | case "--scope": | ||
| 50 | scope = args[i+1] | ||
| 51 | case "--ttl": | ||
| 52 | ttl = args[i+1] | ||
| 53 | } | ||
| 54 | i++ | ||
| 55 | default: | ||
| 56 | return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]") | ||
| 57 | } | ||
| 58 | } | ||
| 59 | if name == "" || (scope != "full" && scope != "read") { | ||
| 60 | return c.fail(protocol.ExitUsage, "usage: token create --name <n> [--scope full|read] [--ttl 30d]") | ||
| 61 | } | ||
| 62 | var expires *time.Time | ||
| 63 | if ttl != "" { | ||
| 64 | d, err := parseTTL(ttl) | ||
| 65 | if err != nil { | ||
| 66 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 67 | } | ||
| 68 | t := time.Now().Add(d) | ||
| 69 | expires = &t | ||
| 70 | } | ||
| 71 | raw, _, err := store.NewToken() | ||
| 72 | if err != nil { | ||
| 73 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 74 | } | ||
| 75 | // The gb_ prefix makes leaked tokens findable by secret scanners. | ||
| 76 | token := "gb_" + raw | ||
| 77 | if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires); err != nil { | ||
| 78 | return c.fail(protocol.ExitUsage, "%v", err) | ||
| 79 | } | ||
| 80 | type out struct { | ||
| 81 | Name string `json:"name"` | ||
| 82 | Scope string `json:"scope"` | ||
| 83 | Token string `json:"token"` | ||
| 84 | } | ||
| 85 | d := out{name, scope, token} | ||
| 86 | return c.emit(d, func(w io.Writer) { | ||
| 87 | fmt.Fprintf(w, "token %q (%s) — shown once, store it now:\n%s\n", d.Name, d.Scope, d.Token) | ||
| 88 | }) | ||
| 89 | } | ||
| 90 | |||
| 91 | func runTokenList(c *Ctx, args []string) int { | ||
| 92 | tokens, err := c.Store.ListAPITokens(c.User.ID) | ||
| 93 | if err != nil { | ||
| 94 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 95 | } | ||
| 96 | type out struct { | ||
| 97 | Name string `json:"name"` | ||
| 98 | Scope string `json:"scope"` | ||
| 99 | CreatedAt string `json:"created_at"` | ||
| 100 | ExpiresAt *time.Time `json:"expires_at,omitempty"` | ||
| 101 | LastUsedAt *time.Time `json:"last_used_at,omitempty"` | ||
| 102 | } | ||
| 103 | var ds []out | ||
| 104 | for _, t := range tokens { | ||
| 105 | ds = append(ds, out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt}) | ||
| 106 | } | ||
| 107 | return c.emit(ds, func(w io.Writer) { | ||
| 108 | for _, d := range ds { | ||
| 109 | exp := "never expires" | ||
| 110 | if d.ExpiresAt != nil { | ||
| 111 | exp = "expires " + d.ExpiresAt.UTC().Format(time.RFC3339) | ||
| 112 | } | ||
| 113 | fmt.Fprintf(w, "%s\t%s\t%s\n", d.Name, d.Scope, exp) | ||
| 114 | } | ||
| 115 | }) | ||
| 116 | } | ||
| 117 | |||
| 118 | func runTokenRevoke(c *Ctx, args []string) int { | ||
| 119 | if len(args) != 1 { | ||
| 120 | return c.fail(protocol.ExitUsage, "usage: token revoke <name>") | ||
| 121 | } | ||
| 122 | if err := c.Store.RevokeAPIToken(c.User.ID, args[0]); err != nil { | ||
| 123 | if errors.Is(err, store.ErrNotFound) { | ||
| 124 | return c.fail(protocol.ExitNotFound, "no token named %q", args[0]) | ||
| 125 | } | ||
| 126 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 127 | } | ||
| 128 | return c.emit(map[string]string{"revoked": args[0]}, func(w io.Writer) { | ||
| 129 | fmt.Fprintf(w, "revoked %s\n", args[0]) | ||
| 130 | }) | ||
| 131 | } | ||
internal/httpd/api.go added +120
| @@ -0,0 +1,120 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "encoding/json" | ||
| 6 | "errors" | ||
| 7 | "io" | ||
| 8 | "net/http" | ||
| 9 | "strings" | ||
| 10 | |||
| 11 | "gitbay.org/gitbay/internal/control" | ||
| 12 | "gitbay.org/gitbay/internal/protocol" | ||
| 13 | "gitbay.org/gitbay/internal/store" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // apiRequest is the wire form of one command invocation. argv is real | ||
| 17 | // argv — no shell, no tokenizer, no quoting rules. | ||
| 18 | type apiRequest struct { | ||
| 19 | Argv []string `json:"argv"` | ||
| 20 | Stdin string `json:"stdin,omitempty"` | ||
| 21 | } | ||
| 22 | |||
| 23 | const maxAPIBody = 1 << 20 | ||
| 24 | |||
| 25 | // apiCmd fronts the same control-command registry the SSH dispatcher uses: | ||
| 26 | // every command, current and future, is reachable here with identical | ||
| 27 | // semantics. Exit codes map onto HTTP statuses; the body is the command's | ||
| 28 | // JSON envelope with exit_code added. | ||
| 29 | func (s *Server) apiCmd(w http.ResponseWriter, r *http.Request) { | ||
| 30 | user, scope, ok := s.apiAuth(w, r) | ||
| 31 | if !ok { | ||
| 32 | return | ||
| 33 | } | ||
| 34 | |||
| 35 | var req apiRequest | ||
| 36 | if err := json.NewDecoder(io.LimitReader(r.Body, maxAPIBody)).Decode(&req); err != nil { | ||
| 37 | apiError(w, http.StatusBadRequest, "body must be JSON: {\"argv\": [...], \"stdin\": \"...\"}") | ||
| 38 | return | ||
| 39 | } | ||
| 40 | if len(req.Argv) == 0 { | ||
| 41 | apiError(w, http.StatusBadRequest, "argv is required") | ||
| 42 | return | ||
| 43 | } | ||
| 44 | switch req.Argv[0] { | ||
| 45 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": | ||
| 46 | apiError(w, http.StatusBadRequest, "git transport does not run over the JSON API; use git with an SSH remote") | ||
| 47 | return | ||
| 48 | } | ||
| 49 | |||
| 50 | var stdout, stderr bytes.Buffer | ||
| 51 | ctx := &control.Ctx{ | ||
| 52 | User: user, | ||
| 53 | Scope: "full", // key scopes are an SSH concept; token scope is below | ||
| 54 | Store: s.st, | ||
| 55 | Cfg: s.cfg, | ||
| 56 | Stdin: strings.NewReader(req.Stdin), | ||
| 57 | Stdout: &stdout, | ||
| 58 | Stderr: &stderr, | ||
| 59 | JSON: true, | ||
| 60 | ViaAPI: true, | ||
| 61 | ReadOnly: scope == "read", | ||
| 62 | } | ||
| 63 | code := control.Dispatch(ctx, req.Argv) | ||
| 64 | |||
| 65 | status := map[int]int{ | ||
| 66 | protocol.ExitOK: http.StatusOK, | ||
| 67 | protocol.ExitUsage: http.StatusBadRequest, | ||
| 68 | protocol.ExitNotFound: http.StatusNotFound, | ||
| 69 | protocol.ExitDenied: http.StatusForbidden, | ||
| 70 | }[code] | ||
| 71 | if status == 0 { | ||
| 72 | status = http.StatusInternalServerError | ||
| 73 | } | ||
| 74 | |||
| 75 | // Commands normally emit exactly one JSON envelope; inject exit_code. | ||
| 76 | // A few (mr diff, help) write raw text instead — wrap those. | ||
| 77 | var body map[string]any | ||
| 78 | if err := json.Unmarshal(stdout.Bytes(), &body); err != nil || body == nil { | ||
| 79 | body = map[string]any{ | ||
| 80 | "protocol_version": protocol.Version, | ||
| 81 | "output": stdout.String(), | ||
| 82 | } | ||
| 83 | } | ||
| 84 | body["exit_code"] = code | ||
| 85 | if msg := strings.TrimSpace(stderr.String()); msg != "" { | ||
| 86 | body["stderr"] = msg | ||
| 87 | } | ||
| 88 | w.Header().Set("Content-Type", "application/json") | ||
| 89 | w.WriteHeader(status) | ||
| 90 | json.NewEncoder(w).Encode(body) | ||
| 91 | } | ||
| 92 | |||
| 93 | // apiAuth resolves the bearer token; failures are uniform 401s. | ||
| 94 | func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, string, bool) { | ||
| 95 | token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") | ||
| 96 | if !ok || token == "" { | ||
| 97 | w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`) | ||
| 98 | apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>") | ||
| 99 | return store.User{}, "", false | ||
| 100 | } | ||
| 101 | user, scope, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token))) | ||
| 102 | if err != nil { | ||
| 103 | if errors.Is(err, store.ErrNotFound) { | ||
| 104 | apiError(w, http.StatusUnauthorized, "invalid or expired token") | ||
| 105 | return store.User{}, "", false | ||
| 106 | } | ||
| 107 | apiError(w, http.StatusInternalServerError, "internal error") | ||
| 108 | return store.User{}, "", false | ||
| 109 | } | ||
| 110 | return user, scope, true | ||
| 111 | } | ||
| 112 | |||
| 113 | func apiError(w http.ResponseWriter, status int, msg string) { | ||
| 114 | w.Header().Set("Content-Type", "application/json") | ||
| 115 | w.WriteHeader(status) | ||
| 116 | json.NewEncoder(w).Encode(map[string]any{ | ||
| 117 | "protocol_version": protocol.Version, | ||
| 118 | "error": msg, | ||
| 119 | }) | ||
| 120 | } | ||
internal/httpd/routes.go +7
| @@ -43,6 +43,13 @@ func (s *Server) Routes() []Route { | |||
| 43 | Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr}, | 43 | Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr}, |
| 44 | ) | 44 | ) |
| 45 | 45 | ||
| 46 | // The JSON API is its own opt-in surface, independent of web.mode. | ||
| 47 | if s.cfg.API.Enabled { | ||
| 48 | routes = append(routes, | ||
| 49 | Route{Method: "POST", Pattern: "/api/v1/cmd", Mutating: true, Handler: s.apiCmd}, | ||
| 50 | ) | ||
| 51 | } | ||
| 52 | |||
| 46 | // Account-mode routes exist only when web.mode = "accounts". In | 53 | // Account-mode routes exist only when web.mode = "accounts". In |
| 47 | // view_only they are never registered — the structural guarantee. | 54 | // view_only they are never registered — the structural guarantee. |
| 48 | if s.cfg.Web.Mode == "accounts" { | 55 | if s.cfg.Web.Mode == "accounts" { |
internal/httpd/routes_test.go +20
| @@ -33,6 +33,26 @@ func TestViewOnlyHasNoMutatingRoutes(t *testing.T) { | |||
| 33 | } | 33 | } |
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | // TestAPIRouteGating: the API route exists only when [api] enabled = true. | ||
| 37 | func TestAPIRouteGating(t *testing.T) { | ||
| 38 | has := func(cfg config.Config) bool { | ||
| 39 | for _, r := range New(cfg, nil).Routes() { | ||
| 40 | if r.Pattern == "/api/v1/cmd" { | ||
| 41 | return true | ||
| 42 | } | ||
| 43 | } | ||
| 44 | return false | ||
| 45 | } | ||
| 46 | if has(config.Default()) { | ||
| 47 | t.Fatal("API route present with api disabled (the default)") | ||
| 48 | } | ||
| 49 | cfg := config.Default() | ||
| 50 | cfg.API.Enabled = true | ||
| 51 | if !has(cfg) { | ||
| 52 | t.Fatal("API route missing with api enabled") | ||
| 53 | } | ||
| 54 | } | ||
| 55 | |||
| 36 | // TestAccountsModeHasLoginRoute is the positive counterpart: switching the | 56 | // TestAccountsModeHasLoginRoute is the positive counterpart: switching the |
| 37 | // mode on registers the session routes. | 57 | // mode on registers the session routes. |
| 38 | func TestAccountsModeHasLoginRoute(t *testing.T) { | 58 | func TestAccountsModeHasLoginRoute(t *testing.T) { |
internal/store/migrations/0004_api_tokens.down.sql added +1
| @@ -0,0 +1 @@ | |||
| 1 | DROP TABLE api_tokens; | ||
internal/store/migrations/0004_api_tokens.up.sql added +11
| @@ -0,0 +1,11 @@ | |||
| 1 | CREATE TABLE api_tokens ( | ||
| 2 | id INTEGER PRIMARY KEY, | ||
| 3 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 4 | name TEXT NOT NULL, | ||
| 5 | token_hash TEXT NOT NULL UNIQUE, | ||
| 6 | scope TEXT NOT NULL DEFAULT 'full' CHECK (scope IN ('full','read')), | ||
| 7 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | ||
| 8 | expires_at TEXT, | ||
| 9 | last_used_at TEXT, | ||
| 10 | UNIQUE (user_id, name) | ||
| 11 | ); | ||
internal/store/tokens.go added +83
| @@ -0,0 +1,83 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | "fmt" | ||
| 7 | "time" | ||
| 8 | ) | ||
| 9 | |||
| 10 | type APIToken struct { | ||
| 11 | Name string | ||
| 12 | Scope string | ||
| 13 | CreatedAt string | ||
| 14 | ExpiresAt *time.Time | ||
| 15 | LastUsedAt *time.Time | ||
| 16 | } | ||
| 17 | |||
| 18 | // CreateAPIToken stores a token hash; expires nil means no expiry. | ||
| 19 | func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time) error { | ||
| 20 | var exp any | ||
| 21 | if expires != nil { | ||
| 22 | exp = fmtTime(*expires) | ||
| 23 | } | ||
| 24 | _, err := s.DB.Exec( | ||
| 25 | "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at) VALUES (?, ?, ?, ?, ?)", | ||
| 26 | userID, name, tokenHash, scope, exp) | ||
| 27 | if isUniqueErr(err) { | ||
| 28 | return fmt.Errorf("you already have a token named %q", name) | ||
| 29 | } | ||
| 30 | return err | ||
| 31 | } | ||
| 32 | |||
| 33 | // APITokenUser resolves a presented token to its user and scope; expired and | ||
| 34 | // unknown tokens fail identically. | ||
| 35 | func (s *Store) APITokenUser(tokenHash string) (User, string, error) { | ||
| 36 | var userID int64 | ||
| 37 | var scope string | ||
| 38 | err := s.DB.QueryRow(` | ||
| 39 | SELECT user_id, scope FROM api_tokens | ||
| 40 | WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`, | ||
| 41 | tokenHash, fmtTime(time.Now())).Scan(&userID, &scope) | ||
| 42 | if errors.Is(err, sql.ErrNoRows) { | ||
| 43 | return User{}, "", ErrNotFound | ||
| 44 | } | ||
| 45 | if err != nil { | ||
| 46 | return User{}, "", err | ||
| 47 | } | ||
| 48 | s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash) | ||
| 49 | u, err := s.UserByID(userID) | ||
| 50 | return u, scope, err | ||
| 51 | } | ||
| 52 | |||
| 53 | func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { | ||
| 54 | rows, err := s.DB.Query( | ||
| 55 | "SELECT name, scope, created_at, expires_at, last_used_at FROM api_tokens WHERE user_id = ? ORDER BY name", userID) | ||
| 56 | if err != nil { | ||
| 57 | return nil, err | ||
| 58 | } | ||
| 59 | defer rows.Close() | ||
| 60 | var out []APIToken | ||
| 61 | for rows.Next() { | ||
| 62 | var t APIToken | ||
| 63 | var exp, used sql.NullString | ||
| 64 | if err := rows.Scan(&t.Name, &t.Scope, &t.CreatedAt, &exp, &used); err != nil { | ||
| 65 | return nil, err | ||
| 66 | } | ||
| 67 | t.ExpiresAt = parseTime(exp) | ||
| 68 | t.LastUsedAt = parseTime(used) | ||
| 69 | out = append(out, t) | ||
| 70 | } | ||
| 71 | return out, rows.Err() | ||
| 72 | } | ||
| 73 | |||
| 74 | func (s *Store) RevokeAPIToken(userID int64, name string) error { | ||
| 75 | res, err := s.DB.Exec("DELETE FROM api_tokens WHERE user_id = ? AND name = ?", userID, name) | ||
| 76 | if err != nil { | ||
| 77 | return err | ||
| 78 | } | ||
| 79 | if n, _ := res.RowsAffected(); n == 0 { | ||
| 80 | return ErrNotFound | ||
| 81 | } | ||
| 82 | return nil | ||
| 83 | } | ||