A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit c327fbdf04

c327fbdf04d6735772a5d661b271d68fe9e39404

parent: f8f33fdafa

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T21:26:17Z

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.
docs/users.org +4 −1
@@ -19,7 +19,10 @@ Or build from source: =go build ./cmd/gitbay= in a clone of
1919
2020 * Getting an account
2121
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:
2326
2427 - closed :: an admin creates your account on the host and registers your
2528 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
130130 return fail(protocol.ExitUsage, "%v", err)
131131 }
132132
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 }
133149 fp := ssh.FingerprintSHA256(pub)
134150 switch cfg.Registration.Mode {
135151 case "invite":
136152 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
138154 }
139155 // One transaction: a failure at any step leaves the invite
140156 // redeemable and no partial account behind.
141157 _, err := st.RedeemInvite(store.HashToken(invite), username, fp, pub.Type(), pub.Marshal())
142158 if err != nil {
143159 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
145161 }
146 return fail(protocol.ExitUsage, "%v", err)
162 return "", err.Error(), protocol.ExitUsage
147163 }
148 fmt.Fprintf(stdout, "welcome, %s — your account is active\n", username)
149 return protocol.ExitOK
164 return fmt.Sprintf("welcome, %s — your account is active\n", username), "", protocol.ExitOK
150165
151166 case "open":
152167 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
154169 }
155170 uid, err := st.RegisterOpen(username, email, fp, pub.Type(), pub.Marshal())
156171 if err != nil {
157 return fail(protocol.ExitUsage, "%v", err)
172 return "", err.Error(), protocol.ExitUsage
158173 }
159174 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
161176 }
162 fmt.Fprintf(stdout,
177 return fmt.Sprintf(
163178 "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))
165 return protocol.ExitOK
179 username, email, siteHost(cfg)), "", protocol.ExitOK
166180
167181 default:
168 return fail(protocol.ExitDenied, "registration is closed on this instance")
182 return "", "registration is closed on this instance", protocol.ExitDenied
169183 }
170184 }
171185
internal/httpd/accounts.go +43
@@ -7,6 +7,8 @@ import (
77 "strings"
88 "time"
99
10 gossh "golang.org/x/crypto/ssh"
11
1012 "gitbay.org/gitbay/internal/control"
1113 "gitbay.org/gitbay/internal/gitutil"
1214 "gitbay.org/gitbay/internal/policy"
@@ -164,6 +166,47 @@ func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.Use
164166 return repo, true
165167 }
166168
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
167210 // issueCreateForm renders the new-issue form, prefilled from the repo's
168211 // default issue template when one exists.
169212 func (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 {
7171 Route{Method: "POST", Pattern: "/logout", Mutating: true,
7272 Handler: s.checkOrigin(s.logout)},
7373 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,
7485 Route{Method: "POST", Pattern: "/new", Mutating: true,
7586 Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))},
7687 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) {
101101 Site string
102102 Host string
103103 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"})
105107 }
106108
107109 func (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>
88 <pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed
99 git clone ssh://git@{{.Host}}/owner/repo.git</pre>
1010 <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}}
1212 </div>
1313 {{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}}