krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
a0dd5878fbda93a49fe7d4503e2ac80fc6f6c8f3
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T23:17:24Z
e2e/accounts_test.go | 217 +++++++++++++++ e2e/ssh_test.go | 6 + internal/config/config.go | 4 + internal/config/config_test.go | 9 +- internal/control/web.go | 38 +++ internal/gitutil/merge.go | 57 ++++ internal/httpd/accounts.go | 302 +++++++++++++++++++++ internal/httpd/routes.go | 24 +- internal/httpd/routes_test.go | 21 +- internal/httpd/web.go | 50 +++- .../store/migrations/0002_login_tokens.down.sql | 1 + internal/store/migrations/0002_login_tokens.up.sql | 7 + internal/store/sessions.go | 81 ++++++ internal/web/static/style.css | 1 + internal/web/templates/blob.html | 2 +- internal/web/templates/edit.html | 11 + internal/web/templates/index.html | 7 + internal/web/templates/issue.html | 6 + internal/web/templates/login.html | 9 + internal/web/templates/mr.html | 6 + internal/web/templates/new.html | 11 + 21 files changed, 858 insertions(+), 12 deletions(-) new file mode 100644 @@ -0,0 +1,217 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +// browser is an HTTP client with a cookie jar, standing in for a logged-in +// user's browser. +func newBrowser(t *testing.T) *http.Client { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + return &http.Client{Jar: jar} +} + +func (i *instance) base() string { return fmt.Sprintf("http://127.0.0.1:%d", i.httpPort) } + +func browserGet(t *testing.T, c *http.Client, url string) (int, string) { + t.Helper() + resp, err := c.Get(url) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(body) +} + +func browserPost(t *testing.T, c *http.Client, u string, form url.Values) (int, string) { + t.Helper() + resp, err := c.PostForm(u, form) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(body) +} + +func TestWebAccounts(t *testing.T) { + inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") + + aliceKey := inst.newKey(t, "alice") + inst.admin(t, "admin", "user", "create", "alice", + "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") + + // A repo with one file to edit. + if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/site"); code != 0 { + t.Fatalf("repo create: %s", errOut) + } + work := t.TempDir() + env := inst.gitEnv(aliceKey) + mustGit(t, work, env, "clone", inst.sshURL("alice/site"), "w") + dir := filepath.Join(work, "w") + os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("original\n"), 0o644) + mustGit(t, dir, env, "checkout", "-q", "-b", "main") + mustGit(t, dir, env, "add", ".") + mustGit(t, dir, env, "commit", "-q", "-m", "base") + mustGit(t, dir, env, "push", "-q", "origin", "main") + + // SSH-minted login URL. + out, errOut, code := inst.ssh(t, aliceKey, "", "web", "login", "--json") + if code != 0 { + t.Fatalf("web login: %s", errOut) + } + var env2 struct { + Data struct { + URL string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(out), &env2); err != nil { + t.Fatalf("web login JSON: %v\n%s", err, out) + } + // The URL carries the configured site host; rewrite to the test port. + loginPath := env2.Data.URL[strings.Index(env2.Data.URL, "/login"):] + + browser := newBrowser(t) + status, body := browserGet(t, browser, inst.base()+loginPath) + if status != 200 || !strings.Contains(body, "logged in as alice") { + t.Fatalf("login redirect landed wrong: %d\n%s", status, body) + } + + // The token is single-use. + fresh := newBrowser(t) + _, body = browserGet(t, fresh, inst.base()+loginPath) + if !strings.Contains(body, "invalid, expired, or already used") { + t.Fatalf("token reuse not refused:\n%s", body) + } + + // Create a repo through the web. + status, _ = browserPost(t, browser, inst.base()+"/new", + url.Values{"name": {"webborn"}, "visibility": {"private"}}) + if status != 200 { + t.Fatalf("web repo create: %d", status) + } + if out, _, code := inst.ssh(t, aliceKey, "", "repo", "show", "alice/webborn"); code != 0 { + t.Fatalf("web-created repo missing over ssh: %s", out) + } + + // Logged-in viewer sees their private repo; anonymous still gets 404. + if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 200 { + t.Fatalf("owner blocked from private repo page: %d", status) + } + if status, _ := inst.get(t, "/alice/webborn"); status != 404 { + t.Fatalf("anonymous sees private repo: %d", status) + } + + // File edit: form loads with current content, POST commits. + status, body = browserGet(t, browser, inst.base()+"/alice/site/edit/main/notes.txt") + if status != 200 || !strings.Contains(body, "original") { + t.Fatalf("edit form: %d\n%s", status, body) + } + status, _ = browserPost(t, browser, inst.base()+"/alice/site/edit/main/notes.txt", + url.Values{"content": {"edited from the web\n"}, "message": {"web edit"}}) + if status != 200 { + t.Fatalf("edit submit: %d", status) + } + + // The edit is a real commit: authored with the verified email, and it + // displays as unsigned — the honest outcome for a server-side commit. + logOut, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "alice/site", "--limit", "1", "--json") + if code != 0 { + t.Fatal("repo log failed") + } + var logEnv struct { + Data []struct { + Subject string `json:"subject"` + AuthorEmail string `json:"author_email"` + Signature struct { + State string `json:"state"` + } `json:"signature"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(logOut), &logEnv); err != nil || len(logEnv.Data) == 0 { + t.Fatalf("log JSON: %v\n%s", err, logOut) + } + tip := logEnv.Data[0] + if tip.Subject != "web edit" || tip.AuthorEmail != "alice@example.test" || tip.Signature.State != "unsigned" { + t.Fatalf("web edit commit wrong: %+v", tip) + } + if status, body = browserGet(t, browser, inst.base()+"/alice/site/raw/main/notes.txt"); !strings.Contains(body, "edited from the web") { + t.Fatalf("edited content not served: %d %q", status, body) + } + + // A require-signed repo refuses web edits instead of violating itself. + if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-signed", "alice/site", "on"); code != 0 { + t.Fatal("require-signed failed") + } + _, body = browserPost(t, browser, inst.base()+"/alice/site/edit/main/notes.txt", + url.Values{"content": {"x"}, "message": {"x"}}) + if !strings.Contains(body, "requires signed commits") { + t.Fatalf("require-signed web edit not refused:\n%s", body) + } + + // Issue participation through the web. + if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/site", "--title", "'from ssh'"); code != 0 { + t.Fatal("issue create failed") + } + status, _ = browserPost(t, browser, inst.base()+"/alice/site/issues/1/comment", + url.Values{"body": {"web comment"}}) + if status != 200 { + t.Fatalf("web comment: %d", status) + } + showOut, _, _ := inst.ssh(t, aliceKey, "", "issue", "show", "alice/site", "1") + if !strings.Contains(showOut, "web comment") { + t.Fatalf("web comment missing over ssh:\n%s", showOut) + } + + // Cross-origin POSTs are refused. + req, _ := http.NewRequest("POST", inst.base()+"/alice/site/issues/1/comment", + strings.NewReader("body=evil")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://evil.example") + resp, err := browser.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 403 { + t.Fatalf("cross-origin POST: %d, want 403", resp.StatusCode) + } + + // Logout kills the session. + if status, _ = browserPost(t, browser, inst.base()+"/logout", url.Values{}); status != 200 { + t.Fatalf("logout: %d", status) + } + if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 404 { + t.Fatalf("session survived logout: %d", status) + } +} + +// TestViewOnlyHasNoLoginOnTheWire is the M8 negative: in view_only mode the +// login route does not exist and web login over ssh is refused. +func TestViewOnlyHasNoLoginOnTheWire(t *testing.T) { + inst := startInstance(t) // default: view_only + aliceKey := inst.newKey(t, "alice") + inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") + + if status, _ := inst.get(t, "/login"); status != 404 { + t.Fatalf("view_only /login = %d, want 404", status) + } + _, errOut, code := inst.ssh(t, aliceKey, "", "web", "login") + if code != 4 || !strings.Contains(errOut, "view-only") { + t.Fatalf("web login in view_only: exit %d, %s", code, errOut) + } +} @@ -46,6 +46,11 @@ func freePort(t *testing.T) int { } func startInstance(t *testing.T) *instance { + return startInstanceWith(t, "") +} + +// startInstanceWith appends extra TOML to the instance config. +func startInstanceWith(t *testing.T, extra string) *instance { t.Helper() inst := &instance{ forged: buildForged(t), @@ -69,6 +74,7 @@ tls = "off" enabled = true port = %d `, inst.root, inst.port, inst.httpPort, inst.gitPort) + cfg += extra + "\n" if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { t.Fatal(err) } @@ -152,6 +152,10 @@ func (c Config) Validate() error { errs = append(errs, errors.New( "web.password_auth = true is meaningless with web.mode = \"view_only\": no login route exists")) } + if c.Web.PasswordAuth && c.Web.Mode == "accounts" { + errs = append(errs, errors.New( + "web.password_auth is not implemented yet; browser sessions are minted over SSH (forge web login)")) + } return errors.Join(errs...) } @@ -60,6 +60,11 @@ func TestContradictions(t *testing.T) { minimal + "\n[web]\nmode = \"view_only\"\npassword_auth = true\n", "password_auth", }, + { + "password auth not implemented", + minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n", + "not implemented", + }, { "bad ssh mode", minimal + "\n[ssh]\nmode = \"tcp\"\n", @@ -103,8 +108,8 @@ func TestValidCombinations(t *testing.T) { minimal + "\n[ssh]\nmode = \"system\"\n", }, { - "accounts web with password auth", - minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n", + "accounts web without password auth", + minimal + "\n[web]\nmode = \"accounts\"\n", }, { "closed registration, no smtp at all", new file mode 100644 @@ -0,0 +1,38 @@ +package control + +import ( + "fmt" + "io" + "time" + + "github.com/krazywarez/forge/internal/protocol" + "github.com/krazywarez/forge/internal/store" +) + +func newStoredToken() (token, hash string, err error) { return store.NewToken() } + +func init() { + register(Command{Path: []string{"web", "login"}, + Summary: "mint a one-time browser login URL", Run: runWebLogin}) +} + +func runWebLogin(c *Ctx, args []string) int { + if len(args) != 0 { + return c.fail(protocol.ExitUsage, "usage: web login [--json]") + } + if c.Cfg.Web.Mode != "accounts" { + return c.fail(protocol.ExitDenied, + "this instance runs the web in view-only mode (web.mode = %q); there is nothing to log in to", c.Cfg.Web.Mode) + } + token, hash, err := newStoredToken() + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if err := c.Store.CreateLoginToken(c.User.ID, hash, 5*time.Minute); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + url := c.Cfg.Server.SiteURL + "/login?token=" + token + return c.emit(map[string]string{"url": url, "expires_in": "5m"}, func(w io.Writer) { + fmt.Fprintf(w, "open within 5 minutes (single use):\n%s\n", url) + }) +} @@ -115,3 +115,60 @@ func MergeBase(dir, a, b string) (string, error) { } return strings.TrimSpace(string(out)), nil } + +// CommitFileChange writes content at path on branch as a new commit and +// advances the branch with compare-and-swap. Used by web edits; hooks do not +// run, so callers enforce policy themselves. +func CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error) { + branchRef := "refs/heads/" + branch + parent, err := ResolveRef(dir, branchRef) + if err != nil { + return "", fmt.Errorf("branch %s: %w", branch, err) + } + + // Hash the new blob. + hb := exec.Command("git", "-C", dir, "hash-object", "-w", "--stdin") + hb.Stdin = strings.NewReader(string(content)) + out, err := hb.Output() + if err != nil { + return "", fmt.Errorf("hash-object: %w", err) + } + blob := strings.TrimSpace(string(out)) + + // Stage the parent tree in a temporary index, splice the blob in, and + // write the new tree. + idx, err := os.CreateTemp("", "forge-index-*") + if err != nil { + return "", err + } + idx.Close() + defer os.Remove(idx.Name()) + env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name()) + + rt := exec.Command("git", "-C", dir, "read-tree", parent+"^{tree}") + rt.Env = env + if out, err := rt.CombinedOutput(); err != nil { + return "", fmt.Errorf("read-tree: %v\n%s", err, out) + } + ui := exec.Command("git", "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path) + ui.Env = env + if out, err := ui.CombinedOutput(); err != nil { + return "", fmt.Errorf("update-index: %v\n%s", err, out) + } + wt := exec.Command("git", "-C", dir, "write-tree") + wt.Env = env + out, err = wt.Output() + if err != nil { + return "", fmt.Errorf("write-tree: %w", err) + } + tree := strings.TrimSpace(string(out)) + + sha, err := CommitTree(dir, tree, []string{parent}, name, email, message) + if err != nil { + return "", err + } + if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil { + return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err) + } + return sha, nil +} new file mode 100644 @@ -0,0 +1,302 @@ +package httpd + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/krazywarez/forge/internal/control" + "github.com/krazywarez/forge/internal/gitutil" + "github.com/krazywarez/forge/internal/policy" + "github.com/krazywarez/forge/internal/store" +) + +const sessionCookie = "forge_session" + +// viewer returns the logged-in user, or a zero User for anonymous visitors. +// Only meaningful in accounts mode; in view_only no session route exists so +// every request is anonymous. +func (s *Server) viewer(r *http.Request) store.User { + ck, err := r.Cookie(sessionCookie) + if err != nil { + return store.User{} + } + u, err := s.st.WebSessionUser(store.HashToken(ck.Value)) + if err != nil { + return store.User{} + } + return u +} + +// requireUser wraps a handler that needs a session. +func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u := s.viewer(r) + if u.ID == 0 { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + h(w, r, u) + } +} + +// checkOrigin rejects cross-site POSTs. Sessions also use SameSite=Strict; +// this is the second layer. +func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" && origin != "null" { + host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://") + if host != r.Host { + http.Error(w, "cross-origin request refused", http.StatusForbidden) + return + } + } + h(w, r) + } +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + s.render(w, "login.html", struct { + Site string + Error string + }{s.siteName(), ""}) + return + } + userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) + if err != nil { + s.render(w, "login.html", struct { + Site string + Error string + }{s.siteName(), "that login link is invalid, expired, or already used — mint a new one"}) + return + } + sessTok, sessHash, err := store.NewToken() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: sessTok, Path: "/", + HttpOnly: true, SameSite: http.SameSiteStrictMode, + Secure: s.cfg.HTTP.TLS != "off", + MaxAge: 7 * 24 * 3600, + }) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + if ck, err := r.Cookie(sessionCookie); err == nil { + s.st.DeleteWebSession(store.HashToken(ck.Value)) + } + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1}) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) { + s.render(w, "new.html", struct { + Site string + Viewer string + Error string + }{s.siteName(), u.Username, ""}) +} + +func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) { + name := r.FormValue("name") + visibility := "public" + if r.FormValue("visibility") == "private" { + visibility = "private" + } + fail := func(msg string) { + s.render(w, "new.html", struct { + Site string + Viewer string + Error string + }{s.siteName(), u.Username, msg}) + } + if err := policy.ValidateName(name); err != nil { + fail(err.Error()) + return + } + id, err := s.st.CreateRepo("user", u.ID, name, visibility) + if err != nil { + fail(err.Error()) + return + } + dir := control.RepoDir(s.cfg.Server.Root, u.Username, name) + if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil { + s.st.DeleteRepo(id) + fail("initializing repository failed") + return + } + http.Redirect(w, r, "/"+u.Username+"/"+name, http.StatusSeeOther) +} + +// repoForUser is repoFor with a write/read permission requirement for a +// logged-in user. +func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User, + perm func(store.User, store.Repo, string) bool) (store.Repo, bool) { + repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo")) + if err != nil { + http.NotFound(w, r) + return store.Repo{}, false + } + grant, err := s.st.AccessRole(repo.ID, u.ID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return store.Repo{}, false + } + if !policy.CanRead(u, repo, grant) { + http.NotFound(w, r) // invisible: same as nonexistent + return store.Repo{}, false + } + if !perm(u, repo, grant) { + http.Error(w, "permission denied", http.StatusForbidden) + return store.Repo{}, false + } + return repo, true +} + +func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { + repo, ok := s.repoForUser(w, r, u, policy.CanRead) + if !ok { + return + } + title := strings.TrimSpace(r.FormValue("title")) + if title == "" { + http.Error(w, "title required", http.StatusBadRequest) + return + } + n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body")) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther) +} + +func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { + repo, ok := s.repoForUser(w, r, u, policy.CanRead) + if !ok { + return + } + n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64) + iss, err := s.st.IssueByNumber(repo.ID, n) + if err != nil { + http.NotFound(w, r) + return + } + body := strings.TrimSpace(r.FormValue("body")) + if body == "" { + http.Error(w, "empty comment", http.StatusBadRequest) + return + } + if err := s.st.AddIssueComment(iss.ID, u.ID, body); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther) +} + +func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { + repo, ok := s.repoForUser(w, r, u, policy.CanRead) + if !ok { + return + } + n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64) + m, err := s.st.MRByNumber(repo.ID, n) + if err != nil { + http.NotFound(w, r) + return + } + body := strings.TrimSpace(r.FormValue("body")) + if body == "" { + http.Error(w, "empty comment", http.StatusBadRequest) + return + } + if err := s.st.AddMRComment(m.ID, u.ID, body); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther) +} + +type editPage struct { + Site string + Viewer string + Repo store.Repo + Ref string + Path string + Content string + Error string +} + +func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) { + repo, ok := s.repoForUser(w, r, u, policy.CanWrite) + if !ok { + return + } + ref := r.PathValue("ref") + filePath := strings.Trim(r.PathValue("path"), "/") + dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) + content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes) + if err != nil { + content = nil // new file + } + if gitutil.IsBinary(content) { + http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest) + return + } + s.render(w, "edit.html", editPage{ + Site: s.siteName(), Viewer: u.Username, Repo: repo, + Ref: ref, Path: filePath, Content: string(content), + }) +} + +func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) { + repo, ok := s.repoForUser(w, r, u, policy.CanWrite) + if !ok { + return + } + ref := r.PathValue("ref") + filePath := strings.Trim(r.PathValue("path"), "/") + fail := func(msg string) { + s.render(w, "edit.html", editPage{ + Site: s.siteName(), Viewer: u.Username, Repo: repo, + Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg, + }) + } + // Web edits produce unsigned commits; a repo that requires signed + // commits must refuse them rather than violate its own policy. + if repo.Settings.RequireSignedCommits { + fail("this repository requires signed commits; web edits are unsigned — push a signed commit over SSH instead") + return + } + email, err := s.st.PrimaryVerifiedEmail(u.ID) + if err != nil { + fail("internal error") + return + } + if email == "" { + fail("commits carry your identity: your account needs a verified primary email") + return + } + message := strings.TrimSpace(r.FormValue("message")) + if message == "" { + message = "edit " + filePath + } + dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) + if _, err := gitutil.CommitFileChange(dir, ref, filePath, + []byte(r.FormValue("content")), u.Username, email, message); err != nil { + fail(err.Error()) + return + } + http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther) +} @@ -43,8 +43,28 @@ func (s *Server) Routes() []Route { Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr}, ) - // Account-mode routes (login, web edits) are appended here in M8 — - // and only when s.cfg.Web.Mode == "accounts". + // Account-mode routes exist only when web.mode = "accounts". In + // view_only they are never registered — the structural guarantee. + if s.cfg.Web.Mode == "accounts" { + routes = append(routes, + Route{Method: "GET", Pattern: "/login", Handler: s.login, Mutating: true}, // consumes a one-time token + Route{Method: "POST", Pattern: "/logout", Mutating: true, + Handler: s.checkOrigin(s.logout)}, + Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, + Route{Method: "POST", Pattern: "/new", Mutating: true, + Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, + Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/new", Mutating: true, + Handler: s.checkOrigin(s.requireUser(s.issueCreateSubmit))}, + Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/comment", Mutating: true, + Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))}, + Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, + Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, + Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", + Handler: s.requireUser(s.editForm)}, + Route{Method: "POST", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", Mutating: true, + Handler: s.checkOrigin(s.requireUser(s.editSubmit))}, + ) + } return routes } @@ -33,11 +33,30 @@ func TestViewOnlyHasNoMutatingRoutes(t *testing.T) { } } +// TestAccountsModeHasLoginRoute is the positive counterpart: switching the +// mode on registers the session routes. +func TestAccountsModeHasLoginRoute(t *testing.T) { + cfg := config.Default() + cfg.Web.Mode = "accounts" + s := New(cfg, nil) + found := false + for _, r := range s.Routes() { + if r.Pattern == "/login" { + found = true + } + } + if !found { + t.Fatal("accounts mode is missing the /login route") + } +} + // TestTopLevelRouteWordsAreReserved keeps the route table and the reserved // username list in agreement: every literal first path segment must be an // unclaimable username. func TestTopLevelRouteWordsAreReserved(t *testing.T) { - s := New(config.Default(), nil) + cfg := config.Default() + cfg.Web.Mode = "accounts" // superset of routes + s := New(cfg, nil) for _, r := range s.Routes() { seg := strings.TrimPrefix(r.Pattern, "/") seg, _, _ = strings.Cut(seg, "/") @@ -3,6 +3,8 @@ package httpd import ( "bytes" "fmt" + + "github.com/krazywarez/forge/internal/policy" "html/template" "net/http" "path" @@ -50,15 +52,32 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } + var viewer store.User + var mine []store.Repo + if s.cfg.Web.Mode == "accounts" { + if viewer = s.viewer(r); viewer.ID != 0 { + all, err := s.st.ListReposForUser(viewer.ID) + if err == nil { + for _, rp := range all { + if rp.Visibility == "private" { + mine = append(mine, rp) + } + } + } + } + } s.render(w, "index.html", struct { - Site string - Repos []store.Repo - }{s.siteName(), repos}) + Site string + Viewer string + Repos []store.Repo + Mine []store.Repo + }{s.siteName(), viewer.Username, repos, mine}) } // repoPage is the shared context for repo-scoped pages. type repoPage struct { Site string + Viewer string Repo store.Repo Ref string CloneURL string @@ -66,10 +85,24 @@ type repoPage struct { } // repoFor resolves the repo for a web request; false means 404 was sent. -// The anonymous web sees public repos only — private and missing repos are -// indistinguishable. +// Anonymous visitors see public repos only; in accounts mode a logged-in +// viewer additionally sees repos their grants allow. Private and missing +// repos are indistinguishable either way. func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) { - repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo")) + var repo store.Repo + var viewer store.User + if s.cfg.Web.Mode == "accounts" { + viewer = s.viewer(r) + } + repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo")) + ok := err == nil + if ok { + grant := "" + if viewer.ID != 0 { + grant, _ = s.st.AccessRole(repo.ID, viewer.ID) + } + ok = policyCanRead(viewer, repo, grant) + } if !ok { http.NotFound(w, r) return repoPage{}, false @@ -79,6 +112,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re } return repoPage{ Site: s.siteName(), + Viewer: viewer.Username, Repo: repo, Ref: ref, CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", @@ -511,3 +545,7 @@ func (s *Server) archive(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz")) gitutil.Archive(p.Dir, ref, prefix, w) } + +func policyCanRead(u store.User, repo store.Repo, grant string) bool { + return policy.CanRead(u, repo, grant) +} new file mode 100644 @@ -0,0 +1 @@ +DROP TABLE login_tokens; new file mode 100644 @@ -0,0 +1,7 @@ +CREATE TABLE login_tokens ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at TEXT NOT NULL, + used_at TEXT +); new file mode 100644 @@ -0,0 +1,81 @@ +package store + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "time" +) + +// NewToken returns a fresh random token and its storage hash. Only the hash +// is persisted; the token itself goes to the user once. +func NewToken() (token, hash string, err error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", "", err + } + token = hex.EncodeToString(b[:]) + return token, HashToken(token), nil +} + +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func fmtTime(t time.Time) string { return t.UTC().Format("2006-01-02T15:04:05.000Z") } + +// CreateLoginToken stores a one-time login token hash. +func (s *Store) CreateLoginToken(userID int64, hash string, ttl time.Duration) error { + _, err := s.DB.Exec( + "INSERT INTO login_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)", + hash, userID, fmtTime(time.Now().Add(ttl))) + return err +} + +// ConsumeLoginToken redeems a token exactly once; expired or used tokens +// fail identically. +func (s *Store) ConsumeLoginToken(hash string) (int64, error) { + res, err := s.DB.Exec(` + UPDATE login_tokens SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`, + hash, fmtTime(time.Now())) + if err != nil { + return 0, err + } + if n, _ := res.RowsAffected(); n == 0 { + return 0, ErrNotFound + } + var userID int64 + err = s.DB.QueryRow("SELECT user_id FROM login_tokens WHERE token_hash = ?", hash).Scan(&userID) + return userID, err +} + +func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error { + _, err := s.DB.Exec( + "INSERT INTO web_sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)", + hash, userID, fmtTime(time.Now().Add(ttl))) + return err +} + +// WebSessionUser resolves a session cookie hash to its user. +func (s *Store) WebSessionUser(hash string) (User, error) { + var userID int64 + err := s.DB.QueryRow( + "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?", + hash, fmtTime(time.Now())).Scan(&userID) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNotFound + } + if err != nil { + return User{}, err + } + return s.UserByID(userID) +} + +func (s *Store) DeleteWebSession(hash string) error { + _, err := s.DB.Exec("DELETE FROM web_sessions WHERE token_hash = ?", hash) + return err +} @@ -40,6 +40,7 @@ pre.diff .add { color: var(--ok); } pre.diff .del { color: var(--bad); } pre.diff .hunk { color: var(--link); } pre.diff .meta { color: var(--muted); } +.error { color: var(--bad); } .badge { display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px; font-size: 12px; border: 1px solid; @@ -2,7 +2,7 @@ {{define "content"}} {{template "repoheader" .}} <p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}{{.Base}} - · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a></p> + · <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> {{if .Binary}}<p>binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> {{else}}<div class="code">{{.CodeHTML}}</div>{{end}} {{end}} new file mode 100644 @@ -0,0 +1,11 @@ +{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} +{{define "content"}} +<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> +{{if .Error}}<p class="error">{{.Error}}</p>{{end}} +<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/edit/{{.Ref}}/{{.Path}}"> +<p><textarea name="content" rows="24" style="width:100%" spellcheck="false">{{.Content}}</textarea></p> +<p><input name="message" placeholder="commit message" style="width:60%"> +<button type="submit">commit to {{.Ref}}</button></p> +<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p> +</form> +{{end}} @@ -1,8 +1,15 @@ {{define "title"}}{{.Site}}{{end}} {{define "content"}} +{{if .Viewer}}<p class="crumbs">logged in as {{.Viewer}} · <a href="/new">new repository</a> · +<form method="post" action="/logout" style="display:inline"><button type="submit">logout</button></form></p>{{end}} <h1>repositories</h1> <table> {{range .Repos}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr> {{else}}<tr><td>no public repositories</td></tr>{{end}} </table> +{{if .Mine}}<h2>your private repositories</h2> +<table> +{{range .Mine}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.Visibility}}</td></tr> +{{end}} +</table>{{end}} {{end}} @@ -9,4 +9,10 @@ {{range .Comments}} <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> {{end}} +{{if .Viewer}} +<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment"> +<p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p> +<p><button type="submit">comment</button></p> +</form> +{{end}} {{end}} new file mode 100644 @@ -0,0 +1,9 @@ +{{define "title"}}login · {{.Site}}{{end}} +{{define "content"}} +<h1>log in</h1> +{{if .Error}}<p class="error">{{.Error}}</p>{{end}} +<p>Browser sessions are minted over SSH — there is no password. From a machine +with your registered key:</p> +<pre class="message">ssh git@{{.Site}} web login</pre> +<p>then open the printed URL within five minutes.</p> +{{end}} @@ -9,6 +9,12 @@ {{range .Comments}} <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> {{end}} +{{if .Viewer}} +<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment"> +<p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p> +<p><button type="submit">comment</button></p> +</form> +{{end}} <h3>diff</h3> <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> {{end}}</pre> new file mode 100644 @@ -0,0 +1,11 @@ +{{define "title"}}new repository · {{.Site}}{{end}} +{{define "content"}} +<h1>new repository</h1> +{{if .Error}}<p class="error">{{.Error}}</p>{{end}} +<form method="post" action="/new"> +<p><label>name <input name="name" required pattern="[a-z0-9][a-z0-9._-]*"></label> (under {{.Viewer}}/)</p> +<p><label><input type="radio" name="visibility" value="public" checked> public</label> + <label><input type="radio" name="visibility" value="private"> private</label></p> +<p><button type="submit">create</button></p> +</form> +{{end}}