Commit a0dd5878fb

a0dd5878fbda93a49fe7d4503e2ac80fc6f6c8f3

parent: 08b325bb1d

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 23:17 UTC

M8: web accounts and editing

- SSH-minted browser sessions: web login mints a single-use 5-minute
  URL (token hashes only in the DB, migration 0002); sessions are
  SameSite=Strict HttpOnly cookies plus an Origin check on every POST
- accounts-mode routes registered only when web.mode = accounts: login,
  logout, repo create, issue create/comment, MR comment, file edit;
  route-table tests cover both modes
- logged-in viewers browse repos their grants allow; anonymous behavior
  unchanged (private = 404); index shows the viewer's private repos
- web file edits commit server-side via a temp-index read-tree/
  update-index/write-tree/commit-tree pipeline with update-ref CAS;
  authored with the user's verified primary email and honestly
  unsigned; refused outright on require_signed_commits repos
- web.password_auth rejected by check-config as not implemented
- e2e: full login flow incl. single-use token, web repo create visible
  over ssh, edit verified via repo log (author email + unsigned),
  cross-origin POST refused, logout kills the session, and view_only
  serves 404 for /login with ssh web login refused

Layout: unified · split

e2e/accounts_test.go added +217
@@ -0,0 +1,217 @@
1package e2e
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net/http"
8 "net/http/cookiejar"
9 "net/url"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14)
15
16// browser is an HTTP client with a cookie jar, standing in for a logged-in
17// user's browser.
18func newBrowser(t *testing.T) *http.Client {
19 t.Helper()
20 jar, err := cookiejar.New(nil)
21 if err != nil {
22 t.Fatal(err)
23 }
24 return &http.Client{Jar: jar}
25}
26
27func (i *instance) base() string { return fmt.Sprintf("http://127.0.0.1:%d", i.httpPort) }
28
29func browserGet(t *testing.T, c *http.Client, url string) (int, string) {
30 t.Helper()
31 resp, err := c.Get(url)
32 if err != nil {
33 t.Fatal(err)
34 }
35 defer resp.Body.Close()
36 body, _ := io.ReadAll(resp.Body)
37 return resp.StatusCode, string(body)
38}
39
40func browserPost(t *testing.T, c *http.Client, u string, form url.Values) (int, string) {
41 t.Helper()
42 resp, err := c.PostForm(u, form)
43 if err != nil {
44 t.Fatal(err)
45 }
46 defer resp.Body.Close()
47 body, _ := io.ReadAll(resp.Body)
48 return resp.StatusCode, string(body)
49}
50
51func TestWebAccounts(t *testing.T) {
52 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
53
54 aliceKey := inst.newKey(t, "alice")
55 inst.admin(t, "admin", "user", "create", "alice",
56 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
57
58 // A repo with one file to edit.
59 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/site"); code != 0 {
60 t.Fatalf("repo create: %s", errOut)
61 }
62 work := t.TempDir()
63 env := inst.gitEnv(aliceKey)
64 mustGit(t, work, env, "clone", inst.sshURL("alice/site"), "w")
65 dir := filepath.Join(work, "w")
66 os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("original\n"), 0o644)
67 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
68 mustGit(t, dir, env, "add", ".")
69 mustGit(t, dir, env, "commit", "-q", "-m", "base")
70 mustGit(t, dir, env, "push", "-q", "origin", "main")
71
72 // SSH-minted login URL.
73 out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json")
74 if code != 0 {
75 t.Fatalf("web login: %s", errOut)
76 }
77 var env2 struct {
78 Data struct {
79 URL string `json:"url"`
80 } `json:"data"`
81 }
82 if err := json.Unmarshal([]byte(out), &env2); err != nil {
83 t.Fatalf("web login JSON: %v\n%s", err, out)
84 }
85 // The URL carries the configured site host; rewrite to the test port.
86 loginPath := env2.Data.URL[strings.Index(env2.Data.URL, "/login"):]
87
88 browser := newBrowser(t)
89 status, body := browserGet(t, browser, inst.base()+loginPath)
90 if status != 200 || !strings.Contains(body, "logged in as alice") {
91 t.Fatalf("login redirect landed wrong: %d\n%s", status, body)
92 }
93
94 // The token is single-use.
95 fresh := newBrowser(t)
96 _, body = browserGet(t, fresh, inst.base()+loginPath)
97 if !strings.Contains(body, "invalid, expired, or already used") {
98 t.Fatalf("token reuse not refused:\n%s", body)
99 }
100
101 // Create a repo through the web.
102 status, _ = browserPost(t, browser, inst.base()+"/new",
103 url.Values{"name": {"webborn"}, "visibility": {"private"}})
104 if status != 200 {
105 t.Fatalf("web repo create: %d", status)
106 }
107 if out, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "alice/webborn"); code != 0 {
108 t.Fatalf("web-created repo missing over ssh: %s", out)
109 }
110
111 // Logged-in viewer sees their private repo; anonymous still gets 404.
112 if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 200 {
113 t.Fatalf("owner blocked from private repo page: %d", status)
114 }
115 if status, _ := inst.get(t, "/alice/webborn"); status != 404 {
116 t.Fatalf("anonymous sees private repo: %d", status)
117 }
118
119 // File edit: form loads with current content, POST commits.
120 status, body = browserGet(t, browser, inst.base()+"/alice/site/edit/main/notes.txt")
121 if status != 200 || !strings.Contains(body, "original") {
122 t.Fatalf("edit form: %d\n%s", status, body)
123 }
124 status, _ = browserPost(t, browser, inst.base()+"/alice/site/edit/main/notes.txt",
125 url.Values{"content": {"edited from the web\n"}, "message": {"web edit"}})
126 if status != 200 {
127 t.Fatalf("edit submit: %d", status)
128 }
129
130 // The edit is a real commit: authored with the verified email, and it
131 // displays as unsigned — the honest outcome for a server-side commit.
132 logOut, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/site", "--limit", "1", "--json")
133 if code != 0 {
134 t.Fatal("repo log failed")
135 }
136 var logEnv struct {
137 Data []struct {
138 Subject string `json:"subject"`
139 AuthorEmail string `json:"author_email"`
140 Signature struct {
141 State string `json:"state"`
142 } `json:"signature"`
143 } `json:"data"`
144 }
145 if err := json.Unmarshal([]byte(logOut), &logEnv); err != nil || len(logEnv.Data) == 0 {
146 t.Fatalf("log JSON: %v\n%s", err, logOut)
147 }
148 tip := logEnv.Data[0]
149 if tip.Subject != "web edit" || tip.AuthorEmail != "alice@example.test" || tip.Signature.State != "unsigned" {
150 t.Fatalf("web edit commit wrong: %+v", tip)
151 }
152 if status, body = browserGet(t, browser, inst.base()+"/alice/site/raw/main/notes.txt"); !strings.Contains(body, "edited from the web") {
153 t.Fatalf("edited content not served: %d %q", status, body)
154 }
155
156 // A require-signed repo refuses web edits instead of violating itself.
157 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/site", "on"); code != 0 {
158 t.Fatal("require-signed failed")
159 }
160 _, body = browserPost(t, browser, inst.base()+"/alice/site/edit/main/notes.txt",
161 url.Values{"content": {"x"}, "message": {"x"}})
162 if !strings.Contains(body, "requires signed commits") {
163 t.Fatalf("require-signed web edit not refused:\n%s", body)
164 }
165
166 // Issue participation through the web.
167 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/site", "--title", "'from ssh'"); code != 0 {
168 t.Fatal("issue create failed")
169 }
170 status, _ = browserPost(t, browser, inst.base()+"/alice/site/issues/1/comment",
171 url.Values{"body": {"web comment"}})
172 if status != 200 {
173 t.Fatalf("web comment: %d", status)
174 }
175 showOut, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/site", "1")
176 if !strings.Contains(showOut, "web comment") {
177 t.Fatalf("web comment missing over ssh:\n%s", showOut)
178 }
179
180 // Cross-origin POSTs are refused.
181 req, _ := http.NewRequest("POST", inst.base()+"/alice/site/issues/1/comment",
182 strings.NewReader("body=evil"))
183 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
184 req.Header.Set("Origin", "https://evil.example")
185 resp, err := browser.Do(req)
186 if err != nil {
187 t.Fatal(err)
188 }
189 resp.Body.Close()
190 if resp.StatusCode != 403 {
191 t.Fatalf("cross-origin POST: %d, want 403", resp.StatusCode)
192 }
193
194 // Logout kills the session.
195 if status, _ = browserPost(t, browser, inst.base()+"/logout", url.Values{}); status != 200 {
196 t.Fatalf("logout: %d", status)
197 }
198 if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 404 {
199 t.Fatalf("session survived logout: %d", status)
200 }
201}
202
203// TestViewOnlyHasNoLoginOnTheWire is the M8 negative: in view_only mode the
204// login route does not exist and web login over ssh is refused.
205func TestViewOnlyHasNoLoginOnTheWire(t *testing.T) {
206 inst := startInstance(t) // default: view_only
207 aliceKey := inst.newKey(t, "alice")
208 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
209
210 if status, _ := inst.get(t, "/login"); status != 404 {
211 t.Fatalf("view_only /login = %d, want 404", status)
212 }
213 _, errOut, code := inst.ssh(t, aliceKey, "", "web", "login")
214 if code != 4 || !strings.Contains(errOut, "view-only") {
215 t.Fatalf("web login in view_only: exit %d, %s", code, errOut)
216 }
217}
e2e/ssh_test.go +6
@@ -46,6 +46,11 @@ func freePort(t *testing.T) int {
46} 46}
47 47
48func startInstance(t *testing.T) *instance { 48func startInstance(t *testing.T) *instance {
49 return startInstanceWith(t, "")
50}
51
52// startInstanceWith appends extra TOML to the instance config.
53func startInstanceWith(t *testing.T, extra string) *instance {
49 t.Helper() 54 t.Helper()
50 inst := &instance{ 55 inst := &instance{
51 forged: buildForged(t), 56 forged: buildForged(t),
@@ -69,6 +74,7 @@ tls = "off"
69enabled = true 74enabled = true
70port = %d 75port = %d
71`, inst.root, inst.port, inst.httpPort, inst.gitPort) 76`, inst.root, inst.port, inst.httpPort, inst.gitPort)
77 cfg += extra + "\n"
72 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { 78 if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil {
73 t.Fatal(err) 79 t.Fatal(err)
74 } 80 }
internal/config/config.go +4
@@ -152,6 +152,10 @@ func (c Config) Validate() error {
152 errs = append(errs, errors.New( 152 errs = append(errs, errors.New(
153 "web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists")) 153 "web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists"))
154 } 154 }
155 if c.Web.PasswordAuth && c.Web.Mode == "accounts" {
156 errs = append(errs, errors.New(
157 "web.password_auth is not implemented yet; browser sessions are minted over SSH (forge web login)"))
158 }
155 159
156 return errors.Join(errs...) 160 return errors.Join(errs...)
157} 161}
internal/config/config_test.go +7 −2
@@ -60,6 +60,11 @@ func TestContradictions(t *testing.T) {
60 minimal + "\n[web]\nmode = \"view_only\"\npassword_auth = true\n", 60 minimal + "\n[web]\nmode = \"view_only\"\npassword_auth = true\n",
61 "password_auth", 61 "password_auth",
62 }, 62 },
63 {
64 "password auth not implemented",
65 minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n",
66 "not implemented",
67 },
63 { 68 {
64 "bad ssh mode", 69 "bad ssh mode",
65 minimal + "\n[ssh]\nmode = \"tcp\"\n", 70 minimal + "\n[ssh]\nmode = \"tcp\"\n",
@@ -103,8 +108,8 @@ func TestValidCombinations(t *testing.T) {
103 minimal + "\n[ssh]\nmode = \"system\"\n", 108 minimal + "\n[ssh]\nmode = \"system\"\n",
104 }, 109 },
105 { 110 {
106 "accounts web with password auth", 111 "accounts web without password auth",
107 minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n", 112 minimal + "\n[web]\nmode = \"accounts\"\n",
108 }, 113 },
109 { 114 {
110 "closed registration, no smtp at all", 115 "closed registration, no smtp at all",
internal/control/web.go added +38
@@ -0,0 +1,38 @@
1package control
2
3import (
4 "fmt"
5 "io"
6 "time"
7
8 "github.com/krazywarez/forge/internal/protocol"
9 "github.com/krazywarez/forge/internal/store"
10)
11
12func newStoredToken() (token, hash string, err error) { return store.NewToken() }
13
14func init() {
15 register(Command{Path: []string{"web", "login"},
16 Summary: "mint a one-time browser login URL", Run: runWebLogin})
17}
18
19func runWebLogin(c *Ctx, args []string) int {
20 if len(args) != 0 {
21 return c.fail(protocol.ExitUsage, "usage: web login [--json]")
22 }
23 if c.Cfg.Web.Mode != "accounts" {
24 return c.fail(protocol.ExitDenied,
25 "this instance runs the web in view-only mode (web.mode = %q); there is nothing to log in to", c.Cfg.Web.Mode)
26 }
27 token, hash, err := newStoredToken()
28 if err != nil {
29 return c.fail(protocol.ExitFailure, "%v", err)
30 }
31 if err := c.Store.CreateLoginToken(c.User.ID, hash, 5*time.Minute); err != nil {
32 return c.fail(protocol.ExitFailure, "%v", err)
33 }
34 url := c.Cfg.Server.SiteURL + "/login?token=" + token
35 return c.emit(map[string]string{"url": url, "expires_in": "5m"}, func(w io.Writer) {
36 fmt.Fprintf(w, "open within 5 minutes (single use):\n%s\n", url)
37 })
38}
internal/gitutil/merge.go +57
@@ -115,3 +115,60 @@ func MergeBase(dir, a, b string) (string, error) {
115 } 115 }
116 return strings.TrimSpace(string(out)), nil 116 return strings.TrimSpace(string(out)), nil
117} 117}
118
119// CommitFileChange writes content at path on branch as a new commit and
120// advances the branch with compare-and-swap. Used by web edits; hooks do not
121// run, so callers enforce policy themselves.
122func CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error) {
123 branchRef := "refs/heads/" + branch
124 parent, err := ResolveRef(dir, branchRef)
125 if err != nil {
126 return "", fmt.Errorf("branch %s: %w", branch, err)
127 }
128
129 // Hash the new blob.
130 hb := exec.Command("git", "-C", dir, "hash-object", "-w", "--stdin")
131 hb.Stdin = strings.NewReader(string(content))
132 out, err := hb.Output()
133 if err != nil {
134 return "", fmt.Errorf("hash-object: %w", err)
135 }
136 blob := strings.TrimSpace(string(out))
137
138 // Stage the parent tree in a temporary index, splice the blob in, and
139 // write the new tree.
140 idx, err := os.CreateTemp("", "forge-index-*")
141 if err != nil {
142 return "", err
143 }
144 idx.Close()
145 defer os.Remove(idx.Name())
146 env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name())
147
148 rt := exec.Command("git", "-C", dir, "read-tree", parent+"^{tree}")
149 rt.Env = env
150 if out, err := rt.CombinedOutput(); err != nil {
151 return "", fmt.Errorf("read-tree: %v\n%s", err, out)
152 }
153 ui := exec.Command("git", "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path)
154 ui.Env = env
155 if out, err := ui.CombinedOutput(); err != nil {
156 return "", fmt.Errorf("update-index: %v\n%s", err, out)
157 }
158 wt := exec.Command("git", "-C", dir, "write-tree")
159 wt.Env = env
160 out, err = wt.Output()
161 if err != nil {
162 return "", fmt.Errorf("write-tree: %w", err)
163 }
164 tree := strings.TrimSpace(string(out))
165
166 sha, err := CommitTree(dir, tree, []string{parent}, name, email, message)
167 if err != nil {
168 return "", err
169 }
170 if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
171 return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
172 }
173 return sha, nil
174}
internal/httpd/accounts.go added +302
@@ -0,0 +1,302 @@
1package httpd
2
3import (
4 "fmt"
5 "net/http"
6 "strconv"
7 "strings"
8 "time"
9
10 "github.com/krazywarez/forge/internal/control"
11 "github.com/krazywarez/forge/internal/gitutil"
12 "github.com/krazywarez/forge/internal/policy"
13 "github.com/krazywarez/forge/internal/store"
14)
15
16const sessionCookie = "forge_session"
17
18// viewer returns the logged-in user, or a zero User for anonymous visitors.
19// Only meaningful in accounts mode; in view_only no session route exists so
20// every request is anonymous.
21func (s *Server) viewer(r *http.Request) store.User {
22 ck, err := r.Cookie(sessionCookie)
23 if err != nil {
24 return store.User{}
25 }
26 u, err := s.st.WebSessionUser(store.HashToken(ck.Value))
27 if err != nil {
28 return store.User{}
29 }
30 return u
31}
32
33// requireUser wraps a handler that needs a session.
34func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
35 return func(w http.ResponseWriter, r *http.Request) {
36 u := s.viewer(r)
37 if u.ID == 0 {
38 http.Redirect(w, r, "/login", http.StatusSeeOther)
39 return
40 }
41 h(w, r, u)
42 }
43}
44
45// checkOrigin rejects cross-site POSTs. Sessions also use SameSite=Strict;
46// this is the second layer.
47func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
48 return func(w http.ResponseWriter, r *http.Request) {
49 if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
50 host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://")
51 if host != r.Host {
52 http.Error(w, "cross-origin request refused", http.StatusForbidden)
53 return
54 }
55 }
56 h(w, r)
57 }
58}
59
60func (s *Server) login(w http.ResponseWriter, r *http.Request) {
61 token := r.URL.Query().Get("token")
62 if token == "" {
63 s.render(w, "login.html", struct {
64 Site string
65 Error string
66 }{s.siteName(), ""})
67 return
68 }
69 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
70 if err != nil {
71 s.render(w, "login.html", struct {
72 Site string
73 Error string
74 }{s.siteName(), "that login link is invalid, expired, or already used — mint a new one"})
75 return
76 }
77 sessTok, sessHash, err := store.NewToken()
78 if err != nil {
79 http.Error(w, "internal error", http.StatusInternalServerError)
80 return
81 }
82 if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
83 http.Error(w, "internal error", http.StatusInternalServerError)
84 return
85 }
86 http.SetCookie(w, &http.Cookie{
87 Name: sessionCookie, Value: sessTok, Path: "/",
88 HttpOnly: true, SameSite: http.SameSiteStrictMode,
89 Secure: s.cfg.HTTP.TLS != "off",
90 MaxAge: 7 * 24 * 3600,
91 })
92 http.Redirect(w, r, "/", http.StatusSeeOther)
93}
94
95func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
96 if ck, err := r.Cookie(sessionCookie); err == nil {
97 s.st.DeleteWebSession(store.HashToken(ck.Value))
98 }
99 http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1})
100 http.Redirect(w, r, "/", http.StatusSeeOther)
101}
102
103func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
104 s.render(w, "new.html", struct {
105 Site string
106 Viewer string
107 Error string
108 }{s.siteName(), u.Username, ""})
109}
110
111func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
112 name := r.FormValue("name")
113 visibility := "public"
114 if r.FormValue("visibility") == "private" {
115 visibility = "private"
116 }
117 fail := func(msg string) {
118 s.render(w, "new.html", struct {
119 Site string
120 Viewer string
121 Error string
122 }{s.siteName(), u.Username, msg})
123 }
124 if err := policy.ValidateName(name); err != nil {
125 fail(err.Error())
126 return
127 }
128 id, err := s.st.CreateRepo("user", u.ID, name, visibility)
129 if err != nil {
130 fail(err.Error())
131 return
132 }
133 dir := control.RepoDir(s.cfg.Server.Root, u.Username, name)
134 if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil {
135 s.st.DeleteRepo(id)
136 fail("initializing repository failed")
137 return
138 }
139 http.Redirect(w, r, "/"+u.Username+"/"+name, http.StatusSeeOther)
140}
141
142// repoForUser is repoFor with a write/read permission requirement for a
143// logged-in user.
144func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
145 perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
146 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
147 if err != nil {
148 http.NotFound(w, r)
149 return store.Repo{}, false
150 }
151 grant, err := s.st.AccessRole(repo.ID, u.ID)
152 if err != nil {
153 http.Error(w, "internal error", http.StatusInternalServerError)
154 return store.Repo{}, false
155 }
156 if !policy.CanRead(u, repo, grant) {
157 http.NotFound(w, r) // invisible: same as nonexistent
158 return store.Repo{}, false
159 }
160 if !perm(u, repo, grant) {
161 http.Error(w, "permission denied", http.StatusForbidden)
162 return store.Repo{}, false
163 }
164 return repo, true
165}
166
167func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
168 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
169 if !ok {
170 return
171 }
172 title := strings.TrimSpace(r.FormValue("title"))
173 if title == "" {
174 http.Error(w, "title required", http.StatusBadRequest)
175 return
176 }
177 n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"))
178 if err != nil {
179 http.Error(w, "internal error", http.StatusInternalServerError)
180 return
181 }
182 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
183}
184
185func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
186 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
187 if !ok {
188 return
189 }
190 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
191 iss, err := s.st.IssueByNumber(repo.ID, n)
192 if err != nil {
193 http.NotFound(w, r)
194 return
195 }
196 body := strings.TrimSpace(r.FormValue("body"))
197 if body == "" {
198 http.Error(w, "empty comment", http.StatusBadRequest)
199 return
200 }
201 if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil {
202 http.Error(w, "internal error", http.StatusInternalServerError)
203 return
204 }
205 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther)
206}
207
208func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
209 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
210 if !ok {
211 return
212 }
213 n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64)
214 m, err := s.st.MRByNumber(repo.ID, n)
215 if err != nil {
216 http.NotFound(w, r)
217 return
218 }
219 body := strings.TrimSpace(r.FormValue("body"))
220 if body == "" {
221 http.Error(w, "empty comment", http.StatusBadRequest)
222 return
223 }
224 if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil {
225 http.Error(w, "internal error", http.StatusInternalServerError)
226 return
227 }
228 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther)
229}
230
231type editPage struct {
232 Site string
233 Viewer string
234 Repo store.Repo
235 Ref string
236 Path string
237 Content string
238 Error string
239}
240
241func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
242 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
243 if !ok {
244 return
245 }
246 ref := r.PathValue("ref")
247 filePath := strings.Trim(r.PathValue("path"), "/")
248 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
249 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
250 if err != nil {
251 content = nil // new file
252 }
253 if gitutil.IsBinary(content) {
254 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
255 return
256 }
257 s.render(w, "edit.html", editPage{
258 Site: s.siteName(), Viewer: u.Username, Repo: repo,
259 Ref: ref, Path: filePath, Content: string(content),
260 })
261}
262
263func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
264 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
265 if !ok {
266 return
267 }
268 ref := r.PathValue("ref")
269 filePath := strings.Trim(r.PathValue("path"), "/")
270 fail := func(msg string) {
271 s.render(w, "edit.html", editPage{
272 Site: s.siteName(), Viewer: u.Username, Repo: repo,
273 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
274 })
275 }
276 // Web edits produce unsigned commits; a repo that requires signed
277 // commits must refuse them rather than violate its own policy.
278 if repo.Settings.RequireSignedCommits {
279 fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead")
280 return
281 }
282 email, err := s.st.PrimaryVerifiedEmail(u.ID)
283 if err != nil {
284 fail("internal error")
285 return
286 }
287 if email == "" {
288 fail("commits carry your identity: your account needs a verified primary email")
289 return
290 }
291 message := strings.TrimSpace(r.FormValue("message"))
292 if message == "" {
293 message = "edit " + filePath
294 }
295 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
296 if _, err := gitutil.CommitFileChange(dir, ref, filePath,
297 []byte(r.FormValue("content")), u.Username, email, message); err != nil {
298 fail(err.Error())
299 return
300 }
301 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
302}
internal/httpd/routes.go +22 −2
@@ -43,8 +43,28 @@ 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 // Account-mode routes (login, web edits) are appended here in M8 — 46 // Account-mode routes exist only when web.mode = "accounts". In
47 // and only when s.cfg.Web.Mode == "accounts". 47 // view_only they are never registered — the structural guarantee.
48 if s.cfg.Web.Mode == "accounts" {
49 routes = append(routes,
50 Route{Method: "GET", Pattern: "/login", Handler: s.login, Mutating: true}, // consumes a one-time token
51 Route{Method: "POST", Pattern: "/logout", Mutating: true,
52 Handler: s.checkOrigin(s.logout)},
53 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
54 Route{Method: "POST", Pattern: "/new", Mutating: true,
55 Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))},
56 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true,
57 Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))},
58 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/comment", Mutating: true,
59 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))},
60 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true,
61 Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))},
62 Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}",
63 Handler: s.requireUser(s.editForm)},
64 Route{Method: "POST", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", Mutating: true,
65 Handler: s.checkOrigin(s.requireUser(s.editSubmit))},
66 )
67 }
48 return routes 68 return routes
49} 69}
50 70
internal/httpd/routes_test.go +20 −1
@@ -33,11 +33,30 @@ func TestViewOnlyHasNoMutatingRoutes(t *testing.T) {
33 } 33 }
34} 34}
35 35
36// TestAccountsModeHasLoginRoute is the positive counterpart: switching the
37// mode on registers the session routes.
38func TestAccountsModeHasLoginRoute(t *testing.T) {
39 cfg := config.Default()
40 cfg.Web.Mode = "accounts"
41 s := New(cfg, nil)
42 found := false
43 for _, r := range s.Routes() {
44 if r.Pattern == "/login" {
45 found = true
46 }
47 }
48 if !found {
49 t.Fatal("accounts mode is missing the /login route")
50 }
51}
52
36// TestTopLevelRouteWordsAreReserved keeps the route table and the reserved 53// TestTopLevelRouteWordsAreReserved keeps the route table and the reserved
37// username list in agreement: every literal first path segment must be an 54// username list in agreement: every literal first path segment must be an
38// unclaimable username. 55// unclaimable username.
39func TestTopLevelRouteWordsAreReserved(t *testing.T) { 56func TestTopLevelRouteWordsAreReserved(t *testing.T) {
40 s := New(config.Default(), nil) 57 cfg := config.Default()
58 cfg.Web.Mode = "accounts" // superset of routes
59 s := New(cfg, nil)
41 for _, r := range s.Routes() { 60 for _, r := range s.Routes() {
42 seg := strings.TrimPrefix(r.Pattern, "/") 61 seg := strings.TrimPrefix(r.Pattern, "/")
43 seg, _, _ = strings.Cut(seg, "/") 62 seg, _, _ = strings.Cut(seg, "/")
internal/httpd/web.go +44 −6
@@ -3,6 +3,8 @@ package httpd
3import ( 3import (
4 "bytes" 4 "bytes"
5 "fmt" 5 "fmt"
6
7 "github.com/krazywarez/forge/internal/policy"
6 "html/template" 8 "html/template"
7 "net/http" 9 "net/http"
8 "path" 10 "path"
@@ -50,15 +52,32 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
50 http.Error(w, "internal error", http.StatusInternalServerError) 52 http.Error(w, "internal error", http.StatusInternalServerError)
51 return 53 return
52 } 54 }
55 var viewer store.User
56 var mine []store.Repo
57 if s.cfg.Web.Mode == "accounts" {
58 if viewer = s.viewer(r); viewer.ID != 0 {
59 all, err := s.st.ListReposForUser(viewer.ID)
60 if err == nil {
61 for _, rp := range all {
62 if rp.Visibility == "private" {
63 mine = append(mine, rp)
64 }
65 }
66 }
67 }
68 }
53 s.render(w, "index.html", struct { 69 s.render(w, "index.html", struct {
54 Site string 70 Site string
55 Repos []store.Repo 71 Viewer string
56 }{s.siteName(), repos}) 72 Repos []store.Repo
73 Mine []store.Repo
74 }{s.siteName(), viewer.Username, repos, mine})
57} 75}
58 76
59// repoPage is the shared context for repo-scoped pages. 77// repoPage is the shared context for repo-scoped pages.
60type repoPage struct { 78type repoPage struct {
61 Site string 79 Site string
80 Viewer string
62 Repo store.Repo 81 Repo store.Repo
63 Ref string 82 Ref string
64 CloneURL string 83 CloneURL string
@@ -66,10 +85,24 @@ type repoPage struct {
66} 85}
67 86
68// repoFor resolves the repo for a web request; false means 404 was sent. 87// repoFor resolves the repo for a web request; false means 404 was sent.
69// The anonymous web sees public repos only — private and missing repos are 88// Anonymous visitors see public repos only; in accounts mode a logged-in
70// indistinguishable. 89// viewer additionally sees repos their grants allow. Private and missing
90// repos are indistinguishable either way.
71func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) { 91func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
72 repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo")) 92 var repo store.Repo
93 var viewer store.User
94 if s.cfg.Web.Mode == "accounts" {
95 viewer = s.viewer(r)
96 }
97 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
98 ok := err == nil
99 if ok {
100 grant := ""
101 if viewer.ID != 0 {
102 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
103 }
104 ok = policyCanRead(viewer, repo, grant)
105 }
73 if !ok { 106 if !ok {
74 http.NotFound(w, r) 107 http.NotFound(w, r)
75 return repoPage{}, false 108 return repoPage{}, false
@@ -79,6 +112,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
79 } 112 }
80 return repoPage{ 113 return repoPage{
81 Site: s.siteName(), 114 Site: s.siteName(),
115 Viewer: viewer.Username,
82 Repo: repo, 116 Repo: repo,
83 Ref: ref, 117 Ref: ref,
84 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", 118 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
@@ -511,3 +545,7 @@ func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
511 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz")) 545 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
512 gitutil.Archive(p.Dir, ref, prefix, w) 546 gitutil.Archive(p.Dir, ref, prefix, w)
513} 547}
548
549func policyCanRead(u store.User, repo store.Repo, grant string) bool {
550 return policy.CanRead(u, repo, grant)
551}
internal/store/migrations/0002_login_tokens.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE login_tokens;
internal/store/migrations/0002_login_tokens.up.sql added +7
@@ -0,0 +1,7 @@
1CREATE TABLE login_tokens (
2 token_hash TEXT PRIMARY KEY,
3 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
5 expires_at TEXT NOT NULL,
6 used_at TEXT
7);
internal/store/sessions.go added +81
@@ -0,0 +1,81 @@
1package store
2
3import (
4 "crypto/rand"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/hex"
8 "errors"
9 "time"
10)
11
12// NewToken returns a fresh random token and its storage hash. Only the hash
13// is persisted; the token itself goes to the user once.
14func NewToken() (token, hash string, err error) {
15 var b [32]byte
16 if _, err := rand.Read(b[:]); err != nil {
17 return "", "", err
18 }
19 token = hex.EncodeToString(b[:])
20 return token, HashToken(token), nil
21}
22
23func HashToken(token string) string {
24 sum := sha256.Sum256([]byte(token))
25 return hex.EncodeToString(sum[:])
26}
27
28func fmtTime(t time.Time) string { return t.UTC().Format("2006-01-02T15:04:05.000Z") }
29
30// CreateLoginToken stores a one-time login token hash.
31func (s *Store) CreateLoginToken(userID int64, hash string, ttl time.Duration) error {
32 _, err := s.DB.Exec(
33 "INSERT INTO login_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
34 hash, userID, fmtTime(time.Now().Add(ttl)))
35 return err
36}
37
38// ConsumeLoginToken redeems a token exactly once; expired or used tokens
39// fail identically.
40func (s *Store) ConsumeLoginToken(hash string) (int64, error) {
41 res, err := s.DB.Exec(`
42 UPDATE login_tokens SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
43 WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`,
44 hash, fmtTime(time.Now()))
45 if err != nil {
46 return 0, err
47 }
48 if n, _ := res.RowsAffected(); n == 0 {
49 return 0, ErrNotFound
50 }
51 var userID int64
52 err = s.DB.QueryRow("SELECT user_id FROM login_tokens WHERE token_hash = ?", hash).Scan(&userID)
53 return userID, err
54}
55
56func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error {
57 _, err := s.DB.Exec(
58 "INSERT INTO web_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
59 hash, userID, fmtTime(time.Now().Add(ttl)))
60 return err
61}
62
63// WebSessionUser resolves a session cookie hash to its user.
64func (s *Store) WebSessionUser(hash string) (User, error) {
65 var userID int64
66 err := s.DB.QueryRow(
67 "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
68 hash, fmtTime(time.Now())).Scan(&userID)
69 if errors.Is(err, sql.ErrNoRows) {
70 return User{}, ErrNotFound
71 }
72 if err != nil {
73 return User{}, err
74 }
75 return s.UserByID(userID)
76}
77
78func (s *Store) DeleteWebSession(hash string) error {
79 _, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash)
80 return err
81}
internal/web/static/style.css +1
@@ -40,6 +40,7 @@ pre.diff .add { color: var(--ok); }
40pre.diff .del { color: var(--bad); } 40pre.diff .del { color: var(--bad); }
41pre.diff .hunk { color: var(--link); } 41pre.diff .hunk { color: var(--link); }
42pre.diff .meta { color: var(--muted); } 42pre.diff .meta { color: var(--muted); }
43.error { color: var(--bad); }
43.badge { 44.badge {
44 display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px; 45 display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px;
45 font-size: 12px; border: 1px solid; 46 font-size: 12px; border: 1px solid;
internal/web/templates/blob.html +1 −1
@@ -2,7 +2,7 @@
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3{{template "repoheader" .}}
4<p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}{{.Base}} 4<p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}{{.Base}}
5 · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a></p> 5 · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">edit</a>{{end}}</p>
6{{if .Binary}}<p>binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> 6{{if .Binary}}<p>binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
7{{else}}<div class="code">{{.CodeHTML}}</div>{{end}} 7{{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
8{{end}} 8{{end}}
internal/web/templates/edit.html added +11
@@ -0,0 +1,11 @@
1{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
5<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}">
6<p><textarea name="content" rows="24" style="width:100%" spellcheck="false">{{.Content}}</textarea></p>
7<p><input name="message" placeholder="commit message" style="width:60%">
8<button type="submit">commit to {{.Ref}}</button></p>
9<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
10</form>
11{{end}}
internal/web/templates/index.html +7
@@ -1,8 +1,15 @@
1{{define "title"}}{{.Site}}{{end}} 1{{define "title"}}{{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{if .Viewer}}<p class="crumbs">logged in as {{.Viewer}} · <a href="/new">new repository</a> ·
4<form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}}
3<h1>repositories</h1> 5<h1>repositories</h1>
4<table> 6<table>
5{{range .Repos}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr> 7{{range .Repos}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr>
6{{else}}<tr><td>no public repositories</td></tr>{{end}} 8{{else}}<tr><td>no public repositories</td></tr>{{end}}
7</table> 9</table>
10{{if .Mine}}<h2>your private repositories</h2>
11<table>
12{{range .Mine}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.Visibility}}</td></tr>
13{{end}}
14</table>{{end}}
8{{end}} 15{{end}}
internal/web/templates/issue.html +6
@@ -9,4 +9,10 @@
9{{range .Comments}} 9{{range .Comments}}
10<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> 10<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div>
11{{end}} 11{{end}}
12{{if .Viewer}}
13<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment">
14<p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p>
15<p><button type="submit">comment</button></p>
16</form>
17{{end}}
12{{end}} 18{{end}}
internal/web/templates/login.html added +9
@@ -0,0 +1,9 @@
1{{define "title"}}login · {{.Site}}{{end}}
2{{define "content"}}
3<h1>log in</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
5<p>Browser sessions are minted over SSH — there is no password. From a machine
6with your registered key:</p>
7<pre class="message">ssh git@{{.Site}} web login</pre>
8<p>then open the printed URL within five minutes.</p>
9{{end}}
internal/web/templates/mr.html +6
@@ -9,6 +9,12 @@
9{{range .Comments}} 9{{range .Comments}}
10<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> 10<div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div>
11{{end}} 11{{end}}
12{{if .Viewer}}
13<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment">
14<p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p>
15<p><button type="submit">comment</button></p>
16</form>
17{{end}}
12<h3>diff</h3> 18<h3>diff</h3>
13<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> 19<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
14{{end}}</pre> 20{{end}}</pre>
internal/web/templates/new.html added +11
@@ -0,0 +1,11 @@
1{{define "title"}}new repository · {{.Site}}{{end}}
2{{define "content"}}
3<h1>new repository</h1>
4{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
5<form method="post" action="/new">
6<p><label>name <input name="name" required pattern="[a-z0-9][a-z0-9._-]*"></label> (under {{.Viewer}}/)</p>
7<p><label><input type="radio" name="visibility" value="public" checked> public</label>
8 <label><input type="radio" name="visibility" value="private"> private</label></p>
9<p><button type="submit">create</button></p>
10</form>
11{{end}}