A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 6b2b26db54

6b2b26db548cdb89367154ba6ff4b0bcce21dd67

parent: 7b6134a766

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-26T05:00:31Z

web: account settings for keys and addresses

SSH keys (with scope), OpenPGP keys, and email addresses are managed from
an authenticated session, dispatched through the same commands the CLI
uses. Public keys are the only credential-shaped input the web takes —
they are not secret, and a new user needs one registered before the CLI
is reachable. Token minting, account export and admin stay SSHOnly, and
the page says so.

Ref #35
e2e/accountweb_test.go added +116
@@ -0,0 +1,116 @@
1package e2e
2
3import (
4 "encoding/json"
5 "net/url"
6 "os"
7 "strings"
8 "testing"
9)
10
11// TestAccountSettingsWeb covers managing your own keys and addresses from a
12// browser session. Public keys are the only credential-shaped input the web
13// accepts; secrets and token minting stay on SSH.
14func TestAccountSettingsWeb(t *testing.T) {
15 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
16 aliceKey := inst.newKey(t, "alice")
17 inst.admin(t, "admin", "user", "create", "alice",
18 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
19
20 out, _, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
21 if code != 0 {
22 t.Fatal("web login failed")
23 }
24 var env struct {
25 Data struct {
26 URL string `json:"url"`
27 } `json:"data"`
28 }
29 json.Unmarshal([]byte(out), &env)
30 browser := newBrowser(t)
31 browserGet(t, browser, inst.base()+env.Data.URL[strings.Index(env.Data.URL, "/login"):])
32
33 status, body := browserGet(t, browser, inst.base()+"/settings")
34 if status != 200 {
35 t.Fatalf("account settings: %d", status)
36 }
37 // The key that signed us in is listed, and its address shows verified.
38 if !strings.Contains(body, "SHA256:") {
39 t.Error("no SSH key fingerprint listed")
40 }
41 if !strings.Contains(body, "alice@example.test") || !strings.Contains(body, "verified") {
42 t.Error("verified address not shown")
43 }
44
45 // Add a second key through the form, then confirm it over SSH — the
46 // web write must land in the same place the CLI reads.
47 second := inst.newKey(t, "alice2")
48 raw, err := os.ReadFile(second + ".pub")
49 if err != nil {
50 t.Fatal(err)
51 }
52 pub := string(raw)
53 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
54 "field": {"key-add"}, "key": {pub}, "scope": {"git"},
55 }); status != 303 && status != 200 {
56 t.Fatalf("key add: %d", status)
57 }
58 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
59 if strings.Count(out, "SHA256:") != 2 || !strings.Contains(out, `"scope":"git"`) {
60 t.Fatalf("key not registered with its scope: %s", out)
61 }
62
63 // A git-scoped key can move git data but cannot run commands, so the
64 // scope the form set is really enforced.
65 if _, _, code := inst.ssh(t, second, "", "whoami"); code == 0 {
66 t.Error("git-scoped key ran a control command")
67 }
68
69 // Removing it through the form removes it for SSH too.
70 fp := gitScopedFingerprint(t, out)
71 if status, _ := browserPost(t, browser, inst.base()+"/settings", url.Values{
72 "field": {"key-remove"}, "fingerprint": {fp},
73 }); status != 303 && status != 200 {
74 t.Fatalf("key remove: %d", status)
75 }
76 out, _, _ = inst.ssh(t, aliceKey, "", "keys", "list", "--json")
77 if strings.Count(out, "SHA256:") != 1 {
78 t.Fatalf("key not removed: %s", out)
79 }
80
81 // Garbage is refused by the same validation the CLI uses, and says so.
82 // The redirect carries the message, so the followed page shows it.
83 _, body = browserPost(t, browser, inst.base()+"/settings", url.Values{
84 "field": {"key-add"}, "key": {"not a key"},
85 })
86 if !strings.Contains(body, `class="error"`) {
87 t.Error("invalid key accepted without an error")
88 }
89
90 // Token minting is SSHOnly and has no web form to reach it.
91 if strings.Contains(body, `value="token-mint"`) {
92 t.Error("token minting exposed on the web")
93 }
94}
95
96// gitScopedFingerprint pulls the fingerprint of the git-scoped key out of
97// "auth keys list --json".
98func gitScopedFingerprint(t *testing.T, blob string) string {
99 t.Helper()
100 var env struct {
101 Data []struct {
102 Fingerprint string `json:"fingerprint"`
103 Scope string `json:"scope"`
104 } `json:"data"`
105 }
106 if err := json.Unmarshal([]byte(blob), &env); err != nil {
107 t.Fatalf("keys list JSON: %v\n%s", err, blob)
108 }
109 for _, k := range env.Data {
110 if k.Scope == "git" {
111 return k.Fingerprint
112 }
113 }
114 t.Fatalf("no git-scoped key in %s", blob)
115 return ""
116}
internal/httpd/account.go added +143
@@ -0,0 +1,143 @@
1package httpd
2
3import (
4 "encoding/json"
5 "net/http"
6 "net/url"
7 "strings"
8
9 "gitbay.org/gitbay/internal/store"
10)
11
12// accountKey is one SSH key as the settings page shows it: enough to
13// recognise which key this is without printing the whole blob.
14type accountKey struct {
15 Fingerprint string
16 Algo string
17 Scope string
18 Comment string
19}
20
21type accountPGP struct {
22 Fingerprint string
23 UIDs []string
24 Expired bool
25 Revoked bool
26}
27
28// accountForm renders the account's own settings: keys, addresses, and the
29// commands for everything that stays on SSH.
30func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
31 var keys []accountKey
32 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
33 for _, k := range list {
34 keys = append(keys, accountKey{
35 Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope,
36 Comment: keyComment(k.Blob),
37 })
38 }
39 }
40 var pgp []accountPGP
41 if list, err := s.st.ListPGPKeys(u.ID); err == nil {
42 for _, k := range list {
43 var uids []string
44 json.Unmarshal([]byte(k.UIDsJSON), &uids)
45 pgp = append(pgp, accountPGP{
46 Fingerprint: k.Fingerprint, UIDs: uids,
47 Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil,
48 })
49 }
50 }
51 emails, _ := s.st.ListEmails(u.ID)
52
53 s.render(w, "account.html", struct {
54 basePage
55 Keys []accountKey
56 PGP []accountPGP
57 Emails []store.Email
58 Host string
59 Notice string
60 Message string
61 }{s.baseFor(u), keys, pgp, emails, s.cfg.SiteHost(),
62 r.URL.Query().Get("e"), r.URL.Query().Get("m")})
63}
64
65// keyComment pulls the trailing comment off an authorized_keys blob, which
66// is how people tell their own keys apart.
67func keyComment(blob []byte) string {
68 f := strings.Fields(string(blob))
69 if len(f) < 3 {
70 return ""
71 }
72 return strings.Join(f[2:], " ")
73}
74
75// accountSubmit routes the account forms to their commands. Everything
76// here is a public key or an address — no secret is accepted over the web.
77func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
78 back := func(msg, note string) {
79 q := ""
80 switch {
81 case msg != "":
82 q = "?e=" + url.QueryEscape(msg)
83 case note != "":
84 q = "?m=" + url.QueryEscape(note)
85 }
86 http.Redirect(w, r, "/settings"+q, http.StatusSeeOther)
87 }
88
89 switch r.FormValue("field") {
90 case "key-add":
91 body := strings.TrimSpace(r.FormValue("key"))
92 if body == "" {
93 back("paste a public key in authorized_keys format", "")
94 return
95 }
96 argv := []string{"keys", "add"}
97 if scope := r.FormValue("scope"); scope == "git" {
98 argv = append(argv, "--scope", "git")
99 }
100 if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok {
101 back(msg, "")
102 return
103 }
104 back("", "key registered")
105 case "key-remove":
106 if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
107 back(msg, "")
108 return
109 }
110 back("", "key removed")
111 case "pgp-add":
112 body := strings.TrimSpace(r.FormValue("key"))
113 if body == "" {
114 back("paste an armored OpenPGP public key", "")
115 return
116 }
117 if msg, ok := s.runControlStdin(u, []string{"pgp", "add"}, body+"\n"); !ok {
118 back(msg, "")
119 return
120 }
121 back("", "PGP key registered")
122 case "pgp-remove":
123 if _, msg, ok := s.runControl(u, []string{"pgp", "remove", r.FormValue("fingerprint")}); !ok {
124 back(msg, "")
125 return
126 }
127 back("", "PGP key removed")
128 case "email-add":
129 if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok {
130 back(msg, "")
131 return
132 }
133 back("", "check that inbox for a verification code")
134 case "email-verify":
135 if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok {
136 back(msg, "")
137 return
138 }
139 back("", "address verified")
140 default:
141 back("unknown form", "")
142 }
143}
internal/httpd/control.go +26
@@ -39,6 +39,32 @@ func (s *Server) runControl(u store.User, argv []string) (out string, msg string
3939 return stdout.String(), m, code == protocol.ExitOK
4040 }
4141
42// runControlStdin is runControl for the handful of commands whose input
43// arrives on stdin. Public keys are the only such input the web accepts:
44// they are not secret, and pasting one into a browser is how people who
45// have not set up the CLI get their first key registered. Secrets, tokens
46// and mirror credentials remain SSHOnly and are refused by the dispatcher.
47func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
48 var stdout, stderr bytes.Buffer
49 ctx := &control.Ctx{
50 User: u,
51 Source: "web",
52 Scope: "full",
53 Store: s.st,
54 Cfg: s.cfg,
55 Stdin: strings.NewReader(stdin),
56 Stdout: &stdout,
57 Stderr: &stderr,
58 ViaAPI: true,
59 }
60 code := control.Dispatch(ctx, argv)
61 m := strings.TrimSpace(stderr.String())
62 if m == "" {
63 m = strings.TrimSpace(stdout.String())
64 }
65 return m, code == protocol.ExitOK
66}
67
4268 // runControlJSON runs a command in JSON mode and returns its data object.
4369 // In JSON mode a failure is an envelope carrying the message rather than
4470 // stderr text, so both paths are read from the same envelope.
internal/httpd/routes.go +3
@@ -88,6 +88,9 @@ func (s *Server) Routes() []Route {
8888 Route{Method: "POST", Pattern: "/logout", Mutating: true,
8989 Handler: s.checkOrigin(s.logout)},
9090 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
91 Route{Method: "GET", Pattern: "/settings", Handler: s.requireUser(s.accountForm)},
92 Route{Method: "POST", Pattern: "/settings", Mutating: true,
93 Handler: s.checkOrigin(s.requireUser(s.accountSubmit))},
9194 )
9295 // Web signup fronts the same registration path as SSH register,
9396 // so it exists only when registration is open or invite.
internal/store/users.go +29
@@ -136,6 +136,35 @@ func (s *Store) UserEmailAddresses(userID int64) ([]string, error) {
136136 return out, rows.Err()
137137 }
138138
139// Email is one address on an account, with the state the signature rules
140// and notification routing depend on.
141type Email struct {
142 Address string
143 Verified bool
144 VerifiedBy string // smtp | admin, empty when unverified
145 Primary bool
146}
147
148// ListEmails returns every address on the account with its state.
149func (s *Store) ListEmails(userID int64) ([]Email, error) {
150 rows, err := s.DB.Query(`SELECT address, verified_at IS NOT NULL,
151 COALESCE(verified_by, ''), is_primary
152 FROM emails WHERE user_id = ? ORDER BY is_primary DESC, address`, userID)
153 if err != nil {
154 return nil, err
155 }
156 defer rows.Close()
157 var out []Email
158 for rows.Next() {
159 var e Email
160 if err := rows.Scan(&e.Address, &e.Verified, &e.VerifiedBy, &e.Primary); err != nil {
161 return nil, err
162 }
163 out = append(out, e)
164 }
165 return out, rows.Err()
166}
167
139168 // SetUserDisabled suspends or restores an account. Disabling also drops
140169 // the user's web sessions; their keys and tokens stay registered but are
141170 // refused at every entry point until re-enabled.
internal/web/static/style.css +17
@@ -269,6 +269,8 @@ ul.raillist.wide .owner { font-size: var(--fs-0); }
269269 }
270270 .railfoot a { color: var(--shell-fg); }
271271 a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); }
272.railfoot .railsettings { color: var(--shell-muted); display: inline-flex; }
273.railfoot .railsettings:hover { color: var(--shell-fg); }
272274 .railfoot .linklike {
273275 background: none; border: 0; padding: 0; cursor: pointer;
274276 color: var(--shell-muted); font: inherit; text-decoration: underline;
@@ -795,6 +797,21 @@ pre.message {
795797 }
796798
797799 /* repo facts: the counts a visitor sizes a project up with */
800/* account settings */
801table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; }
802table.keys td.mono, .mono { font-family: var(--mono); font-size: var(--fs-1); overflow-wrap: anywhere; }
803table.keys td.act { text-align: right; }
804ul.plain { list-style: none; margin: 0 0 var(--sp-3); padding: 0; }
805ul.plain li { padding: var(--sp-1) 0; display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
806form.setform.stack { display: block; }
807form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; display: block; margin-bottom: var(--sp-2); }
808p.notice {
809 border-left: 3px solid var(--ok);
810 background: var(--hover);
811 padding: var(--sp-2) var(--sp-3);
812 margin: 0 0 var(--sp-4);
813}
814
798815 .facts { margin: 0 0 var(--sp-4); }
799816 .facts .counts {
800817 display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4);
internal/web/templates/account.html added +91
@@ -0,0 +1,91 @@
1{{define "title"}}account settings{{end}}
2{{define "content"}}
3<h1>Account settings</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}}
6
7<h2>SSH keys</h2>
8<p class="meta">Your keys are your identity here. A <code>full</code> key can run
9commands and push; a <code>git</code> key can only move git data, which is what
10a CI checkout wants.</p>
11{{if .Keys}}<div class="tablewrap"><table class="keys">
12<tr class="cols"><th scope="col">fingerprint</th><th scope="col">type</th><th scope="col">scope</th><th scope="col"></th></tr>
13{{range .Keys}}<tr>
14 <td class="mono">{{.Fingerprint}}{{with .Comment}}<br><span class="muted">{{.}}</span>{{end}}</td>
15 <td>{{.Algo}}</td>
16 <td>{{.Scope}}</td>
17 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="key-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}"><button type="submit" class="linklike">Remove</button></form></td>
18</tr>
19{{end}}</table></div>
20{{else}}<p class="none">No SSH keys — which cannot be right, since you signed in.</p>{{end}}
21<details class="editbox">
22 <summary>Add an SSH key</summary>
23 <form method="post" action="/settings" class="setform stack">
24 <input type="hidden" name="field" value="key-add">
25 <label for="key">Public key</label>
26 <textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@machine"></textarea>
27 <label for="scope">Scope</label>
28 <select id="scope" name="scope">
29 <option value="full">full — commands and git</option>
30 <option value="git">git — git transport only</option>
31 </select>
32 <button type="submit" class="primary">Add key</button>
33 </form>
34</details>
35
36<h2>Email addresses</h2>
37<p class="meta">A verified address is what ties your signed commits to this
38account, and where notifications go.</p>
39{{if .Emails}}<ul class="plain">
40{{range .Emails}}<li>{{.Address}}
41 {{if .Primary}}<span class="chip">primary</span>{{end}}
42 {{if .Verified}}<span class="badge badge-verified">verified{{with .VerifiedBy}} · {{.}}{{end}}</span>
43 {{else}}<span class="badge badge-unsigned">unverified</span>{{end}}</li>
44{{end}}</ul>{{end}}
45<details class="editbox">
46 <summary>Add an address</summary>
47 <form method="post" action="/settings" class="setform">
48 <input type="hidden" name="field" value="email-add">
49 <label for="address">Address</label>
50 <input type="email" id="address" name="address" required placeholder="you@example.org">
51 <button type="submit">Send code</button>
52 </form>
53 <form method="post" action="/settings" class="setform">
54 <input type="hidden" name="field" value="email-verify">
55 <label for="code">Verification code</label>
56 <input type="text" id="code" name="code" required placeholder="from the mail">
57 <button type="submit">Verify</button>
58 </form>
59</details>
60
61<h2>OpenPGP keys</h2>
62<p class="meta">Only needed if you sign commits with GPG. SSH signing
63(<code>gpg.format = ssh</code>) uses the keys above.</p>
64{{if .PGP}}<div class="tablewrap"><table class="keys">
65<tr class="cols"><th scope="col">fingerprint</th><th scope="col">identities</th><th scope="col"></th></tr>
66{{range .PGP}}<tr>
67 <td class="mono">{{.Fingerprint}}</td>
68 <td>{{range .UIDs}}{{.}}<br>{{end}}{{if .Revoked}}<span class="badge badge-bad_signature">revoked</span>{{else if .Expired}}<span class="badge badge-signed_key_expired">expired</span>{{end}}</td>
69 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="pgp-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}"><button type="submit" class="linklike">Remove</button></form></td>
70</tr>
71{{end}}</table></div>
72{{else}}<p class="none">No OpenPGP keys.</p>{{end}}
73<details class="editbox">
74 <summary>Add a PGP key</summary>
75 <form method="post" action="/settings" class="setform stack">
76 <input type="hidden" name="field" value="pgp-add">
77 <label for="pgpkey">Armored public key</label>
78 <textarea id="pgpkey" name="key" rows="6" required placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"></textarea>
79 <button type="submit" class="primary">Add key</button>
80 </form>
81</details>
82
83<h2>On SSH only</h2>
84<p class="meta">Anything whose input is a credential stays on the command line,
85where it can be piped instead of pasted:</p>
86<pre class="message">gitbay auth token mint --name laptop # API tokens
87gitbay auth export &gt; account.bundle # your account, portable
88gitbay admin ... # instance administration</pre>
89<p class="meta">All of the above works from stock OpenSSH too:
90<code>ssh git@{{.Host}} auth whoami</code>.</p>
91{{end}}
internal/web/templates/layout.html +1
@@ -41,6 +41,7 @@
4141 </div>
4242 <div class="railfoot">
4343 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span>{{.Viewer}}</a>
44 <a class="railsettings" href="/settings" title="Account settings" aria-label="Account settings"><svg class="icon" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492ZM6.254 8a1.746 1.746 0 1 1 3.492 0 1.746 1.746 0 0 1-3.492 0Z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319Zm-2.153.425c.18-.613 1.048-.613 1.229 0l.093.319a2.373 2.373 0 0 0 3.416 1.416l.292-.16c.561-.306 1.175.308.87.87l-.16.292a2.373 2.373 0 0 0 1.415 3.416l.319.093c.613.18.613 1.048 0 1.229l-.319.093a2.373 2.373 0 0 0-1.416 3.416l.16.292c.305.561-.309 1.175-.87.87l-.292-.16a2.373 2.373 0 0 0-3.416 1.415l-.093.319c-.181.613-1.049.613-1.229 0l-.093-.319a2.373 2.373 0 0 0-3.416-1.416l-.292.16c-.561.305-1.175-.309-.87-.87l.16-.292a2.373 2.373 0 0 0-1.415-3.416l-.32-.093c-.612-.181-.612-1.049 0-1.229l.32-.093a2.373 2.373 0 0 0 1.416-3.416l-.16-.292c-.306-.561.308-1.175.87-.87l.292.16a2.373 2.373 0 0 0 3.416-1.416l.093-.318Z"/></svg></a>
4445 <form method="post" action="/logout"><button type="submit" class="linklike">Log out</button></form>
4546 {{else}}<a href="/login">Sign in</a>{{end}}
4647 </div>