Commit a0dd5878fb
Verified · cmc
Layout: unified · split
e2e/accounts_test.go added +217
| @@ -0,0 +1,217 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 18 | func 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 | ||
| 27 | func (i *instance) base() string { return fmt.Sprintf("http://127.0.0.1:%d", i.httpPort) } | |
| 28 | ||
| 29 | func 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 | ||
| 40 | func 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 | ||
| 51 | func 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. | |
| 205 | func 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 | |
| 48 | 48 | func startInstance(t *testing.T) *instance { |
| 49 | return startInstanceWith(t, "") | |
| 50 | } | |
| 51 | ||
| 52 | // startInstanceWith appends extra TOML to the instance config. | |
| 53 | func startInstanceWith(t *testing.T, extra string) *instance { | |
| 49 | 54 | t.Helper() |
| 50 | 55 | inst := &instance{ |
| 51 | 56 | forged: buildForged(t), |
| @@ -69,6 +74,7 @@ tls = "off" | ||
| 69 | 74 | enabled = true |
| 70 | 75 | port = %d |
| 71 | 76 | `, inst.root, inst.port, inst.httpPort, inst.gitPort) |
| 77 | cfg += extra + "\n" | |
| 72 | 78 | if err := os.WriteFile(inst.config, []byte(cfg), 0o600); err != nil { |
| 73 | 79 | t.Fatal(err) |
| 74 | 80 | } |
internal/config/config.go +4
| @@ -152,6 +152,10 @@ func (c Config) Validate() error { | ||
| 152 | 152 | errs = append(errs, errors.New( |
| 153 | 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 | 160 | return errors.Join(errs...) |
| 157 | 161 | } |
internal/config/config_test.go +7 −2
| @@ -60,6 +60,11 @@ func TestContradictions(t *testing.T) { | ||
| 60 | 60 | minimal + "\n[web]\nmode = \"view_only\"\npassword_auth = true\n", |
| 61 | 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 | 69 | "bad ssh mode", |
| 65 | 70 | minimal + "\n[ssh]\nmode = \"tcp\"\n", |
| @@ -103,8 +108,8 @@ func TestValidCombinations(t *testing.T) { | ||
| 103 | 108 | minimal + "\n[ssh]\nmode = \"system\"\n", |
| 104 | 109 | }, |
| 105 | 110 | { |
| 106 | "accounts web with password auth", | |
| 107 | minimal + "\n[web]\nmode = \"accounts\"\npassword_auth = true\n", | |
| 111 | "accounts web without password auth", | |
| 112 | minimal + "\n[web]\nmode = \"accounts\"\n", | |
| 108 | 113 | }, |
| 109 | 114 | { |
| 110 | 115 | "closed registration, no smtp at all", |
internal/control/web.go added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "io" | |
| 6 | "time" | |
| 7 | ||
| 8 | "github.com/krazywarez/forge/internal/protocol" | |
| 9 | "github.com/krazywarez/forge/internal/store" | |
| 10 | ) | |
| 11 | ||
| 12 | func newStoredToken() (token, hash string, err error) { return store.NewToken() } | |
| 13 | ||
| 14 | func init() { | |
| 15 | register(Command{Path: []string{"web", "login"}, | |
| 16 | Summary: "mint a one-time browser login URL", Run: runWebLogin}) | |
| 17 | } | |
| 18 | ||
| 19 | func 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 | 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. | |
| 122 | func 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 16 | const 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. | |
| 21 | func (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. | |
| 34 | func (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. | |
| 47 | func (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 | ||
| 60 | func (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 | ||
| 95 | func (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 | ||
| 103 | func (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 | ||
| 111 | func (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. | |
| 144 | func (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 | ||
| 167 | func (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 | ||
| 185 | func (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 | ||
| 208 | func (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 | ||
| 231 | type 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 | ||
| 241 | func (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 | ||
| 263 | func (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 | 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 — | |
| 47 | // and only when s.cfg.Web.Mode == "accounts". | |
| 46 | // Account-mode routes exist only when web.mode = "accounts". In | |
| 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 | 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. | |
| 38 | func 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 | 53 | // TestTopLevelRouteWordsAreReserved keeps the route table and the reserved |
| 37 | 54 | // username list in agreement: every literal first path segment must be an |
| 38 | 55 | // unclaimable username. |
| 39 | 56 | func 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 | 60 | for _, r := range s.Routes() { |
| 42 | 61 | seg := strings.TrimPrefix(r.Pattern, "/") |
| 43 | 62 | seg, _, _ = strings.Cut(seg, "/") |
internal/httpd/web.go +44 −6
| @@ -3,6 +3,8 @@ package httpd | ||
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "fmt" |
| 6 | ||
| 7 | "github.com/krazywarez/forge/internal/policy" | |
| 6 | 8 | "html/template" |
| 7 | 9 | "net/http" |
| 8 | 10 | "path" |
| @@ -50,15 +52,32 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | ||
| 50 | 52 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 51 | 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 | 69 | s.render(w, "index.html", struct { |
| 54 | Site string | |
| 55 | Repos []store.Repo | |
| 56 | }{s.siteName(), repos}) | |
| 70 | Site string | |
| 71 | Viewer string | |
| 72 | Repos []store.Repo | |
| 73 | Mine []store.Repo | |
| 74 | }{s.siteName(), viewer.Username, repos, mine}) | |
| 57 | 75 | } |
| 58 | 76 | |
| 59 | 77 | // repoPage is the shared context for repo-scoped pages. |
| 60 | 78 | type repoPage struct { |
| 61 | 79 | Site string |
| 80 | Viewer string | |
| 62 | 81 | Repo store.Repo |
| 63 | 82 | Ref string |
| 64 | 83 | CloneURL string |
| @@ -66,10 +85,24 @@ type repoPage struct { | ||
| 66 | 85 | } |
| 67 | 86 | |
| 68 | 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 | |
| 70 | // indistinguishable. | |
| 88 | // Anonymous visitors see public repos only; in accounts mode a logged-in | |
| 89 | // viewer additionally sees repos their grants allow. Private and missing | |
| 90 | // repos are indistinguishable either way. | |
| 71 | 91 | func (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 | 106 | if !ok { |
| 74 | 107 | http.NotFound(w, r) |
| 75 | 108 | return repoPage{}, false |
| @@ -79,6 +112,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re | ||
| 79 | 112 | } |
| 80 | 113 | return repoPage{ |
| 81 | 114 | Site: s.siteName(), |
| 115 | Viewer: viewer.Username, | |
| 82 | 116 | Repo: repo, |
| 83 | 117 | Ref: ref, |
| 84 | 118 | CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", |
| @@ -511,3 +545,7 @@ func (s *Server) archive(w http.ResponseWriter, r *http.Request) { | ||
| 511 | 545 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz")) |
| 512 | 546 | gitutil.Archive(p.Dir, ref, prefix, w) |
| 513 | 547 | } |
| 548 | ||
| 549 | func 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 @@ | ||
| 1 | DROP TABLE login_tokens; | |
internal/store/migrations/0002_login_tokens.up.sql added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | CREATE 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 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 14 | func 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 | ||
| 23 | func HashToken(token string) string { | |
| 24 | sum := sha256.Sum256([]byte(token)) | |
| 25 | return hex.EncodeToString(sum[:]) | |
| 26 | } | |
| 27 | ||
| 28 | func 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. | |
| 31 | func (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. | |
| 40 | func (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 | ||
| 56 | func (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. | |
| 64 | func (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 | ||
| 78 | func (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); } | ||
| 40 | 40 | pre.diff .del { color: var(--bad); } |
| 41 | 41 | pre.diff .hunk { color: var(--link); } |
| 42 | 42 | pre.diff .meta { color: var(--muted); } |
| 43 | .error { color: var(--bad); } | |
| 43 | 44 | .badge { |
| 44 | 45 | display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px; |
| 45 | 46 | font-size: 12px; border: 1px solid; |
internal/web/templates/blob.html +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{define "content"}} |
| 3 | 3 | {{template "repoheader" .}} |
| 4 | 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 | 6 | {{if .Binary}}<p>binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> |
| 7 | 7 | {{else}}<div class="code">{{.CodeHTML}}</div>{{end}} |
| 8 | 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 | 1 | {{define "title"}}{{.Site}}{{end}} |
| 2 | 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 | 5 | <h1>repositories</h1> |
| 4 | 6 | <table> |
| 5 | 7 | {{range .Repos}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr> |
| 6 | 8 | {{else}}<tr><td>no public repositories</td></tr>{{end}} |
| 7 | 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 | 15 | {{end}} |
internal/web/templates/issue.html +6
| @@ -9,4 +9,10 @@ | ||
| 9 | 9 | {{range .Comments}} |
| 10 | 10 | <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> |
| 11 | 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 | 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 | |
| 6 | with 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 | 9 | {{range .Comments}} |
| 10 | 10 | <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><pre class="message">{{.Body}}</pre></div> |
| 11 | 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 | 18 | <h3>diff</h3> |
| 13 | 19 | <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> |
| 14 | 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}} | |