Commit c327fbdf04

c327fbdf04d6735772a5d661b271d68fe9e39404

parent: f8f33fdafa

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 21:26 UTC

Add web signup for open and invite instances

Closes #9

GET/POST /register (accounts mode, registration not closed — the route
is otherwise never registered) front the exact SSH registration path:
RegisterAccount is extracted from the register command and shared, so
the same transactions and rules apply — invites burn atomically, open
signups send the verification mail and stay pending. The form takes a
pasted SSH public key; the success page shows the SSH next steps. The
landing page links signup when it exists.

Layout: unified · split

docs/users.org +4 −1
@@ -19,7 +19,10 @@ Or build from source: =go build ./cmd/gitbay= in a clone of
19 19
20* Getting an account 20* Getting an account
21 21
22How you join depends on the instance's registration mode: 22How you join depends on the instance's registration mode. On instances
23with web accounts enabled, =/register= offers the same signup as a
24browser form (paste your SSH public key); everything below works from
25the terminal alone:
23 26
24- closed :: an admin creates your account on the host and registers your 27- closed :: an admin creates your account on the host and registers your
25 first SSH key. Nothing for you to do but hand over your public key. 28 first SSH key. Nothing for you to do but hand over your public key.
e2e/websignup_test.go added +79
@@ -0,0 +1,79 @@
1package e2e
2
3import (
4 "fmt"
5 "net/url"
6 "os"
7 "strings"
8 "testing"
9)
10
11func TestWebSignup(t *testing.T) {
12 smtp := startFakeSMTP(t)
13 inst := startInstanceWith(t, fmt.Sprintf(
14 "[web]\nmode = \"accounts\"\n[registration]\nmode = \"invite\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n",
15 smtp.addr))
16
17 // The landing page advertises signup; the form renders.
18 status, body := inst.get(t, "/")
19 if status != 200 || !strings.Contains(body, `href="/register"`) {
20 t.Fatalf("landing signup link: %d", status)
21 }
22 status, body = inst.get(t, "/register")
23 if status != 200 || !strings.Contains(body, "invite-only") || !strings.Contains(body, `name="key"`) {
24 t.Fatalf("register form: %d\n%s", status, body)
25 }
26
27 // Invite issued over the admin path; redeemed through the browser.
28 inst.admin(t, "admin", "invite", "--email", "erin@example.test")
29 inviteCode := extractCode(t, smtp.waitMail(t, 0))
30 key := inst.newKey(t, "erin")
31 pub, err := os.ReadFile(key + ".pub")
32 if err != nil {
33 t.Fatal(err)
34 }
35
36 browser := newBrowser(t)
37 // A garbage key re-renders the form with the error, keeping the input.
38 _, body = browserPost(t, browser, inst.base()+"/register", url.Values{
39 "username": {"erin"}, "invite": {inviteCode}, "key": {"not a key"}})
40 if !strings.Contains(body, "does not parse as an SSH public key") || !strings.Contains(body, `value="erin"`) {
41 t.Fatalf("bad key handling:\n%s", body)
42 }
43 // A bad invite fails without burning anything.
44 _, body = browserPost(t, browser, inst.base()+"/register", url.Values{
45 "username": {"erin"}, "invite": {"deadbeef"}, "key": {string(pub)}})
46 if !strings.Contains(body, "invalid or already used") {
47 t.Fatalf("bad invite:\n%s", body)
48 }
49 // The real thing: account is active immediately (invite proves the mailbox).
50 status, body = browserPost(t, browser, inst.base()+"/register", url.Values{
51 "username": {"erin"}, "invite": {inviteCode}, "key": {string(pub)}})
52 if status != 200 || !strings.Contains(body, "welcome, erin") {
53 t.Fatalf("signup: %d\n%s", status, body)
54 }
55 out, errOut, code := inst.ssh(t, key, "", "whoami")
56 if code != 0 || !strings.Contains(out, "erin") {
57 t.Fatalf("ssh after web signup: exit %d, %s%s", code, out, errOut)
58 }
59 // The invite is burned: reusing it (fresh browser, fresh key) fails.
60 key2 := inst.newKey(t, "mallory")
61 pub2, _ := os.ReadFile(key2 + ".pub")
62 _, body = browserPost(t, newBrowser(t), inst.base()+"/register", url.Values{
63 "username": {"mallory"}, "invite": {inviteCode}, "key": {string(pub2)}})
64 if !strings.Contains(body, "invalid or already used") {
65 t.Fatalf("invite reuse:\n%s", body)
66 }
67}
68
69func TestWebSignupClosedInstance(t *testing.T) {
70 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
71 // Closed registration: no signup route at all, and no landing hint.
72 if status, _ := inst.get(t, "/register"); status != 404 {
73 t.Fatalf("register on closed instance: %d", status)
74 }
75 _, body := inst.get(t, "/")
76 if strings.Contains(body, `href="/register"`) {
77 t.Fatal("closed landing advertises signup")
78 }
79}
internal/control/register.go +26 −12
@@ -130,42 +130,56 @@ func RunRegister(cfg config.Config, st *store.Store, pub ssh.PublicKey, argv []s
130 return fail(protocol.ExitUsage, "%v", err) 130 return fail(protocol.ExitUsage, "%v", err)
131 } 131 }
132 132
133 msg, errMsg, code := RegisterAccount(cfg, st, pub, username, email, invite)
134 if code != protocol.ExitOK {
135 return fail(code, "%s", errMsg)
136 }
137 fmt.Fprint(stdout, msg)
138 return protocol.ExitOK
139}
140
141// RegisterAccount creates an account for pub under the instance's
142// registration mode. On success it returns the human message and ExitOK;
143// otherwise an error message and the classifying exit code. Shared by the
144// SSH register command and the web signup form.
145func RegisterAccount(cfg config.Config, st *store.Store, pub ssh.PublicKey, username, email, invite string) (string, string, int) {
146 if err := policy.ValidateOwnerName(username); err != nil {
147 return "", err.Error(), protocol.ExitUsage
148 }
133 fp := ssh.FingerprintSHA256(pub) 149 fp := ssh.FingerprintSHA256(pub)
134 switch cfg.Registration.Mode { 150 switch cfg.Registration.Mode {
135 case "invite": 151 case "invite":
136 if invite == "" { 152 if invite == "" {
137 return fail(protocol.ExitDenied, "this instance is invite-only: register --username <name> --invite <code>") 153 return "", "this instance is invite-only: an invite code is required", protocol.ExitDenied
138 } 154 }
139 // One transaction: a failure at any step leaves the invite 155 // One transaction: a failure at any step leaves the invite
140 // redeemable and no partial account behind. 156 // redeemable and no partial account behind.
141 _, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal()) 157 _, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal())
142 if err != nil { 158 if err != nil {
143 if errors.Is(err, store.ErrNotFound) { 159 if errors.Is(err, store.ErrNotFound) {
144 return fail(protocol.ExitDenied, "that invite is invalid or already used") 160 return "", "that invite is invalid or already used", protocol.ExitDenied
145 } 161 }
146 return fail(protocol.ExitUsage, "%v", err) 162 return "", err.Error(), protocol.ExitUsage
147 } 163 }
148 fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username) 164 return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK
149 return protocol.ExitOK
150 165
151 case "open": 166 case "open":
152 if email == "" || !strings.Contains(email, "@") { 167 if email == "" || !strings.Contains(email, "@") {
153 return fail(protocol.ExitUsage, "usage: register --username <name> --email <address>") 168 return "", "a valid email address is required", protocol.ExitUsage
154 } 169 }
155 uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal()) 170 uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal())
156 if err != nil { 171 if err != nil {
157 return fail(protocol.ExitUsage, "%v", err) 172 return "", err.Error(), protocol.ExitUsage
158 } 173 }
159 if err := sendVerification(cfg, st, uid, email); err != nil { 174 if err := sendVerification(cfg, st, uid, email); err != nil {
160 return fail(protocol.ExitFailure, "sending verification mail: %v", err) 175 return "", "sending verification mail: " + err.Error(), protocol.ExitFailure
161 } 176 }
162 fmt.Fprintf(stdout, 177 return fmt.Sprintf(
163 "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify <code>\n", 178 "account %s created. A verification code was sent to %s.\nActivate with:\n\n ssh git@%s email verify <code>\n",
164 username, email, siteHost(cfg)) 179 username, email, siteHost(cfg)), "", protocol.ExitOK
165 return protocol.ExitOK
166 180
167 default: 181 default:
168 return fail(protocol.ExitDenied, "registration is closed on this instance") 182 return "", "registration is closed on this instance", protocol.ExitDenied
169 } 183 }
170} 184}
171 185
internal/httpd/accounts.go +43
@@ -7,6 +7,8 @@ import (
7 "strings" 7 "strings"
8 "time" 8 "time"
9 9
10 gossh "golang.org/x/crypto/ssh"
11
10 "gitbay.org/gitbay/internal/control" 12 "gitbay.org/gitbay/internal/control"
11 "gitbay.org/gitbay/internal/gitutil" 13 "gitbay.org/gitbay/internal/gitutil"
12 "gitbay.org/gitbay/internal/policy" 14 "gitbay.org/gitbay/internal/policy"
@@ -164,6 +166,47 @@ func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.Use
164 return repo, true 166 return repo, true
165} 167}
166 168
169// signupForm and signupSubmit front the SSH registration path for open
170// and invite instances: same store transactions, same rules, a pasted
171// public key instead of the connecting one.
172func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
173 s.renderSignup(w, "", "")
174}
175
176func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
177 s.render(w, "register.html", struct {
178 Site string
179 Viewer string
180 Host string
181 Mode string // open | invite
182 Error string
183 Username string
184 }{s.siteName(), "", s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
185}
186
187func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
188 username := strings.TrimSpace(r.FormValue("username"))
189 keyText := strings.TrimSpace(r.FormValue("key"))
190 pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
191 if err != nil {
192 s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
193 return
194 }
195 msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
196 strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
197 if code != 0 {
198 s.renderSignup(w, errMsg, username)
199 return
200 }
201 s.render(w, "registered.html", struct {
202 Site string
203 Viewer string
204 Username string
205 Message string
206 Host string
207 }{s.siteName(), "", username, msg, s.cfg.SiteHost()})
208}
209
167// issueCreateForm renders the new-issue form, prefilled from the repo's 210// issueCreateForm renders the new-issue form, prefilled from the repo's
168// default issue template when one exists. 211// default issue template when one exists.
169func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) { 212func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
internal/httpd/routes.go +11
@@ -71,6 +71,17 @@ func (s *Server) Routes() []Route {
71 Route{Method: "POST", Pattern: "/logout", Mutating: true, 71 Route{Method: "POST", Pattern: "/logout", Mutating: true,
72 Handler: s.checkOrigin(s.logout)}, 72 Handler: s.checkOrigin(s.logout)},
73 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, 73 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
74 )
75 // Web signup fronts the same registration path as SSH register,
76 // so it exists only when registration is open or invite.
77 if s.cfg.Registration.Mode != "closed" {
78 routes = append(routes,
79 Route{Method: "GET", Pattern: "/register", Handler: s.signupForm},
80 Route{Method: "POST", Pattern: "/register", Mutating: true,
81 Handler: s.checkOrigin(s.signupSubmit)},
82 )
83 }
84 routes = append(routes,
74 Route{Method: "POST", Pattern: "/new", Mutating: true, 85 Route{Method: "POST", Pattern: "/new", Mutating: true,
75 Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, 86 Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))},
76 Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new", 87 Route{Method: "GET", Pattern: "/{owner}/{repo}/issues/new",
internal/httpd/web.go +3 −1
@@ -101,7 +101,9 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
101 Site string 101 Site string
102 Host string 102 Host string
103 Accounts bool 103 Accounts bool
104 }{s.siteName(), host, s.cfg.Web.Mode == "accounts"}) 104 Signup bool
105 }{s.siteName(), host, s.cfg.Web.Mode == "accounts",
106 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
105} 107}
106 108
107func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) { 109func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
internal/web/templates/landing.html +1 −1
@@ -8,6 +8,6 @@ the web is a fast, readable rendering of that state.</p>
8<pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed 8<pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed
9git clone ssh://git@{{.Host}}/owner/repo.git</pre> 9git clone ssh://git@{{.Host}}/owner/repo.git</pre>
10<p><a class="explorelink" href="/explore">explore public repositories →</a></p> 10<p><a class="explorelink" href="/explore">explore public repositories →</a></p>
11{{if .Accounts}}<p class="meta">have an account? mint a browser session from your terminal: <code>gitbay web login</code></p>{{end}} 11{{if .Accounts}}<p class="meta">have an account? mint a browser session from your terminal: <code>gitbay web login</code>{{if .Signup}} · new here? <a href="/register">create an account</a>{{end}}</p>{{end}}
12</div> 12</div>
13{{end}} 13{{end}}
internal/web/templates/register.html added +18
@@ -0,0 +1,18 @@
1{{define "title"}}register · {{.Site}}{{end}}
2{{define "content"}}
3<div class="landing">
4<h1>create an account</h1>
5{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p>
6{{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}}
7{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
8<form method="post" action="/register" class="signupform">
9<p><label>username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p>
10{{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p>
11{{else}}<p><label>email<br><input type="text" name="email" required></label></p>{{end}}
12<p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br>
13<textarea name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p>
14<p><button type="submit">create account</button></p>
15</form>
16<p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite &lt;code&gt;{{else}}--email you@example.org{{end}}</code></p>
17</div>
18{{end}}
internal/web/templates/registered.html added +11
@@ -0,0 +1,11 @@
1{{define "title"}}welcome · {{.Site}}{{end}}
2{{define "content"}}
3<div class="landing">
4<h1>welcome, {{.Username}}</h1>
5<pre class="quickstart">{{.Message}}</pre>
6<p>Everything runs over SSH with the key you registered:</p>
7<pre class="quickstart">ssh git@{{.Host}} whoami
8ssh git@{{.Host}} repo create {{.Username}}/hello
9gitbay web login # mints a browser session from your terminal</pre>
10</div>
11{{end}}