diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0a7a64b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+# runtime state: bare repos, sqlite db, uploads
+/data/
+
+# local config (see simplegit.toml.example)
+/simplegit.toml
+
+# built binary
+/simplegit
+
+# local UAT scratch (scripts/local-uat.sh)
+/.uat/
+.projectmemory-browse/
+
+# internal LLM work files (memory, to-dos, handoffs)
+/.LLM_Memory/
diff --git a/cmd/simplegit/main.go b/cmd/simplegit/main.go
new file mode 100644
index 0000000..c9179e5
--- /dev/null
+++ b/cmd/simplegit/main.go
@@ -0,0 +1,271 @@
+// Command simplegit is a self-hosted git host: one static binary serving
+// smart-HTTP git and a server-rendered web UI.
+package main
+
+import (
+ "bufio"
+ "database/sql"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "git.josie-c.com/josie/simplegit/internal/auth"
+ "git.josie-c.com/josie/simplegit/internal/config"
+ "git.josie-c.com/josie/simplegit/internal/db"
+ "git.josie-c.com/josie/simplegit/internal/git"
+ "git.josie-c.com/josie/simplegit/internal/web"
+ "golang.org/x/term"
+)
+
+func main() {
+ if len(os.Args) < 2 {
+ usage()
+ os.Exit(2)
+ }
+
+ var err error
+ switch os.Args[1] {
+ case "serve":
+ err = runServe(os.Args[2:])
+ case "adduser":
+ err = runAddUser(os.Args[2:])
+ case "hook":
+ err = runHook(os.Args[2:])
+ case "-h", "--help", "help":
+ usage()
+ return
+ default:
+ fmt.Fprintf(os.Stderr, "simplegit: unknown command %q\n", os.Args[1])
+ usage()
+ os.Exit(2)
+ }
+
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "simplegit:", err)
+ os.Exit(1)
+ }
+}
+
+func usage() {
+ fmt.Fprint(os.Stderr, `usage: simplegit <command> [flags]
+
+commands:
+ serve run the HTTP server
+ adduser create the account
+ hook post-receive callback (invoked by git, not by hand)
+`)
+}
+
+func runServe(args []string) error {
+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
+ configPath := fs.String("config", "simplegit.toml", "path to TOML config file")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ cfg, database, err := openStore(*configPath)
+ if err != nil {
+ return err
+ }
+ defer database.Close()
+
+ server, err := web.New(database, cfg)
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("simplegit: listening on %s (base URL %s)\n", cfg.ListenAddr, cfg.BaseURL)
+ return server.Listen()
+}
+
+// usernames become URL path segments (/user/repo), so keep them boring.
+var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`)
+
+func runAddUser(args []string) error {
+ fs := flag.NewFlagSet("adduser", flag.ExitOnError)
+ configPath := fs.String("config", "simplegit.toml", "path to TOML config file")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if fs.NArg() != 1 {
+ return fmt.Errorf("usage: simplegit adduser <username>")
+ }
+ username := fs.Arg(0)
+ if !usernamePattern.MatchString(username) {
+ return fmt.Errorf("username %q must be letters, digits, dot, dash or underscore", username)
+ }
+
+ _, database, err := openStore(*configPath)
+ if err != nil {
+ return err
+ }
+ defer database.Close()
+
+ _, err = db.GetUserByName(database, username)
+ if err == nil {
+ return fmt.Errorf("user %q already exists", username)
+ }
+ if !errors.Is(err, db.ErrNotFound) {
+ return err
+ }
+
+ password, err := readPassword()
+ if err != nil {
+ return err
+ }
+ hash, err := auth.HashPassword(password)
+ if err != nil {
+ return err
+ }
+ id, err := db.CreateUser(database, username, hash)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("simplegit: created user %q (id %d)\n", username, id)
+ return nil
+}
+
+// readPassword takes the password from stdin when it is piped — so scripts
+// can do `printf '%s\n' "$pw" | simplegit adduser josie` without exposing
+// it in the process list — and falls back to the hidden terminal prompt.
+func readPassword() (string, error) {
+ if term.IsTerminal(int(os.Stdin.Fd())) {
+ return promptPassword()
+ }
+ line, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ return "", fmt.Errorf("read password: %w", err)
+ }
+ password := strings.TrimRight(string(line), "\r\n")
+ if password == "" {
+ return "", errors.New("password must not be empty")
+ }
+ return password, nil
+}
+
+func promptPassword() (string, error) {
+ fmt.Print("password: ")
+ first, err := term.ReadPassword(int(os.Stdin.Fd()))
+ fmt.Println()
+ if err != nil {
+ return "", fmt.Errorf("read password: %w", err)
+ }
+ fmt.Print("password (again): ")
+ second, err := term.ReadPassword(int(os.Stdin.Fd()))
+ fmt.Println()
+ if err != nil {
+ return "", fmt.Errorf("read password: %w", err)
+ }
+ if string(first) != string(second) {
+ return "", errors.New("passwords do not match")
+ }
+ if len(first) == 0 {
+ return "", errors.New("password must not be empty")
+ }
+ return string(first), nil
+}
+
+func runHook(args []string) error {
+ fs := flag.NewFlagSet("hook", flag.ExitOnError)
+ repoPath := fs.String("repo", "", "path to the bare repository (set by the installed hook)")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if *repoPath == "" {
+ return errors.New("hook: --repo is required")
+ }
+ owner, name, dataDir, err := splitRepoPath(*repoPath)
+ if err != nil {
+ return err
+ }
+
+ database, err := db.Open(filepath.Join(dataDir, "simplegit.db"))
+ if err != nil {
+ return err
+ }
+ defer database.Close()
+
+ branches, err := pushedBranches(os.Stdin)
+ if err != nil {
+ return err
+ }
+
+ // Establish a default branch when HEAD points at a branch that does not
+ // exist and this push created one (e.g. a first push of "master").
+ defaultBranch := ""
+ if current, err := git.DefaultBranch(*repoPath); err == nil {
+ if exists, _ := git.RefExists(*repoPath, current); !exists && len(branches) > 0 {
+ defaultBranch = branches[0]
+ if err := git.SetDefaultBranch(*repoPath, defaultBranch); err != nil {
+ return err
+ }
+ }
+ }
+ return db.RecordPush(database, owner, name, time.Now().Unix(), defaultBranch)
+}
+
+// splitRepoPath maps <dataDir>/repos/<owner>/<name>.git back to its parts.
+func splitRepoPath(repoPath string) (owner, name, dataDir string, err error) {
+ abs, err := filepath.Abs(repoPath)
+ if err != nil {
+ return "", "", "", fmt.Errorf("resolve repo path: %w", err)
+ }
+ if !strings.HasSuffix(abs, ".git") {
+ return "", "", "", fmt.Errorf("hook: repo path %q is not a .git directory", repoPath)
+ }
+ name = strings.TrimSuffix(filepath.Base(abs), ".git")
+ owner = filepath.Base(filepath.Dir(abs))
+ dataDir = filepath.Dir(filepath.Dir(filepath.Dir(abs)))
+ return owner, name, dataDir, nil
+}
+
+// pushedBranches returns the branch names in post-receive stdin updates.
+func pushedBranches(r io.Reader) ([]string, error) {
+ var branches []string
+ scanner := bufio.NewScanner(r)
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) < 3 {
+ continue
+ }
+ if branch, ok := strings.CutPrefix(fields[2], "refs/heads/"); ok {
+ branches = append(branches, branch)
+ }
+ }
+ return branches, scanner.Err()
+}
+
+// openStore loads the config, creates the runtime directory layout, and
+// opens the metadata database — the setup shared by serve and adduser.
+func openStore(configPath string) (config.Config, *sql.DB, error) {
+ cfg, err := config.Load(configPath)
+ if err != nil {
+ return cfg, nil, err
+ }
+ if err := ensureDataDir(cfg.DataDir); err != nil {
+ return cfg, nil, err
+ }
+ database, err := db.Open(filepath.Join(cfg.DataDir, "simplegit.db"))
+ if err != nil {
+ return cfg, nil, err
+ }
+ return cfg, database, nil
+}
+
+// ensureDataDir creates the runtime directory layout, idempotently. 0700
+// keeps session tokens, password hashes, and private repo objects away
+// from other local accounts.
+func ensureDataDir(dir string) error {
+ for _, sub := range []string{"", "repos", "uploads"} {
+ if err := os.MkdirAll(filepath.Join(dir, sub), 0o700); err != nil {
+ return fmt.Errorf("create data dir: %w", err)
+ }
+ }
+ return nil
+}
diff --git a/cmd/simplegit/main_test.go b/cmd/simplegit/main_test.go
new file mode 100644
index 0000000..6932934
--- /dev/null
+++ b/cmd/simplegit/main_test.go
@@ -0,0 +1,159 @@
+package main
+
+import (
+ "database/sql"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+ "git.josie-c.com/josie/simplegit/internal/git"
+)
+
+func runGit(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+}
+
+func hashObject(t *testing.T, repoPath, content string) string {
+ t.Helper()
+ cmd := exec.Command("git", "hash-object", "-w", "--stdin")
+ cmd.Dir = repoPath
+ cmd.Stdin = strings.NewReader(content)
+ out, err := cmd.Output()
+ if err != nil {
+ t.Fatalf("hash-object: %v", err)
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// commitToBranch writes a one-file commit and points branch at it.
+func commitToBranch(t *testing.T, repoPath, branch string) string {
+ t.Helper()
+ blob := hashObject(t, repoPath, "hello\n")
+ treeCmd := exec.Command("git", "mktree")
+ treeCmd.Dir = repoPath
+ treeCmd.Stdin = strings.NewReader("100644 blob " + blob + "\tfile\n")
+ treeOut, err := treeCmd.Output()
+ if err != nil {
+ t.Fatalf("mktree: %v", err)
+ }
+ commitCmd := exec.Command("git", "commit-tree", strings.TrimSpace(string(treeOut)), "-m", "seed")
+ commitCmd.Dir = repoPath
+ commitCmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
+ commitOut, err := commitCmd.Output()
+ if err != nil {
+ t.Fatalf("commit-tree: %v", err)
+ }
+ sha := strings.TrimSpace(string(commitOut))
+ runGit(t, repoPath, "update-ref", "refs/heads/"+branch, sha)
+ return sha
+}
+
+func newHookFixture(t *testing.T) (string, *sql.DB) {
+ t.Helper()
+ dataDir := t.TempDir()
+ repoPath := filepath.Join(dataDir, "repos", "josie", "demo.git")
+ if err := os.MkdirAll(filepath.Dir(repoPath), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ if err := git.InitBare(repoPath, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ database, err := db.Open(filepath.Join(dataDir, "simplegit.db"))
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { database.Close() })
+
+ ownerID, err := db.CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if _, err := db.CreateRepo(database, ownerID, "demo", "", "private"); err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ return repoPath, database
+}
+
+func runHookWithStdin(t *testing.T, repoPath, stdin string) error {
+ t.Helper()
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatalf("pipe: %v", err)
+ }
+ old := os.Stdin
+ os.Stdin = r
+ defer func() {
+ os.Stdin = old
+ r.Close()
+ }()
+ go func() {
+ _, _ = io.WriteString(w, stdin)
+ w.Close()
+ }()
+ return runHook([]string{"--repo", repoPath})
+}
+
+func TestRunHookEstablishesDefaultBranch(t *testing.T) {
+ repoPath, database := newHookFixture(t)
+ sha := commitToBranch(t, repoPath, "master")
+
+ stdin := "0000000000000000000000000000000000000000 " + sha + " refs/heads/master\n"
+ if err := runHookWithStdin(t, repoPath, stdin); err != nil {
+ t.Fatalf("runHook: %v", err)
+ }
+
+ repo, err := db.GetRepoByName(database, "josie", "demo")
+ if err != nil {
+ t.Fatalf("GetRepoByName: %v", err)
+ }
+ if repo.DefaultBranch != "master" {
+ t.Errorf("DefaultBranch = %q, want master", repo.DefaultBranch)
+ }
+ if !repo.PushedAt.Valid {
+ t.Error("PushedAt is NULL, want a timestamp")
+ }
+ if branch, err := git.DefaultBranch(repoPath); err != nil || branch != "master" {
+ t.Errorf("HEAD branch = %q, %v; want master", branch, err)
+ }
+}
+
+func TestRunHookKeepsExistingDefaultBranch(t *testing.T) {
+ repoPath, database := newHookFixture(t)
+ sha := commitToBranch(t, repoPath, "main")
+
+ stdin := "0000000000000000000000000000000000000000 " + sha + " refs/heads/main\n"
+ if err := runHookWithStdin(t, repoPath, stdin); err != nil {
+ t.Fatalf("runHook: %v", err)
+ }
+ repo, err := db.GetRepoByName(database, "josie", "demo")
+ if err != nil {
+ t.Fatalf("GetRepoByName: %v", err)
+ }
+ if repo.DefaultBranch != "main" {
+ t.Errorf("DefaultBranch = %q, want unchanged main", repo.DefaultBranch)
+ }
+}
+
+func TestSplitRepoPath(t *testing.T) {
+ owner, name, dataDir, err := splitRepoPath("/srv/simplegit/data/repos/josie/demo.git")
+ if err != nil {
+ t.Fatalf("splitRepoPath: %v", err)
+ }
+ if owner != "josie" || name != "demo" || dataDir != "/srv/simplegit/data" {
+ t.Errorf("split = %q/%q in %q", owner, name, dataDir)
+ }
+ if _, _, _, err := splitRepoPath("/tmp/not-a-repo"); err == nil {
+ t.Error("splitRepoPath accepted a non-.git path")
+ }
+}
diff --git a/deploy/Caddyfile.simplegit.example b/deploy/Caddyfile.simplegit.example
new file mode 100644
index 0000000..5e88b9f
--- /dev/null
+++ b/deploy/Caddyfile.simplegit.example
@@ -0,0 +1,5 @@
+# simplegit site (Caddy, automatic HTTPS)
+# Installed by scripts/install.sh with the domain and loopback substituted.
+{{DOMAIN}} {
+ reverse_proxy {{LOOPBACK}}:8080
+}
diff --git a/deploy/apache-simplegit.conf.example b/deploy/apache-simplegit.conf.example
new file mode 100644
index 0000000..0b4f936
--- /dev/null
+++ b/deploy/apache-simplegit.conf.example
@@ -0,0 +1,37 @@
+# simplegit reverse proxy (Apache httpd + Let's Encrypt)
+# Installed by scripts/install.sh with the domain substituted.
+# Edit ServerName here if the derived domain is wrong.
+
+<VirtualHost *:80>
+ ServerName {{DOMAIN}}
+ DocumentRoot /var/www/html
+ <Location "/.well-known/acme-challenge/">
+ Require all granted
+ </Location>
+ RewriteEngine On
+ RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
+ RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=301,L]
+</VirtualHost>
+
+<VirtualHost *:443>
+ ServerName {{DOMAIN}}
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/{{DOMAIN}}/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/{{DOMAIN}}/privkey.pem
+
+ ProxyPreserveHost On
+ ProxyRequests Off
+ ProxyTimeout 600s
+ Timeout 600
+ AllowEncodedSlashes NoDecode
+
+ SetEnv no-gzip 1
+ SetEnv dont-vary 1
+ LimitRequestBody 0
+
+ ProxyPass / http://{{LOOPBACK}}:8080/ retry=0
+ ProxyPassReverse / http://{{LOOPBACK}}:8080/
+
+ ErrorLog ${APACHE_LOG_DIR}/simplegit-error.log
+ CustomLog ${APACHE_LOG_DIR}/simplegit-access.log combined
+</VirtualHost>
diff --git a/docs/self-host.md b/docs/self-host.md
new file mode 100644
index 0000000..fc682e0
--- /dev/null
+++ b/docs/self-host.md
@@ -0,0 +1,259 @@
+# Self-hosting simplegit
+
+simplegit is one static binary: it serves smart-HTTP git and a
+server-rendered web UI. TLS is terminated by a reverse proxy; the binary
+listens on plain HTTP and requires the `git` binary on `PATH` at runtime.
+
+# 1. Install (recommended)
+
+`scripts/install.sh` does steps 1-5 for you (binary, system user, data dir,
+config, proxy example conf, systemd unit):
+
+```sh
+sudo scripts/install.sh https://git.example.com apache
+# or: ... https://git.example.com caddy | none
+```
+
+The second argument picks the reverse-proxy example conf, with the domain
+substituted: `apache` installs `/etc/apache2/sites-available/simplegit.conf`
+(and enables modules/site), `caddy` installs the site into the Caddyfile
+(or a `Caddyfile.simplegit` to `import`), `none` skips it. The unit is
+enabled but not started; the config is written only if absent.
+
+Manual equivalent, if you prefer to do it by hand:
+
+### 1a. Build
+
+```sh
+go build -o /usr/local/bin/simplegit ./cmd/simplegit
+```
+
+The binary embeds templates and static assets, so there is nothing else to
+copy. It needs `git` (with `http-backend`) installed on the host.
+
+## 2. Config
+
+Create `simplegit.toml` (see `simplegit.toml.example`). Every key is
+optional.
+
+```toml
+data_dir = "/var/lib/simplegit" # bare repos, SQLite DB, uploads
+listen_addr = "127.0.0.1:8080" # plain HTTP; the proxy fronts it
+base_url = "https://git.example.com" # public URL; builds clone URLs/links
+```
+
+`base_url` must be the public HTTPS URL, or clone URLs and `Secure` session
+cookies will be wrong.
+
+## 3. Account and data dir
+
+```sh
+simplegit adduser -config simplegit.toml <username>
+```
+
+Flags come **before** the positional username (Go's flag parsing stops at
+the first non-flag). In a terminal you get a hidden password prompt (asked
+twice); for scripting, pipe the password on stdin so it never appears in
+the process list:
+
+```sh
+printf '%s\n' "$PASSWORD" | simplegit adduser -config simplegit.toml <username>
+```
+
+`serve` and `adduser` create the data-dir layout on first run, mode 0700.
+If the directory already exists from an older install, tighten it once:
+`chmod -R go-rwx /var/lib/simplegit`. Setting `UMask=0077` in the systemd
+unit keeps everything the service creates private as well.
+
+## 4. Run it
+
+```sh
+simplegit serve -config /etc/simplegit/simplegit.toml
+```
+
+### systemd unit
+
+```ini
+[Unit]
+Description=simplegit
+After=network.target
+
+[Service]
+ExecStart=/usr/local/bin/simplegit serve -config /etc/simplegit/simplegit.toml
+Restart=on-failure
+User=simplegit
+Group=simplegit
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Run it as a dedicated user that owns `data_dir`. It does not need root and
+binds only the loopback address the proxy forwards to.
+
+## 5. Reverse proxy (TLS)
+
+Caddy terminates TLS automatically:
+
+```
+git.example.com {
+ reverse_proxy 127.0.0.1:8080
+}
+```
+
+Two proxy requirements:
+
+- **Do not rewrite or buffer git request/response bodies.** Pushes stream a
+ packfile both ways.
+- **Do not pass `X-Forwarded-For` expectations to the app** — the guest-write
+ rate limiter keys on the socket `RemoteAddr`, so behind a proxy all clients
+ share one bucket. (Trusting the forwarded header is a deliberate future
+ decision, not current behavior.)
+
+nginx: `proxy_pass http://127.0.0.1:8080;` with `proxy_request_buffering off;`
+and a generous `client_max_body_size` (pushes and release uploads can be
+large).
+
+A request body that reaches the app while a response is being written is
+handled safely (the CGI stdin is drained before the response), but proxies
+that buffer or retry bodies are still discouraged.
+
+### Apache httpd + certbot
+
+Apache streams request bodies by default, so large pushes work once the
+timeouts are raised. On Debian/Ubuntu:
+
+```sh
+apt install apache2 certbot python3-certbot-apache
+a2enmod ssl proxy proxy_http headers rewrite
+```
+
+Point the DNS record at the host, then use a hand-owned vhost so certbot
+never rewrites your proxy config — `/etc/apache2/sites-available/simplegit.conf`:
+
+```apache
+<VirtualHost *:80>
+ ServerName git.example.com
+ DocumentRoot /var/www/html
+ <Location "/.well-known/acme-challenge/">
+ Require all granted
+ </Location>
+ RewriteEngine On
+ RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/
+ RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [R=301,L]
+</VirtualHost>
+
+<VirtualHost *:443>
+ ServerName git.example.com
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/git.example.com/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/git.example.com/privkey.pem
+
+ ProxyPreserveHost On
+ ProxyRequests Off
+ # big pushes / release uploads
+ ProxyTimeout 600s
+ Timeout 600
+ # pass %2f through instead of 404ing
+ AllowEncodedSlashes NoDecode
+
+ # never re-encode the git stream
+ SetEnv no-gzip 1
+ SetEnv dont-vary 1
+ # unlimited (the default, but explicit)
+ LimitRequestBody 0
+
+ ProxyPass / http://[IP_ADDRESS]:8080/ retry=0
+ ProxyPassReverse / http://[IP_ADDRESS]:8080/
+
+ ErrorLog ${APACHE_LOG_DIR}/simplegit-error.log
+ CustomLog ${APACHE_LOG_DIR}/simplegit-access.log combined
+</VirtualHost>
+```
+
+```sh
+a2ensite simplegit && apachectl configtest && systemctl reload apache2
+certbot certonly --webroot -w /var/www/html -d git.example.com
+```
+
+(`certbot --apache -d git.example.com` also works, but writes its own
+`-le-ssl.conf` that you would then have to add the proxy block to; the
+`certonly` route keeps the file yours.) Renewal is automatic via the systemd
+timer — verify with `certbot renew --dry-run`.
+
+Apache-specific gotchas, all load-bearing:
+
+- **`ProxyPreserveHost On`** is mandatory, or Apache sends `Host: [IP_ADDRESS]`
+ and clone URLs/redirects come out wrong.
+- **`SetEnv no-gzip 1`** — `mod_deflate` re-encoding the streaming git
+ response breaks some clients.
+- **`ProxyTimeout` / `Timeout`** default to 60s and kill big pushes.
+- **`AllowEncodedSlashes NoDecode`** — Apache's default 404s any URL with `%2f`.
+- **Keep comments on their own lines.** On a 2.4.66/Ubuntu build, inline
+ `# …` comments on `ProxyTimeout`/`AllowEncodedSlashes` lines made
+ `apachectl configtest` fail with "takes one argument" (the comment text
+ parsed as a second argument); the same vhost passed once the comments
+ moved to their own lines. The example above is comment-free on
+ directive lines for that reason.
+- **The rate limiter sees `[IP_ADDRESS]`.** `mod_proxy` sets `X-Forwarded-For`
+ automatically, but the app deliberately doesn't trust it (see above).
+- Firewall only 80/443; keep the app on loopback.
+
+## 6. Dogfood (host simplegit on itself)
+
+```sh
+# create the repo in the web UI, then:
+git remote add origin https://git.example.com/<user>/simplegit.git
+git push -u origin main
+git tag v1.0.0 && git push origin v1.0.0
+```
+
+Then on the repo page: file an issue, open a pull request from a branch, and
+publish a release (Releases → new release, pick the tag, attach a binary).
+
+## 7. Local UAT (single machine, no proxy)
+
+To exercise the whole UI and git flows locally, without TLS or a reverse
+proxy, use the helper script:
+
+```sh
+scripts/local-uat.sh # default port 8090
+scripts/local-uat.sh 9000 # or pick a port
+```
+
+It builds the binary, writes a throwaway config, creates the user
+(`josie` / `hunter2`), and serves on `127.0.0.1:<port>`. The binary and the
+whole data dir live in `.uat/` (gitignored); delete that directory to reset.
+Override with `UAT_DIR`, `UAT_USER`, `UAT_PASS`.
+
+Manual equivalent:
+
+```sh
+go build -o .uat/simplegit ./cmd/simplegit
+mkdir -p .uat/data
+printf 'data_dir = ".uat/data"\nlisten_addr = "127.0.0.1:8090"\nbase_url = "http://127.0.0.1:8090"\n' > .uat/sg.toml
+printf '%s\n' hunter2 | .uat/simplegit adduser -config .uat/sg.toml josie
+.uat/simplegit serve -config .uat/sg.toml
+```
+
+`base_url` is plain `http://` here, so the session cookie is not `Secure` —
+correct for localhost, wrong for production.
+
+Create a repo in the UI, then push with:
+
+```sh
+git -c credential.helper= push http://josie:hunter2@127.0.0.1:8090/josie/<repo>.git main
+```
+
+## 8. Backups
+
+Everything lives under `data_dir`:
+
+- `repos/<user>/<repo>.git` — the bare repositories (authoritative history)
+- `simplegit.db` (+ `-wal`, `-shm`) — users, sessions, tokens, issues, PRs,
+ releases metadata
+- `uploads/releases/<id>/` — release attachments
+
+Back up all three together; the DB and uploads reference each other. SQLite
+is in WAL mode, so snapshot with `sqlite3 simplegit.db ".backup <dest>"` (or
+copy the DB plus its `-wal`) rather than copying a live file blindly.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..4d03087
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,25 @@
+module git.josie-c.com/josie/simplegit
+
+go 1.27.1
+
+require (
+ github.com/BurntSushi/toml v1.6.0
+ modernc.org/sqlite v1.60.1
+)
+
+require (
+ github.com/alecthomas/chroma/v2 v2.27.0 // indirect
+ github.com/dlclark/regexp2/v2 v2.2.1 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/yuin/goldmark v1.8.6 // indirect
+ golang.org/x/crypto v0.57.0 // indirect
+ golang.org/x/sys v0.48.0 // indirect
+ golang.org/x/term v0.46.0 // indirect
+ modernc.org/libc v1.77.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.12.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e65ba7a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,62 @@
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
+github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
+github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
+github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
+github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
+golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
+golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
+golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
+golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
+modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
+modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
+modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
+modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
+modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
new file mode 100644
index 0000000..5476f47
--- /dev/null
+++ b/internal/auth/auth.go
@@ -0,0 +1,64 @@
+// Package auth provides password hashing and opaque session tokens.
+package auth
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+// HashPassword returns a bcrypt hash of password.
+func HashPassword(password string) (string, error) {
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return "", fmt.Errorf("hash password: %w", err)
+ }
+ return string(hash), nil
+}
+
+// CheckPassword reports whether password matches the stored hash.
+func CheckPassword(hash, password string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
+
+// NewToken returns a random 256-bit hex string for a session cookie.
+func NewToken() (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("generate token: %w", err)
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+// apiTokenPrefix marks git tokens and tells them apart from passwords in
+// the HTTP basic-auth password field.
+const apiTokenPrefix = "sg_"
+
+// NewAPIToken returns a fresh git token: the prefix plus 32 random bytes.
+func NewAPIToken() (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("generate api token: %w", err)
+ }
+ return apiTokenPrefix + base64.RawURLEncoding.EncodeToString(buf), nil
+}
+
+// HashToken returns the hex SHA-256 digest used to store and look up a git
+// token. A fast digest is correct here: the token is high-entropy, so there
+// is nothing to brute-force, and an indexed digest lookup keeps auth O(1).
+func HashToken(token string) string {
+ sum := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(sum[:])
+}
+
+// TokenHint is the non-secret prefix shown in the token list.
+func TokenHint(token string) string {
+ if len(token) <= 8 {
+ return token
+ }
+ return token[:8]
+}
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
new file mode 100644
index 0000000..8c22ccd
--- /dev/null
+++ b/internal/auth/auth_test.go
@@ -0,0 +1,81 @@
+package auth
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestHashAndCheckPassword(t *testing.T) {
+ hash, err := HashPassword("correct horse")
+ if err != nil {
+ t.Fatalf("HashPassword: %v", err)
+ }
+ if !CheckPassword(hash, "correct horse") {
+ t.Error("CheckPassword with the same password = false, want true")
+ }
+ if CheckPassword(hash, "battery staple") {
+ t.Error("CheckPassword with wrong password = true, want false")
+ }
+ first, err := HashPassword("same password")
+ if err != nil {
+ t.Fatalf("HashPassword: %v", err)
+ }
+ second, err := HashPassword("same password")
+ if err != nil {
+ t.Fatalf("HashPassword: %v", err)
+ }
+ if first == second {
+ t.Error("two hashes of one password are identical, want salted")
+ }
+}
+
+func TestNewToken(t *testing.T) {
+ seen := map[string]bool{}
+ for range 10 {
+ token, err := NewToken()
+ if err != nil {
+ t.Fatalf("NewToken: %v", err)
+ }
+ if len(token) != 64 {
+ t.Errorf("token length = %d, want 64", len(token))
+ }
+ if seen[token] {
+ t.Errorf("token %s repeated", token)
+ }
+ seen[token] = true
+ }
+}
+
+func TestAPIToken(t *testing.T) {
+ seen := map[string]bool{}
+ for range 10 {
+ token, err := NewAPIToken()
+ if err != nil {
+ t.Fatalf("NewAPIToken: %v", err)
+ }
+ if !strings.HasPrefix(token, "sg_") {
+ t.Errorf("token %q lacks sg_ prefix", token)
+ }
+ if seen[token] {
+ t.Errorf("token %s repeated", token)
+ }
+ seen[token] = true
+ }
+
+ if HashToken("sg_abc") != HashToken("sg_abc") {
+ t.Error("HashToken is not deterministic")
+ }
+ if HashToken("sg_abc") == HashToken("sg_abd") {
+ t.Error("HashToken collided on differing input")
+ }
+ if len(HashToken("sg_abc")) != 64 {
+ t.Errorf("hash length = %d, want 64", len(HashToken("sg_abc")))
+ }
+
+ if hint := TokenHint("sg_abcdefghij"); hint != "sg_abcde" {
+ t.Errorf("TokenHint = %q, want sg_abcde", hint)
+ }
+ if hint := TokenHint("short"); hint != "short" {
+ t.Errorf("TokenHint(short) = %q, want short", hint)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..9d71fda
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,49 @@
+// Package config loads simplegit's runtime settings from a TOML file.
+package config
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+
+ "github.com/BurntSushi/toml"
+)
+
+// Config holds the settings needed to run the server.
+type Config struct {
+ // DataDir is the root for runtime state: bare repos, the SQLite
+ // database, and uploads.
+ DataDir string `toml:"data_dir"`
+ // ListenAddr is the address the HTTP server binds. TLS is terminated
+ // by the reverse proxy, so this is plain HTTP.
+ ListenAddr string `toml:"listen_addr"`
+ // BaseURL is the public URL, used to build clone URLs and links.
+ BaseURL string `toml:"base_url"`
+}
+
+// Default returns the built-in configuration, used when no file is present.
+// Listen on loopback: the server speaks plain HTTP and must not be
+// reachable by anything that can see a public interface.
+func Default() Config {
+ return Config{
+ DataDir: "data",
+ ListenAddr: "127.0.0.1:8080",
+ BaseURL: "http://localhost:8080",
+ }
+}
+
+// Load reads path on top of the defaults. A missing file is not an error:
+// the defaults are returned so the binary runs out of the box.
+func Load(path string) (Config, error) {
+ cfg := Default()
+ if path == "" {
+ return cfg, nil
+ }
+ if _, err := toml.DecodeFile(path, &cfg); err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ return cfg, nil
+ }
+ return Config{}, fmt.Errorf("load config %s: %w", path, err)
+ }
+ return cfg, nil
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 0000000..6e9fc5d
--- /dev/null
+++ b/internal/db/db.go
@@ -0,0 +1,165 @@
+// Package db owns the SQLite schema and all queries. Migrations are
+// embedded in the binary and applied at startup.
+package db
+
+import (
+ "database/sql"
+ "embed"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ _ "modernc.org/sqlite"
+)
+
+//go:embed migrations/*.sql
+var migrationsFS embed.FS
+
+// Open opens the SQLite database at path, applies any pending migrations,
+// and returns a ready connection pool.
+func Open(path string) (*sql.DB, error) {
+ dsn := "file:" + path + "?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
+ database, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("open sqlite %s: %w", path, err)
+ }
+ if err := database.Ping(); err != nil {
+ database.Close()
+ return nil, fmt.Errorf("open sqlite %s: %w", path, err)
+ }
+ // SQLite creates the file world-readable by default; it holds session
+ // tokens and password hashes, so tighten it regardless of umask.
+ if err := os.Chmod(path, 0o600); err != nil && !os.IsNotExist(err) {
+ database.Close()
+ return nil, fmt.Errorf("chmod sqlite %s: %w", path, err)
+ }
+ if err := migrate(database); err != nil {
+ database.Close()
+ return nil, err
+ }
+ return database, nil
+}
+
+// migrate applies every embedded migration not yet recorded in
+// schema_migrations, in filename order, each in its own transaction.
+func migrate(database *sql.DB) error {
+ if _, err := database.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
+ version TEXT PRIMARY KEY,
+ applied_at INTEGER NOT NULL
+ )`); err != nil {
+ return fmt.Errorf("create schema_migrations: %w", err)
+ }
+
+ names, err := fs.Glob(migrationsFS, "migrations/*.sql")
+ if err != nil {
+ return fmt.Errorf("list migrations: %w", err)
+ }
+ sort.Strings(names)
+
+ for _, name := range names {
+ version := filepath.Base(name)
+
+ var applied int
+ if err := database.QueryRow(
+ `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version,
+ ).Scan(&applied); err != nil {
+ return fmt.Errorf("check migration %s: %w", version, err)
+ }
+ if applied > 0 {
+ continue
+ }
+
+ script, err := migrationsFS.ReadFile(name)
+ if err != nil {
+ return fmt.Errorf("read migration %s: %w", version, err)
+ }
+ if err := apply(database, version, string(script)); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func apply(database *sql.DB, version, script string) error {
+ tx, err := database.Begin()
+ if err != nil {
+ return fmt.Errorf("begin migration %s: %w", version, err)
+ }
+ defer tx.Rollback()
+
+ if _, err := tx.Exec(script); err != nil {
+ return fmt.Errorf("apply migration %s: %w", version, err)
+ }
+ if _, err := tx.Exec(
+ `INSERT INTO schema_migrations (version, applied_at) VALUES (?, unixepoch())`, version,
+ ); err != nil {
+ return fmt.Errorf("record migration %s: %w", version, err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit migration %s: %w", version, err)
+ }
+ return nil
+}
+
+// rowScanner is satisfied by *sql.Row and *sql.Rows, letting entity
+// scanners serve both single-row lookups and list queries.
+type rowScanner interface {
+ Scan(dest ...any) error
+}
+
+// listQuery runs query and appends each row scanned by scan.
+func listQuery[T any](database *sql.DB, query string, args []any, scan func(rowScanner, *T) error) ([]T, error) {
+ rows, err := database.Query(query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var items []T
+ for rows.Next() {
+ var item T
+ if err := scan(rows, &item); err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+// execScoped runs a scoped UPDATE or DELETE and maps zero affected rows to
+// ErrNotFound, so callers never mistake "row absent" for success.
+func execScoped(database *sql.DB, op, query string, args ...any) error {
+ result, err := database.Exec(query, args...)
+ if err != nil {
+ return fmt.Errorf("%s: %w", op, err)
+ }
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("%s: %w", op, err)
+ }
+ if affected == 0 {
+ return fmt.Errorf("%s: %w", op, ErrNotFound)
+ }
+ return nil
+}
+
+// execLastID runs an INSERT and returns the new row id.
+func execLastID(database *sql.DB, query string, args ...any) (int64, error) {
+ result, err := database.Exec(query, args...)
+ if err != nil {
+ return 0, err
+ }
+ return result.LastInsertId()
+}
+
+// isUniqueViolation reports whether err is SQLite's UNIQUE constraint
+// failure — a per-repo number collision when it surfaces from an insert.
+func isUniqueViolation(err error) bool {
+ return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
+}
diff --git a/internal/db/db_test.go b/internal/db/db_test.go
new file mode 100644
index 0000000..a096ff0
--- /dev/null
+++ b/internal/db/db_test.go
@@ -0,0 +1,121 @@
+package db
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestOpenAppliesMigrations(t *testing.T) {
+ database, err := Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer database.Close()
+
+ for _, table := range []string{"users", "sessions", "repos", "tokens", "issues", "issue_comments", "pulls", "pull_comments", "releases", "release_assets", "schema_migrations"} {
+ var name string
+ err := database.QueryRow(
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table,
+ ).Scan(&name)
+ if err != nil {
+ t.Errorf("table %s missing: %v", table, err)
+ }
+ }
+
+ var applied int
+ if err := database.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&applied); err != nil {
+ t.Fatalf("count migrations: %v", err)
+ }
+ if applied != 5 {
+ t.Errorf("applied migrations = %d, want 5", applied)
+ }
+}
+
+func TestOpenIsIdempotent(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "test.db")
+
+ first, err := Open(path)
+ if err != nil {
+ t.Fatalf("first Open: %v", err)
+ }
+ first.Close()
+
+ second, err := Open(path)
+ if err != nil {
+ t.Fatalf("second Open: %v", err)
+ }
+ defer second.Close()
+
+ var applied int
+ if err := second.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&applied); err != nil {
+ t.Fatalf("count migrations: %v", err)
+ }
+ if applied != 5 {
+ t.Errorf("applied migrations after reopen = %d, want 5", applied)
+ }
+}
+
+func TestSchemaAcceptsRows(t *testing.T) {
+ database, err := Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer database.Close()
+
+ res, err := database.Exec(
+ `INSERT INTO users (username, password_hash) VALUES (?, ?)`, "josie", "x",
+ )
+ if err != nil {
+ t.Fatalf("insert user: %v", err)
+ }
+ userID, err := res.LastInsertId()
+ if err != nil {
+ t.Fatalf("last insert id: %v", err)
+ }
+
+ if _, err := database.Exec(
+ `INSERT INTO repos (owner_id, name) VALUES (?, ?)`, userID, "simplegit",
+ ); err != nil {
+ t.Fatalf("insert repo: %v", err)
+ }
+
+ var visibility, branch string
+ var createdAt int64
+ if err := database.QueryRow(
+ `SELECT visibility, default_branch, created_at FROM repos WHERE owner_id = ? AND name = ?`,
+ userID, "simplegit",
+ ).Scan(&visibility, &branch, &createdAt); err != nil {
+ t.Fatalf("select repo: %v", err)
+ }
+ if visibility != "private" {
+ t.Errorf("default visibility = %q, want private", visibility)
+ }
+ if branch != "main" {
+ t.Errorf("default branch = %q, want main", branch)
+ }
+ if createdAt <= 0 {
+ t.Errorf("created_at = %d, want > 0", createdAt)
+ }
+}
+
+func TestForeignKeysEnforced(t *testing.T) {
+ database, err := Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ defer database.Close()
+
+ var enabled int
+ if err := database.QueryRow(`PRAGMA foreign_keys`).Scan(&enabled); err != nil {
+ t.Fatalf("pragma foreign_keys: %v", err)
+ }
+ if enabled != 1 {
+ t.Fatalf("foreign_keys = %d, want 1", enabled)
+ }
+
+ if _, err := database.Exec(
+ `INSERT INTO repos (owner_id, name) VALUES (?, ?)`, 999, "orphan",
+ ); err == nil {
+ t.Error("insert with missing owner_id succeeded, want foreign key error")
+ }
+}
diff --git a/internal/db/issues.go b/internal/db/issues.go
new file mode 100644
index 0000000..48f5aa0
--- /dev/null
+++ b/internal/db/issues.go
@@ -0,0 +1,255 @@
+package db
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+)
+
+// Issue is a row in the issues table. AuthorID is NULL for guest-authored
+// rows; Pending marks guest content awaiting owner moderation.
+type Issue struct {
+ ID int64
+ RepoID int64
+ Number int64
+ Title string
+ Body string
+ State string
+ Pending bool
+ AuthorID sql.NullInt64
+ AuthorName string
+ AuthorEmail string
+ CreatedAt int64
+ ClosedAt sql.NullInt64
+}
+
+// IssueComment is a row in the issue_comments table.
+type IssueComment struct {
+ ID int64
+ IssueID int64
+ Body string
+ Pending bool
+ AuthorID sql.NullInt64
+ AuthorName string
+ AuthorEmail string
+ CreatedAt int64
+}
+
+const issueColumns = `SELECT id, repo_id, number, title, body, state, pending,
+ author_id, author_name, author_email, created_at, closed_at FROM issues WHERE `
+
+// CreateIssue inserts an issue with a per-repo number of MAX(number)+1,
+// computed atomically inside the INSERT. authorID is 0 for a guest.
+func CreateIssue(database *sql.DB, repoID int64, title, body string, authorID int64, authorName, authorEmail string, pending bool) (Issue, error) {
+ const insert = `INSERT INTO issues (repo_id, number, title, body, pending, author_id, author_name, author_email)
+ VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE repo_id = ?), ?, ?, ?, ?, ?, ?)`
+ args := []any{repoID, repoID, title, body, boolToInt(pending), nullableID(authorID), authorName, authorEmail}
+ // A concurrent insert can claim the computed number; retry the insert once
+ // (the subselect then recomputes MAX+1).
+ id, err := execLastID(database, insert, args...)
+ if err != nil && isUniqueViolation(err) {
+ id, err = execLastID(database, insert, args...)
+ }
+ if err != nil {
+ return Issue{}, fmt.Errorf("create issue: %w", err)
+ }
+ return GetIssueByID(database, id)
+}
+
+// GetIssueByID looks up an issue by primary key.
+func GetIssueByID(database *sql.DB, id int64) (Issue, error) {
+ return getIssue(database, issueColumns+`id = ?`, id)
+}
+
+// GetIssueByNumber looks up an issue by its per-repo number.
+func GetIssueByNumber(database *sql.DB, repoID, number int64) (Issue, error) {
+ return getIssue(database, issueColumns+`repo_id = ? AND number = ?`, repoID, number)
+}
+
+// scanIssue reads one issues row (see issueColumns) into issue.
+func scanIssue(s rowScanner, issue *Issue) error {
+ var pending int
+ if err := s.Scan(
+ &issue.ID, &issue.RepoID, &issue.Number, &issue.Title, &issue.Body,
+ &issue.State, &pending, &issue.AuthorID, &issue.AuthorName,
+ &issue.AuthorEmail, &issue.CreatedAt, &issue.ClosedAt); err != nil {
+ return err
+ }
+ issue.Pending = pending != 0
+ return nil
+}
+
+func getIssue(database *sql.DB, query string, args ...any) (Issue, error) {
+ var issue Issue
+ err := scanIssue(database.QueryRow(query, args...), &issue)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Issue{}, fmt.Errorf("issue: %w", ErrNotFound)
+ }
+ if err != nil {
+ return Issue{}, fmt.Errorf("get issue: %w", err)
+ }
+ return issue, nil
+}
+
+// ListIssues returns a repo's issues. includePending must be true only for
+// the owner; state, when non-empty, filters to 'open' or 'closed'. Pending
+// rows sort first so the owner sees what needs review.
+func ListIssues(database *sql.DB, repoID int64, includePending bool, state string) ([]Issue, error) {
+ query := issueColumns + `repo_id = ?`
+ args := []any{repoID}
+ if !includePending {
+ query += ` AND pending = 0`
+ }
+ if state == "open" || state == "closed" {
+ query += ` AND state = ?`
+ args = append(args, state)
+ }
+ query += ` ORDER BY pending DESC, number DESC`
+
+ issues, err := listQuery(database, query, args, scanIssue)
+ if err != nil {
+ return nil, fmt.Errorf("list issues: %w", err)
+ }
+ return issues, nil
+}
+
+// SetIssueState flips an issue between open and closed (closed_at tracks the
+// close time). Scoped to the repo so a number cannot cross repos.
+func SetIssueState(database *sql.DB, repoID, number int64, state string) error {
+ if state != "open" && state != "closed" {
+ return fmt.Errorf("set issue state %q: invalid state", state)
+ }
+ query := `UPDATE issues SET state = ?, closed_at = NULL WHERE repo_id = ? AND number = ?`
+ args := []any{state, repoID, number}
+ if state == "closed" {
+ query = `UPDATE issues SET state = 'closed', closed_at = unixepoch() WHERE repo_id = ? AND number = ?`
+ args = []any{repoID, number}
+ }
+ return execScoped(database, "set issue state", query, args...)
+}
+
+// ApproveIssue publishes a pending issue.
+func ApproveIssue(database *sql.DB, repoID, number int64) error {
+ return execScoped(database, "approve issue",
+ `UPDATE issues SET pending = 0 WHERE repo_id = ? AND number = ?`, repoID, number)
+}
+
+// DeleteIssue removes an issue row; its comments cascade.
+func DeleteIssue(database *sql.DB, repoID, number int64) error {
+ return execScoped(database, "delete issue",
+ `DELETE FROM issues WHERE repo_id = ? AND number = ?`, repoID, number)
+}
+
+// HasDuplicateIssue reports whether a row with the same title, body, and
+// claimed author already exists in the repo — the dedupe behind guest filing.
+func HasDuplicateIssue(database *sql.DB, repoID int64, title, body, authorName, authorEmail string) (bool, error) {
+ var n int
+ err := database.QueryRow(
+ `SELECT COUNT(*) FROM issues WHERE repo_id = ? AND title = ? AND body = ?
+ AND author_name = ? AND author_email = ?`,
+ repoID, title, body, authorName, authorEmail).Scan(&n)
+ if err != nil {
+ return false, fmt.Errorf("duplicate issue check: %w", err)
+ }
+ return n > 0, nil
+}
+
+const issueCommentColumns = `SELECT id, issue_id, body, pending, author_id,
+ author_name, author_email, created_at FROM issue_comments WHERE `
+
+// CreateComment inserts a comment. authorID is 0 for a guest.
+func CreateComment(database *sql.DB, issueID int64, body string, authorID int64, authorName, authorEmail string, pending bool) (IssueComment, error) {
+ id, err := execLastID(database,
+ `INSERT INTO issue_comments (issue_id, body, pending, author_id, author_name, author_email)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ issueID, body, boolToInt(pending), nullableID(authorID), authorName, authorEmail)
+ if err != nil {
+ return IssueComment{}, fmt.Errorf("create comment: %w", err)
+ }
+ return getComment(database, issueCommentColumns+`id = ?`, id)
+}
+
+// scanIssueComment reads one issue_comments row into c.
+func scanIssueComment(s rowScanner, c *IssueComment) error {
+ var pending int
+ if err := s.Scan(
+ &c.ID, &c.IssueID, &c.Body, &pending, &c.AuthorID,
+ &c.AuthorName, &c.AuthorEmail, &c.CreatedAt); err != nil {
+ return err
+ }
+ c.Pending = pending != 0
+ return nil
+}
+
+func getComment(database *sql.DB, query string, args ...any) (IssueComment, error) {
+ var c IssueComment
+ err := scanIssueComment(database.QueryRow(query, args...), &c)
+ if errors.Is(err, sql.ErrNoRows) {
+ return IssueComment{}, fmt.Errorf("comment: %w", ErrNotFound)
+ }
+ if err != nil {
+ return IssueComment{}, fmt.Errorf("get comment: %w", err)
+ }
+ return c, nil
+}
+
+// ListComments returns an issue's comments oldest first. includePending must
+// be true only for the owner.
+func ListComments(database *sql.DB, issueID int64, includePending bool) ([]IssueComment, error) {
+ query := issueCommentColumns + `issue_id = ?`
+ if !includePending {
+ query += ` AND pending = 0`
+ }
+ query += ` ORDER BY created_at, id`
+
+ comments, err := listQuery(database, query, []any{issueID}, scanIssueComment)
+ if err != nil {
+ return nil, fmt.Errorf("list comments: %w", err)
+ }
+ return comments, nil
+}
+
+// ApproveComment publishes a pending comment, scoped to its issue so a
+// comment id cannot be approved through a different issue.
+func ApproveComment(database *sql.DB, issueID, id int64) error {
+ return execScoped(database, "approve comment",
+ `UPDATE issue_comments SET pending = 0 WHERE id = ? AND issue_id = ?`, id, issueID)
+}
+
+// DeleteComment removes a comment, scoped to its issue.
+func DeleteComment(database *sql.DB, issueID, id int64) error {
+ return execScoped(database, "delete comment",
+ `DELETE FROM issue_comments WHERE id = ? AND issue_id = ?`, id, issueID)
+}
+
+// CountPending returns how many issues, comments, pull requests, and PR
+// comments in a repo await owner moderation.
+func CountPending(database *sql.DB, repoID int64) (int, error) {
+ var n int
+ err := database.QueryRow(
+ `SELECT (SELECT COUNT(*) FROM issues WHERE repo_id = ? AND pending = 1)
+ + (SELECT COUNT(*) FROM issue_comments c JOIN issues i ON i.id = c.issue_id
+ WHERE i.repo_id = ? AND c.pending = 1)
+ + (SELECT COUNT(*) FROM pulls WHERE repo_id = ? AND pending = 1)
+ + (SELECT COUNT(*) FROM pull_comments c JOIN pulls p ON p.id = c.pull_id
+ WHERE p.repo_id = ? AND c.pending = 1)`,
+ repoID, repoID, repoID, repoID).Scan(&n)
+ if err != nil {
+ return 0, fmt.Errorf("count pending: %w", err)
+ }
+ return n, nil
+}
+
+func boolToInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+func nullableID(id int64) any {
+ if id == 0 {
+ return nil
+ }
+ return id
+}
diff --git a/internal/db/issues_test.go b/internal/db/issues_test.go
new file mode 100644
index 0000000..d73b873
--- /dev/null
+++ b/internal/db/issues_test.go
@@ -0,0 +1,218 @@
+package db
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestCreateIssueNumbersPerRepo(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repoA, err := CreateRepo(database, ownerID, "alpha", "", "public")
+ if err != nil {
+ t.Fatalf("CreateRepo A: %v", err)
+ }
+ repoB, err := CreateRepo(database, ownerID, "beta", "", "public")
+ if err != nil {
+ t.Fatalf("CreateRepo B: %v", err)
+ }
+
+ first, err := CreateIssue(database, repoA.ID, "one", "body", ownerID, "josie", "", false)
+ if err != nil {
+ t.Fatalf("CreateIssue A1: %v", err)
+ }
+ second, err := CreateIssue(database, repoA.ID, "two", "body", ownerID, "josie", "", false)
+ if err != nil {
+ t.Fatalf("CreateIssue A2: %v", err)
+ }
+ other, err := CreateIssue(database, repoB.ID, "other", "body", 0, "guest", "g@example.com", true)
+ if err != nil {
+ t.Fatalf("CreateIssue B1: %v", err)
+ }
+
+ if first.Number != 1 || second.Number != 2 {
+ t.Errorf("repo A numbers = %d, %d, want 1, 2", first.Number, second.Number)
+ }
+ if other.Number != 1 {
+ t.Errorf("repo B number = %d, want 1 (independent)", other.Number)
+ }
+ if other.Pending != true {
+ t.Error("guest issue Pending = false, want true")
+ }
+ if other.AuthorID.Valid {
+ t.Error("guest issue AuthorID is set, want NULL")
+ }
+ if other.AuthorName != "guest" || other.AuthorEmail != "g@example.com" {
+ t.Errorf("guest author = %q/%q, want guest/g@example.com", other.AuthorName, other.AuthorEmail)
+ }
+ if !first.AuthorID.Valid || first.AuthorID.Int64 != ownerID {
+ t.Errorf("owner issue AuthorID = %+v, want %d", first.AuthorID, ownerID)
+ }
+}
+
+func TestGetIssueByNumberIsRepoScoped(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repoA, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ repoB, _ := CreateRepo(database, ownerID, "beta", "", "public")
+ if _, err := CreateIssue(database, repoA.ID, "a1", "", ownerID, "josie", "", false); err != nil {
+ t.Fatalf("CreateIssue: %v", err)
+ }
+
+ if _, err := GetIssueByNumber(database, repoB.ID, 1); !errors.Is(err, ErrNotFound) {
+ t.Errorf("GetIssueByNumber(other repo, 1) err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestListIssuesPendingAndState(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+
+ published, _ := CreateIssue(database, repo.ID, "published", "", ownerID, "josie", "", false)
+ pending, _ := CreateIssue(database, repo.ID, "pending", "", 0, "guest", "", true)
+
+ public, err := ListIssues(database, repo.ID, false, "")
+ if err != nil {
+ t.Fatalf("ListIssues public: %v", err)
+ }
+ if len(public) != 1 || public[0].Title != "published" {
+ t.Errorf("public list = %+v, want only the published issue", public)
+ }
+
+ owner, err := ListIssues(database, repo.ID, true, "")
+ if err != nil {
+ t.Fatalf("ListIssues owner: %v", err)
+ }
+ if len(owner) != 2 || !owner[0].Pending {
+ t.Errorf("owner list = %+v, want 2 with pending first", owner)
+ }
+
+ if err := SetIssueState(database, repo.ID, published.Number, "closed"); err != nil {
+ t.Fatalf("SetIssueState closed: %v", err)
+ }
+ closed, err := ListIssues(database, repo.ID, true, "closed")
+ if err != nil {
+ t.Fatalf("ListIssues closed: %v", err)
+ }
+ if len(closed) != 1 || closed[0].Number != published.Number {
+ t.Errorf("closed list = %+v, want the closed issue", closed)
+ }
+ open, err := ListIssues(database, repo.ID, true, "open")
+ if err != nil {
+ t.Fatalf("ListIssues open: %v", err)
+ }
+ if len(open) != 1 || open[0].Number != pending.Number {
+ t.Errorf("open list = %+v, want the pending issue", open)
+ }
+
+ reloaded, err := GetIssueByNumber(database, repo.ID, published.Number)
+ if err != nil {
+ t.Fatalf("GetIssueByNumber: %v", err)
+ }
+ if !reloaded.ClosedAt.Valid {
+ t.Error("ClosedAt is NULL after close")
+ }
+ if err := SetIssueState(database, repo.ID, published.Number, "open"); err != nil {
+ t.Fatalf("SetIssueState reopen: %v", err)
+ }
+ reopened, _ := GetIssueByNumber(database, repo.ID, published.Number)
+ if reopened.ClosedAt.Valid {
+ t.Error("ClosedAt is set after reopen, want NULL")
+ }
+}
+
+func TestApproveAndDeleteIssue(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ issue, _ := CreateIssue(database, repo.ID, "spam?", "", 0, "guest", "", true)
+
+ if err := ApproveIssue(database, repo.ID, issue.Number); err != nil {
+ t.Fatalf("ApproveIssue: %v", err)
+ }
+ got, _ := GetIssueByNumber(database, repo.ID, issue.Number)
+ if got.Pending {
+ t.Error("issue still pending after approve")
+ }
+
+ if err := DeleteIssue(database, repo.ID, issue.Number); err != nil {
+ t.Fatalf("DeleteIssue: %v", err)
+ }
+ if _, err := GetIssueByNumber(database, repo.ID, issue.Number); !errors.Is(err, ErrNotFound) {
+ t.Errorf("GetIssueByNumber after delete err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestCommentsModerationAndCount(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ issue, _ := CreateIssue(database, repo.ID, "topic", "", ownerID, "josie", "", false)
+
+ ownerComment, _ := CreateComment(database, issue.ID, "owner reply", ownerID, "josie", "", false)
+ guestComment, _ := CreateComment(database, issue.ID, "guest reply", 0, "guest", "guest@example.com", true)
+
+ public, err := ListComments(database, issue.ID, false)
+ if err != nil {
+ t.Fatalf("ListComments public: %v", err)
+ }
+ if len(public) != 1 || public[0].ID != ownerComment.ID {
+ t.Errorf("public comments = %+v, want only the owner comment", public)
+ }
+
+ owner, err := ListComments(database, issue.ID, true)
+ if err != nil {
+ t.Fatalf("ListComments owner: %v", err)
+ }
+ if len(owner) != 2 {
+ t.Errorf("owner comments = %d, want 2", len(owner))
+ }
+
+ pending, err := CountPending(database, repo.ID)
+ if err != nil {
+ t.Fatalf("CountPending: %v", err)
+ }
+ if pending != 1 {
+ t.Errorf("CountPending = %d, want 1", pending)
+ }
+
+ if err := ApproveComment(database, issue.ID, guestComment.ID); err != nil {
+ t.Fatalf("ApproveComment: %v", err)
+ }
+ if pending, _ = CountPending(database, repo.ID); pending != 0 {
+ t.Errorf("CountPending after approve = %d, want 0", pending)
+ }
+
+ if err := DeleteComment(database, issue.ID, guestComment.ID); err != nil {
+ t.Fatalf("DeleteComment: %v", err)
+ }
+ if _, err := getComment(database, issueCommentColumns+`id = ?`, guestComment.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("comment after delete err = %v, want ErrNotFound", err)
+ }
+
+ if err := ApproveComment(database, issue.ID+999, ownerComment.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("ApproveComment wrong issue err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestDeleteIssueCascadesComments(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ issue, _ := CreateIssue(database, repo.ID, "topic", "", ownerID, "josie", "", false)
+ if _, err := CreateComment(database, issue.ID, "reply", ownerID, "josie", "", false); err != nil {
+ t.Fatalf("CreateComment: %v", err)
+ }
+
+ if err := DeleteIssue(database, repo.ID, issue.Number); err != nil {
+ t.Fatalf("DeleteIssue: %v", err)
+ }
+ comments, err := ListComments(database, issue.ID, true)
+ if err != nil {
+ t.Fatalf("ListComments: %v", err)
+ }
+ if len(comments) != 0 {
+ t.Errorf("comments after issue delete = %d, want 0 (cascade)", len(comments))
+ }
+}
diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql
new file mode 100644
index 0000000..22dee34
--- /dev/null
+++ b/internal/db/migrations/0001_init.sql
@@ -0,0 +1,39 @@
+-- 0001_init: users, sessions, repos.
+--
+-- Timestamps are unix epoch seconds (INTEGER) so they map straight onto
+-- Go's time.Time with no driver-specific parsing.
+
+-- Single-user host, but the table stays generic so CLI-added accounts
+-- remain possible later.
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+);
+
+-- Browser login sessions. token is a random opaque string.
+CREATE TABLE sessions (
+ token TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ expires_at INTEGER NOT NULL
+);
+
+CREATE INDEX sessions_user_id_idx ON sessions (user_id);
+
+-- One row per bare repo on disk at data/repos/{username}/{name}.git.
+CREATE TABLE repos (
+ id INTEGER PRIMARY KEY,
+ owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ visibility TEXT NOT NULL DEFAULT 'private'
+ CHECK (visibility IN ('public', 'private')),
+ default_branch TEXT NOT NULL DEFAULT 'main',
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ pushed_at INTEGER,
+ UNIQUE (owner_id, name)
+);
+
+CREATE INDEX repos_visibility_idx ON repos (visibility);
\ No newline at end of file
diff --git a/internal/db/migrations/0002_tokens.sql b/internal/db/migrations/0002_tokens.sql
new file mode 100644
index 0000000..e416a17
--- /dev/null
+++ b/internal/db/migrations/0002_tokens.sql
@@ -0,0 +1,16 @@
+-- 0002_tokens: HTTP basic-auth git tokens.
+--
+-- Tokens are long-lived push/clone credentials. Only the SHA-256 digest is
+-- stored; the plaintext is shown once at creation. hint is the non-secret
+-- prefix kept for display.
+CREATE TABLE tokens (
+ id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ hint TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ last_used_at INTEGER
+);
+
+CREATE INDEX tokens_user_id_idx ON tokens (user_id);
diff --git a/internal/db/migrations/0003_issues.sql b/internal/db/migrations/0003_issues.sql
new file mode 100644
index 0000000..3931e40
--- /dev/null
+++ b/internal/db/migrations/0003_issues.sql
@@ -0,0 +1,40 @@
+-- 0003_issues: per-repo issues and comments with guest filing + moderation.
+--
+-- Author identity: an authenticated author sets author_id (and author_name
+-- as a display copy); a guest leaves author_id NULL and supplies a
+-- self-claimed author_name plus optional author_email (owner-visible only).
+-- pending=1 hides guest content from the public until the owner approves it;
+-- owner-authored rows are never pending. number is per-repo (MAX+1).
+CREATE TABLE issues (
+ id INTEGER PRIMARY KEY,
+ repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+ number INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL DEFAULT '',
+ state TEXT NOT NULL DEFAULT 'open'
+ CHECK (state IN ('open', 'closed')),
+ pending INTEGER NOT NULL DEFAULT 0
+ CHECK (pending IN (0, 1)),
+ author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ author_name TEXT NOT NULL DEFAULT '',
+ author_email TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ closed_at INTEGER,
+ UNIQUE (repo_id, number)
+);
+
+CREATE INDEX issues_repo_id_idx ON issues (repo_id);
+
+CREATE TABLE issue_comments (
+ id INTEGER PRIMARY KEY,
+ issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
+ body TEXT NOT NULL,
+ pending INTEGER NOT NULL DEFAULT 0
+ CHECK (pending IN (0, 1)),
+ author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ author_name TEXT NOT NULL DEFAULT '',
+ author_email TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+);
+
+CREATE INDEX issue_comments_issue_id_idx ON issue_comments (issue_id);
diff --git a/internal/db/migrations/0004_pulls.sql b/internal/db/migrations/0004_pulls.sql
new file mode 100644
index 0000000..1334773
--- /dev/null
+++ b/internal/db/migrations/0004_pulls.sql
@@ -0,0 +1,44 @@
+-- 0004_pulls: branch-to-branch pull requests and PR-level comments.
+--
+-- Same authorship/moderation model as 0003_issues: an authenticated author
+-- sets author_id (+ a display author_name); a guest leaves author_id NULL
+-- and supplies a self-claimed name and optional email. pending=1 hides guest
+-- content until the owner approves it. base/head are branch names within the
+-- repo (no forks). number is per-repo (MAX+1).
+CREATE TABLE pulls (
+ id INTEGER PRIMARY KEY,
+ repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+ number INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL DEFAULT '',
+ base TEXT NOT NULL,
+ head TEXT NOT NULL,
+ state TEXT NOT NULL DEFAULT 'open'
+ CHECK (state IN ('open', 'closed', 'merged')),
+ pending INTEGER NOT NULL DEFAULT 0
+ CHECK (pending IN (0, 1)),
+ author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ author_name TEXT NOT NULL DEFAULT '',
+ author_email TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ closed_at INTEGER,
+ merged_at INTEGER,
+ merge_commit TEXT NOT NULL DEFAULT '',
+ UNIQUE (repo_id, number)
+);
+
+CREATE INDEX pulls_repo_id_idx ON pulls (repo_id);
+
+CREATE TABLE pull_comments (
+ id INTEGER PRIMARY KEY,
+ pull_id INTEGER NOT NULL REFERENCES pulls(id) ON DELETE CASCADE,
+ body TEXT NOT NULL,
+ pending INTEGER NOT NULL DEFAULT 0
+ CHECK (pending IN (0, 1)),
+ author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ author_name TEXT NOT NULL DEFAULT '',
+ author_email TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+);
+
+CREATE INDEX pull_comments_pull_id_idx ON pull_comments (pull_id);
diff --git a/internal/db/migrations/0005_releases.sql b/internal/db/migrations/0005_releases.sql
new file mode 100644
index 0000000..445716a
--- /dev/null
+++ b/internal/db/migrations/0005_releases.sql
@@ -0,0 +1,30 @@
+-- 0005_releases: releases attached to existing tags, with binary assets.
+--
+-- Unlike issues/pulls, releases are owner-only publish artifacts: there is
+-- no guest author and no moderation queue. A release names one tag that
+-- already exists in the repo (UNIQUE per repo). release_assets are files
+-- uploaded to the release; stored_name is the opaque on-disk name under
+-- uploads/releases/<release_id>/, while filename is what the user sees.
+CREATE TABLE releases (
+ id INTEGER PRIMARY KEY,
+ repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
+ tag TEXT NOT NULL,
+ title TEXT NOT NULL,
+ notes TEXT NOT NULL DEFAULT '',
+ author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
+ UNIQUE (repo_id, tag)
+);
+
+CREATE INDEX releases_repo_id_idx ON releases (repo_id);
+
+CREATE TABLE release_assets (
+ id INTEGER PRIMARY KEY,
+ release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
+ filename TEXT NOT NULL,
+ stored_name TEXT NOT NULL,
+ size INTEGER NOT NULL,
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
+);
+
+CREATE INDEX release_assets_release_id_idx ON release_assets (release_id);
diff --git a/internal/db/pulls.go b/internal/db/pulls.go
new file mode 100644
index 0000000..888d199
--- /dev/null
+++ b/internal/db/pulls.go
@@ -0,0 +1,235 @@
+package db
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+)
+
+// Pull is a row in the pulls table. AuthorID is NULL for guest-authored
+// rows; Pending marks guest content awaiting owner moderation.
+type Pull struct {
+ ID int64
+ RepoID int64
+ Number int64
+ Title string
+ Body string
+ Base string
+ Head string
+ State string
+ Pending bool
+ AuthorID sql.NullInt64
+ AuthorName string
+ AuthorEmail string
+ CreatedAt int64
+ ClosedAt sql.NullInt64
+ MergedAt sql.NullInt64
+ MergeCommit string
+}
+
+// PullComment is a row in the pull_comments table.
+type PullComment struct {
+ ID int64
+ PullID int64
+ Body string
+ Pending bool
+ AuthorID sql.NullInt64
+ AuthorName string
+ AuthorEmail string
+ CreatedAt int64
+}
+
+const pullColumns = `SELECT id, repo_id, number, title, body, base, head, state,
+ pending, author_id, author_name, author_email, created_at, closed_at,
+ merged_at, merge_commit FROM pulls WHERE `
+
+// CreatePull inserts a pull request with a per-repo number of MAX(number)+1,
+// computed atomically inside the INSERT. authorID is 0 for a guest.
+func CreatePull(database *sql.DB, repoID int64, title, body, base, head string, authorID int64, authorName, authorEmail string, pending bool) (Pull, error) {
+ const insert = `INSERT INTO pulls (repo_id, number, title, body, base, head, pending, author_id, author_name, author_email)
+ VALUES (?, (SELECT COALESCE(MAX(number), 0) + 1 FROM pulls WHERE repo_id = ?), ?, ?, ?, ?, ?, ?, ?, ?)`
+ args := []any{repoID, repoID, title, body, base, head, boolToInt(pending), nullableID(authorID), authorName, authorEmail}
+ // A concurrent insert can claim the computed number; retry the insert once
+ // (the subselect then recomputes MAX+1).
+ id, err := execLastID(database, insert, args...)
+ if err != nil && isUniqueViolation(err) {
+ id, err = execLastID(database, insert, args...)
+ }
+ if err != nil {
+ return Pull{}, fmt.Errorf("create pull: %w", err)
+ }
+ return GetPullByID(database, id)
+}
+
+// GetPullByID looks up a pull request by primary key.
+func GetPullByID(database *sql.DB, id int64) (Pull, error) {
+ return getPull(database, pullColumns+`id = ?`, id)
+}
+
+// GetPullByNumber looks up a pull request by its per-repo number.
+func GetPullByNumber(database *sql.DB, repoID, number int64) (Pull, error) {
+ return getPull(database, pullColumns+`repo_id = ? AND number = ?`, repoID, number)
+}
+
+// scanPull reads one pulls row (see pullColumns) into p.
+func scanPull(s rowScanner, p *Pull) error {
+ var pending int
+ if err := s.Scan(
+ &p.ID, &p.RepoID, &p.Number, &p.Title, &p.Body, &p.Base, &p.Head,
+ &p.State, &pending, &p.AuthorID, &p.AuthorName, &p.AuthorEmail,
+ &p.CreatedAt, &p.ClosedAt, &p.MergedAt, &p.MergeCommit); err != nil {
+ return err
+ }
+ p.Pending = pending != 0
+ return nil
+}
+
+func getPull(database *sql.DB, query string, args ...any) (Pull, error) {
+ var p Pull
+ err := scanPull(database.QueryRow(query, args...), &p)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Pull{}, fmt.Errorf("pull: %w", ErrNotFound)
+ }
+ if err != nil {
+ return Pull{}, fmt.Errorf("get pull: %w", err)
+ }
+ return p, nil
+}
+
+// ListPulls returns a repo's pull requests. includePending must be true only
+// for the owner; state, when non-empty, filters to 'open', 'closed', or
+// 'merged'. Pending rows sort first so the owner sees what needs review.
+func ListPulls(database *sql.DB, repoID int64, includePending bool, state string) ([]Pull, error) {
+ query := pullColumns + `repo_id = ?`
+ args := []any{repoID}
+ if !includePending {
+ query += ` AND pending = 0`
+ }
+ switch state {
+ case "open", "closed", "merged":
+ query += ` AND state = ?`
+ args = append(args, state)
+ }
+ query += ` ORDER BY pending DESC, number DESC`
+
+ pulls, err := listQuery(database, query, args, scanPull)
+ if err != nil {
+ return nil, fmt.Errorf("list pulls: %w", err)
+ }
+ return pulls, nil
+}
+
+// SetPullState opens or closes a pull request (closed_at tracks the close
+// time). Scoped to the repo so a number cannot cross repos.
+func SetPullState(database *sql.DB, repoID, number int64, state string) error {
+ if state != "open" && state != "closed" {
+ return fmt.Errorf("set pull state %q: invalid state", state)
+ }
+ query := `UPDATE pulls SET state = ?, closed_at = NULL WHERE repo_id = ? AND number = ?`
+ args := []any{state, repoID, number}
+ if state == "closed" {
+ query = `UPDATE pulls SET state = 'closed', closed_at = unixepoch() WHERE repo_id = ? AND number = ?`
+ args = []any{repoID, number}
+ }
+ return execScoped(database, "set pull state", query, args...)
+}
+
+// SetPullMerged records a completed merge.
+func SetPullMerged(database *sql.DB, repoID, number int64, mergeCommit string) error {
+ return execScoped(database, "set pull merged",
+ `UPDATE pulls SET state = 'merged', merged_at = unixepoch(), merge_commit = ? WHERE repo_id = ? AND number = ?`,
+ mergeCommit, repoID, number)
+}
+
+// ApprovePull publishes a pending pull request.
+func ApprovePull(database *sql.DB, repoID, number int64) error {
+ return execScoped(database, "approve pull",
+ `UPDATE pulls SET pending = 0 WHERE repo_id = ? AND number = ?`, repoID, number)
+}
+
+// DeletePull removes a pull request; its comments cascade.
+func DeletePull(database *sql.DB, repoID, number int64) error {
+ return execScoped(database, "delete pull",
+ `DELETE FROM pulls WHERE repo_id = ? AND number = ?`, repoID, number)
+}
+
+// HasDuplicatePull reports whether a row with the same title, body, branch
+// pair, and claimed author already exists in the repo.
+func HasDuplicatePull(database *sql.DB, repoID int64, title, body, base, head, authorName, authorEmail string) (bool, error) {
+ var n int
+ err := database.QueryRow(
+ `SELECT COUNT(*) FROM pulls WHERE repo_id = ? AND title = ? AND body = ?
+ AND base = ? AND head = ? AND author_name = ? AND author_email = ?`,
+ repoID, title, body, base, head, authorName, authorEmail).Scan(&n)
+ if err != nil {
+ return false, fmt.Errorf("duplicate pull check: %w", err)
+ }
+ return n > 0, nil
+}
+
+const pullCommentColumns = `SELECT id, pull_id, body, pending, author_id,
+ author_name, author_email, created_at FROM pull_comments WHERE `
+
+// CreatePullComment inserts a PR-level comment. authorID is 0 for a guest.
+func CreatePullComment(database *sql.DB, pullID int64, body string, authorID int64, authorName, authorEmail string, pending bool) (PullComment, error) {
+ id, err := execLastID(database,
+ `INSERT INTO pull_comments (pull_id, body, pending, author_id, author_name, author_email)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ pullID, body, boolToInt(pending), nullableID(authorID), authorName, authorEmail)
+ if err != nil {
+ return PullComment{}, fmt.Errorf("create pull comment: %w", err)
+ }
+ return getPullComment(database, pullCommentColumns+`id = ?`, id)
+}
+
+// scanPullComment reads one pull_comments row into c.
+func scanPullComment(s rowScanner, c *PullComment) error {
+ var pending int
+ if err := s.Scan(
+ &c.ID, &c.PullID, &c.Body, &pending, &c.AuthorID,
+ &c.AuthorName, &c.AuthorEmail, &c.CreatedAt); err != nil {
+ return err
+ }
+ c.Pending = pending != 0
+ return nil
+}
+
+func getPullComment(database *sql.DB, query string, args ...any) (PullComment, error) {
+ var c PullComment
+ err := scanPullComment(database.QueryRow(query, args...), &c)
+ if errors.Is(err, sql.ErrNoRows) {
+ return PullComment{}, fmt.Errorf("pull comment: %w", ErrNotFound)
+ }
+ if err != nil {
+ return PullComment{}, fmt.Errorf("get pull comment: %w", err)
+ }
+ return c, nil
+}
+
+// ListPullComments returns a pull request's comments oldest first.
+// includePending must be true only for the owner.
+func ListPullComments(database *sql.DB, pullID int64, includePending bool) ([]PullComment, error) {
+ query := pullCommentColumns + `pull_id = ?`
+ if !includePending {
+ query += ` AND pending = 0`
+ }
+ query += ` ORDER BY created_at, id`
+
+ comments, err := listQuery(database, query, []any{pullID}, scanPullComment)
+ if err != nil {
+ return nil, fmt.Errorf("list pull comments: %w", err)
+ }
+ return comments, nil
+}
+
+// ApprovePullComment publishes a pending comment, scoped to its pull.
+func ApprovePullComment(database *sql.DB, pullID, id int64) error {
+ return execScoped(database, "approve pull comment",
+ `UPDATE pull_comments SET pending = 0 WHERE id = ? AND pull_id = ?`, id, pullID)
+}
+
+// DeletePullComment removes a comment, scoped to its pull.
+func DeletePullComment(database *sql.DB, pullID, id int64) error {
+ return execScoped(database, "delete pull comment",
+ `DELETE FROM pull_comments WHERE id = ? AND pull_id = ?`, id, pullID)
+}
diff --git a/internal/db/pulls_test.go b/internal/db/pulls_test.go
new file mode 100644
index 0000000..46fbf6a
--- /dev/null
+++ b/internal/db/pulls_test.go
@@ -0,0 +1,168 @@
+package db
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestCreatePullNumbersPerRepo(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repoA, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ repoB, _ := CreateRepo(database, ownerID, "beta", "", "public")
+
+ first, err := CreatePull(database, repoA.ID, "one", "body", "main", "feature", ownerID, "josie", "", false)
+ if err != nil {
+ t.Fatalf("CreatePull A1: %v", err)
+ }
+ second, err := CreatePull(database, repoA.ID, "two", "body", "main", "feature2", ownerID, "josie", "", false)
+ if err != nil {
+ t.Fatalf("CreatePull A2: %v", err)
+ }
+ other, err := CreatePull(database, repoB.ID, "other", "body", "main", "feature", 0, "guest", "[EMAIL]", true)
+ if err != nil {
+ t.Fatalf("CreatePull B1: %v", err)
+ }
+
+ if first.Number != 1 || second.Number != 2 || other.Number != 1 {
+ t.Errorf("numbers = %d, %d, %d, want 1, 2, 1", first.Number, second.Number, other.Number)
+ }
+ if first.State != "open" {
+ t.Errorf("default state = %q, want open", first.State)
+ }
+ if !other.Pending || other.AuthorID.Valid {
+ t.Errorf("guest pull = %+v, want pending with NULL author_id", other)
+ }
+ if !first.AuthorID.Valid || first.AuthorID.Int64 != ownerID {
+ t.Errorf("owner pull AuthorID = %+v, want %d", first.AuthorID, ownerID)
+ }
+ if _, err := GetPullByNumber(database, repoB.ID, 2); !errors.Is(err, ErrNotFound) {
+ t.Errorf("cross-repo pull err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestListPullsFilters(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+
+ open, _ := CreatePull(database, repo.ID, "open", "", "main", "feature", ownerID, "josie", "", false)
+ closed, _ := CreatePull(database, repo.ID, "closed", "", "main", "feature2", ownerID, "josie", "", false)
+ merged, _ := CreatePull(database, repo.ID, "merged", "", "main", "feature3", ownerID, "josie", "", false)
+ pending, _ := CreatePull(database, repo.ID, "pending", "", "main", "feature4", 0, "guest", "", true)
+
+ if err := SetPullState(database, repo.ID, closed.Number, "closed"); err != nil {
+ t.Fatalf("SetPullState closed: %v", err)
+ }
+ if err := SetPullMerged(database, repo.ID, merged.Number, "deadbeef"); err != nil {
+ t.Fatalf("SetPullMerged: %v", err)
+ }
+
+ public, err := ListPulls(database, repo.ID, false, "")
+ if err != nil {
+ t.Fatalf("ListPulls public: %v", err)
+ }
+ if len(public) != 3 {
+ t.Errorf("public pulls = %d, want 3 (pending hidden)", len(public))
+ }
+ owner, _ := ListPulls(database, repo.ID, true, "")
+ if len(owner) != 4 || !owner[0].Pending {
+ t.Errorf("owner pulls = %+v, want 4 with pending first", owner)
+ }
+
+ openList, _ := ListPulls(database, repo.ID, true, "open")
+ if len(openList) != 2 {
+ t.Errorf("open pulls = %d, want 2 (open + pending)", len(openList))
+ }
+ closedList, _ := ListPulls(database, repo.ID, true, "closed")
+ if len(closedList) != 1 || closedList[0].Number != closed.Number {
+ t.Errorf("closed pulls = %+v, want the closed one", closedList)
+ }
+ mergedList, _ := ListPulls(database, repo.ID, true, "merged")
+ if len(mergedList) != 1 || mergedList[0].MergeCommit != "deadbeef" || !mergedList[0].MergedAt.Valid {
+ t.Errorf("merged pulls = %+v, want merged with commit and timestamp", mergedList)
+ }
+ _ = open
+ _ = pending
+}
+
+func TestSetPullStateReopen(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ pull, _ := CreatePull(database, repo.ID, "t", "", "main", "feature", ownerID, "josie", "", false)
+
+ if err := SetPullState(database, repo.ID, pull.Number, "closed"); err != nil {
+ t.Fatalf("close: %v", err)
+ }
+ closed, _ := GetPullByNumber(database, repo.ID, pull.Number)
+ if closed.State != "closed" || !closed.ClosedAt.Valid {
+ t.Errorf("after close = %+v, want closed with closed_at", closed)
+ }
+ if err := SetPullState(database, repo.ID, pull.Number, "open"); err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ reopened, _ := GetPullByNumber(database, repo.ID, pull.Number)
+ if reopened.ClosedAt.Valid {
+ t.Error("closed_at set after reopen, want NULL")
+ }
+}
+
+func TestApproveAndDeletePull(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ pull, _ := CreatePull(database, repo.ID, "guest pr", "", "main", "feature", 0, "guest", "", true)
+ if _, err := CreatePullComment(database, pull.ID, "reply", ownerID, "josie", "", false); err != nil {
+ t.Fatalf("CreatePullComment: %v", err)
+ }
+
+ if pending, _ := CountPending(database, repo.ID); pending != 1 {
+ t.Errorf("CountPending = %d, want 1", pending)
+ }
+ if err := ApprovePull(database, repo.ID, pull.Number); err != nil {
+ t.Fatalf("ApprovePull: %v", err)
+ }
+ got, _ := GetPullByNumber(database, repo.ID, pull.Number)
+ if got.Pending {
+ t.Error("pull still pending after approve")
+ }
+
+ if err := DeletePull(database, repo.ID, pull.Number); err != nil {
+ t.Fatalf("DeletePull: %v", err)
+ }
+ if _, err := GetPullByNumber(database, repo.ID, pull.Number); !errors.Is(err, ErrNotFound) {
+ t.Errorf("pull after delete err = %v, want ErrNotFound", err)
+ }
+ comments, _ := ListPullComments(database, pull.ID, true)
+ if len(comments) != 0 {
+ t.Errorf("comments after cascade = %d, want 0", len(comments))
+ }
+}
+
+func TestPullCommentsModeration(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ pull, _ := CreatePull(database, repo.ID, "t", "", "main", "feature", ownerID, "josie", "", false)
+
+ ownerComment, _ := CreatePullComment(database, pull.ID, "owner", ownerID, "josie", "", false)
+ guestComment, _ := CreatePullComment(database, pull.ID, "guest", 0, "guest", "", true)
+
+ public, _ := ListPullComments(database, pull.ID, false)
+ if len(public) != 1 || public[0].ID != ownerComment.ID {
+ t.Errorf("public comments = %+v, want only the owner one", public)
+ }
+ if err := ApprovePullComment(database, pull.ID, guestComment.ID); err != nil {
+ t.Fatalf("ApprovePullComment: %v", err)
+ }
+ if pending, _ := CountPending(database, repo.ID); pending != 0 {
+ t.Errorf("CountPending after approve = %d, want 0", pending)
+ }
+ if err := DeletePullComment(database, pull.ID, guestComment.ID); err != nil {
+ t.Fatalf("DeletePullComment: %v", err)
+ }
+ if err := ApprovePullComment(database, pull.ID+999, ownerComment.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("approve wrong pull err = %v, want ErrNotFound", err)
+ }
+}
diff --git a/internal/db/releases.go b/internal/db/releases.go
new file mode 100644
index 0000000..4592775
--- /dev/null
+++ b/internal/db/releases.go
@@ -0,0 +1,148 @@
+package db
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+)
+
+// ErrReleaseExists marks a tag that already has a release in the repo.
+var ErrReleaseExists = errors.New("release already exists for tag")
+
+// Release is a row in the releases table: a title and markdown notes
+// attached to a tag that already exists in the repository.
+type Release struct {
+ ID int64
+ RepoID int64
+ Tag string
+ Title string
+ Notes string
+ AuthorID sql.NullInt64
+ CreatedAt int64
+}
+
+// ReleaseAsset is a file uploaded to a release. StoredName is the opaque
+// name on disk; Filename is the name shown to users.
+type ReleaseAsset struct {
+ ID int64
+ ReleaseID int64
+ Filename string
+ StoredName string
+ Size int64
+ CreatedAt int64
+}
+
+const releaseColumns = `SELECT id, repo_id, tag, title, notes, author_id, created_at FROM releases WHERE `
+
+// CreateRelease stores a release for repoID's tag. A tag may have at most
+// one release (UNIQUE(repo_id, tag)); a duplicate returns ErrReleaseExists.
+func CreateRelease(database *sql.DB, repoID int64, tag, title, notes string, authorID int64) (Release, error) {
+ id, err := execLastID(database,
+ `INSERT INTO releases (repo_id, tag, title, notes, author_id) VALUES (?, ?, ?, ?, ?)`,
+ repoID, tag, title, notes, nullableID(authorID))
+ if err != nil {
+ if isUniqueViolation(err) {
+ return Release{}, fmt.Errorf("create release %s: %w", tag, ErrReleaseExists)
+ }
+ return Release{}, fmt.Errorf("create release: %w", err)
+ }
+ return GetReleaseByID(database, id)
+}
+
+// GetReleaseByID looks up a release by primary key.
+func GetReleaseByID(database *sql.DB, id int64) (Release, error) {
+ return getRelease(database, releaseColumns+`id = ?`, id)
+}
+
+// GetReleaseByTag looks up a repo's release by tag.
+func GetReleaseByTag(database *sql.DB, repoID int64, tag string) (Release, error) {
+ return getRelease(database, releaseColumns+`repo_id = ? AND tag = ?`, repoID, tag)
+}
+
+// scanRelease reads one releases row (see releaseColumns) into r.
+func scanRelease(s rowScanner, r *Release) error {
+ return s.Scan(&r.ID, &r.RepoID, &r.Tag, &r.Title, &r.Notes, &r.AuthorID, &r.CreatedAt)
+}
+
+func getRelease(database *sql.DB, query string, args ...any) (Release, error) {
+ var r Release
+ err := scanRelease(database.QueryRow(query, args...), &r)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Release{}, fmt.Errorf("release: %w", ErrNotFound)
+ }
+ if err != nil {
+ return Release{}, fmt.Errorf("get release: %w", err)
+ }
+ return r, nil
+}
+
+// ListReleases returns a repo's releases, newest first.
+func ListReleases(database *sql.DB, repoID int64) ([]Release, error) {
+ releases, err := listQuery(database,
+ releaseColumns+`repo_id = ? ORDER BY created_at DESC, id DESC`, []any{repoID}, scanRelease)
+ if err != nil {
+ return nil, fmt.Errorf("list releases: %w", err)
+ }
+ return releases, nil
+}
+
+// DeleteRelease removes a release, scoped to its repo; its assets cascade.
+func DeleteRelease(database *sql.DB, repoID, id int64) error {
+ return execScoped(database, "delete release",
+ `DELETE FROM releases WHERE id = ? AND repo_id = ?`, id, repoID)
+}
+
+const releaseAssetColumns = `SELECT id, release_id, filename, stored_name, size, created_at FROM release_assets WHERE `
+
+// CreateReleaseAsset records an uploaded file on a release.
+func CreateReleaseAsset(database *sql.DB, releaseID int64, filename, storedName string, size int64) (ReleaseAsset, error) {
+ id, err := execLastID(database,
+ `INSERT INTO release_assets (release_id, filename, stored_name, size) VALUES (?, ?, ?, ?)`,
+ releaseID, filename, storedName, size)
+ if err != nil {
+ return ReleaseAsset{}, fmt.Errorf("create release asset: %w", err)
+ }
+ return getReleaseAsset(database, releaseAssetColumns+`id = ?`, id)
+}
+
+// scanReleaseAsset reads one release_assets row into a.
+func scanReleaseAsset(s rowScanner, a *ReleaseAsset) error {
+ return s.Scan(&a.ID, &a.ReleaseID, &a.Filename, &a.StoredName, &a.Size, &a.CreatedAt)
+}
+
+func getReleaseAsset(database *sql.DB, query string, args ...any) (ReleaseAsset, error) {
+ var a ReleaseAsset
+ err := scanReleaseAsset(database.QueryRow(query, args...), &a)
+ if errors.Is(err, sql.ErrNoRows) {
+ return ReleaseAsset{}, fmt.Errorf("release asset: %w", ErrNotFound)
+ }
+ if err != nil {
+ return ReleaseAsset{}, fmt.Errorf("get release asset: %w", err)
+ }
+ return a, nil
+}
+
+// ListReleaseAssets returns a release's assets oldest first.
+func ListReleaseAssets(database *sql.DB, releaseID int64) ([]ReleaseAsset, error) {
+ assets, err := listQuery(database,
+ releaseAssetColumns+`release_id = ? ORDER BY created_at, id`, []any{releaseID}, scanReleaseAsset)
+ if err != nil {
+ return nil, fmt.Errorf("list release assets: %w", err)
+ }
+ return assets, nil
+}
+
+// GetReleaseAssetForRepo looks up an asset scoped to a repo, so a download
+// cannot reach another repo's release by guessing an id.
+func GetReleaseAssetForRepo(database *sql.DB, repoID, id int64) (ReleaseAsset, error) {
+ return getReleaseAsset(database,
+ `SELECT a.id, a.release_id, a.filename, a.stored_name, a.size, a.created_at
+ FROM release_assets a JOIN releases r ON r.id = a.release_id
+ WHERE a.id = ? AND r.repo_id = ?`, id, repoID)
+}
+
+// DeleteReleaseAsset removes an asset, scoped to its release.
+func DeleteReleaseAsset(database *sql.DB, releaseID, id int64) error {
+ return execScoped(database, "delete release asset",
+ `DELETE FROM release_assets WHERE id = ? AND release_id = ?`, id, releaseID)
+}
diff --git a/internal/db/releases_test.go b/internal/db/releases_test.go
new file mode 100644
index 0000000..b76de25
--- /dev/null
+++ b/internal/db/releases_test.go
@@ -0,0 +1,99 @@
+package db
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestCreateReleaseAndLookup(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ other, _ := CreateRepo(database, ownerID, "beta", "", "public")
+
+ first, err := CreateRelease(database, repo.ID, "v1.0.0", "One", "notes **one**", ownerID)
+ if err != nil {
+ t.Fatalf("CreateRelease: %v", err)
+ }
+ if first.Tag != "v1.0.0" || first.Title != "One" || !first.AuthorID.Valid || first.AuthorID.Int64 != ownerID {
+ t.Errorf("release = %+v, want tag/title/author set", first)
+ }
+
+ second, err := CreateRelease(database, repo.ID, "v1.0.1", "Two", "notes two", ownerID)
+ if err != nil {
+ t.Fatalf("CreateRelease second: %v", err)
+ }
+
+ if got, err := GetReleaseByID(database, first.ID); err != nil || got.Tag != "v1.0.0" {
+ t.Errorf("GetReleaseByID = (%+v, %v), want v1.0.0", got, err)
+ }
+ if got, err := GetReleaseByTag(database, repo.ID, "v1.0.0"); err != nil || got.ID != first.ID {
+ t.Errorf("GetReleaseByTag = (%+v, %v), want id %d", got, err, first.ID)
+ }
+ if _, err := GetReleaseByTag(database, other.ID, "v1.0.0"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("cross-repo GetReleaseByTag err = %v, want ErrNotFound", err)
+ }
+
+ list, err := ListReleases(database, repo.ID)
+ if err != nil {
+ t.Fatalf("ListReleases: %v", err)
+ }
+ if len(list) != 2 || list[0].ID != second.ID {
+ t.Errorf("ListReleases = %+v, want newest (v1.0.1) first", list)
+ }
+
+ if _, err := CreateRelease(database, repo.ID, "v1.0.0", "dupe", "", ownerID); !errors.Is(err, ErrReleaseExists) {
+ t.Errorf("duplicate CreateRelease err = %v, want ErrReleaseExists", err)
+ }
+}
+
+func TestDeleteReleaseScoped(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ other, _ := CreateRepo(database, ownerID, "beta", "", "public")
+ release, _ := CreateRelease(database, repo.ID, "v1", "One", "", ownerID)
+
+ if err := DeleteRelease(database, other.ID, release.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("cross-repo DeleteRelease err = %v, want ErrNotFound", err)
+ }
+ if err := DeleteRelease(database, repo.ID, release.ID); err != nil {
+ t.Fatalf("DeleteRelease: %v", err)
+ }
+ if _, err := GetReleaseByID(database, release.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("release after delete err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestReleaseAssets(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, _ := CreateUser(database, "josie", "hash")
+ repo, _ := CreateRepo(database, ownerID, "alpha", "", "public")
+ other, _ := CreateRepo(database, ownerID, "beta", "", "public")
+ release, _ := CreateRelease(database, repo.ID, "v1", "One", "", ownerID)
+
+ asset, err := CreateReleaseAsset(database, release.ID, "simplegit-linux", "deadbeef", 12345)
+ if err != nil {
+ t.Fatalf("CreateReleaseAsset: %v", err)
+ }
+ if asset.Filename != "simplegit-linux" || asset.StoredName != "deadbeef" || asset.Size != 12345 {
+ t.Errorf("asset = %+v", asset)
+ }
+
+ assets, err := ListReleaseAssets(database, release.ID)
+ if err != nil || len(assets) != 1 {
+ t.Fatalf("ListReleaseAssets = (%+v, %v), want one", assets, err)
+ }
+ if got, err := GetReleaseAssetForRepo(database, repo.ID, asset.ID); err != nil || got.ID != asset.ID {
+ t.Errorf("GetReleaseAssetForRepo = (%+v, %v), want id %d", got, err, asset.ID)
+ }
+ if _, err := GetReleaseAssetForRepo(database, other.ID, asset.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("cross-repo GetReleaseAssetForRepo err = %v, want ErrNotFound", err)
+ }
+ if err := DeleteReleaseAsset(database, release.ID, asset.ID); err != nil {
+ t.Fatalf("DeleteReleaseAsset: %v", err)
+ }
+ if _, err := GetReleaseAssetForRepo(database, repo.ID, asset.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("asset after delete err = %v, want ErrNotFound", err)
+ }
+}
diff --git a/internal/db/repos.go b/internal/db/repos.go
new file mode 100644
index 0000000..d1853b8
--- /dev/null
+++ b/internal/db/repos.go
@@ -0,0 +1,125 @@
+package db
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+)
+
+// Repo is a row in the repos table.
+type Repo struct {
+ ID int64
+ OwnerID int64
+ Name string
+ Description string
+ Visibility string
+ DefaultBranch string
+ PushedAt sql.NullInt64
+}
+
+// CreateRepo inserts a repo row relying on column defaults for
+// default_branch and created_at, then returns the stored row.
+func CreateRepo(database *sql.DB, ownerID int64, name, description, visibility string) (Repo, error) {
+ id, err := execLastID(database,
+ `INSERT INTO repos (owner_id, name, description, visibility) VALUES (?, ?, ?, ?)`,
+ ownerID, name, description, visibility)
+ if err != nil {
+ return Repo{}, fmt.Errorf("create repo %s: %w", name, err)
+ }
+ return GetRepoByID(database, id)
+}
+
+const repoColumns = `SELECT id, owner_id, name, description, visibility, default_branch, pushed_at FROM repos WHERE `
+
+// scanRepo reads one repos row (see repoColumns) into repo.
+func scanRepo(s rowScanner, repo *Repo) error {
+ return s.Scan(&repo.ID, &repo.OwnerID, &repo.Name, &repo.Description,
+ &repo.Visibility, &repo.DefaultBranch, &repo.PushedAt)
+}
+
+// GetRepoByID looks up a repo by primary key.
+func GetRepoByID(database *sql.DB, id int64) (Repo, error) {
+ var repo Repo
+ err := scanRepo(database.QueryRow(repoColumns+`id = ?`, id), &repo)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Repo{}, fmt.Errorf("repo %d: %w", id, ErrNotFound)
+ }
+ if err != nil {
+ return Repo{}, fmt.Errorf("get repo %d: %w", id, err)
+ }
+ return repo, nil
+}
+
+// GetRepoByName looks up a repo by owner username and repo name.
+func GetRepoByName(database *sql.DB, ownerName, repoName string) (Repo, error) {
+ var repo Repo
+ err := scanRepo(database.QueryRow(
+ repoColumns+`owner_id = (SELECT id FROM users WHERE username = ?) AND name = ?`,
+ ownerName, repoName), &repo)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Repo{}, fmt.Errorf("repo %s/%s: %w", ownerName, repoName, ErrNotFound)
+ }
+ if err != nil {
+ return Repo{}, fmt.Errorf("get repo %s/%s: %w", ownerName, repoName, err)
+ }
+ return repo, nil
+}
+
+// DeleteRepo removes a repo row; its bare directory on disk is the
+// caller's responsibility.
+func DeleteRepo(database *sql.DB, id int64) error {
+ return execScoped(database, fmt.Sprintf("delete repo %d", id),
+ `DELETE FROM repos WHERE id = ?`, id)
+}
+
+// UpdateRepoVisibility flips a repo between public and private.
+func UpdateRepoVisibility(database *sql.DB, id int64, visibility string) error {
+ return execScoped(database, fmt.Sprintf("update repo %d visibility", id),
+ `UPDATE repos SET visibility = ? WHERE id = ?`, visibility, id)
+}
+
+// RenameRepo changes a repo's name; the caller moves the directory on disk.
+func RenameRepo(database *sql.DB, id int64, name string) error {
+ return execScoped(database, fmt.Sprintf("rename repo %d", id),
+ `UPDATE repos SET name = ? WHERE id = ?`, name, id)
+}
+
+// RecordPush updates a repo's pushed_at and, when defaultBranch is
+// non-empty, its default_branch. It returns ErrNotFound when no row
+// matches (a push to a repo with no metadata row).
+func RecordPush(database *sql.DB, ownerName, repoName string, pushedAt int64, defaultBranch string) error {
+ query := `UPDATE repos SET pushed_at = ? WHERE owner_id = (SELECT id FROM users WHERE username = ?) AND name = ?`
+ args := []any{pushedAt, ownerName, repoName}
+ if defaultBranch != "" {
+ query = `UPDATE repos SET pushed_at = ?, default_branch = ? WHERE owner_id = (SELECT id FROM users WHERE username = ?) AND name = ?`
+ args = []any{pushedAt, defaultBranch, ownerName, repoName}
+ }
+ return execScoped(database, fmt.Sprintf("record push %s/%s", ownerName, repoName), query, args...)
+}
+
+// RepoView is a repo row plus its owner's username, for listings.
+type RepoView struct {
+ Repo
+ OwnerName string
+}
+
+// ListRepos returns public repos plus, when viewerID is non-zero, that
+// user's own repos. Ordered by owner then name.
+func ListRepos(database *sql.DB, viewerID int64) ([]RepoView, error) {
+ repos, err := listQuery(database,
+ `SELECT r.id, r.owner_id, r.name, r.description, r.visibility,
+ r.default_branch, r.pushed_at, u.username
+ FROM repos r JOIN users u ON u.id = r.owner_id
+ WHERE r.visibility = 'public' OR r.owner_id = ?
+ ORDER BY u.username, r.name`, []any{viewerID}, scanRepoView)
+ if err != nil {
+ return nil, fmt.Errorf("list repos: %w", err)
+ }
+ return repos, nil
+}
+
+// scanRepoView reads one repos+owner row (see ListRepos) into view.
+func scanRepoView(s rowScanner, view *RepoView) error {
+ return s.Scan(&view.ID, &view.OwnerID, &view.Name, &view.Description,
+ &view.Visibility, &view.DefaultBranch, &view.PushedAt, &view.OwnerName)
+}
diff --git a/internal/db/repos_test.go b/internal/db/repos_test.go
new file mode 100644
index 0000000..6c569ae
--- /dev/null
+++ b/internal/db/repos_test.go
@@ -0,0 +1,150 @@
+package db
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestCreateAndGetRepo(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+
+ repo, err := CreateRepo(database, ownerID, "demo", "desc", "public")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ if repo.ID == 0 || repo.OwnerID != ownerID {
+ t.Errorf("repo IDs = %+v, want nonzero id and owner %d", repo, ownerID)
+ }
+ if repo.DefaultBranch != "main" || repo.Visibility != "public" {
+ t.Errorf("repo = %+v, want column defaults main/public", repo)
+ }
+ if repo.PushedAt.Valid {
+ t.Errorf("PushedAt = %d, want NULL", repo.PushedAt.Int64)
+ }
+
+ byID, err := GetRepoByID(database, repo.ID)
+ if err != nil {
+ t.Fatalf("GetRepoByID: %v", err)
+ }
+ if byID.Name != "demo" {
+ t.Errorf("GetRepoByID name = %q, want demo", byID.Name)
+ }
+ byName, err := GetRepoByName(database, "josie", "demo")
+ if err != nil {
+ t.Fatalf("GetRepoByName: %v", err)
+ }
+ if byName.ID != repo.ID {
+ t.Errorf("GetRepoByName id = %d, want %d", byName.ID, repo.ID)
+ }
+ if _, err := GetRepoByName(database, "josie", "nope"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("missing repo err = %v, want ErrNotFound", err)
+ }
+ if _, err := GetRepoByName(database, "nobody", "demo"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("missing owner err = %v, want ErrNotFound", err)
+ }
+ if _, err := CreateRepo(database, ownerID, "demo", "", "private"); err == nil {
+ t.Error("duplicate (owner, name) accepted")
+ }
+}
+
+func TestUpdateRepoVisibilityAndRename(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ repo, err := CreateRepo(database, ownerID, "demo", "", "private")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+
+ if err := UpdateRepoVisibility(database, repo.ID, "public"); err != nil {
+ t.Fatalf("UpdateRepoVisibility: %v", err)
+ }
+ got, err := GetRepoByID(database, repo.ID)
+ if err != nil {
+ t.Fatalf("GetRepoByID: %v", err)
+ }
+ if got.Visibility != "public" {
+ t.Errorf("Visibility = %q, want public", got.Visibility)
+ }
+
+ if err := RenameRepo(database, repo.ID, "renamed"); err != nil {
+ t.Fatalf("RenameRepo: %v", err)
+ }
+ if _, err := GetRepoByName(database, "josie", "renamed"); err != nil {
+ t.Errorf("renamed repo not found: %v", err)
+ }
+ if _, err := GetRepoByName(database, "josie", "demo"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("old name still resolves: %v", err)
+ }
+
+ if err := UpdateRepoVisibility(database, repo.ID+99, "public"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("visibility on missing repo err = %v, want ErrNotFound", err)
+ }
+ if err := RenameRepo(database, repo.ID+99, "x"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("rename missing repo err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestRecordPush(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if _, err := CreateRepo(database, ownerID, "demo", "", "private"); err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+
+ if err := RecordPush(database, "josie", "demo", 12345, ""); err != nil {
+ t.Fatalf("RecordPush touch: %v", err)
+ }
+ repo, err := GetRepoByName(database, "josie", "demo")
+ if err != nil {
+ t.Fatalf("GetRepoByName: %v", err)
+ }
+ if !repo.PushedAt.Valid || repo.PushedAt.Int64 != 12345 {
+ t.Errorf("PushedAt = %+v, want 12345", repo.PushedAt)
+ }
+ if repo.DefaultBranch != "main" {
+ t.Errorf("DefaultBranch = %q, want unchanged main", repo.DefaultBranch)
+ }
+
+ if err := RecordPush(database, "josie", "demo", 12346, "master"); err != nil {
+ t.Fatalf("RecordPush default: %v", err)
+ }
+ repo, err = GetRepoByName(database, "josie", "demo")
+ if err != nil {
+ t.Fatalf("GetRepoByName: %v", err)
+ }
+ if repo.DefaultBranch != "master" || repo.PushedAt.Int64 != 12346 {
+ t.Errorf("repo = %+v, want default master and pushed_at 12346", repo)
+ }
+
+ if err := RecordPush(database, "josie", "nope", 1, ""); !errors.Is(err, ErrNotFound) {
+ t.Errorf("RecordPush missing repo err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestDeleteRepo(t *testing.T) {
+ database := openTestDB(t)
+ ownerID, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ repo, err := CreateRepo(database, ownerID, "demo", "", "private")
+ if err != nil {
+ t.Fatalf("CreateRepo: %v", err)
+ }
+ if err := DeleteRepo(database, repo.ID); err != nil {
+ t.Fatalf("DeleteRepo: %v", err)
+ }
+ if _, err := GetRepoByID(database, repo.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("deleted repo err = %v, want ErrNotFound", err)
+ }
+}
diff --git a/internal/db/sessions.go b/internal/db/sessions.go
new file mode 100644
index 0000000..d5f9c9e
--- /dev/null
+++ b/internal/db/sessions.go
@@ -0,0 +1,73 @@
+package db
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+)
+
+// Session is a row in the sessions table.
+type Session struct {
+ Token string
+ UserID int64
+ ExpiresAt int64
+}
+
+// CreateSession stores a new login session for user userID.
+func CreateSession(database *sql.DB, token string, userID, expiresAt int64) error {
+ _, err := database.Exec(
+ `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`,
+ token, userID, expiresAt,
+ )
+ if err != nil {
+ return fmt.Errorf("create session: %w", err)
+ }
+ return nil
+}
+
+// GetSession looks up a session by token, returning ErrNotFound for
+// unknown or expired tokens alike.
+func GetSession(database *sql.DB, token string) (Session, error) {
+ var session Session
+ err := database.QueryRow(
+ `SELECT token, user_id, expires_at FROM sessions
+ WHERE token = ? AND expires_at > unixepoch()`,
+ token,
+ ).Scan(&session.Token, &session.UserID, &session.ExpiresAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Session{}, fmt.Errorf("session: %w", ErrNotFound)
+ }
+ if err != nil {
+ return Session{}, fmt.Errorf("get session: %w", err)
+ }
+ return session, nil
+}
+
+// DeleteSession removes a session at logout.
+func DeleteSession(database *sql.DB, token string) error {
+ _, err := database.Exec(`DELETE FROM sessions WHERE token = ?`, token)
+ if err != nil {
+ return fmt.Errorf("delete session: %w", err)
+ }
+ return nil
+}
+
+// DeleteOtherSessions revokes every session for a user except keepToken,
+// so a password change signs other browsers out.
+func DeleteOtherSessions(database *sql.DB, userID int64, keepToken string) error {
+ _, err := database.Exec(
+ `DELETE FROM sessions WHERE user_id = ? AND token != ?`, userID, keepToken)
+ if err != nil {
+ return fmt.Errorf("delete other sessions for user %d: %w", userID, err)
+ }
+ return nil
+}
+
+// DeleteExpiredSessions purges sessions whose expiry has passed; callers
+// use it as opportunistic housekeeping, so no error on zero rows.
+func DeleteExpiredSessions(database *sql.DB) error {
+ if _, err := database.Exec(`DELETE FROM sessions WHERE expires_at <= unixepoch()`); err != nil {
+ return fmt.Errorf("delete expired sessions: %w", err)
+ }
+ return nil
+}
diff --git a/internal/db/tokens.go b/internal/db/tokens.go
new file mode 100644
index 0000000..49a69c9
--- /dev/null
+++ b/internal/db/tokens.go
@@ -0,0 +1,77 @@
+package db
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+)
+
+// Token is a row in the tokens table. The plaintext is never stored.
+type Token struct {
+ ID int64
+ UserID int64
+ Name string
+ Hint string
+ CreatedAt int64
+ LastUsedAt sql.NullInt64
+}
+
+const tokenColumns = `SELECT id, user_id, name, hint, created_at, last_used_at FROM tokens WHERE `
+
+// CreateToken stores a git token (tokenHash is a digest; hint a display
+// prefix) and returns the stored row.
+func CreateToken(database *sql.DB, userID int64, name, hint, tokenHash string) (Token, error) {
+ id, err := execLastID(database,
+ `INSERT INTO tokens (user_id, name, hint, token_hash) VALUES (?, ?, ?, ?)`,
+ userID, name, hint, tokenHash)
+ if err != nil {
+ return Token{}, fmt.Errorf("create token: %w", err)
+ }
+ return getToken(database, tokenColumns+`id = ?`, id)
+}
+
+// GetTokenByHash looks up a token by its digest; ErrNotFound when absent.
+func GetTokenByHash(database *sql.DB, tokenHash string) (Token, error) {
+ return getToken(database, tokenColumns+`token_hash = ?`, tokenHash)
+}
+
+// scanToken reads one tokens row (see tokenColumns) into tok.
+func scanToken(s rowScanner, tok *Token) error {
+ return s.Scan(&tok.ID, &tok.UserID, &tok.Name, &tok.Hint, &tok.CreatedAt, &tok.LastUsedAt)
+}
+
+func getToken(database *sql.DB, query string, arg any) (Token, error) {
+ var tok Token
+ err := scanToken(database.QueryRow(query, arg), &tok)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Token{}, fmt.Errorf("token: %w", ErrNotFound)
+ }
+ if err != nil {
+ return Token{}, fmt.Errorf("get token: %w", err)
+ }
+ return tok, nil
+}
+
+// ListTokens returns a user's tokens, newest first.
+func ListTokens(database *sql.DB, userID int64) ([]Token, error) {
+ tokens, err := listQuery(database,
+ tokenColumns+`user_id = ? ORDER BY created_at DESC, id DESC`, []any{userID}, scanToken)
+ if err != nil {
+ return nil, fmt.Errorf("list tokens: %w", err)
+ }
+ return tokens, nil
+}
+
+// TouchToken records that a token was just used.
+func TouchToken(database *sql.DB, id int64) error {
+ if _, err := database.Exec(`UPDATE tokens SET last_used_at = unixepoch() WHERE id = ?`, id); err != nil {
+ return fmt.Errorf("touch token %d: %w", id, err)
+ }
+ return nil
+}
+
+// DeleteToken revokes a token, scoped to its owner.
+func DeleteToken(database *sql.DB, userID, id int64) error {
+ return execScoped(database, fmt.Sprintf("delete token %d", id),
+ `DELETE FROM tokens WHERE id = ? AND user_id = ?`, id, userID)
+}
diff --git a/internal/db/tokens_test.go b/internal/db/tokens_test.go
new file mode 100644
index 0000000..6c7c16b
--- /dev/null
+++ b/internal/db/tokens_test.go
@@ -0,0 +1,86 @@
+package db
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestTokenLifecycle(t *testing.T) {
+ database := openTestDB(t)
+ userID, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+
+ tok, err := CreateToken(database, userID, "laptop", "sg_abcd123", "hash-1")
+ if err != nil {
+ t.Fatalf("CreateToken: %v", err)
+ }
+ if tok.ID == 0 || tok.UserID != userID || tok.Name != "laptop" || tok.Hint != "sg_abcd123" {
+ t.Errorf("token = %+v, want populated row", tok)
+ }
+ if tok.LastUsedAt.Valid {
+ t.Errorf("LastUsedAt = %+v, want NULL", tok.LastUsedAt)
+ }
+
+ byHash, err := GetTokenByHash(database, "hash-1")
+ if err != nil {
+ t.Fatalf("GetTokenByHash: %v", err)
+ }
+ if byHash.ID != tok.ID {
+ t.Errorf("GetTokenByHash id = %d, want %d", byHash.ID, tok.ID)
+ }
+ if _, err := GetTokenByHash(database, "nope"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("missing hash err = %v, want ErrNotFound", err)
+ }
+
+ if err := TouchToken(database, tok.ID); err != nil {
+ t.Fatalf("TouchToken: %v", err)
+ }
+ byHash, err = GetTokenByHash(database, "hash-1")
+ if err != nil {
+ t.Fatalf("GetTokenByHash after touch: %v", err)
+ }
+ if !byHash.LastUsedAt.Valid {
+ t.Error("LastUsedAt still NULL after TouchToken")
+ }
+
+ if _, err := CreateToken(database, userID, "dup", "sg_x", "hash-1"); err == nil {
+ t.Error("duplicate token_hash accepted, want UNIQUE error")
+ }
+
+ tokens, err := ListTokens(database, userID)
+ if err != nil {
+ t.Fatalf("ListTokens: %v", err)
+ }
+ if len(tokens) != 1 || tokens[0].ID != tok.ID {
+ t.Errorf("ListTokens = %+v, want the one token", tokens)
+ }
+
+ if err := DeleteToken(database, userID+1, tok.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("DeleteToken by non-owner err = %v, want ErrNotFound", err)
+ }
+ if err := DeleteToken(database, userID, tok.ID); err != nil {
+ t.Fatalf("DeleteToken: %v", err)
+ }
+ if _, err := GetTokenByHash(database, "hash-1"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("token still present after delete: %v", err)
+ }
+}
+
+func TestTokenCascadeOnUserDelete(t *testing.T) {
+ database := openTestDB(t)
+ userID, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if _, err := CreateToken(database, userID, "laptop", "sg_abcd123", "hash-1"); err != nil {
+ t.Fatalf("CreateToken: %v", err)
+ }
+ if _, err := database.Exec(`DELETE FROM users WHERE id = ?`, userID); err != nil {
+ t.Fatalf("delete user: %v", err)
+ }
+ if _, err := GetTokenByHash(database, "hash-1"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("token survived user delete: %v", err)
+ }
+}
diff --git a/internal/db/users.go b/internal/db/users.go
new file mode 100644
index 0000000..4dc9964
--- /dev/null
+++ b/internal/db/users.go
@@ -0,0 +1,66 @@
+package db
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+)
+
+// ErrNotFound is returned when a lookup by key matches no row.
+var ErrNotFound = errors.New("not found")
+
+// User is a row in the users table.
+type User struct {
+ ID int64
+ Username string
+ PasswordHash string
+}
+
+// CreateUser inserts a user and returns its id.
+func CreateUser(database *sql.DB, username, passwordHash string) (int64, error) {
+ id, err := execLastID(database,
+ `INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, passwordHash)
+ if err != nil {
+ return 0, fmt.Errorf("create user %s: %w", username, err)
+ }
+ return id, nil
+}
+
+// scanUser reads one users row into user.
+func scanUser(s rowScanner, user *User) error {
+ return s.Scan(&user.ID, &user.Username, &user.PasswordHash)
+}
+
+// GetUserByName looks up a user by their unique username.
+func GetUserByName(database *sql.DB, username string) (User, error) {
+ var user User
+ err := scanUser(database.QueryRow(
+ `SELECT id, username, password_hash FROM users WHERE username = ?`, username), &user)
+ if errors.Is(err, sql.ErrNoRows) {
+ return User{}, fmt.Errorf("user %s: %w", username, ErrNotFound)
+ }
+ if err != nil {
+ return User{}, fmt.Errorf("get user %s: %w", username, err)
+ }
+ return user, nil
+}
+
+// GetUserByID looks up a user by primary key.
+func GetUserByID(database *sql.DB, id int64) (User, error) {
+ var user User
+ err := scanUser(database.QueryRow(
+ `SELECT id, username, password_hash FROM users WHERE id = ?`, id), &user)
+ if errors.Is(err, sql.ErrNoRows) {
+ return User{}, fmt.Errorf("user %d: %w", id, ErrNotFound)
+ }
+ if err != nil {
+ return User{}, fmt.Errorf("get user %d: %w", id, err)
+ }
+ return user, nil
+}
+
+// UpdateUserPassword replaces a user's password hash.
+func UpdateUserPassword(database *sql.DB, id int64, passwordHash string) error {
+ return execScoped(database, fmt.Sprintf("update password for user %d", id),
+ `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, id)
+}
diff --git a/internal/db/users_sessions_test.go b/internal/db/users_sessions_test.go
new file mode 100644
index 0000000..c95f119
--- /dev/null
+++ b/internal/db/users_sessions_test.go
@@ -0,0 +1,161 @@
+package db
+
+import (
+ "database/sql"
+ "errors"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func openTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+ database, err := Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ t.Cleanup(func() { database.Close() })
+ return database
+}
+
+func TestGetUserByName(t *testing.T) {
+ database := openTestDB(t)
+ id, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+
+ user, err := GetUserByName(database, "josie")
+ if err != nil {
+ t.Fatalf("GetUserByName: %v", err)
+ }
+ if user.ID != id || user.PasswordHash != "hash" {
+ t.Errorf("user = %+v, want id %d and stored hash", user, id)
+ }
+ if _, err := GetUserByName(database, "nobody"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("missing user err = %v, want ErrNotFound", err)
+ }
+ if _, err := CreateUser(database, "josie", "other"); err == nil {
+ t.Error("duplicate username accepted")
+ }
+}
+
+func TestGetUserByID(t *testing.T) {
+ database := openTestDB(t)
+ id, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ user, err := GetUserByID(database, id)
+ if err != nil {
+ t.Fatalf("GetUserByID: %v", err)
+ }
+ if user.Username != "josie" {
+ t.Errorf("username = %q, want josie", user.Username)
+ }
+ if _, err := GetUserByID(database, 999); !errors.Is(err, ErrNotFound) {
+ t.Errorf("missing user err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestSessionLifecycle(t *testing.T) {
+ database := openTestDB(t)
+ id, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+
+ if err := CreateSession(database, "tok", id, time.Now().Add(time.Hour).Unix()); err != nil {
+ t.Fatalf("CreateSession: %v", err)
+ }
+ session, err := GetSession(database, "tok")
+ if err != nil {
+ t.Fatalf("GetSession: %v", err)
+ }
+ if session.UserID != id {
+ t.Errorf("session.UserID = %d, want %d", session.UserID, id)
+ }
+
+ if err := DeleteSession(database, "tok"); err != nil {
+ t.Fatalf("DeleteSession: %v", err)
+ }
+ if _, err := GetSession(database, "tok"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("deleted session err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestExpiredSessionNotReturned(t *testing.T) {
+ database := openTestDB(t)
+ id, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if err := CreateSession(database, "old", id, time.Now().Add(-time.Hour).Unix()); err != nil {
+ t.Fatalf("CreateSession: %v", err)
+ }
+ if _, err := GetSession(database, "old"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("expired session err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestSessionsCascadeWithUser(t *testing.T) {
+ database := openTestDB(t)
+ id, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if err := CreateSession(database, "tok", id, time.Now().Add(time.Hour).Unix()); err != nil {
+ t.Fatalf("CreateSession: %v", err)
+ }
+ if _, err := database.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil {
+ t.Fatalf("delete user: %v", err)
+ }
+ if _, err := GetSession(database, "tok"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("orphaned session err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestUpdateUserPassword(t *testing.T) {
+ database := openTestDB(t)
+ id, err := CreateUser(database, "josie", "old-hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if err := UpdateUserPassword(database, id, "new-hash"); err != nil {
+ t.Fatalf("UpdateUserPassword: %v", err)
+ }
+ user, err := GetUserByID(database, id)
+ if err != nil {
+ t.Fatalf("GetUserByID: %v", err)
+ }
+ if user.PasswordHash != "new-hash" {
+ t.Errorf("PasswordHash = %q, want new-hash", user.PasswordHash)
+ }
+ if err := UpdateUserPassword(database, id+1, "x"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("missing user err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestDeleteOtherSessions(t *testing.T) {
+ database := openTestDB(t)
+ id, err := CreateUser(database, "josie", "hash")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ for _, token := range []string{"keep", "drop-1", "drop-2"} {
+ if err := CreateSession(database, token, id, 9999999999); err != nil {
+ t.Fatalf("CreateSession %s: %v", token, err)
+ }
+ }
+ if err := DeleteOtherSessions(database, id, "keep"); err != nil {
+ t.Fatalf("DeleteOtherSessions: %v", err)
+ }
+ if _, err := GetSession(database, "keep"); err != nil {
+ t.Errorf("kept session gone: %v", err)
+ }
+ for _, token := range []string{"drop-1", "drop-2"} {
+ if _, err := GetSession(database, token); !errors.Is(err, ErrNotFound) {
+ t.Errorf("session %s err = %v, want ErrNotFound", token, err)
+ }
+ }
+}
diff --git a/internal/git/backend.go b/internal/git/backend.go
new file mode 100644
index 0000000..2fcf6dd
--- /dev/null
+++ b/internal/git/backend.go
@@ -0,0 +1,152 @@
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+)
+
+// ServeBackend execs `git http-backend` as a CGI for req, translating it
+// into the environment http-backend expects: GIT_PROJECT_ROOT is
+// projectRoot, PATH_INFO is the request path, and remoteUser (when the
+// caller has authenticated someone) is passed as REMOTE_USER. service is
+// the single git service the caller has authorized; it becomes
+// QUERY_STRING, so the raw request query cannot smuggle in a second
+// service that re-routes the CGI. The CGI response — including any Status
+// header — is written to w. The returned error only reports exec/pipe
+// failures; a 404 from http-backend is a successful response.
+func ServeBackend(projectRoot, remoteUser, service string, req *http.Request, w http.ResponseWriter) error {
+ query := ""
+ if service != "" {
+ query = "service=" + url.QueryEscape(service)
+ }
+ env := []string{
+ "GIT_PROJECT_ROOT=" + projectRoot,
+ "GIT_HTTP_EXPORT_ALL=1",
+ "GATEWAY_INTERFACE=CGI/1.1",
+ "SERVER_PROTOCOL=" + req.Proto,
+ "REQUEST_METHOD=" + req.Method,
+ "PATH_INFO=" + req.URL.Path,
+ "QUERY_STRING=" + query,
+ "CONTENT_TYPE=" + req.Header.Get("Content-Type"),
+ "CONTENT_LENGTH=" + req.Header.Get("Content-Length"),
+ "LANG=C",
+ "LC_ALL=C",
+ "PATH=" + os.Getenv("PATH"),
+ }
+ if remoteUser != "" {
+ env = append(env, "REMOTE_USER="+remoteUser)
+ }
+ // The post-receive hook needs to find this binary; ServeBackend is the
+ // only caller that runs receive-pack, so it passes the path explicitly
+ // rather than letting the hook depend on the ambient environment.
+ if self, err := os.Executable(); err == nil {
+ env = append(env, "SIMPLEGIT_BIN="+self)
+ }
+
+ cmd := exec.Command("git", "http-backend")
+ cmd.Env = env
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
+ // The request body is streamed into http-backend's stdin through an
+ // explicit pipe so we can wait for the copy to finish before writing the
+ // response. net/http starts a background reader for the request body as
+ // soon as the handler writes a response; that reader would race this copy
+ // and truncate large pushes (git's receive-pack then waits forever for
+ // the missing pack bytes).
+ var stdinDone chan error
+ if req.Method != http.MethodGet && req.Body != nil {
+ pr, pw, err := os.Pipe()
+ if err != nil {
+ return fmt.Errorf("http-backend stdin pipe: %w", err)
+ }
+ cmd.Stdin = pr
+ stdinDone = make(chan error, 1)
+ go func() {
+ _, copyErr := io.Copy(pw, req.Body)
+ pw.Close()
+ stdinDone <- copyErr
+ }()
+ defer pr.Close()
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("http-backend stdout pipe: %w", err)
+ }
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("start http-backend: %w", err)
+ }
+ if stdinDone != nil {
+ if err := <-stdinDone; err != nil {
+ cmd.Wait()
+ return fmt.Errorf("feed http-backend stdin: %w", err)
+ }
+ }
+
+ br := bufio.NewReader(stdout)
+ status, headers, err := readCGIHeaders(br)
+ if err != nil {
+ cmd.Wait()
+ return fmt.Errorf("read http-backend headers: %w: %s", err, strings.TrimSpace(stderr.String()))
+ }
+ for _, header := range headers {
+ if !strings.EqualFold(header[0], "Transfer-Encoding") {
+ w.Header().Add(header[0], header[1])
+ }
+ }
+ w.WriteHeader(status)
+ if flusher, ok := w.(http.Flusher); ok {
+ flusher.Flush()
+ }
+ if _, err := io.Copy(w, br); err != nil {
+ cmd.Wait()
+ return fmt.Errorf("stream http-backend body: %w", err)
+ }
+ if err := cmd.Wait(); err != nil {
+ return fmt.Errorf("http-backend: %w: %s", err, strings.TrimSpace(stderr.String()))
+ }
+ return nil
+}
+
+// readCGIHeaders parses the CGI header block, pulling the optional Status
+// header out as the HTTP code; everything else is returned verbatim.
+func readCGIHeaders(r *bufio.Reader) (int, [][2]string, error) {
+ status := http.StatusOK
+ var headers [][2]string
+ for {
+ line, err := r.ReadString('\n')
+ line = strings.TrimRight(line, "\r\n")
+ if line == "" {
+ if err != nil {
+ return 0, nil, fmt.Errorf("eof before end of headers: %w", err)
+ }
+ return status, headers, nil
+ }
+ key, value, ok := strings.Cut(line, ":")
+ if !ok {
+ return 0, nil, fmt.Errorf("malformed header line %q", line)
+ }
+ value = strings.TrimSpace(value)
+ if strings.EqualFold(key, "Status") {
+ code, _, _ := strings.Cut(value, " ")
+ parsed, err := strconv.Atoi(code)
+ if err != nil {
+ return 0, nil, fmt.Errorf("bad Status header %q", value)
+ }
+ status = parsed
+ continue
+ }
+ headers = append(headers, [2]string{key, value})
+ if err != nil {
+ return 0, nil, fmt.Errorf("eof mid-headers: %w", err)
+ }
+ }
+}
diff --git a/internal/git/backend_test.go b/internal/git/backend_test.go
new file mode 100644
index 0000000..55246ef
--- /dev/null
+++ b/internal/git/backend_test.go
@@ -0,0 +1,51 @@
+package git
+
+import (
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestServeBackendAdvertisement(t *testing.T) {
+ root := t.TempDir()
+ repoPath := filepath.Join(root, "josie", "demo.git")
+ if err := InitBare(repoPath, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/josie/demo.git/info/refs?service=git-upload-pack", nil)
+ rec := httptest.NewRecorder()
+ if err := ServeBackend(root, "", "git-upload-pack", req, rec); err != nil {
+ t.Fatalf("ServeBackend: %v", err)
+ }
+ if rec.Code != 200 {
+ t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body)
+ }
+ if ct := rec.Header().Get("Content-Type"); ct != "application/x-git-upload-pack-advertisement" {
+ t.Errorf("Content-Type = %q, want upload-pack advertisement", ct)
+ }
+ body := rec.Body.String()
+ if !strings.HasPrefix(body, "001e# service=git-upload-pack\n0000") {
+ t.Errorf("body lacks smart-HTTP service header, starts: %q", body)
+ }
+ if !strings.Contains(body, "0000000000000000000000000000000000000000 capabilities^{}") {
+ t.Errorf("body lacks empty-repo zero-oid advertisement: %q", body)
+ }
+}
+
+func TestServeBackendUnknownRepo(t *testing.T) {
+ root := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(root, "josie"), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ req := httptest.NewRequest("GET", "/josie/missing.git/info/refs?service=git-upload-pack", nil)
+ rec := httptest.NewRecorder()
+ if err := ServeBackend(root, "", "git-upload-pack", req, rec); err != nil {
+ t.Fatalf("ServeBackend: %v", err)
+ }
+ if rec.Code != 404 {
+ t.Errorf("status = %d, want 404 (body %q)", rec.Code, rec.Body)
+ }
+}
diff --git a/internal/git/browse.go b/internal/git/browse.go
new file mode 100644
index 0000000..b4a62c9
--- /dev/null
+++ b/internal/git/browse.go
@@ -0,0 +1,325 @@
+package git
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os/exec"
+ "strconv"
+ "strings"
+)
+
+// TreeEntry is one item in a tree listing at a ref.
+type TreeEntry struct {
+ Mode string
+ Type string // "blob", "tree", or "commit" for a submodule
+ OID string
+ Path string // relative to the repo root
+}
+
+// LsTree lists one directory level of ref. dir is "" for the root;
+// entry paths come back relative to the repo root.
+func LsTree(repoPath, ref, dir string) ([]TreeEntry, error) {
+ args := []string{"ls-tree", "-z", ref}
+ if dir != "" {
+ args = append(args, "--", dir+"/")
+ }
+ cmd := gitCommand(repoPath, args...)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("git ls-tree %v in %s: %w: %s", args, repoPath, err, strings.TrimSpace(stderr.String()))
+ }
+
+ var entries []TreeEntry
+ for _, line := range bytes.Split(out, []byte{0}) {
+ if len(line) == 0 {
+ continue
+ }
+ // "<mode> <type> <oid>\t<path>"
+ meta, path, ok := bytes.Cut(line, []byte{'\t'})
+ if !ok {
+ return nil, fmt.Errorf("git ls-tree: malformed entry %q", line)
+ }
+ fields := strings.Fields(string(meta))
+ if len(fields) != 3 {
+ return nil, fmt.Errorf("git ls-tree: malformed meta %q", meta)
+ }
+ entries = append(entries, TreeEntry{
+ Mode: fields[0], Type: fields[1], OID: fields[2], Path: string(path),
+ })
+ }
+ return entries, nil
+}
+
+// LsTreePaths lists every file path in ref's tree, recursively, in git's
+// tree order. Directories are implied by path segments and not listed.
+func LsTreePaths(repoPath, ref string) ([]string, error) {
+ cmd := gitCommand(repoPath, "ls-tree", "-r", "-z", "--name-only", ref)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("git ls-tree -r %s in %s: %w: %s", ref, repoPath, err, strings.TrimSpace(stderr.String()))
+ }
+ var paths []string
+ for _, p := range bytes.Split(out, []byte{0}) {
+ if len(p) > 0 {
+ paths = append(paths, string(p))
+ }
+ }
+ return paths, nil
+}
+
+// ShowFile returns the contents of path at ref, reading at most limit+1
+// bytes; callers detect oversized content with len(source) > limit.
+func ShowFile(repoPath, ref, path string, limit int) ([]byte, error) {
+ cmd := gitCommand(repoPath, "show", ref+":"+path)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, oversized, err := runBounded(cmd, limit)
+ if oversized {
+ return out, nil
+ }
+ if err != nil {
+ var ee *exec.ExitError
+ if errors.As(err, &ee) && ee.ExitCode() == 128 {
+ return nil, fmt.Errorf("git show %s:%s: %w: %s", ref, path, ErrNotFound, strings.TrimSpace(stderr.String()))
+ }
+ return nil, fmt.Errorf("git show %s:%s: %w: %s", ref, path, err, strings.TrimSpace(stderr.String()))
+ }
+ return out, nil
+}
+
+var ErrNotFound = errors.New("not found")
+
+// RefExists reports whether ref resolves in the repository (an empty
+// bare repo resolves nothing).
+func RefExists(repoPath, ref string) (bool, error) {
+ cmd := gitCommand(repoPath, "rev-parse", "--verify", "--quiet", ref)
+ if err := cmd.Run(); err != nil {
+ var ee *exec.ExitError
+ if errors.As(err, &ee) && ee.ExitCode() == 1 {
+ return false, nil
+ }
+ return false, fmt.Errorf("git rev-parse %s in %s: %w", ref, repoPath, err)
+ }
+ return true, nil
+}
+
+// RefNames lists the short names of every branch and tag, so callers can
+// test ref membership without one subprocess per candidate.
+func RefNames(repoPath string) ([]string, error) {
+ return forEachRefNames(repoPath, "refs/heads", "refs/tags")
+}
+
+// forEachRefNames lists %(refname:short) for each matching pattern.
+func forEachRefNames(repoPath string, patterns ...string) ([]string, error) {
+ args := append([]string{"for-each-ref", "--format=%(refname:short)"}, patterns...)
+ cmd := gitCommand(repoPath, args...)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("git for-each-ref in %s: %w: %s", repoPath, err, strings.TrimSpace(stderr.String()))
+ }
+ var names []string
+ for _, line := range strings.Split(string(out), "\n") {
+ if name := strings.TrimSpace(line); name != "" {
+ names = append(names, name)
+ }
+ }
+ return names, nil
+}
+
+// CatFileInfo inspects an object (typically "ref:path") in a single
+// batched cat-file read: it reports the object type, its size, whether the
+// first max bytes contain a NUL (the same binary heuristic as the blob
+// view), and whether the object exceeds max. At most max+1 bytes are read
+// whatever the object's size; unknown objects return ErrNotFound.
+func CatFileInfo(repoPath, object string, max int) (objectType string, size int, isBinary, truncated bool, err error) {
+ if object == "" || strings.HasPrefix(object, "-") {
+ return "", 0, false, false, fmt.Errorf("git cat-file: invalid object %q", object)
+ }
+ cmd := gitCommand(repoPath, "cat-file", "--batch", "--buffer")
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return "", 0, false, false, fmt.Errorf("cat-file stdin: %w", err)
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return "", 0, false, false, fmt.Errorf("cat-file stdout: %w", err)
+ }
+ if err := cmd.Start(); err != nil {
+ return "", 0, false, false, fmt.Errorf("start cat-file: %w", err)
+ }
+ // done stops the (read-only) cat-file in every path; Wait reaps it.
+ done := func() {
+ stdout.Close()
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ }
+
+ if _, err := io.WriteString(stdin, object+"\n"); err != nil {
+ done()
+ return "", 0, false, false, fmt.Errorf("write cat-file request: %w", err)
+ }
+ stdin.Close()
+
+ line, err := bufio.NewReader(stdout).ReadString('\n')
+ if err != nil {
+ done()
+ return "", 0, false, false, fmt.Errorf("read cat-file header: %w", err)
+ }
+ fields := strings.Fields(line)
+ if len(fields) < 2 || fields[1] == "missing" {
+ done()
+ return "", 0, false, false, fmt.Errorf("git cat-file %s: %w", object, ErrNotFound)
+ }
+ if len(fields) < 3 {
+ done()
+ return "", 0, false, false, fmt.Errorf("git cat-file %s: malformed header %q", object, line)
+ }
+ size, err = strconv.Atoi(fields[2])
+ if err != nil {
+ done()
+ return "", 0, false, false, fmt.Errorf("git cat-file %s: bad size %q: %w", object, fields[2], err)
+ }
+ objectType = fields[1]
+
+ if objectType == "blob" {
+ br := bufio.NewReader(stdout)
+ buf := make([]byte, max+1)
+ n, err := io.ReadFull(br, buf)
+ if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
+ done()
+ return "", 0, false, false, fmt.Errorf("read cat-file body: %w", err)
+ }
+ truncated = n > max
+ for _, b := range buf[:min(n, max)] {
+ if b == 0 {
+ isBinary = true
+ break
+ }
+ }
+ }
+ done()
+ return objectType, size, isBinary, truncated, nil
+}
+
+// Commit is the metadata of one revision.
+type Commit struct {
+ SHA string
+ Author string
+ Email string
+ Date string // author date, ISO 8601
+ Subject string
+ Body string
+ Parents []string
+}
+
+const (
+ logFormat = "%x1e%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%b"
+ recordSep = "\x1e"
+ fieldSep = "\x1f"
+)
+
+func parseLog(out []byte) []Commit {
+ var commits []Commit
+ for _, rec := range strings.Split(string(out), recordSep) {
+ rec = strings.TrimPrefix(rec, "\n")
+ if rec == "" {
+ continue
+ }
+ fields := strings.Split(rec, fieldSep)
+ if len(fields) < 6 {
+ continue
+ }
+ c := Commit{
+ SHA: fields[0], Author: fields[1], Email: fields[2],
+ Date: fields[3], Subject: fields[4], Body: strings.TrimSpace(fields[5]),
+ }
+ commits = append(commits, c)
+ }
+ return commits
+}
+
+// Log returns up to limit commits reachable from ref, newest first.
+func Log(repoPath, ref string, limit int) ([]Commit, error) {
+ cmd := gitCommand(repoPath, "log", "-n", strconv.Itoa(limit), "--format="+logFormat, ref, "--")
+ // trailing `--` disambiguates the rev from paths (ref is regex-validated upstream).
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("git log %s in %s: %w: %s", ref, repoPath, err, strings.TrimSpace(stderr.String()))
+ }
+ return parseLog(out), nil
+}
+
+// CommitAt returns the single commit addressed by rev (full or short sha).
+func CommitAt(repoPath, rev string) (Commit, error) {
+ commits, err := Log(repoPath, rev, 1)
+ if err != nil {
+ return Commit{}, err
+ }
+ if len(commits) == 0 {
+ return Commit{}, fmt.Errorf("commit %s: %w", rev, ErrNotFound)
+ }
+ parents, err := parentsOf(repoPath, commits[0].SHA)
+ if err != nil {
+ return Commit{}, err
+ }
+ commits[0].Parents = parents
+ return commits[0], nil
+}
+
+func parentsOf(repoPath, sha string) ([]string, error) {
+ cmd := gitCommand(repoPath, "rev-list", "--parents", "-n", "1", sha)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("git rev-list %s: %w: %s", sha, err, strings.TrimSpace(stderr.String()))
+ }
+ fields := strings.Fields(string(out))
+ if len(fields) < 2 {
+ return nil, nil // root commit
+ }
+ return fields[1:], nil
+}
+
+// ShowPatch returns the diff a commit introduces (format suppressed, so
+// only the patch body), reading at most limit+1 bytes.
+func ShowPatch(repoPath, sha string, limit int) ([]byte, error) {
+ cmd := gitCommand(repoPath, "show", "--format=", "--patch", "--find-renames", sha)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, oversized, err := runBounded(cmd, limit)
+ if oversized {
+ return out, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("git show %s: %w: %s", sha, err, strings.TrimSpace(stderr.String()))
+ }
+ return out, nil
+}
+
+// CommitCount returns the number of commits reachable from ref.
+func CommitCount(repoPath, ref string) (int, error) {
+ cmd := gitCommand(repoPath, "rev-list", "--count", ref)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ return 0, fmt.Errorf("git rev-list --count %s: %w: %s", ref, err, strings.TrimSpace(stderr.String()))
+ }
+ n, err := strconv.Atoi(strings.TrimSpace(string(out)))
+ if err != nil {
+ return 0, fmt.Errorf("git rev-list --count %s: parse %q: %w", ref, out, err)
+ }
+ return n, nil
+}
diff --git a/internal/git/browse_test.go b/internal/git/browse_test.go
new file mode 100644
index 0000000..7eacd35
--- /dev/null
+++ b/internal/git/browse_test.go
@@ -0,0 +1,181 @@
+package git
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func runGit(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v in %s: %v: %s", args, dir, err, out)
+ }
+}
+
+// seedBare gives a bare repo one commit on main with a few files.
+func seedBare(t *testing.T, barePath string) {
+ t.Helper()
+ work := filepath.Join(t.TempDir(), "work")
+ runGit(t, "", "clone", "-q", barePath, work)
+ files := map[string]string{
+ "README.md": "# demo\n\n**hi** there\n",
+ "LICENSE": "MIT License\n\nPermission is hereby granted, free of charge...\n",
+ "hello.c": "int main(void) { return 0; }\n",
+ "sub/note.txt": "note\n",
+ }
+ for rel, content := range files {
+ full := filepath.Join(work, rel)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
+ t.Fatalf("write %s: %v", rel, err)
+ }
+ }
+ runGit(t, work, "add", ".")
+ runGit(t, work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "seed")
+ runGit(t, work, "push", "-q", "origin", "main")
+}
+
+func TestRefExists(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ exists, err := RefExists(path, "main")
+ if err != nil {
+ t.Fatalf("RefExists: %v", err)
+ }
+ if exists {
+ t.Error("fresh bare repo resolves main, want empty")
+ }
+ seedBare(t, path)
+ if exists, err := RefExists(path, "main"); err != nil || !exists {
+ t.Fatalf("RefExists after seed = (%v, %v), want (true, nil)", exists, err)
+ }
+ if exists, err := RefExists(path, "nosuchbranch"); err != nil || exists {
+ t.Errorf("RefExists nosuchbranch = (%v, %v), want (false, nil)", exists, err)
+ }
+}
+
+func TestLsTree(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ seedBare(t, path)
+
+ entries, err := LsTree(path, "main", "")
+ if err != nil {
+ t.Fatalf("LsTree root: %v", err)
+ }
+ types := map[string]string{}
+ for _, e := range entries {
+ types[e.Path] = e.Type
+ }
+ if len(entries) != 4 {
+ t.Errorf("root entries = %d, want 4: %+v", len(entries), entries)
+ }
+ if types["README.md"] != "blob" || types["LICENSE"] != "blob" || types["hello.c"] != "blob" {
+ t.Errorf("root blobs wrong: %+v", types)
+ }
+ if types["sub"] != "tree" {
+ t.Errorf("sub type = %q, want tree", types["sub"])
+ }
+
+ sub, err := LsTree(path, "main", "sub")
+ if err != nil {
+ t.Fatalf("LsTree sub: %v", err)
+ }
+ if len(sub) != 1 || sub[0].Path != "sub/note.txt" || sub[0].Type != "blob" {
+ t.Errorf("sub entries = %+v, want one blob at sub/note.txt", sub)
+ }
+}
+
+func TestShowFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ seedBare(t, path)
+
+ content, err := ShowFile(path, "main", "README.md", 1<<20)
+ if err != nil {
+ t.Fatalf("ShowFile: %v", err)
+ }
+ if !strings.Contains(string(content), "**hi** there") {
+ t.Errorf("content = %q", content)
+ }
+ if _, err := ShowFile(path, "main", "nope.txt", 1<<20); err == nil {
+ t.Error("ShowFile missing file = nil error, want error")
+ } else if !strings.Contains(err.Error(), "not found") && !strings.Contains(err.Error(), "does not exist") {
+ t.Logf("missing-file error text: %v", err)
+ }
+}
+
+// An oversized object must never be buffered in full: ShowFile stops at
+// limit+1 bytes and kills the subprocess.
+func TestShowFileBounded(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ seedBare(t, path)
+
+ content, err := ShowFile(path, "main", "README.md", 4)
+ if err != nil {
+ t.Fatalf("ShowFile bounded: %v", err)
+ }
+ if len(content) != 5 {
+ t.Errorf("bounded content = %d bytes (%q), want limit+1 = 5", len(content), content)
+ }
+}
+
+// A hostile parent environment (GIT_DIR et al.) must not redirect browse
+// commands to a different repository — gitCommand pins a minimal env.
+func TestBrowseIgnoresInheritedGitEnv(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ seedBare(t, path)
+
+ decoy := filepath.Join(t.TempDir(), "decoy.git")
+ if err := InitBare(decoy, "main"); err != nil {
+ t.Fatalf("InitBare decoy: %v", err)
+ }
+ work := filepath.Join(t.TempDir(), "decoy-work")
+ runGit(t, "", "init", "-q", "-b", "main", work)
+ if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("DECOY\n"), 0o644); err != nil {
+ t.Fatalf("write decoy readme: %v", err)
+ }
+ runGit(t, work, "add", ".")
+ runGit(t, work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "decoy")
+ runGit(t, work, "push", "-q", decoy, "main")
+
+ t.Setenv("GIT_DIR", decoy)
+ t.Setenv("GIT_WORK_TREE", work)
+
+ content, err := ShowFile(path, "main", "README.md", 1<<20)
+ if err != nil {
+ t.Fatalf("ShowFile with hostile GIT_DIR: %v", err)
+ }
+ if strings.Contains(string(content), "DECOY") {
+ t.Errorf("ShowFile served the decoy repo: %q", content)
+ }
+ if !strings.Contains(string(content), "**hi** there") {
+ t.Errorf("ShowFile content = %q, want the seeded README", content)
+ }
+ entries, err := LsTree(path, "main", "")
+ if err != nil {
+ t.Fatalf("LsTree with hostile GIT_DIR: %v", err)
+ }
+ if len(entries) != 4 {
+ t.Errorf("LsTree returned %d entries, want 4 from demo (decoy has 1)", len(entries))
+ }
+}
diff --git a/internal/git/commits_test.go b/internal/git/commits_test.go
new file mode 100644
index 0000000..41c7eac
--- /dev/null
+++ b/internal/git/commits_test.go
@@ -0,0 +1,67 @@
+package git
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestLogCommitAtShowPatch(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ seedBare(t, path)
+
+ work := filepath.Join(t.TempDir(), "work2")
+ runGit(t, "", "clone", "-q", path, work)
+ if err := os.WriteFile(filepath.Join(work, "new.txt"), []byte("new\n"), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ runGit(t, work, "add", ".")
+ runGit(t, work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "second")
+ runGit(t, work, "push", "-q", "origin", "main")
+
+ logs, err := Log(path, "main", 50)
+ if err != nil {
+ t.Fatalf("Log: %v", err)
+ }
+ if len(logs) != 2 {
+ t.Fatalf("log length = %d, want 2: %+v", len(logs), logs)
+ }
+ if logs[0].Subject != "second" || logs[1].Subject != "seed" {
+ t.Errorf("subjects = %q, %q, want second, seed", logs[0].Subject, logs[1].Subject)
+ }
+ if logs[0].Author != "t" || !strings.Contains(logs[0].Date, "T") {
+ t.Errorf("first commit author/date = %q %q", logs[0].Author, logs[0].Date)
+ }
+ // Log lists rows only; parents come from CommitAt.
+ head, err := CommitAt(path, logs[0].SHA)
+ if err != nil {
+ t.Fatalf("CommitAt head: %v", err)
+ }
+ if len(head.Parents) != 1 || head.Parents[0] != logs[1].SHA {
+ t.Errorf("head parents = %v, want [%s]", head.Parents, logs[1].SHA)
+ }
+
+ root, err := CommitAt(path, logs[1].SHA[:8])
+ if err != nil {
+ t.Fatalf("CommitAt short sha: %v", err)
+ }
+ if root.Subject != "seed" || len(root.Parents) != 0 {
+ t.Errorf("root commit = %+v, want seed with no parents", root)
+ }
+
+ patch, err := ShowPatch(path, logs[0].SHA, 1<<20)
+ if err != nil {
+ t.Fatalf("ShowPatch: %v", err)
+ }
+ if !strings.Contains(string(patch), "+++ b/new.txt") {
+ t.Errorf("patch lacks new file diff: %q", patch)
+ }
+
+ if _, err := Log(path, "nosuchref", 10); err == nil {
+ t.Error("Log on missing ref = nil error, want error")
+ }
+}
diff --git a/internal/git/git.go b/internal/git/git.go
new file mode 100644
index 0000000..e4c45fc
--- /dev/null
+++ b/internal/git/git.go
@@ -0,0 +1,130 @@
+// Package git is the only package allowed to exec the git binary.
+// Every subprocess runs with an explicit, minimal environment so
+// inherited GIT_* variables cannot redirect commands to another
+// repository or inject git configuration.
+package git
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// gitTimeout bounds every browse/merge subprocess; the CGI streaming path
+// (backend.go) is exempt — pushes stream bodies of arbitrary duration.
+const gitTimeout = 2 * time.Minute
+
+// gitCommand builds a git subprocess rooted at repoPath ("" to inherit
+// the process working directory) with a minimal, explicit environment.
+// It is bounded by gitTimeout; the context's cancel releases the deadline
+// timer when it fires and doubles as the lostcancel silencer.
+func gitCommand(repoPath string, args ...string) *exec.Cmd {
+ ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Cancel = func() error {
+ err := cmd.Process.Kill()
+ cancel()
+ return err
+ }
+ cmd.Dir = repoPath
+ cmd.Env = []string{
+ "PATH=" + os.Getenv("PATH"),
+ "LANG=C",
+ "LC_ALL=C",
+ }
+ return cmd
+}
+
+// runBounded runs cmd and returns at most limit+1 bytes of its stdout.
+// When the output exceeds limit the subprocess is killed early and
+// oversized is true, so a huge object or patch never lands fully in
+// memory; callers decide what the cap means. Any stderr buffer must be
+// attached to cmd before the call; wait errors surface unformatted.
+func runBounded(cmd *exec.Cmd, limit int) (out []byte, oversized bool, err error) {
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, false, err
+ }
+ if err := cmd.Start(); err != nil {
+ return nil, false, err
+ }
+ var buf bytes.Buffer
+ n, readErr := io.CopyN(&buf, stdout, int64(limit)+1)
+ if n > int64(limit) {
+ stdout.Close()
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ return buf.Bytes(), true, nil
+ }
+ if readErr != nil && !errors.Is(readErr, io.EOF) {
+ _ = cmd.Wait()
+ return nil, false, readErr
+ }
+ if err := cmd.Wait(); err != nil {
+ return nil, false, err
+ }
+ return buf.Bytes(), false, nil
+}
+
+// InitBare creates a bare repository at path with HEAD pointing at branch,
+// then installs the post-receive hook that calls back into this binary.
+func InitBare(path, branch string) error {
+ cmd := gitCommand("", "init", "--bare", "--initial-branch="+branch, path)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("git init --bare %s: %w: %s", path, err, strings.TrimSpace(string(out)))
+ }
+ return installPostReceive(path)
+}
+
+// installPostReceive writes hooks/post-receive so a push updates repo
+// metadata. Git runs hooks without our pinned environment, so the callback
+// binary comes from SIMPLEGIT_BIN, which ServeBackend sets explicitly (it
+// is absent for out-of-band pushes, where the hook is a no-op). The repo
+// path is derived from the hook's own location, so a rename keeps working.
+func installPostReceive(repoPath string) error {
+ absRepo, err := filepath.Abs(repoPath)
+ if err != nil {
+ return fmt.Errorf("resolve repo path: %w", err)
+ }
+ script := "#!/bin/sh\n" +
+ "# installed by simplegit; records the push in the metadata DB.\n" +
+ `[ -n "$SIMPLEGIT_BIN" ] || exit 0` + "\n" +
+ `repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)` + "\n" +
+ `exec "$SIMPLEGIT_BIN" hook --repo "$repo"` + "\n"
+ hookPath := filepath.Join(absRepo, "hooks", "post-receive")
+ if err := os.WriteFile(hookPath, []byte(script), 0o755); err != nil {
+ return fmt.Errorf("write post-receive hook: %w", err)
+ }
+ if err := os.Chmod(hookPath, 0o755); err != nil {
+ return fmt.Errorf("chmod post-receive hook: %w", err)
+ }
+ return nil
+}
+
+// DefaultBranch returns the branch HEAD points at (e.g. "main").
+func DefaultBranch(repoPath string) (string, error) {
+ cmd := gitCommand(repoPath, "symbolic-ref", "--short", "HEAD")
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("git symbolic-ref HEAD in %s: %w: %s", repoPath, err, strings.TrimSpace(stderr.String()))
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// SetDefaultBranch points HEAD at refs/heads/branch.
+func SetDefaultBranch(repoPath, branch string) error {
+ cmd := gitCommand(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("git symbolic-ref HEAD %s: %w: %s", branch, err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
diff --git a/internal/git/git_test.go b/internal/git/git_test.go
new file mode 100644
index 0000000..e2f026b
--- /dev/null
+++ b/internal/git/git_test.go
@@ -0,0 +1,82 @@
+package git
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestInitBare(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+
+ head, err := os.ReadFile(filepath.Join(path, "HEAD"))
+ if err != nil {
+ t.Fatalf("read HEAD: %v", err)
+ }
+ if !strings.Contains(string(head), "ref: refs/heads/main") {
+ t.Errorf("HEAD = %q, want ref: refs/heads/main", head)
+ }
+
+ cmd := exec.Command("git", "rev-parse", "--is-bare-repository")
+ cmd.Dir = path
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("rev-parse: %v: %s", err, out)
+ } else if strings.TrimSpace(string(out)) != "true" {
+ t.Errorf("is-bare-repository = %q, want true", out)
+ }
+}
+
+func TestInitBareReportsFailure(t *testing.T) {
+ file := filepath.Join(t.TempDir(), "occupied")
+ if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+ if err := InitBare(file, "main"); err == nil {
+ t.Error("InitBare over a regular file = nil, want error")
+ }
+}
+
+func TestInitBareInstallsPostReceive(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ hook := filepath.Join(path, "hooks", "post-receive")
+ info, err := os.Stat(hook)
+ if err != nil {
+ t.Fatalf("stat post-receive: %v", err)
+ }
+ if info.Mode()&0o111 == 0 {
+ t.Error("post-receive is not executable")
+ }
+ script, err := os.ReadFile(hook)
+ if err != nil {
+ t.Fatalf("read post-receive: %v", err)
+ }
+ for _, want := range []string{"SIMPLEGIT_BIN", "hook --repo", "dirname"} {
+ if !strings.Contains(string(script), want) {
+ t.Errorf("post-receive lacks %q: %q", want, script)
+ }
+ }
+}
+
+func TestDefaultBranchRoundTrip(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ if branch, err := DefaultBranch(path); err != nil || branch != "main" {
+ t.Fatalf("DefaultBranch = %q, %v; want main", branch, err)
+ }
+ if err := SetDefaultBranch(path, "trunk"); err != nil {
+ t.Fatalf("SetDefaultBranch: %v", err)
+ }
+ if branch, err := DefaultBranch(path); err != nil || branch != "trunk" {
+ t.Errorf("DefaultBranch after set = %q, %v; want trunk", branch, err)
+ }
+}
diff --git a/internal/git/merge.go b/internal/git/merge.go
new file mode 100644
index 0000000..9a516a9
--- /dev/null
+++ b/internal/git/merge.go
@@ -0,0 +1,180 @@
+package git
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os/exec"
+ "strings"
+)
+
+// ErrMergeConflict marks a merge that cannot complete without resolving
+// conflicts by hand.
+var ErrMergeConflict = errors.New("merge conflict")
+
+// ErrAlreadyMerged marks a head whose changes are already contained in base.
+var ErrAlreadyMerged = errors.New("already merged")
+
+// ErrNoCommonAncestor marks two revisions with unrelated histories.
+var ErrNoCommonAncestor = errors.New("no common ancestor")
+
+// ResolveCommit resolves rev (branch, tag, sha) to a full commit SHA, or
+// ErrNotFound when it does not name a commit.
+func ResolveCommit(repoPath, rev string) (string, error) {
+ cmd := gitCommand(repoPath, "rev-parse", "--verify", "--quiet", rev+"^{commit}")
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ var ee *exec.ExitError
+ if errors.As(err, &ee) && ee.ExitCode() == 1 {
+ return "", fmt.Errorf("rev %s: %w", rev, ErrNotFound)
+ }
+ return "", fmt.Errorf("git rev-parse %s in %s: %w: %s", rev, repoPath, err, strings.TrimSpace(stderr.String()))
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// refsHeads is the branch-ref prefix used to build explicit ref arguments.
+const refsHeads = "refs/heads/"
+
+// Branches lists the local branch names (refs/heads), sorted.
+func Branches(repoPath string) ([]string, error) {
+ return forEachRefNames(repoPath, refsHeads)
+}
+
+// MergeBase returns the best common ancestor of a and b, or
+// ErrNoCommonAncestor when the histories are unrelated.
+func MergeBase(repoPath, a, b string) (string, error) {
+ cmd := gitCommand(repoPath, "merge-base", a, b)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ var ee *exec.ExitError
+ if errors.As(err, &ee) && ee.ExitCode() == 1 {
+ return "", fmt.Errorf("merge-base %s %s: %w", a, b, ErrNoCommonAncestor)
+ }
+ return "", fmt.Errorf("git merge-base %s %s: %w: %s", a, b, err, strings.TrimSpace(stderr.String()))
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+// Diff returns the patch that head introduces on top of its merge base with
+// base (git's three-dot form), renames detected and colour suppressed.
+// At most limit+1 bytes are read; callers detect the cap with len.
+func Diff(repoPath, base, head string, limit int) ([]byte, error) {
+ cmd := gitCommand(repoPath, "diff", "--no-color", "--patch", "--find-renames", base+"..."+head)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, oversized, err := runBounded(cmd, limit)
+ if oversized {
+ return out, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("git diff %s...%s: %w: %s", base, head, err, strings.TrimSpace(stderr.String()))
+ }
+ return out, nil
+}
+
+// Merge advances the base branch to include head: a fast-forward when base
+// is an ancestor of head, otherwise a two-parent merge commit. It updates
+// refs/heads/base and reports the new head SHA and whether it fast-forwarded.
+// authorName becomes the merge commit's author/committer (the host has no
+// global git identity to inherit).
+func Merge(repoPath, base, head, message, authorName string) (sha string, fastForward bool, err error) {
+ baseSHA, err := ResolveCommit(repoPath, refsHeads+base)
+ if err != nil {
+ return "", false, fmt.Errorf("merge base %s: %w", base, err)
+ }
+ headSHA, err := ResolveCommit(repoPath, refsHeads+head)
+ if err != nil {
+ return "", false, fmt.Errorf("merge head %s: %w", head, err)
+ }
+
+ mergeBase, err := MergeBase(repoPath, baseSHA, headSHA)
+ if err != nil {
+ return "", false, err
+ }
+ if mergeBase == baseSHA {
+ if err := updateRef(repoPath, refsHeads+base, headSHA); err != nil {
+ return "", false, err
+ }
+ return headSHA, true, nil
+ }
+ if mergeBase == headSHA {
+ // head is already an ancestor of base: its changes are contained.
+ return headSHA, true, ErrAlreadyMerged
+ }
+
+ tree, conflict, err := mergeTree(repoPath, baseSHA, headSHA)
+ if err != nil {
+ return "", false, err
+ }
+ if conflict {
+ return "", false, fmt.Errorf("merge %s into %s: %w", head, base, ErrMergeConflict)
+ }
+ commit, err := commitTree(repoPath, tree, baseSHA, headSHA, message, authorName)
+ if err != nil {
+ return "", false, err
+ }
+ if err := updateRef(repoPath, refsHeads+base, commit); err != nil {
+ return "", false, err
+ }
+ return commit, false, nil
+}
+
+// mergeTree merges two commits without a work tree, writing the result tree
+// and reporting whether the merge conflicted.
+func mergeTree(repoPath, baseSHA, headSHA string) (tree string, conflict bool, err error) {
+ cmd := gitCommand(repoPath, "merge-tree", "--write-tree", baseSHA, headSHA)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ runErr := cmd.Run()
+ if runErr != nil {
+ var ee *exec.ExitError
+ if errors.As(runErr, &ee) && ee.ExitCode() == 1 {
+ return "", true, nil
+ }
+ return "", false, fmt.Errorf("git merge-tree: %w: %s", runErr, strings.TrimSpace(stderr.String()))
+ }
+ line, _, _ := strings.Cut(stdout.String(), "\n")
+ if line = strings.TrimSpace(line); line == "" {
+ return "", false, errors.New("git merge-tree: no tree produced")
+ }
+ return line, false, nil
+}
+
+// commitTree creates a two-parent merge commit object.
+func commitTree(repoPath, tree, parentA, parentB, message, authorName string) (string, error) {
+ if authorName == "" {
+ authorName = "simplegit"
+ }
+ cmd := gitCommand(repoPath, "commit-tree", tree, "-p", parentA, "-p", parentB, "-m", message)
+ // gitCommand pins a minimal env with no identity and no HOME, so supply
+ // one explicitly; the merge commit must record some author.
+ cmd.Env = append(cmd.Env,
+ "GIT_AUTHOR_NAME="+authorName,
+ "GIT_AUTHOR_EMAIL="+authorName+"@simplegit",
+ "GIT_COMMITTER_NAME="+authorName,
+ "GIT_COMMITTER_EMAIL="+authorName+"@simplegit",
+ )
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ return "", fmt.Errorf("git commit-tree: %w: %s", err, strings.TrimSpace(stderr.String()))
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+func updateRef(repoPath, ref, sha string) error {
+ cmd := gitCommand(repoPath, "update-ref", ref, sha)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("git update-ref %s: %w: %s", ref, err, strings.TrimSpace(stderr.String()))
+ }
+ return nil
+}
diff --git a/internal/git/merge_test.go b/internal/git/merge_test.go
new file mode 100644
index 0000000..504fae9
--- /dev/null
+++ b/internal/git/merge_test.go
@@ -0,0 +1,159 @@
+package git
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func writeFile(t *testing.T, dir, rel, content string) {
+ t.Helper()
+ full := filepath.Join(dir, rel)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
+ t.Fatalf("write %s: %v", rel, err)
+ }
+}
+
+// bareWithMain returns a bare repo and a clone whose main has one commit.
+func bareWithMain(t *testing.T) (bare, work string) {
+ t.Helper()
+ bare = filepath.Join(t.TempDir(), "m.git")
+ runGit(t, "", "init", "-q", "--bare", "--initial-branch=main", bare)
+ work = filepath.Join(t.TempDir(), "work")
+ runGit(t, "", "clone", "-q", bare, work)
+ runGit(t, work, "config", "user.email", "t@t")
+ runGit(t, work, "config", "user.name", "t")
+ writeFile(t, work, "base.txt", "base\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "base")
+ runGit(t, work, "push", "-q", "origin", "main")
+ return bare, work
+}
+
+func TestMergeFastForward(t *testing.T) {
+ bare, work := bareWithMain(t)
+ runGit(t, work, "checkout", "-qb", "feature")
+ writeFile(t, work, "feature.txt", "feature\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "feature work")
+ runGit(t, work, "push", "-q", "origin", "feature")
+ headSHA, err := ResolveCommit(bare, "refs/heads/feature")
+ if err != nil {
+ t.Fatalf("ResolveCommit: %v", err)
+ }
+
+ sha, ff, err := Merge(bare, "main", "feature", "Merge feature", "tester")
+ if err != nil {
+ t.Fatalf("Merge: %v", err)
+ }
+ if !ff {
+ t.Error("Merge reported not fast-forward, want fast-forward")
+ }
+ if sha != headSHA {
+ t.Errorf("merge sha = %s, want feature head %s", sha, headSHA)
+ }
+ mainSHA, _ := ResolveCommit(bare, "refs/heads/main")
+ if mainSHA != headSHA {
+ t.Errorf("main = %s, want advanced to %s", mainSHA, headSHA)
+ }
+}
+
+func TestMergeCreatesMergeCommit(t *testing.T) {
+ bare, work := bareWithMain(t)
+ runGit(t, work, "checkout", "-qb", "feature")
+ writeFile(t, work, "feature.txt", "feature\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "feature work")
+ runGit(t, work, "push", "-q", "origin", "feature")
+ runGit(t, work, "checkout", "-q", "main")
+ writeFile(t, work, "main.txt", "main\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "main work")
+ runGit(t, work, "push", "-q", "origin", "main")
+
+ sha, ff, err := Merge(bare, "main", "feature", "Merge feature", "tester")
+ if err != nil {
+ t.Fatalf("Merge: %v", err)
+ }
+ if ff {
+ t.Error("Merge reported fast-forward, want a merge commit")
+ }
+ commit, err := CommitAt(bare, sha)
+ if err != nil {
+ t.Fatalf("CommitAt merge: %v", err)
+ }
+ if len(commit.Parents) != 2 {
+ t.Errorf("merge parents = %d, want 2", len(commit.Parents))
+ }
+ entries, err := LsTree(bare, "refs/heads/main", "")
+ if err != nil {
+ t.Fatalf("LsTree: %v", err)
+ }
+ var names []string
+ for _, e := range entries {
+ names = append(names, filepath.Base(e.Path))
+ }
+ joined := strings.Join(names, ",")
+ if !strings.Contains(joined, "feature.txt") || !strings.Contains(joined, "main.txt") {
+ t.Errorf("merged tree = %v, want both feature.txt and main.txt", names)
+ }
+}
+
+func TestMergeConflictLeavesBase(t *testing.T) {
+ bare, work := bareWithMain(t)
+ writeFile(t, work, "conflict.txt", "original\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "add conflict file")
+ runGit(t, work, "push", "-q", "origin", "main")
+
+ runGit(t, work, "checkout", "-qb", "feature")
+ writeFile(t, work, "conflict.txt", "feature side\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "feature edit")
+ runGit(t, work, "push", "-q", "origin", "feature")
+
+ runGit(t, work, "checkout", "-q", "main")
+ writeFile(t, work, "conflict.txt", "main side\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "main edit")
+ runGit(t, work, "push", "-q", "origin", "main")
+ before, _ := ResolveCommit(bare, "refs/heads/main")
+
+ if _, _, err := Merge(bare, "main", "feature", "Merge feature", "tester"); !errors.Is(err, ErrMergeConflict) {
+ t.Fatalf("Merge err = %v, want ErrMergeConflict", err)
+ }
+ after, _ := ResolveCommit(bare, "refs/heads/main")
+ if after != before {
+ t.Errorf("main moved on conflict: %s -> %s", before, after)
+ }
+}
+
+func TestDiffThreeDot(t *testing.T) {
+ bare, work := bareWithMain(t)
+ runGit(t, work, "checkout", "-qb", "feature")
+ writeFile(t, work, "feature.txt", "feature\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "feature work")
+ runGit(t, work, "push", "-q", "origin", "feature")
+
+ patch, err := Diff(bare, "main", "feature", 1<<20)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ if !strings.Contains(string(patch), "feature.txt") {
+ t.Errorf("diff missing feature.txt: %s", patch)
+ }
+
+ branches, err := Branches(bare)
+ if err != nil {
+ t.Fatalf("Branches: %v", err)
+ }
+ if len(branches) != 2 || branches[0] != "feature" || branches[1] != "main" {
+ t.Errorf("branches = %v, want [feature main]", branches)
+ }
+}
diff --git a/internal/git/tags.go b/internal/git/tags.go
new file mode 100644
index 0000000..993d697
--- /dev/null
+++ b/internal/git/tags.go
@@ -0,0 +1,52 @@
+package git
+
+import (
+ "bytes"
+ "fmt"
+ "strings"
+)
+
+// Tag is a tag ref in the repository. Commit is the commit the tag points
+// at (dereferenced for an annotated tag); Annotated reports whether the tag
+// is an annotated tag object rather than a lightweight ref.
+type Tag struct {
+ Name string
+ Commit string
+ Annotated bool
+}
+
+// Tags lists refs/tags, sorted by name. for-each-ref's %(*objectname)
+// dereferences an annotated tag to the commit it names, so both tag kinds
+// report a commit SHA.
+func Tags(repoPath string) ([]Tag, error) {
+ cmd := gitCommand(repoPath, "for-each-ref",
+ "--format=%(refname:short) %(objecttype) %(objectname) %(*objectname)", "refs/tags/")
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("git for-each-ref refs/tags/ in %s: %w: %s", repoPath, err, strings.TrimSpace(stderr.String()))
+ }
+
+ var tags []Tag
+ for _, line := range strings.Split(string(out), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ continue
+ }
+ tag := Tag{Name: fields[0], Annotated: fields[1] == "tag"}
+ switch {
+ case len(fields) >= 4:
+ tag.Commit = fields[3]
+ case tag.Annotated:
+ // Annotated tag whose target is not a commit: resolve it.
+ if sha, err := ResolveCommit(repoPath, fields[0]); err == nil {
+ tag.Commit = sha
+ }
+ default:
+ tag.Commit = fields[2]
+ }
+ tags = append(tags, tag)
+ }
+ return tags, nil
+}
diff --git a/internal/git/tags_test.go b/internal/git/tags_test.go
new file mode 100644
index 0000000..88a137a
--- /dev/null
+++ b/internal/git/tags_test.go
@@ -0,0 +1,57 @@
+package git
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestTagsLightweightAndAnnotated(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "demo.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ seedBare(t, path)
+ runGit(t, path, "tag", "v1", "main")
+ runGit(t, path, "-c", "user.email=t@t", "-c", "user.name=t",
+ "tag", "-a", "v2", "-m", "release two", "main")
+
+ mainSHA, err := ResolveCommit(path, "main")
+ if err != nil {
+ t.Fatalf("ResolveCommit main: %v", err)
+ }
+ tags, err := Tags(path)
+ if err != nil {
+ t.Fatalf("Tags: %v", err)
+ }
+ if len(tags) != 2 {
+ t.Fatalf("len(tags) = %d, want 2 (%+v)", len(tags), tags)
+ }
+ if tags[0].Name != "v1" || tags[1].Name != "v2" {
+ t.Errorf("tag order = %q, %q, want v1, v2", tags[0].Name, tags[1].Name)
+ }
+ if tags[0].Annotated {
+ t.Error("v1 reported annotated, want lightweight")
+ }
+ if !tags[1].Annotated {
+ t.Error("v2 reported lightweight, want annotated")
+ }
+ for _, tag := range tags {
+ if tag.Commit != mainSHA {
+ t.Errorf("tag %s commit = %s, want %s", tag.Name, tag.Commit, mainSHA)
+ }
+ }
+}
+
+func TestTagsEmptyRepo(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "empty.git")
+ if err := InitBare(path, "main"); err != nil {
+ t.Fatalf("InitBare: %v", err)
+ }
+ tags, err := Tags(path)
+ if err != nil {
+ t.Fatalf("Tags: %v", err)
+ }
+ if len(tags) != 0 {
+ t.Errorf("tags in empty repo = %+v, want none", tags)
+ }
+}
diff --git a/internal/render/render.go b/internal/render/render.go
new file mode 100644
index 0000000..6ffbcb7
--- /dev/null
+++ b/internal/render/render.go
@@ -0,0 +1,147 @@
+// Package render converts repository content for the browser.
+package render
+
+import (
+ "bytes"
+ "fmt"
+
+ "github.com/alecthomas/chroma/v2"
+ chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
+ "github.com/alecthomas/chroma/v2/lexers"
+ "github.com/alecthomas/chroma/v2/styles"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/util"
+)
+
+// htmlEscaper replaces goldmark's pass-through rendering of raw HTML
+// (block and inline) with escaped text, so READMEs cannot inject script.
+type htmlEscaper struct{}
+
+func (htmlEscaper) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(ast.KindHTMLBlock, escapeHTMLBlock)
+ reg.Register(ast.KindRawHTML, escapeRawHTML)
+}
+
+func escapeHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if entering {
+ n := node.(*ast.HTMLBlock)
+ lines := n.Lines()
+ for i := range lines.Len() {
+ segment := lines.At(i)
+ if _, err := w.Write(util.EscapeHTML(segment.Value(source))); err != nil {
+ return ast.WalkStop, err
+ }
+ }
+ if n.HasClosure() {
+ if _, err := w.Write(util.EscapeHTML(n.ClosureLine.Value(source))); err != nil {
+ return ast.WalkStop, err
+ }
+ }
+ }
+ return ast.WalkSkipChildren, nil
+}
+
+func escapeRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if entering {
+ if _, err := w.Write(util.EscapeHTML(node.Text(source))); err != nil {
+ return ast.WalkStop, err
+ }
+ }
+ return ast.WalkSkipChildren, nil
+}
+
+var markdown = goldmark.New(
+ goldmark.WithRendererOptions(
+ renderer.WithNodeRenderers(util.Prioritized(htmlEscaper{}, 50)),
+ ),
+)
+
+// Markdown renders markdown source to HTML. Raw HTML in the source is
+// escaped rather than passed through.
+func Markdown(source []byte) ([]byte, error) {
+ var out bytes.Buffer
+ if err := markdown.Convert(source, &out); err != nil {
+ return nil, fmt.Errorf("render markdown: %w", err)
+ }
+ return out.Bytes(), nil
+}
+
+// highlightStyle inherits chroma's monokai (for complete token coverage)
+// and overrides the tokens that matter to the simplegit palette. The class
+// names are emitted by the formatter; their rules come from ChromaCSS.
+var highlightStyle = func() *chroma.Style {
+ style, err := styles.Get("monokai").Builder().
+ Add(chroma.Background, "bg:#1e1f3a").
+ Add(chroma.Text, "#dcdcf5").
+ Add(chroma.Comment, "italic #8f92c4").
+ Add(chroma.Keyword, "#a5aef0").
+ Add(chroma.KeywordConstant, "#e0af68").
+ Add(chroma.KeywordDeclaration, "#a5aef0").
+ Add(chroma.KeywordNamespace, "#a5aef0").
+ Add(chroma.KeywordType, "#e0af68").
+ Add(chroma.Name, "#dcdcf5").
+ Add(chroma.NameOther, "#dcdcf5").
+ Add(chroma.NameConstant, "#e0af68").
+ Add(chroma.NameException, "#e06c75").
+ Add(chroma.NameBuiltin, "#e0af68").
+ Add(chroma.NameClass, "#7bc275").
+ Add(chroma.NameFunction, "#a5aef0").
+ Add(chroma.NameDecorator, "#e0af68").
+ Add(chroma.NameTag, "#a5aef0").
+ Add(chroma.NameAttribute, "#e0af68").
+ Add(chroma.String, "#7bc275").
+ Add(chroma.LiteralStringEscape, "#e0af68").
+ Add(chroma.Number, "#e0af68").
+ Add(chroma.Literal, "#7bc275").
+ Add(chroma.LiteralDate, "#e0af68").
+ Add(chroma.Operator, "#8f92c4").
+ Add(chroma.Punctuation, "#8f92c4").
+ Add(chroma.GenericDeleted, "#e06c75").
+ Add(chroma.GenericInserted, "#7bc275").
+ Add(chroma.GenericHeading, "#a5aef0").
+ Add(chroma.GenericSubheading, "#a5aef0").
+ Add(chroma.GenericEmph, "italic").
+ Add(chroma.GenericStrong, "bold").
+ Add(chroma.Error, "underline #e06c75").
+ Add(chroma.LineHighlight, "bg:#2a2c52").
+ Add(chroma.LineNumbers, "#8f92c4").
+ Add(chroma.LineNumbersTable, "#8f92c4").
+ Build()
+ if err != nil {
+ panic("render: build highlight style: " + err.Error())
+ }
+ return style
+}()
+
+// highlighter emits CSS classes rather than inline styles, so the palette
+// lives in ChromaCSS and can be tuned against design.md.
+var highlighter = chromahtml.New(chromahtml.WithClasses(true))
+
+// ChromaCSS returns the stylesheet that styles the classes CodeHTML emits.
+func ChromaCSS() ([]byte, error) {
+ var buf bytes.Buffer
+ if err := highlighter.WriteCSS(&buf, highlightStyle); err != nil {
+ return nil, fmt.Errorf("render chroma css: %w", err)
+ }
+ return buf.Bytes(), nil
+}
+
+// CodeHTML syntax-highlights a file by name. handled=false means no lexer
+// matched and the caller should fall back to a plain escaped <pre>.
+func CodeHTML(filename, content string) (html string, handled bool, err error) {
+ lexer := lexers.Match(filename)
+ if lexer == nil {
+ return "", false, nil
+ }
+ it, err := lexer.Tokenise(nil, content)
+ if err != nil {
+ return "", true, fmt.Errorf("tokenise %s: %w", filename, err)
+ }
+ var buf bytes.Buffer
+ if err := highlighter.Format(&buf, highlightStyle, it); err != nil {
+ return "", true, fmt.Errorf("highlight %s: %w", filename, err)
+ }
+ return buf.String(), true, nil
+}
diff --git a/internal/render/render_test.go b/internal/render/render_test.go
new file mode 100644
index 0000000..6918f55
--- /dev/null
+++ b/internal/render/render_test.go
@@ -0,0 +1,85 @@
+package render
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestMarkdownRenders(t *testing.T) {
+ out, err := Markdown([]byte("# demo\n\n**hi** there\n"))
+ if err != nil {
+ t.Fatalf("Markdown: %v", err)
+ }
+ html := string(out)
+ if !strings.Contains(html, "<strong>hi</strong>") {
+ t.Errorf("html = %q, want bold", html)
+ }
+}
+
+func TestMarkdownEscapesRawHTML(t *testing.T) {
+ out, err := Markdown([]byte("<script>alert(1)</script>\n"))
+ if err != nil {
+ t.Fatalf("Markdown: %v", err)
+ }
+ html := string(out)
+ if strings.Contains(html, "<script>") {
+ t.Errorf("raw script passed through: %q", html)
+ }
+ if !strings.Contains(html, "<script>") {
+ t.Errorf("script not escaped: %q", html)
+ }
+}
+
+func TestCodeHTMLHighlightsKnownExt(t *testing.T) {
+ html, handled, err := CodeHTML("main.go", "package main\n")
+ if err != nil || !handled {
+ t.Fatalf("CodeHTML handled=%v err=%v", handled, err)
+ }
+ if !strings.Contains(html, `class="chroma"`) {
+ t.Errorf("no .chroma wrapper: %q", html)
+ }
+ if !strings.Contains(html, `<span class="kn">`) {
+ t.Errorf("keyword not class-highlighted: %q", html)
+ }
+ if strings.Contains(html, "style=") {
+ t.Errorf("inline styles leaked into class mode: %q", html)
+ }
+}
+
+func TestCodeHTMLDiffClasses(t *testing.T) {
+ patch := "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n"
+ html, handled, err := CodeHTML("x.diff", patch)
+ if err != nil || !handled {
+ t.Fatalf("CodeHTML diff handled=%v err=%v", handled, err)
+ }
+ for _, cls := range []string{`class="gd"`, `class="gi"`} {
+ if !strings.Contains(html, cls) {
+ t.Errorf("diff missing %s: %q", cls, html)
+ }
+ }
+}
+
+func TestChromaCSS(t *testing.T) {
+ css, err := ChromaCSS()
+ if err != nil {
+ t.Fatalf("ChromaCSS: %v", err)
+ }
+ style := string(css)
+ if !strings.Contains(style, ".chroma .k") {
+ t.Errorf("no keyword rule in CSS")
+ }
+ if !strings.Contains(style, "#a5aef0") {
+ t.Errorf("palette color missing from CSS")
+ }
+ for _, leaked := range []string{"#66d9ef", "#a6e22e", "#ae81ff"} {
+ if strings.Contains(style, leaked) {
+ t.Errorf("off-palette monokai color %s leaked into CSS", leaked)
+ }
+ }
+}
+
+func TestCodeHTMLUnknownExt(t *testing.T) {
+ if _, handled, err := CodeHTML("file.unknownext", "x"); handled || err != nil {
+ t.Errorf("handled=%v err=%v, want false,nil", handled, err)
+ }
+}
diff --git a/internal/web/auth.go b/internal/web/auth.go
new file mode 100644
index 0000000..87eceed
--- /dev/null
+++ b/internal/web/auth.go
@@ -0,0 +1,137 @@
+package web
+
+import (
+ "errors"
+ "log"
+ "net/http"
+ "time"
+
+ "git.josie-c.com/josie/simplegit/internal/auth"
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+// loginFormMax caps the login POST body; credentials are always tiny.
+const loginFormMax = 1 << 16
+
+type repoItem struct {
+ Owner string
+ Name string
+ Description string
+ Visibility string
+}
+
+type homeData struct {
+ Username string
+ Repos []repoItem
+}
+
+// repoItems projects a repo listing for a page; owner filters to one
+// account's repos ("" keeps everything).
+func repoItems(repos []db.RepoView, owner string) []repoItem {
+ var items []repoItem
+ for _, repo := range repos {
+ if owner != "" && repo.OwnerName != owner {
+ continue
+ }
+ items = append(items, repoItem{
+ Owner: repo.OwnerName, Name: repo.Name,
+ Description: repo.Description, Visibility: repo.Visibility,
+ })
+ }
+ return items
+}
+
+func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
+ // "GET /" is also the mux catch-all, so serve only the root here;
+ // repo browsing registers its own patterns.
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ user := currentUser(r)
+ data := homeData{}
+ var viewerID int64
+ if user != nil {
+ data.Username = user.Username
+ viewerID = user.ID
+ }
+ repos, err := db.ListRepos(s.database, viewerID)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ data.Repos = repoItems(repos, "")
+ s.render(w, "home.html", http.StatusOK, data)
+}
+
+func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
+ s.render(w, "login.html", http.StatusOK, pageData{})
+}
+
+// handleLogin authenticates with the stored bcrypt hash, minting a fresh
+// random session on success. Only failed attempts consume the per-IP budget
+// (refused outright once the window is full), so a failure spray can never
+// lock out a correct password; a dummy bcrypt runs on the unknown-user path
+// so all failures cost the same.
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+ ip := clientIP(r)
+ r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+
+ fail := func() {
+ if !s.logins.allow(ip) {
+ http.Error(w, "too many login attempts; try again later", http.StatusTooManyRequests)
+ return
+ }
+ s.render(w, "login.html", http.StatusUnauthorized,
+ pageData{Username: username, Error: "invalid username or password"})
+ }
+ user, err := db.GetUserByName(s.database, username)
+ if errors.Is(err, db.ErrNotFound) {
+ // Keep the response timing uniform with the wrong-password path.
+ _, _ = auth.HashPassword(password)
+ fail()
+ return
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if !auth.CheckPassword(user.PasswordHash, password) {
+ fail()
+ return
+ }
+
+ token, err := auth.NewToken()
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ // Opportunistic housekeeping: sessions only leave the table at logout,
+ // so without this the expired rows would accumulate forever.
+ if err := db.DeleteExpiredSessions(s.database); err != nil {
+ log.Printf("web: login purge sessions: %v", err)
+ }
+ if err := db.CreateSession(s.database, token, user.ID, time.Now().Add(sessionDuration).Unix()); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ s.logins.reset(ip)
+ http.SetCookie(w, s.sessionCookie(token, sessionDuration))
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+ if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
+ if err := db.DeleteSession(s.database, cookie.Value); err != nil {
+ log.Printf("web: logout: %v", err)
+ }
+ }
+ http.SetCookie(w, s.sessionCookie("", -1))
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+}
diff --git a/internal/web/comments.go b/internal/web/comments.go
new file mode 100644
index 0000000..afb713d
--- /dev/null
+++ b/internal/web/comments.go
@@ -0,0 +1,51 @@
+package web
+
+import (
+ "database/sql"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+// commentRow is the projection of an issue or PR comment row onto the
+// shared comment machinery; each store type gets a one-line ToRow.
+type commentRow struct {
+ ID int64
+ Body string
+ Pending bool
+ AuthorID sql.NullInt64
+ AuthorName string
+ AuthorEmail string
+ CreatedAt int64
+}
+
+func issueCommentRow(c db.IssueComment) commentRow {
+ return commentRow{c.ID, c.Body, c.Pending, c.AuthorID, c.AuthorName, c.AuthorEmail, c.CreatedAt}
+}
+
+func pullCommentRow(c db.PullComment) commentRow {
+ return commentRow{c.ID, c.Body, c.Pending, c.AuthorID, c.AuthorName, c.AuthorEmail, c.CreatedAt}
+}
+
+// commentViews renders comment rows into views. ownerID marks the owner
+// badge; ownerView decides whether pending rows are shown at all.
+func commentViews[T any](items []T, ownerID int64, ownerView bool, base string, row func(T) commentRow) []commentView {
+ var views []commentView
+ for _, item := range items {
+ c := row(item)
+ if c.Pending && !ownerView {
+ continue
+ }
+ views = append(views, commentView{
+ ID: c.ID,
+ BodyHTML: markdownHTML(c.Body),
+ Pending: c.Pending,
+ Author: guestAuthorName(c.AuthorName),
+ AuthorIsOwner: c.AuthorID.Valid && c.AuthorID.Int64 == ownerID,
+ AuthorEmail: c.AuthorEmail,
+ Created: issuedAt(c.CreatedAt),
+ IsOwner: ownerView,
+ Base: base,
+ })
+ }
+ return views
+}
diff --git a/internal/web/commits.go b/internal/web/commits.go
new file mode 100644
index 0000000..32e1706
--- /dev/null
+++ b/internal/web/commits.go
@@ -0,0 +1,157 @@
+package web
+
+import (
+ "html/template"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "git.josie-c.com/josie/simplegit/internal/git"
+ "git.josie-c.com/josie/simplegit/internal/render"
+)
+
+const commitsPerPage = 50
+
+var shaPattern = regexp.MustCompile(`^[0-9a-fA-F]{4,40}$`)
+
+type commitRow struct {
+ SHA string // short, 7 chars
+ Href string
+ Subject string
+ Author string
+ Date string // trimmed ISO timestamp
+}
+
+type commitsData struct {
+ navData
+ Ref string
+ Commits []commitRow
+}
+
+type commitData struct {
+ navData
+ SHA string
+ Subject string
+ Body string
+ Author string
+ Email string
+ Date string
+ Parents []commitRow
+ TreeHref string
+ DiffHTML template.HTML
+ Notice string
+}
+
+func (s *Server) handleCommits(w http.ResponseWriter, r *http.Request) {
+ repo, repoPath, user, ok := s.resolveRepo(w, r)
+ if !ok {
+ return
+ }
+ owner := r.PathValue("user")
+ ref := strings.Trim(r.PathValue("ref"), "/")
+ if ref == "" {
+ ref = repo.DefaultBranch
+ }
+ if !validRef(ref) {
+ http.NotFound(w, r)
+ return
+ }
+ exists, err := git.RefExists(repoPath, ref)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if !exists {
+ http.NotFound(w, r)
+ return
+ }
+ base := "/" + owner + "/" + repo.Name
+
+ log, err := git.Log(repoPath, ref, commitsPerPage)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ data := commitsData{navData: nav(r, repo, user, "code"), Ref: ref}
+ for _, c := range log {
+ data.Commits = append(data.Commits, commitRow{
+ SHA: shortSHA(c.SHA),
+ Href: base + "/commit/" + c.SHA,
+ Subject: c.Subject,
+ Author: c.Author,
+ Date: trimDate(c.Date),
+ })
+ }
+ s.render(w, "commits.html", http.StatusOK, data)
+}
+
+func (s *Server) handleCommit(w http.ResponseWriter, r *http.Request) {
+ repo, repoPath, user, ok := s.resolveRepo(w, r)
+ if !ok {
+ return
+ }
+ owner := r.PathValue("user")
+ sha := r.PathValue("sha")
+ if !shaPattern.MatchString(sha) {
+ http.NotFound(w, r)
+ return
+ }
+ base := "/" + owner + "/" + repo.Name
+
+ c, err := git.CommitAt(repoPath, sha)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ data := commitData{
+ navData: nav(r, repo, user, "code"),
+ SHA: c.SHA, Subject: c.Subject,
+ Body: c.Body, Author: c.Author, Email: c.Email, Date: trimDate(c.Date),
+ TreeHref: base + "/tree/" + c.SHA + "/",
+ }
+ for _, p := range c.Parents {
+ if pc, err := git.CommitAt(repoPath, p); err == nil {
+ data.Parents = append(data.Parents, commitRow{
+ SHA: shortSHA(p), Href: base + "/commit/" + p, Subject: pc.Subject,
+ })
+ } else {
+ data.Parents = append(data.Parents, commitRow{SHA: shortSHA(p), Href: base + "/commit/" + p})
+ }
+ }
+
+ patch, err := git.ShowPatch(repoPath, c.SHA, maxDiffBytes)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if len(patch) > 0 {
+ data.DiffHTML, data.Notice = renderDiffHTML(patch)
+ }
+ s.render(w, "commit.html", http.StatusOK, data)
+}
+
+// renderDiffHTML caps a patch at maxDiffBytes and renders it highlighted,
+// falling back to an escaped <pre>; the notice is set when truncated.
+// Shared by the commit view and the PR diff.
+func renderDiffHTML(patch []byte) (template.HTML, string) {
+ notice := ""
+ if len(patch) > maxDiffBytes {
+ patch = patch[:maxDiffBytes]
+ notice = diffTruncatedNotice
+ }
+ if html, handled, err := render.CodeHTML(diffLexeme, string(patch)); err == nil && handled {
+ return template.HTML(html), notice
+ }
+ return template.HTML("<pre>" + template.HTMLEscapeString(string(patch)) + "</pre>"), notice
+}
+
+func shortSHA(sha string) string {
+ return sha[:min(len(sha), 7)]
+}
+
+func trimDate(iso string) string {
+ if len(iso) >= 16 {
+ return iso[:16]
+ }
+ return iso
+}
diff --git a/internal/web/commits_test.go b/internal/web/commits_test.go
new file mode 100644
index 0000000..7d3d66c
--- /dev/null
+++ b/internal/web/commits_test.go
@@ -0,0 +1,99 @@
+package web
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestCommitsListAndPrivateGate(t *testing.T) {
+ httpServer, _, dataDir := newTestServer(t)
+ loggedIn := newLoggedInClient(t, httpServer)
+ createRepo(t, loggedIn, httpServer, "pub", "public")
+ createRepo(t, loggedIn, httpServer, "sec", "private")
+ seedFiles(t, dataDir, "pub")
+ seedFiles(t, dataDir, "sec")
+
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/commits")
+ if err != nil {
+ t.Fatalf("GET commits: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, "seed") {
+ t.Fatalf("commits page = %d, want 200 with subject: %q", resp.StatusCode, body)
+ }
+ if !strings.Contains(body, "/josie/pub/commit/") {
+ t.Error("no commit links")
+ }
+
+ resp, err = noFollowClient().Get(httpServer.URL + "/josie/sec/commits")
+ if err != nil {
+ t.Fatalf("GET private commits: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("anonymous private commits = %d, want 404 (no existence oracle)", resp.StatusCode)
+ }
+
+ resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/commits/nosuchbranch")
+ if err != nil {
+ t.Fatalf("GET bad ref: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("bad ref = %d, want 404", resp.StatusCode)
+ }
+}
+
+func TestCommitView(t *testing.T) {
+ httpServer, _, dataDir := newTestServer(t)
+ loggedIn := newLoggedInClient(t, httpServer)
+ createRepo(t, loggedIn, httpServer, "pub", "public")
+ seedFiles(t, dataDir, "pub")
+
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/commits")
+ if err != nil {
+ t.Fatalf("GET commits: %v", err)
+ }
+ listing := readAll(t, resp)
+ start := strings.Index(listing, "/josie/pub/commit/")
+ if start < 0 {
+ t.Fatal("no commit link to follow")
+ }
+ rest := listing[start:]
+ sha := rest[len("/josie/pub/commit/"):]
+ sha = sha[:strings.IndexByte(sha, '"')]
+
+ resp, err = (&http.Client{}).Get(httpServer.URL + sha2href(sha))
+ if err != nil {
+ t.Fatalf("GET commit: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("commit view = %d", resp.StatusCode)
+ }
+ for _, want := range []string{"seed", sha, "README", "browse files at this commit"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("commit view lacks %q", want)
+ }
+ }
+
+ resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/commit/notahex!")
+ if err != nil {
+ t.Fatalf("GET bad sha: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("bad sha = %d, want 404", resp.StatusCode)
+ }
+ resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/commit/0000000000000000000000000000000000000000")
+ if err != nil {
+ t.Fatalf("GET missing sha: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("missing sha = %d, want 404", resp.StatusCode)
+ }
+}
+
+func sha2href(sha string) string { return "/josie/pub/commit/" + sha }
diff --git a/internal/web/consts.go b/internal/web/consts.go
new file mode 100644
index 0000000..ae59424
--- /dev/null
+++ b/internal/web/consts.go
@@ -0,0 +1,44 @@
+package web
+
+import (
+ "net/http"
+ "time"
+)
+
+// Shared limits, states, and literals for the web handlers, so issues.go,
+// pulls.go, comments.go, and the settings pages share one home.
+const (
+ maxIssueBody = 64 << 10
+ issueFormMax = 1 << 20
+ maxTitleLen = 300
+ maxNameLen = 100
+ maxEmailLen = 200
+
+ guestThreadCap = 10
+ guestCommentCap = 60
+ guestWindow = time.Hour
+
+ tokenLabelMax = 100
+
+ maxDiffBytes = 2 << 20
+ diffTruncatedNotice = "Diff is larger than 2 MB; truncated."
+ diffLexeme = "x.diff"
+
+ rawLimit = 16 << 20
+
+ visibilityPublic = "public"
+ visibilityPrivate = "private"
+
+ stateOpen = "open"
+ stateClosed = "closed"
+ stateMerged = "merged"
+
+ submittedParam = "submitted"
+ honeypotField = "website"
+ showAll = "all"
+)
+
+// submitted reports whether the ?submitted=1 confirmation flag is set.
+func submitted(r *http.Request) bool {
+ return r.URL.Query().Get(submittedParam) == "1"
+}
diff --git a/internal/web/git.go b/internal/web/git.go
new file mode 100644
index 0000000..3332ee8
--- /dev/null
+++ b/internal/web/git.go
@@ -0,0 +1,158 @@
+package web
+
+import (
+ "errors"
+ "log"
+ "net/http"
+ "path/filepath"
+ "strings"
+
+ "git.josie-c.com/josie/simplegit/internal/auth"
+ "git.josie-c.com/josie/simplegit/internal/db"
+ "git.josie-c.com/josie/simplegit/internal/git"
+)
+
+// isOwner reports whether user is the repository's owner.
+func isOwner(user *db.User, repo db.Repo) bool {
+ return user != nil && user.ID == repo.OwnerID
+}
+
+// canAccess reports whether user may perform the git operation: reads need
+// a public repo or ownership, writes always need ownership.
+func canAccess(user *db.User, repo db.Repo, write bool) bool {
+ if write {
+ return isOwner(user, repo)
+ }
+ return repo.Visibility == visibilityPublic || isOwner(user, repo)
+}
+
+// gitAuth resolves who the remote git client is for this request and
+// authorizes it. Order: browser session, then HTTP basic (password check
+// now, git tokens in M2.3); anonymous is allowed only to read public repos.
+// On failure it writes the response and returns ok=false.
+func (s *Server) gitAuth(w http.ResponseWriter, r *http.Request, repo db.Repo, write bool) (string, bool) {
+ if user := currentUser(r); user != nil {
+ if !canAccess(user, repo, write) {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return "", false
+ }
+ return user.Username, true
+ }
+ if username, secret, supplied := r.BasicAuth(); supplied {
+ user, ok := s.authenticateSecret(username, secret)
+ if !ok {
+ // Basic-auth failures share the login budget: the web password
+ // is a git credential, so guessing here is guessing there.
+ if !s.logins.allow(clientIP(r)) {
+ http.Error(w, "too many failed authentications; try again later", http.StatusTooManyRequests)
+ return "", false
+ }
+ s.gitChallenge(w)
+ return "", false
+ }
+ s.logins.reset(clientIP(r))
+ if !canAccess(user, repo, write) {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return "", false
+ }
+ return user.Username, true
+ }
+ if !write && repo.Visibility == visibilityPublic {
+ return "", true
+ }
+ s.gitChallenge(w)
+ return "", false
+}
+
+func (s *Server) gitChallenge(w http.ResponseWriter) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="simplegit"`)
+ http.Error(w, "authentication required", http.StatusUnauthorized)
+}
+
+// authenticateSecret checks a basic-auth username/secret pair against the
+// user's password hash, then against a git token digest. A token used this
+// way is recorded as used.
+func (s *Server) authenticateSecret(username, secret string) (*db.User, bool) {
+ user, err := db.GetUserByName(s.database, username)
+ if err != nil {
+ // Keep the timing flat with the wrong-password path.
+ _, _ = auth.HashPassword(secret)
+ return nil, false
+ }
+ if auth.CheckPassword(user.PasswordHash, secret) {
+ return &user, true
+ }
+ token, err := db.GetTokenByHash(s.database, auth.HashToken(secret))
+ if err != nil || token.UserID != user.ID {
+ return nil, false
+ }
+ _ = db.TouchToken(s.database, token.ID)
+ return &user, true
+}
+
+// gitRepoAndAuth handles the shared preamble: resolve {user}/{repo}.git to
+// a DB row and authenticate the client for the given operation.
+func (s *Server) gitRepoAndAuth(w http.ResponseWriter, r *http.Request, write bool) (string, bool) {
+ owner := r.PathValue("user")
+ repoGit := r.PathValue("repoGit")
+ if !strings.HasSuffix(repoGit, ".git") {
+ http.NotFound(w, r)
+ return "", false
+ }
+ repo, err := db.GetRepoByName(s.database, owner, strings.TrimSuffix(repoGit, ".git"))
+ if errors.Is(err, db.ErrNotFound) {
+ http.NotFound(w, r)
+ return "", false
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return "", false
+ }
+ return s.gitAuth(w, r, repo, write)
+}
+
+func (s *Server) serveBackend(w http.ResponseWriter, r *http.Request, remoteUser, service string) {
+ projectRoot := filepath.Join(s.cfg.DataDir, "repos")
+ if err := git.ServeBackend(projectRoot, remoteUser, service, r, w); err != nil {
+ log.Printf("web: http-backend %s: %v", r.URL.Path, err)
+ }
+}
+
+// handleGitRefs serves GET /{user}/{repo}.git/info/refs — the ref
+// advertisement. Only the smart protocol is offered: the service is either
+// upload-pack (read) or receive-pack (push, owner-only).
+func (s *Server) handleGitRefs(w http.ResponseWriter, r *http.Request) {
+ service := r.URL.Query().Get("service")
+ if service != "git-upload-pack" && service != "git-receive-pack" {
+ if _, ok := s.gitRepoAndAuth(w, r, false); !ok {
+ return
+ }
+ http.Error(w, "smart HTTP only: a service parameter is required", http.StatusForbidden)
+ return
+ }
+ remoteUser, ok := s.gitRepoAndAuth(w, r, service == "git-receive-pack")
+ if !ok {
+ return
+ }
+ s.serveBackend(w, r, remoteUser, service)
+}
+
+// handleGitUploadPack serves the POST body half of a clone/fetch.
+func (s *Server) handleGitUploadPack(w http.ResponseWriter, r *http.Request) {
+ remoteUser, ok := s.gitRepoAndAuth(w, r, false)
+ if !ok {
+ return
+ }
+ s.serveBackend(w, r, remoteUser, "git-upload-pack")
+}
+
+// handleGitReceivePack serves a push: owner-only, then http-backend with
+// REMOTE_USER set, which is what lets git allow receive-pack (http.receivepack
+// stays unset on purpose — the authorization decision lives here in Go).
+func (s *Server) handleGitReceivePack(w http.ResponseWriter, r *http.Request) {
+ remoteUser, ok := s.gitRepoAndAuth(w, r, true)
+ if !ok {
+ return
+ }
+ s.serveBackend(w, r, remoteUser, "git-receive-pack")
+}
diff --git a/internal/web/git_test.go b/internal/web/git_test.go
new file mode 100644
index 0000000..c8fc0f4
--- /dev/null
+++ b/internal/web/git_test.go
@@ -0,0 +1,308 @@
+package web
+
+import (
+ "database/sql"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+func addUser(t *testing.T, database *sql.DB, username, password string) {
+ t.Helper()
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
+ if err != nil {
+ t.Fatalf("hash password: %v", err)
+ }
+ if _, err := db.CreateUser(database, username, string(hash)); err != nil {
+ t.Fatalf("create user %s: %v", username, err)
+ }
+}
+
+func loginAs(t *testing.T, httpServer *httptest.Server, username, password string) *http.Client {
+ t.Helper()
+ client := &http.Client{Transport: httpServer.Client().Transport}
+ client.Jar, _ = cookiejar.New(nil)
+ resp, err := client.PostForm(httpServer.URL+"/login",
+ url.Values{"username": {username}, "password": {password}})
+ if err != nil {
+ t.Fatalf("POST /login %s: %v", username, err)
+ }
+ if body := readAll(t, resp); !strings.Contains(body, `href="/`+username+`"`) {
+ t.Fatalf("login as %s failed: %q", username, body)
+ }
+ return client
+}
+
+func runGit(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+ return string(out)
+}
+
+func createRepo(t *testing.T, client *http.Client, server *httptest.Server, name, visibility string) {
+ t.Helper()
+ resp, err := client.PostForm(server.URL+"/new", url.Values{
+ "name": {name}, "visibility": {visibility},
+ })
+ if err != nil {
+ t.Fatalf("POST /new %s: %v", name, err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("create %s: status %d body %q", name, resp.StatusCode, body)
+ }
+}
+
+func TestGitPublicCloneAnonymous(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub.git/info/refs?service=git-upload-pack")
+ if err != nil {
+ t.Fatalf("anonymous clone refs: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200: %q", resp.StatusCode, body)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "application/x-git-upload-pack-advertisement") {
+ t.Errorf("Content-Type = %q", ct)
+ }
+}
+
+func TestGitPrivateGate(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "sec", "private")
+ refsURL := httpServer.URL + "/josie/sec.git/info/refs?service=git-upload-pack"
+
+ resp, err := (&http.Client{}).Get(refsURL)
+ if err != nil {
+ t.Fatalf("anonymous private refs: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("anonymous status = %d, want 401", resp.StatusCode)
+ }
+ if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Basic") {
+ t.Errorf("WWW-Authenticate = %q, want Basic challenge", resp.Header.Get("WWW-Authenticate"))
+ }
+
+ badReq, _ := http.NewRequest("GET", refsURL, nil)
+ badReq.SetBasicAuth("josie", "wrong")
+ badResp, err := (&http.Client{}).Do(badReq)
+ if err != nil {
+ t.Fatalf("bad basic refs: %v", err)
+ }
+ readAll(t, badResp)
+ if badResp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("bad basic status = %d, want 401", badResp.StatusCode)
+ }
+
+ goodReq, _ := http.NewRequest("GET", refsURL, nil)
+ goodReq.SetBasicAuth("josie", "hunter2")
+ goodResp, err := (&http.Client{}).Do(goodReq)
+ if err != nil {
+ t.Fatalf("good basic refs: %v", err)
+ }
+ readAll(t, goodResp)
+ if goodResp.StatusCode != http.StatusOK {
+ t.Errorf("basic-auth status = %d, want 200", goodResp.StatusCode)
+ }
+
+ signedIn := newLoggedInClient(t, httpServer)
+ resp, err = signedIn.Get(refsURL)
+ if err != nil {
+ t.Fatalf("session refs: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("session-cookie status = %d, want 200", resp.StatusCode)
+ }
+}
+
+func TestGitUnknownPaths(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ for _, path := range []string{
+ "/josie/nope.git/info/refs?service=git-upload-pack",
+ "/other/pub.git/info/refs?service=git-upload-pack",
+ "/josie/pub/info/refs?service=git-upload-pack",
+ "/josie/pub.git/not-a-service",
+ } {
+ resp, err := (&http.Client{}).Get(httpServer.URL + path)
+ if err != nil {
+ t.Fatalf("GET %s: %v", path, err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("GET %s = %d, want 404", path, resp.StatusCode)
+ }
+ }
+}
+
+func TestGitSmartOnly(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+
+ for _, path := range []string{
+ "/josie/pub.git/info/refs",
+ "/josie/pub.git/info/refs?service=nonsense",
+ } {
+ resp, err := (&http.Client{}).Get(httpServer.URL + path)
+ if err != nil {
+ t.Fatalf("GET %s: %v", path, err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusForbidden {
+ t.Errorf("GET %s = %d, want 403: %q", path, resp.StatusCode, body)
+ }
+ }
+}
+
+func TestGitReceivePackAuth(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+ addUser(t, database, "mallory", "pw")
+ refsURL := httpServer.URL + "/josie/pub.git/info/refs?service=git-receive-pack"
+
+ resp, err := (&http.Client{}).Get(refsURL)
+ if err != nil {
+ t.Fatalf("anonymous receive-pack refs: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("anonymous status = %d, want 401 challenge", resp.StatusCode)
+ }
+
+ ownerReq, _ := http.NewRequest("GET", refsURL, nil)
+ ownerReq.SetBasicAuth("josie", "hunter2")
+ ownerResp, err := (&http.Client{}).Do(ownerReq)
+ if err != nil {
+ t.Fatalf("owner receive-pack refs: %v", err)
+ }
+ readAll(t, ownerResp)
+ if ownerResp.StatusCode != http.StatusOK {
+ t.Errorf("owner status = %d, want 200", ownerResp.StatusCode)
+ }
+
+ otherReq, _ := http.NewRequest("GET", refsURL, nil)
+ otherReq.SetBasicAuth("mallory", "pw")
+ otherResp, err := (&http.Client{}).Do(otherReq)
+ if err != nil {
+ t.Fatalf("non-owner receive-pack refs: %v", err)
+ }
+ readAll(t, otherResp)
+ if otherResp.StatusCode != http.StatusForbidden {
+ t.Errorf("non-owner status = %d, want 403", otherResp.StatusCode)
+ }
+}
+
+func TestGitRefsPinsAuthorizedService(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+ addUser(t, database, "mallory", "pw")
+
+ refsURL := httpServer.URL + "/josie/pub.git/info/refs?service=git-upload-pack&service=git-receive-pack"
+ req, _ := http.NewRequest("GET", refsURL, nil)
+ req.SetBasicAuth("mallory", "pw")
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ t.Fatalf("dup-service refs: %v", err)
+ }
+ readAll(t, resp)
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "git-upload-pack-advertisement") {
+ t.Errorf("Content-Type = %q, want upload-pack advertisement (read auth pins the service)", ct)
+ }
+}
+
+func TestGitPushOwner(t *testing.T) {
+ httpServer, _, dataDir := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+
+ u, err := url.Parse(httpServer.URL)
+ if err != nil {
+ t.Fatalf("parse server URL: %v", err)
+ }
+ repoURL := "http://josie:hunter2@" + u.Host + "/josie/pub.git"
+
+ work := filepath.Join(t.TempDir(), "work")
+ runGit(t, "", "-c", "credential.helper=", "clone", "-q", repoURL, work)
+ if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("hello\n"), 0o644); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+ runGit(t, work, "add", ".")
+ runGit(t, work, "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "first")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+
+ bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
+ if out, err := exec.Command("git", "-C", bare, "rev-parse", "--verify", "refs/heads/main").CombinedOutput(); err != nil {
+ t.Fatalf("pushed ref missing: %v: %s", err, out)
+ }
+}
+
+func TestGitPushNonOwnerDenied(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+ addUser(t, database, "mallory", "pw")
+
+ req, _ := http.NewRequest("POST", httpServer.URL+"/josie/pub.git/git-receive-pack",
+ strings.NewReader("x"))
+ req.SetBasicAuth("mallory", "pw")
+ req.Header.Set("Content-Type", "application/x-git-receive-pack-request")
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ t.Fatalf("POST receive-pack as non-owner: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusForbidden {
+ t.Errorf("non-owner push = %d, want 403", resp.StatusCode)
+ }
+}
+
+// Basic-auth failures on the git endpoints share the login budget: the web
+// password is a git credential, so guessing here is throttled like /login.
+func TestGitBasicAuthRateLimited(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "sec", "private")
+ refsURL := httpServer.URL + "/josie/sec.git/info/refs?service=git-receive-pack"
+
+ for i := 0; i < loginAttempts; i++ {
+ req, err := http.NewRequest("GET", refsURL, nil)
+ if err != nil {
+ t.Fatalf("request %d: %v", i+1, err)
+ }
+ req.SetBasicAuth("josie", "wrong")
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ t.Fatalf("attempt %d: %v", i+1, err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("attempt %d = %d, want 401", i+1, resp.StatusCode)
+ }
+ }
+ req, _ := http.NewRequest("GET", refsURL, nil)
+ req.SetBasicAuth("josie", "wrong")
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ t.Fatalf("limited attempt: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusTooManyRequests {
+ t.Errorf("attempt %d = %d, want 429", loginAttempts+1, resp.StatusCode)
+ }
+}
diff --git a/internal/web/issues.go b/internal/web/issues.go
new file mode 100644
index 0000000..0b68343
--- /dev/null
+++ b/internal/web/issues.go
@@ -0,0 +1,599 @@
+package web
+
+import (
+ "database/sql"
+ "errors"
+ "html/template"
+ "net"
+ "net/http"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+ "git.josie-c.com/josie/simplegit/internal/render"
+)
+
+// repoPage resolves {user}/{repo} for the HTML pages with the site-wide
+// visibility gate. On failure it has written the response.
+func (s *Server) repoPage(w http.ResponseWriter, r *http.Request) (db.Repo, *db.User, bool) {
+ repo, err := db.GetRepoByName(s.database, r.PathValue("user"), r.PathValue("repo"))
+ if errors.Is(err, db.ErrNotFound) {
+ http.NotFound(w, r)
+ return db.Repo{}, nil, false
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return db.Repo{}, nil, false
+ }
+ user := currentUser(r)
+ // Private repos 404 for everyone but the owner, so existence is not an
+ // anonymous oracle.
+ if repo.Visibility != visibilityPublic && !isOwner(user, repo) {
+ http.NotFound(w, r)
+ return db.Repo{}, nil, false
+ }
+ return repo, user, true
+}
+
+// canWriteContent reports whether the caller may author issues/comments:
+// the owner always, a guest only on a public repo.
+func canWriteContent(user *db.User, repo db.Repo) bool {
+ return isOwner(user, repo) || repo.Visibility == visibilityPublic
+}
+
+func issueBasePath(r *http.Request) string {
+ return "/" + r.PathValue("user") + "/" + r.PathValue("repo") + "/issues"
+}
+
+func issueNumber(r *http.Request) (int64, bool) {
+ number, err := strconv.ParseInt(r.PathValue("number"), 10, 64)
+ return number, err == nil && number > 0
+}
+
+// threadHref builds an issue/PR URL from its base path and number.
+func threadHref(base string, number int64) string {
+ return base + "/" + strconv.FormatInt(number, 10)
+}
+
+// showFilter normalizes the ?show= list filter: anything not in allowed
+// falls back to "open". state is "" for showAll, so list queries skip the
+// state predicate.
+func showFilter(r *http.Request, allowed ...string) (show, state string) {
+ show = r.URL.Query().Get("show")
+ if !slices.Contains(allowed, show) {
+ show = stateOpen
+ }
+ if show == showAll {
+ return show, ""
+ }
+ return show, show
+}
+
+// pathID parses the {id} path value (a comment or asset id).
+func pathID(r *http.Request) (int64, bool) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ return id, err == nil && id > 0
+}
+
+func isHTMX(r *http.Request) bool {
+ return r.Header.Get("HX-Request") == "true"
+}
+
+func clientIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+func issuedAt(epoch int64) string {
+ return time.Unix(epoch, 0).UTC().Format("2006-01-02 15:04")
+}
+
+func guestAuthorName(name string) string {
+ if name == "" {
+ return "Anonymous"
+ }
+ return name
+}
+
+// truncate cuts s to max bytes without splitting a UTF-8 rune.
+func truncate(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ for max > 0 && !utf8.RuneStart(s[max]) {
+ max--
+ }
+ return s[:max]
+}
+
+// formText reads a form value, trimmed and capped at max bytes.
+func formText(r *http.Request, name string, max int) string {
+ return truncate(strings.TrimSpace(r.FormValue(name)), max)
+}
+
+func markdownHTML(source string) template.HTML {
+ html, err := render.Markdown([]byte(source))
+ if err != nil {
+ return template.HTML("<pre>" + template.HTMLEscapeString(source) + "</pre>")
+ }
+ return template.HTML(html)
+}
+
+// issueIdentity returns the stored author (id 0 for a guest) for a new row.
+// An authenticated author is attributed to their account; a guest supplies a
+// self-claimed display name and optional email. A guest cannot claim the
+// repo owner's name — an approved row would otherwise read as the owner's.
+func issueIdentity(r *http.Request, user *db.User, ownerName string) (int64, string, string) {
+ if user != nil {
+ return user.ID, user.Username, ""
+ }
+ name := formText(r, "author_name", maxNameLen)
+ if strings.EqualFold(name, ownerName) {
+ name = ""
+ }
+ email := formText(r, "author_email", maxEmailLen)
+ return 0, guestAuthorName(name), email
+}
+
+type issueListRow struct {
+ Number int64
+ Title string
+ State string
+ Pending bool
+ Author string
+ AuthorIsOwner bool
+ Created string
+ Href string
+}
+
+type issueListData struct {
+ navData
+ Show string
+ IsOwner bool
+ CanWrite bool
+ PendingCount int
+ Issues []issueListRow
+}
+
+// handleIssues renders the issue list. Anonymous visitors of a public repo
+// see non-pending issues; the owner's ?show=owner view is the pending
+// moderation queue.
+func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) {
+ repo, user, ok := s.repoPage(w, r)
+ if !ok {
+ return
+ }
+ ownerView := isOwner(user, repo)
+ show, state := showFilter(r, stateClosed, showAll)
+
+ issues, err := db.ListIssues(s.database, repo.ID, ownerView, state)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ data := issueListData{
+ navData: nav(r, repo, user, "issues"), Show: show,
+ IsOwner: ownerView, CanWrite: canWriteContent(user, repo),
+ }
+ if ownerView {
+ if data.PendingCount, err = db.CountPending(s.database, repo.ID); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ }
+ for _, issue := range issues {
+ data.Issues = append(data.Issues, issueListRow{
+ Number: issue.Number,
+ Title: issue.Title,
+ State: issue.State,
+ Pending: issue.Pending,
+ Author: guestAuthorName(issue.AuthorName),
+ AuthorIsOwner: issue.AuthorID.Valid &&
+ issue.AuthorID.Int64 == repo.OwnerID,
+ Created: issuedAt(issue.CreatedAt),
+ Href: threadHref(issueBasePath(r), issue.Number),
+ })
+ }
+ s.render(w, "issues.html", http.StatusOK, data)
+}
+
+type issueNewData struct {
+ navData
+ IsOwner bool
+ Title string
+ Body string
+ Error string
+}
+
+// handleIssueNewForm renders the issue form. Guests may file on public repos.
+func (s *Server) handleIssueNewForm(w http.ResponseWriter, r *http.Request) {
+ repo, user, ok := s.repoPage(w, r)
+ if !ok {
+ return
+ }
+ if !canWriteContent(user, repo) {
+ http.NotFound(w, r)
+ return
+ }
+ s.render(w, "issue_new.html", http.StatusOK, issueNewData{
+ navData: nav(r, repo, user, "issues"),
+ IsOwner: isOwner(user, repo),
+ })
+}
+
+// handleCreateIssue stores a new issue. The owner's issue is published
+// immediately; a guest's is pending owner moderation.
+func (s *Server) handleCreateIssue(w http.ResponseWriter, r *http.Request) {
+ repo, user, ok := s.repoPage(w, r)
+ if !ok {
+ return
+ }
+ if !canWriteContent(user, repo) {
+ http.NotFound(w, r)
+ return
+ }
+ trusted := isOwner(user, repo)
+ if !trusted && !s.guestThreads.allow(clientIP(r)) {
+ http.Error(w, "too many issues filed; try again later", http.StatusTooManyRequests)
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, issueFormMax)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ title := formText(r, "title", maxTitleLen)
+ body := formText(r, "body", maxIssueBody)
+ fail := func(msg string) {
+ data := issueNewData{
+ navData: nav(r, repo, user, "issues"), IsOwner: trusted,
+ Title: title, Body: body, Error: msg,
+ }
+ s.render(w, "issue_new.html", http.StatusUnprocessableEntity, data)
+ }
+ if title == "" {
+ fail("a title is required")
+ return
+ }
+ if !trusted && strings.TrimSpace(r.FormValue(honeypotField)) != "" {
+ // Honeypot: pretend success, store nothing.
+ http.Redirect(w, r, issueBasePath(r)+"?"+submittedParam+"=1", http.StatusSeeOther)
+ return
+ }
+
+ authorID, authorName, authorEmail := issueIdentity(r, user, r.PathValue("user"))
+ if !trusted {
+ dup, err := db.HasDuplicateIssue(s.database, repo.ID, title, body, authorName, authorEmail)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if dup {
+ // Identical filing already stored; answer exactly like a fresh
+ // submission so a spammer gets no oracle.
+ s.render(w, "issue_submitted.html", http.StatusOK, nav(r, repo, user, "issues"))
+ return
+ }
+ }
+ issue, err := db.CreateIssue(s.database, repo.ID, title, body, authorID, authorName, authorEmail, !trusted)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if trusted {
+ http.Redirect(w, r, threadHref(issueBasePath(r), issue.Number), http.StatusSeeOther)
+ return
+ }
+ s.render(w, "issue_submitted.html", http.StatusOK, nav(r, repo, user, "issues"))
+}
+
+type commentView struct {
+ ID int64
+ BodyHTML template.HTML
+ Pending bool
+ Author string
+ AuthorIsOwner bool
+ AuthorEmail string
+ Created string
+ IsOwner bool
+ Base string
+}
+
+type issueViewData struct {
+ navData
+ Number int64
+ Title string
+ State string
+ Pending bool
+ Author string
+ AuthorIsOwner bool
+ AuthorEmail string
+ Created string
+ BodyHTML template.HTML
+ Comments []commentView
+ IsOwner bool
+ CanWrite bool
+ Base string
+ Submitted bool
+}
+
+// fetchByNumber loads a thread row by (repo, number), mapping not-found to
+// 404 and anything else to the generic 500 — the preamble every issue/PR
+// handler shares.
+func fetchByNumber[T any](s *Server, w http.ResponseWriter, r *http.Request, repoID, number int64, fetch func(*sql.DB, int64, int64) (T, error)) (T, bool) {
+ var zero T
+ item, err := fetch(s.database, repoID, number)
+ if errors.Is(err, db.ErrNotFound) {
+ http.NotFound(w, r)
+ return zero, false
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return zero, false
+ }
+ return item, true
+}
+
+// handleIssueView shows one issue and its comments. A non-owner cannot see a
+// pending issue; pending comments are visible only to the owner.
+func (s *Server) handleIssueView(w http.ResponseWriter, r *http.Request) {
+ repo, user, number, ok := s.repoNumber(w, r)
+ if !ok {
+ return
+ }
+ issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber)
+ if !ok {
+ return
+ }
+ ownerView := isOwner(user, repo)
+ if issue.Pending && !ownerView {
+ http.NotFound(w, r)
+ return
+ }
+ comments, err := db.ListComments(s.database, issue.ID, ownerView)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ base := threadHref(issueBasePath(r), number)
+ data := issueViewData{
+ navData: nav(r, repo, user, "issues"), Number: number,
+ Title: issue.Title, State: issue.State, Pending: issue.Pending,
+ Author: guestAuthorName(issue.AuthorName),
+ AuthorIsOwner: issue.AuthorID.Valid && issue.AuthorID.Int64 == repo.OwnerID,
+ AuthorEmail: issue.AuthorEmail,
+ Created: issuedAt(issue.CreatedAt),
+ BodyHTML: markdownHTML(issue.Body),
+ IsOwner: ownerView, CanWrite: canWriteContent(user, repo),
+ Base: base,
+ Submitted: submitted(r),
+ }
+ data.Comments = commentViews(comments, repo.OwnerID, ownerView, base, issueCommentRow)
+ s.render(w, "issue.html", http.StatusOK, data)
+}
+
+type issueStateData struct {
+ Base string
+ State string
+ IsOwner bool
+ Pending bool
+}
+
+// handleIssueState closes or reopens an issue (owner-only).
+func (s *Server) handleIssueState(w http.ResponseWriter, r *http.Request, state string) {
+ repo, _, number, ok := s.ownerNumber(w, r)
+ if !ok {
+ return
+ }
+ issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber)
+ if !ok {
+ return
+ }
+ if err := db.SetIssueState(s.database, repo.ID, number, state); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ base := threadHref(issueBasePath(r), number)
+ if isHTMX(r) {
+ s.renderFragment(w, "issue.html", "state", issueStateData{
+ Base: base, State: state, IsOwner: true, Pending: issue.Pending,
+ })
+ return
+ }
+ http.Redirect(w, r, base, http.StatusSeeOther)
+}
+
+// handleCreateComment stores an issue comment through the shared comment
+// pipeline: owner comments publish immediately, guest comments are pending
+// moderation and are never echoed back as a visible comment.
+func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
+ repo, user, number, ok := s.repoNumber(w, r)
+ if !ok {
+ return
+ }
+ base := threadHref(issueBasePath(r), number)
+ issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber)
+ if !ok {
+ return
+ }
+ if issue.Pending && !isOwner(user, repo) {
+ http.NotFound(w, r)
+ return
+ }
+ s.createComment(w, r, repo, user, issue.ID, base, commentFlow{
+ page: "issue.html", pendingFrag: "comment_pending", commentFrag: "comment",
+ create: func(in commentInput) (commentView, error) {
+ created, err := db.CreateComment(s.database, in.parentID, in.body, in.authorID, in.authorName, in.authorEmail, in.pending)
+ if err != nil {
+ return commentView{}, err
+ }
+ return commentViews([]db.IssueComment{created}, repo.OwnerID, true, base, issueCommentRow)[0], nil
+ },
+ })
+}
+
+// commentFlow names the issue/pull-specific template pieces and the comment
+// constructor used by the shared createComment pipeline.
+type commentFlow struct {
+ page string
+ pendingFrag string
+ commentFrag string
+ create func(commentInput) (commentView, error)
+}
+
+// commentInput is everything createComment has resolved by the time it is
+// ready to store the comment.
+type commentInput struct {
+ parentID int64
+ body string
+ authorID int64
+ authorName string
+ authorEmail string
+ pending bool
+}
+
+// createComment is the shared guest/owner comment pipeline for issues and
+// pull requests.
+func (s *Server) createComment(w http.ResponseWriter, r *http.Request, repo db.Repo, user *db.User, parentID int64, base string, f commentFlow) {
+ trusted := isOwner(user, repo)
+ if !canWriteContent(user, repo) {
+ http.NotFound(w, r)
+ return
+ }
+ if !trusted && !s.guestComments.allow(clientIP(r)) {
+ http.Error(w, "too many comments; try again later", http.StatusTooManyRequests)
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, issueFormMax)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ body := formText(r, "body", maxIssueBody)
+ if body == "" {
+ if isHTMX(r) {
+ http.Error(w, "a comment cannot be empty", http.StatusUnprocessableEntity)
+ } else {
+ http.Redirect(w, r, base, http.StatusSeeOther)
+ }
+ return
+ }
+ if !trusted && strings.TrimSpace(r.FormValue(honeypotField)) != "" {
+ if isHTMX(r) {
+ s.renderFragment(w, f.page, f.pendingFrag, nil)
+ } else {
+ http.Redirect(w, r, base+"?"+submittedParam+"=1", http.StatusSeeOther)
+ }
+ return
+ }
+
+ authorID, authorName, authorEmail := issueIdentity(r, user, r.PathValue("user"))
+ view, err := f.create(commentInput{parentID, body, authorID, authorName, authorEmail, !trusted})
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if !trusted {
+ if isHTMX(r) {
+ s.renderFragment(w, f.page, f.pendingFrag, nil)
+ } else {
+ http.Redirect(w, r, base+"?"+submittedParam+"=1", http.StatusSeeOther)
+ }
+ return
+ }
+ if isHTMX(r) {
+ s.renderFragment(w, f.page, f.commentFrag, view)
+ return
+ }
+ http.Redirect(w, r, base, http.StatusSeeOther)
+}
+
+// handleApproveIssue publishes a pending issue (owner-only).
+func (s *Server) handleApproveIssue(w http.ResponseWriter, r *http.Request) {
+ s.moderateNumber(w, r, func(repo db.Repo, number int64) error {
+ return db.ApproveIssue(s.database, repo.ID, number)
+ }, issueBasePath(r)+"/"+r.PathValue("number"))
+}
+
+// handleDeleteIssue removes an issue (owner-only).
+func (s *Server) handleDeleteIssue(w http.ResponseWriter, r *http.Request) {
+ s.moderateNumber(w, r, func(repo db.Repo, number int64) error {
+ return db.DeleteIssue(s.database, repo.ID, number)
+ }, issueBasePath(r))
+}
+
+// moderateNumber resolves the owner-only thread target, runs fn on it, and
+// redirects to the caller's next page. Shared by issue and PR moderation.
+func (s *Server) moderateNumber(w http.ResponseWriter, r *http.Request, fn func(db.Repo, int64) error, redirect string) {
+ repo, _, number, ok := s.ownerNumber(w, r)
+ if !ok {
+ return
+ }
+ if err := fn(repo, number); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, redirect, http.StatusSeeOther)
+}
+
+// repoNumber resolves a repo page and parses the {number} path value.
+func (s *Server) repoNumber(w http.ResponseWriter, r *http.Request) (db.Repo, *db.User, int64, bool) {
+ repo, user, ok := s.repoPage(w, r)
+ if !ok {
+ return db.Repo{}, nil, 0, false
+ }
+ number, ok := issueNumber(r)
+ if !ok {
+ http.NotFound(w, r)
+ return db.Repo{}, nil, 0, false
+ }
+ return repo, user, number, true
+}
+
+// ownerNumber resolves a repo page, requires ownership, and parses {number}.
+func (s *Server) ownerNumber(w http.ResponseWriter, r *http.Request) (db.Repo, *db.User, int64, bool) {
+ repo, user, number, ok := s.repoNumber(w, r)
+ if !ok {
+ return db.Repo{}, nil, 0, false
+ }
+ if !isOwner(user, repo) {
+ http.NotFound(w, r)
+ return db.Repo{}, nil, 0, false
+ }
+ return repo, user, number, true
+}
+
+// handleModerateComment approves or deletes an issue comment (owner-only).
+func (s *Server) handleModerateComment(w http.ResponseWriter, r *http.Request, approve bool) {
+ repo, _, number, ok := s.ownerNumber(w, r)
+ if !ok {
+ return
+ }
+ base := threadHref(issueBasePath(r), number)
+ issue, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetIssueByNumber)
+ if !ok {
+ return
+ }
+ id, ok := pathID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ moderate := db.ApproveComment
+ if !approve {
+ moderate = db.DeleteComment
+ }
+ if err := moderate(s.database, issue.ID, id); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, base, http.StatusSeeOther)
+}
diff --git a/internal/web/issues_test.go b/internal/web/issues_test.go
new file mode 100644
index 0000000..fb33eee
--- /dev/null
+++ b/internal/web/issues_test.go
@@ -0,0 +1,507 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+func postIssue(t *testing.T, client *http.Client, server string, owner, repo string, form url.Values) *http.Response {
+ t.Helper()
+ resp, err := client.PostForm(server+"/"+owner+"/"+repo+"/issues/new", form)
+ if err != nil {
+ t.Fatalf("POST issue: %v", err)
+ }
+ return resp
+}
+
+// noFollow stops the client at the redirect so tests can assert on 303s.
+func noFollow(c *http.Client) *http.Client {
+ c.CheckRedirect = func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }
+ return c
+}
+
+// The route table must register without a ServeMux conflict; New().Handler()
+// panics if two issue patterns are mutually non-specific.
+func TestIssueRoutesRegister(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ client := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, client, httpServer, "pub", "public")
+
+ for _, path := range []string{
+ "/josie/pub/issues",
+ "/josie/pub/issues/new",
+ } {
+ resp, err := client.Get(httpServer.URL + path)
+ if err != nil {
+ t.Fatalf("GET %s: %v", path, err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("GET %s status = %d, want 200", path, resp.StatusCode)
+ }
+ }
+}
+
+func TestOwnerFilesPublishedIssue(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ client := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, client, httpServer, "pub", "public")
+
+ resp := postIssue(t, client, httpServer.URL, "josie", "pub", url.Values{
+ "title": {"hello"}, "body": {"**bold**"},
+ })
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("owner POST issue status = %d, want 303", resp.StatusCode)
+ }
+
+ repo, err := db.GetRepoByName(database, "josie", "pub")
+ if err != nil {
+ t.Fatalf("GetRepoByName: %v", err)
+ }
+ issues, err := db.ListIssues(database, repo.ID, false, "")
+ if err != nil {
+ t.Fatalf("ListIssues: %v", err)
+ }
+ if len(issues) != 1 || issues[0].Pending {
+ t.Fatalf("issues = %+v, want one published issue", issues)
+ }
+
+ anonymous := &http.Client{}
+ view, err := anonymous.Get(httpServer.URL + "/josie/pub/issues/1")
+ if err != nil {
+ t.Fatalf("anonymous GET issue: %v", err)
+ }
+ body := readAll(t, view)
+ if view.StatusCode != http.StatusOK {
+ t.Fatalf("anonymous issue status = %d, want 200", view.StatusCode)
+ }
+ if !strings.Contains(body, "opened by josie") || !strings.Contains(body, "owner") {
+ t.Errorf("issue page lacks owner attribution: %q", body)
+ }
+ if !strings.Contains(body, "<strong>bold</strong>") {
+ t.Errorf("issue body not rendered as markdown: %q", body)
+ }
+}
+
+// A guest filing the identical issue twice gets the success page twice but
+// only one row is stored — no oracle for probing, no moderation pile-up.
+func TestGuestIssueDuplicateSilentlyDeduped(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+
+ guest := noFollow(&http.Client{})
+ form := url.Values{
+ "title": {"typo in readme"}, "body": {"please fix"}, "author_name": {"passer-by"},
+ }
+ first := postIssue(t, guest, httpServer.URL, "josie", "pub", form)
+ body := readAll(t, first)
+ if first.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
+ t.Fatalf("first submit status=%d body=%q", first.StatusCode, body)
+ }
+ second := postIssue(t, guest, httpServer.URL, "josie", "pub", form)
+ body = readAll(t, second)
+ if second.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
+ t.Fatalf("duplicate submit status=%d body=%q, want the same notice", second.StatusCode, body)
+ }
+
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ issues, _ := db.ListIssues(database, repo.ID, true, "")
+ if len(issues) != 1 {
+ t.Errorf("issues = %d rows, want 1 after dedupe", len(issues))
+ }
+}
+
+// A guest claiming the repo owner's display name is stored as Anonymous, so
+// an approved row can never read as the owner's.
+func TestGuestCannotClaimOwnerName(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+
+ guest := noFollow(&http.Client{})
+ resp := postIssue(t, guest, httpServer.URL, "josie", "pub", url.Values{
+ "title": {"hello"}, "body": {"world"}, "author_name": {"JoSie"},
+ })
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
+ t.Fatalf("guest submit status=%d body=%q", resp.StatusCode, body)
+ }
+
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ issues, _ := db.ListIssues(database, repo.ID, true, "")
+ if len(issues) != 1 {
+ t.Fatalf("issues = %d rows, want 1", len(issues))
+ }
+ if issues[0].AuthorName != "Anonymous" {
+ t.Errorf("AuthorName = %q, want Anonymous for a guest claiming the owner name", issues[0].AuthorName)
+ }
+}
+
+// The shared guest_fields define must render on the anonymous new-issue
+// form and on an issue page's comment form.
+func TestGuestFieldsRender(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+
+ resp, err := httpServer.Client().Get(httpServer.URL + "/josie/pub/issues/new")
+ if err != nil {
+ t.Fatalf("GET issues/new: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="author_name"`) || !strings.Contains(body, `name="author_email"`) {
+ t.Fatalf("anonymous new-issue form lacks the guest fieldset: status=%d body=%q", resp.StatusCode, body)
+ }
+
+ filed := postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{
+ "title": {"owner issue"}, "body": {"body"},
+ })
+ readAll(t, filed)
+
+ resp, err = httpServer.Client().Get(httpServer.URL + "/josie/pub/issues/1")
+ if err != nil {
+ t.Fatalf("GET issue view: %v", err)
+ }
+ body = readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="comment_name"`) {
+ t.Fatalf("anonymous issue view lacks the guest comment fieldset: status=%d body=%q", resp.StatusCode, body)
+ }
+}
+
+func TestGuestIssuePendingModeration(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+
+ guest := noFollow(&http.Client{})
+ resp := postIssue(t, guest, httpServer.URL, "josie", "pub", url.Values{
+ "title": {"typo in readme"}, "body": {"please fix"}, "author_name": {"passer-by"},
+ })
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
+ t.Fatalf("guest submit status=%d body=%q, want a review notice", resp.StatusCode, body)
+ }
+
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ issues, _ := db.ListIssues(database, repo.ID, true, "")
+ if len(issues) != 1 || !issues[0].Pending || issues[0].AuthorID.Valid {
+ t.Fatalf("issues = %+v, want one pending guest issue with NULL author_id", issues)
+ }
+ if issues[0].AuthorName != "passer-by" {
+ t.Errorf("AuthorName = %q, want passer-by", issues[0].AuthorName)
+ }
+
+ // Hidden from the public both in the list and by direct URL.
+ list, err := guest.Get(httpServer.URL + "/josie/pub/issues")
+ if err != nil {
+ t.Fatalf("anonymous list: %v", err)
+ }
+ if body := readAll(t, list); strings.Contains(body, "typo in readme") {
+ t.Error("pending issue leaked into the anonymous list")
+ }
+ direct, err := guest.Get(httpServer.URL + "/josie/pub/issues/1")
+ if err != nil {
+ t.Fatalf("anonymous direct: %v", err)
+ }
+ readAll(t, direct)
+ if direct.StatusCode != http.StatusNotFound {
+ t.Errorf("anonymous pending issue status = %d, want 404", direct.StatusCode)
+ }
+
+ // The owner sees it and can approve it.
+ ownerList, err := owner.Get(httpServer.URL + "/josie/pub/issues")
+ if err != nil {
+ t.Fatalf("owner list: %v", err)
+ }
+ if body := readAll(t, ownerList); !strings.Contains(body, "typo in readme") || !strings.Contains(body, "awaiting review") {
+ t.Errorf("owner list lacks the pending issue: %q", body)
+ }
+ approve, err := owner.Post(httpServer.URL+"/josie/pub/issues/1/approve", "", nil)
+ if err != nil {
+ t.Fatalf("approve: %v", err)
+ }
+ readAll(t, approve)
+ if approve.StatusCode != http.StatusSeeOther {
+ t.Fatalf("approve status = %d, want 303", approve.StatusCode)
+ }
+ published, err := guest.Get(httpServer.URL + "/josie/pub/issues/1")
+ if err != nil {
+ t.Fatalf("anonymous after approve: %v", err)
+ }
+ if body := readAll(t, published); published.StatusCode != http.StatusOK || !strings.Contains(body, "typo in readme") {
+ t.Errorf("approved issue not public: status=%d body=%q", published.StatusCode, body)
+ }
+}
+
+func TestPrivateRepoIssuesOwnerOnly(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "priv", "private")
+ addUser(t, database, "mallory", "pw")
+
+ // Anonymous on a private repo gets the sign-in redirect.
+ anon := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+ resp, err := anon.Get(httpServer.URL + "/josie/priv/issues")
+ if err != nil {
+ t.Fatalf("anonymous private issues: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("anonymous private issues = %d, want 404 (no existence oracle)", resp.StatusCode)
+ }
+
+ // A signed-in non-owner sees 404 and cannot file.
+ mallory := loginAs(t, httpServer, "mallory", "pw")
+ resp, err = mallory.Get(httpServer.URL + "/josie/priv/issues")
+ if err != nil {
+ t.Fatalf("mallory private issues: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("mallory private issues status = %d, want 404", resp.StatusCode)
+ }
+ resp = postIssue(t, mallory, httpServer.URL, "josie", "priv", url.Values{"title": {"x"}})
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("mallory file on private status = %d, want 404", resp.StatusCode)
+ }
+}
+
+func TestCloseReopenOwnerOnly(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ addUser(t, database, "mallory", "pw")
+ if resp := postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"t"}}); resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("owner issue status = %d", resp.StatusCode)
+ }
+
+ mallory := loginAs(t, httpServer, "mallory", "pw")
+ resp, err := mallory.Post(httpServer.URL+"/josie/pub/issues/1/close", "", nil)
+ if err != nil {
+ t.Fatalf("mallory close: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("non-owner close status = %d, want 404", resp.StatusCode)
+ }
+
+ resp, err = owner.Post(httpServer.URL+"/josie/pub/issues/1/close", "", nil)
+ if err != nil {
+ t.Fatalf("owner close: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("owner close status = %d, want 303", resp.StatusCode)
+ }
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ issue, _ := db.GetIssueByNumber(database, repo.ID, 1)
+ if issue.State != "closed" || !issue.ClosedAt.Valid {
+ t.Errorf("issue after close = %+v, want closed with closed_at", issue)
+ }
+}
+
+func TestGuestCommentModeration(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"topic"}})
+
+ guest := noFollow(&http.Client{})
+ resp, err := guest.PostForm(httpServer.URL+"/josie/pub/issues/1/comments",
+ url.Values{"body": {"guest says hi"}, "author_name": {"anon"}})
+ if err != nil {
+ t.Fatalf("guest comment: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("guest comment status = %d, want 303", resp.StatusCode)
+ }
+
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ issue, _ := db.GetIssueByNumber(database, repo.ID, 1)
+
+ // Guest comment is invisible to the public.
+ resp, err = guest.Get(httpServer.URL + "/josie/pub/issues/1")
+ if err != nil {
+ t.Fatalf("guest view: %v", err)
+ }
+ if body := readAll(t, resp); strings.Contains(body, "guest says hi") {
+ t.Error("pending guest comment visible publicly")
+ }
+ comments, _ := db.ListComments(database, issue.ID, false)
+ if len(comments) != 0 {
+ t.Errorf("public comments = %d, want 0", len(comments))
+ }
+
+ // Owner sees it, approves it, and it becomes public.
+ resp, err = owner.Get(httpServer.URL + "/josie/pub/issues/1")
+ if err != nil {
+ t.Fatalf("owner view: %v", err)
+ }
+ body := readAll(t, resp)
+ if !strings.Contains(body, "guest says hi") || !strings.Contains(body, "pending") {
+ t.Errorf("owner view lacks pending comment: %q", body)
+ }
+ comments, _ = db.ListComments(database, issue.ID, true)
+ if len(comments) != 1 {
+ t.Fatalf("owner comments = %d, want 1", len(comments))
+ }
+ resp, err = owner.Post(httpServer.URL+"/josie/pub/issues/1/comments/"+strconv.FormatInt(comments[0].ID, 10)+"/approve", "", nil)
+ if err != nil {
+ t.Fatalf("approve comment: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("approve comment status = %d, want 303", resp.StatusCode)
+ }
+ resp, _ = guest.Get(httpServer.URL + "/josie/pub/issues/1")
+ if body := readAll(t, resp); !strings.Contains(body, "guest says hi") {
+ t.Error("approved comment still hidden")
+ }
+}
+
+func TestOwnerCommentHTMXFragment(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"topic"}})
+
+ req, err := http.NewRequest("POST", httpServer.URL+"/josie/pub/issues/1/comments",
+ strings.NewReader(url.Values{"body": {"a reply"}}.Encode()))
+ if err != nil {
+ t.Fatalf("NewRequest: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("HX-Request", "true")
+ resp, err := owner.Do(req)
+ if err != nil {
+ t.Fatalf("htmx comment: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("htmx comment status = %d, want 200", resp.StatusCode)
+ }
+ if !strings.Contains(body, `id="comment-`) {
+ t.Errorf("fragment lacks a comment article: %q", body)
+ }
+ if strings.Contains(body, "<html") {
+ t.Error("htmx response is a full page, want a fragment")
+ }
+}
+
+func TestIssueBodyEscapesHTML(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{
+ "title": {"xss"}, "body": {"<script>alert(1)</script>"},
+ })
+
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/issues/1")
+ if err != nil {
+ t.Fatalf("GET issue: %v", err)
+ }
+ body := readAll(t, resp)
+ if strings.Contains(body, "<script>alert(1)</script>") {
+ t.Error("raw script survived markdown rendering")
+ }
+ if !strings.Contains(body, "<script>") {
+ t.Errorf("expected escaped script text: %q", body)
+ }
+}
+
+func TestGuestHoneypotDropsIssue(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+
+ guest := noFollow(&http.Client{})
+ resp := postIssue(t, guest, httpServer.URL, "josie", "pub", url.Values{
+ "title": {"spam"}, "website": {"http://spam.example"},
+ })
+ readAll(t, resp)
+
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ issues, _ := db.ListIssues(database, repo.ID, true, "")
+ if len(issues) != 0 {
+ t.Errorf("honeypot issue was stored: %+v", issues)
+ }
+}
+
+func TestIssueNumberNotFound(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+
+ resp, err := owner.Get(httpServer.URL + "/josie/pub/issues/99")
+ if err != nil {
+ t.Fatalf("GET missing issue: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("missing issue status = %d, want 404", resp.StatusCode)
+ }
+ resp, err = owner.Get(httpServer.URL + "/josie/pub/issues/notanumber")
+ if err != nil {
+ t.Fatalf("GET bad issue number: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("bad issue number status = %d, want 404", resp.StatusCode)
+ }
+}
+
+func TestIssueDeletedOwnerOnly(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ addUser(t, database, "mallory", "pw")
+ postIssue(t, owner, httpServer.URL, "josie", "pub", url.Values{"title": {"doomed"}})
+
+ mallory := loginAs(t, httpServer, "mallory", "pw")
+ resp, err := mallory.Post(httpServer.URL+"/josie/pub/issues/1/delete", "", nil)
+ if err != nil {
+ t.Fatalf("mallory delete: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("non-owner delete status = %d, want 404", resp.StatusCode)
+ }
+
+ resp, err = owner.Post(httpServer.URL+"/josie/pub/issues/1/delete", "", nil)
+ if err != nil {
+ t.Fatalf("owner delete: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("owner delete status = %d, want 303", resp.StatusCode)
+ }
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ if _, err := db.GetIssueByNumber(database, repo.ID, 1); !errors.Is(err, db.ErrNotFound) {
+ t.Errorf("issue after delete err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestTruncateKeepsValidUTF8(t *testing.T) {
+ if got := truncate("é", 1); got != "" {
+ t.Errorf("truncate cut mid-rune: got %q, want empty", got)
+ }
+ if got := truncate("aé", 2); got != "a" {
+ t.Errorf("truncate = %q, want %q", got, "a")
+ }
+ if got := truncate("abc", 3); got != "abc" {
+ t.Errorf("truncate = %q, want %q", got, "abc")
+ }
+}
diff --git a/internal/web/profile.go b/internal/web/profile.go
new file mode 100644
index 0000000..26531b8
--- /dev/null
+++ b/internal/web/profile.go
@@ -0,0 +1,47 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+// profileData is the model for a user's profile page: their repos plus the
+// viewer's identity, so the owner sees account actions.
+type profileData struct {
+ Username string
+ Profile string
+ IsSelf bool
+ Repos []repoItem
+}
+
+func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("user")
+ profileUser, err := db.GetUserByName(s.database, name)
+ if errors.Is(err, db.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+
+ viewer := currentUser(r)
+ data := profileData{Profile: profileUser.Username}
+ var viewerID int64
+ if viewer != nil {
+ data.Username = viewer.Username
+ viewerID = viewer.ID
+ data.IsSelf = viewer.ID == profileUser.ID
+ }
+
+ repos, err := db.ListRepos(s.database, viewerID)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ data.Repos = repoItems(repos, name)
+ s.render(w, "profile.html", http.StatusOK, data)
+}
diff --git a/internal/web/profile_test.go b/internal/web/profile_test.go
new file mode 100644
index 0000000..0415104
--- /dev/null
+++ b/internal/web/profile_test.go
@@ -0,0 +1,65 @@
+package web
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestProfileAnonymousListsPublicOnly(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ loggedIn := newLoggedInClient(t, httpServer)
+ createRepo(t, loggedIn, httpServer, "pub", "public")
+ createRepo(t, loggedIn, httpServer, "sec", "private")
+
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie")
+ if err != nil {
+ t.Fatalf("GET /josie: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200: %q", resp.StatusCode, body)
+ }
+ if !strings.Contains(body, "/josie/pub") {
+ t.Error("public repo missing from profile")
+ }
+ if strings.Contains(body, "/josie/sec") {
+ t.Error("private repo leaked to anonymous profile")
+ }
+ if strings.Contains(body, "sign out") {
+ t.Error("anonymous profile shows account actions")
+ }
+}
+
+func TestProfileOwnerShowsAccountActions(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ loggedIn := newLoggedInClient(t, httpServer)
+ createRepo(t, loggedIn, httpServer, "sec", "private")
+
+ resp, err := loggedIn.Get(httpServer.URL + "/josie")
+ if err != nil {
+ t.Fatalf("GET /josie: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200: %q", resp.StatusCode, body)
+ }
+ if !strings.Contains(body, "/josie/sec") {
+ t.Error("owner profile missing private repo")
+ }
+ if !strings.Contains(body, "sign out") || !strings.Contains(body, `href="/settings"`) {
+ t.Errorf("owner profile lacks sign out/settings: %q", body)
+ }
+}
+
+func TestProfileUnknownUser(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/nobody")
+ if err != nil {
+ t.Fatalf("GET /nobody: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", resp.StatusCode)
+ }
+}
diff --git a/internal/web/pulls.go b/internal/web/pulls.go
new file mode 100644
index 0000000..11af6f4
--- /dev/null
+++ b/internal/web/pulls.go
@@ -0,0 +1,460 @@
+package web
+
+import (
+ "errors"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "slices"
+ "strings"
+ "time"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+ "git.josie-c.com/josie/simplegit/internal/git"
+)
+
+func pullBasePath(r *http.Request) string {
+ return "/" + r.PathValue("user") + "/" + r.PathValue("repo") + "/pulls"
+}
+
+type pullListRow struct {
+ Number int64
+ Title string
+ State string
+ Pending bool
+ Base string
+ Head string
+ Author string
+ AuthorIsOwner bool
+ Created string
+ Href string
+}
+
+type pullListData struct {
+ navData
+ Show string
+ IsOwner bool
+ CanWrite bool
+ PendingCount int
+ Pulls []pullListRow
+}
+
+// handlePulls renders the pull-request list. Anonymous visitors of a public
+// repo see non-pending PRs; the owner additionally sees pending ones.
+func (s *Server) handlePulls(w http.ResponseWriter, r *http.Request) {
+ repo, user, ok := s.repoPage(w, r)
+ if !ok {
+ return
+ }
+ ownerView := isOwner(user, repo)
+ show, state := showFilter(r, stateClosed, stateMerged, showAll)
+
+ pulls, err := db.ListPulls(s.database, repo.ID, ownerView, state)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ data := pullListData{
+ navData: nav(r, repo, user, "pulls"), Show: show,
+ IsOwner: ownerView, CanWrite: canWriteContent(user, repo),
+ }
+ if ownerView {
+ if data.PendingCount, err = db.CountPending(s.database, repo.ID); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ }
+ for _, p := range pulls {
+ data.Pulls = append(data.Pulls, pullListRow{
+ Number: p.Number,
+ Title: p.Title,
+ State: p.State,
+ Pending: p.Pending,
+ Base: p.Base,
+ Head: p.Head,
+ Author: guestAuthorName(p.AuthorName),
+ AuthorIsOwner: p.AuthorID.Valid &&
+ p.AuthorID.Int64 == repo.OwnerID,
+ Created: issuedAt(p.CreatedAt),
+ Href: threadHref(pullBasePath(r), p.Number),
+ })
+ }
+ s.render(w, "pulls.html", http.StatusOK, data)
+}
+
+type pullNewData struct {
+ navData
+ IsOwner bool
+ Branches []string
+ Base string
+ Head string
+ Title string
+ Body string
+ Error string
+}
+
+// handlePullNewForm renders the open-PR form, listing the repo's branches.
+func (s *Server) handlePullNewForm(w http.ResponseWriter, r *http.Request) {
+ repo, user, ok := s.repoPage(w, r)
+ if !ok {
+ return
+ }
+ if !canWriteContent(user, repo) {
+ http.NotFound(w, r)
+ return
+ }
+ branches, err := git.Branches(s.repoPathFor(r.PathValue("user"), repo))
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ base := repo.DefaultBranch
+ head := ""
+ for _, branch := range branches {
+ if branch != base && head == "" {
+ head = branch
+ }
+ }
+ data := pullNewData{
+ navData: nav(r, repo, user, "pulls"),
+ IsOwner: isOwner(user, repo),
+ Branches: branches, Base: base, Head: head,
+ }
+ s.render(w, "pull_new.html", http.StatusOK, data)
+}
+
+// handleCreatePull validates the branch pair and stores the PR. The owner's
+// PR is published immediately; a guest's is pending owner moderation.
+func (s *Server) handleCreatePull(w http.ResponseWriter, r *http.Request) {
+ repo, user, ok := s.repoPage(w, r)
+ if !ok {
+ return
+ }
+ repoPath := s.repoPathFor(r.PathValue("user"), repo)
+ trusted := isOwner(user, repo)
+ if !canWriteContent(user, repo) {
+ http.NotFound(w, r)
+ return
+ }
+ if !trusted && !s.guestThreads.allow(clientIP(r)) {
+ http.Error(w, "too many pull requests opened; try again later", http.StatusTooManyRequests)
+ return
+ }
+ branches, err := git.Branches(repoPath)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+
+ r.Body = http.MaxBytesReader(w, r.Body, issueFormMax)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ base := strings.TrimSpace(r.FormValue("base"))
+ head := strings.TrimSpace(r.FormValue("head"))
+ title := formText(r, "title", maxTitleLen)
+ body := formText(r, "body", maxIssueBody)
+ fail := func(status int, msg string) {
+ data := pullNewData{
+ navData: nav(r, repo, user, "pulls"), IsOwner: trusted,
+ Branches: branches, Base: base, Head: head, Title: title, Body: body, Error: msg,
+ }
+ s.render(w, "pull_new.html", status, data)
+ }
+ if title == "" {
+ fail(http.StatusUnprocessableEntity, "a title is required")
+ return
+ }
+ if !validRef(base) || !validRef(head) {
+ fail(http.StatusUnprocessableEntity, "base and head must be branch names")
+ return
+ }
+ if base == head {
+ fail(http.StatusUnprocessableEntity, "base and head must differ")
+ return
+ }
+ if !slices.Contains(branches, base) || !slices.Contains(branches, head) {
+ fail(http.StatusUnprocessableEntity, "both base and head must be existing branches")
+ return
+ }
+ if !trusted && strings.TrimSpace(r.FormValue(honeypotField)) != "" {
+ http.Redirect(w, r, pullBasePath(r)+"?"+submittedParam+"=1", http.StatusSeeOther)
+ return
+ }
+
+ authorID, authorName, authorEmail := issueIdentity(r, user, r.PathValue("user"))
+ if !trusted {
+ dup, err := db.HasDuplicatePull(s.database, repo.ID, title, body, base, head, authorName, authorEmail)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if dup {
+ // Identical PR already stored; answer exactly like a fresh one.
+ s.render(w, "pull_submitted.html", http.StatusOK, nav(r, repo, user, "pulls"))
+ return
+ }
+ }
+ pull, err := db.CreatePull(s.database, repo.ID, title, body, base, head, authorID, authorName, authorEmail, !trusted)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if trusted {
+ http.Redirect(w, r, threadHref(pullBasePath(r), pull.Number), http.StatusSeeOther)
+ return
+ }
+ s.render(w, "pull_submitted.html", http.StatusOK, nav(r, repo, user, "pulls"))
+}
+
+type pullViewData struct {
+ navData
+ Number int64
+ Title string
+ State string
+ Pending bool
+ Base string
+ Head string
+ Author string
+ AuthorIsOwner bool
+ AuthorEmail string
+ Created string
+ BodyHTML template.HTML
+ DiffHTML template.HTML
+ DiffNotice string
+ MergeCommit string
+ Comments []commentView
+ IsOwner bool
+ CanWrite bool
+ BasePath string
+ Submitted bool
+ Error string
+}
+
+// handlePullView shows one PR: metadata, the three-dot diff, and comments. A
+// non-owner cannot see a pending PR; pending comments are owner-only.
+func (s *Server) handlePullView(w http.ResponseWriter, r *http.Request) {
+ repo, user, number, ok := s.repoNumber(w, r)
+ if !ok {
+ return
+ }
+ pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
+ if !ok {
+ return
+ }
+ ownerView := isOwner(user, repo)
+ if pull.Pending && !ownerView {
+ http.NotFound(w, r)
+ return
+ }
+ comments, err := db.ListPullComments(s.database, pull.ID, ownerView)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ data := s.newPullViewData(r, repo, user, pull, ownerView)
+ data.Comments = commentViews(comments, repo.OwnerID, ownerView, data.BasePath, pullCommentRow)
+ s.render(w, "pull.html", http.StatusOK, data)
+}
+
+func (s *Server) newPullViewData(r *http.Request, repo db.Repo, user *db.User, pull db.Pull, ownerView bool) pullViewData {
+ data := pullViewData{
+ navData: nav(r, repo, user, "pulls"), Number: pull.Number,
+ Title: pull.Title, State: pull.State, Pending: pull.Pending,
+ Base: pull.Base, Head: pull.Head,
+ Author: guestAuthorName(pull.AuthorName),
+ AuthorIsOwner: pull.AuthorID.Valid && pull.AuthorID.Int64 == repo.OwnerID,
+ AuthorEmail: pull.AuthorEmail,
+ Created: issuedAt(pull.CreatedAt),
+ BodyHTML: markdownHTML(pull.Body),
+ MergeCommit: pull.MergeCommit,
+ IsOwner: ownerView, CanWrite: canWriteContent(user, repo),
+ BasePath: pullBasePath(r),
+ Submitted: submitted(r),
+ }
+ s.attachPullDiff(&data, repo, pull)
+ return data
+}
+
+// attachPullDiff renders git's three-dot base...head patch. The stored
+// branch names are re-validated before they reach git, so a corrupted or
+// legacy row cannot smuggle options into the diff command.
+func (s *Server) attachPullDiff(data *pullViewData, repo db.Repo, pull db.Pull) {
+ if pull.State == stateMerged {
+ return
+ }
+ if !validRef(pull.Base) || !validRef(pull.Head) {
+ data.DiffNotice = "could not compute the diff between base and head."
+ return
+ }
+ patch, err := git.Diff(s.repoPathFor(data.Owner, repo), pull.Base, pull.Head, maxDiffBytes)
+ if err != nil {
+ data.DiffNotice = "could not compute the diff between base and head."
+ return
+ }
+ if len(patch) == 0 {
+ data.DiffNotice = "No changes between base and head."
+ return
+ }
+ data.DiffHTML, data.DiffNotice = renderDiffHTML(patch)
+}
+
+type pullStateData struct {
+ BasePath string
+ State string
+ IsOwner bool
+ Pending bool
+}
+
+// handlePullMerge merges head into base (owner-only) with a fast-forward when
+// possible, otherwise a merge commit.
+func (s *Server) handlePullMerge(w http.ResponseWriter, r *http.Request) {
+ repo, user, number, ok := s.ownerNumber(w, r)
+ if !ok {
+ return
+ }
+ pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
+ if !ok {
+ return
+ }
+ if pull.State != stateOpen {
+ s.renderPullError(w, r, repo, user, pull, "This pull request is not open.")
+ return
+ }
+ message := fmt.Sprintf("Merge branch '%s' into %s", pull.Head, pull.Base)
+ sha, _, err := git.Merge(s.repoPathFor(r.PathValue("user"), repo), pull.Base, pull.Head, message, user.Username)
+ switch {
+ case errors.Is(err, git.ErrMergeConflict):
+ s.renderPullError(w, r, repo, user, pull, "This pull request has merge conflicts and cannot be merged automatically.")
+ return
+ case errors.Is(err, git.ErrNoCommonAncestor):
+ s.renderPullError(w, r, repo, user, pull, "Base and head have unrelated histories and cannot be merged.")
+ return
+ case errors.Is(err, git.ErrNotFound):
+ s.renderPullError(w, r, repo, user, pull, "A branch no longer exists; this pull request cannot be merged.")
+ return
+ case errors.Is(err, git.ErrAlreadyMerged):
+ // head's changes are already in base; record the merge without a new commit.
+ case err != nil:
+ s.internalError(w, r, err)
+ return
+ }
+ if err := db.SetPullMerged(s.database, repo.ID, number, sha); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ _ = db.RecordPush(s.database, r.PathValue("user"), repo.Name, time.Now().Unix(), "")
+ http.Redirect(w, r, threadHref(pullBasePath(r), number), http.StatusSeeOther)
+}
+
+func (s *Server) renderPullError(w http.ResponseWriter, r *http.Request, repo db.Repo, user *db.User, pull db.Pull, msg string) {
+ ownerView := isOwner(user, repo)
+ data := s.newPullViewData(r, repo, user, pull, ownerView)
+ data.Error = msg
+ comments, err := db.ListPullComments(s.database, pull.ID, ownerView)
+ if err != nil {
+ log.Printf("web: list pull comments %d: %v", pull.ID, err)
+ }
+ data.Comments = commentViews(comments, repo.OwnerID, ownerView, data.BasePath, pullCommentRow)
+ s.render(w, "pull.html", http.StatusUnprocessableEntity, data)
+}
+
+// handlePullState closes or reopens a PR (owner-only).
+func (s *Server) handlePullState(w http.ResponseWriter, r *http.Request, state string) {
+ repo, user, number, ok := s.ownerNumber(w, r)
+ if !ok {
+ return
+ }
+ pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
+ if !ok {
+ return
+ }
+ if pull.State == stateMerged {
+ s.renderPullError(w, r, repo, user, pull, "A merged pull request cannot be reopened.")
+ return
+ }
+ if err := db.SetPullState(s.database, repo.ID, number, state); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ basePath := threadHref(pullBasePath(r), number)
+ if isHTMX(r) {
+ s.renderFragment(w, "pull.html", "pstate", pullStateData{
+ BasePath: basePath, State: state, IsOwner: true, Pending: pull.Pending,
+ })
+ return
+ }
+ http.Redirect(w, r, basePath, http.StatusSeeOther)
+}
+
+// handleCreatePullComment stores a PR comment through the shared comment
+// pipeline: owner comments publish immediately, guest comments are pending
+// moderation.
+func (s *Server) handleCreatePullComment(w http.ResponseWriter, r *http.Request) {
+ repo, user, number, ok := s.repoNumber(w, r)
+ if !ok {
+ return
+ }
+ basePath := threadHref(pullBasePath(r), number)
+ pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
+ if !ok {
+ return
+ }
+ if pull.Pending && !isOwner(user, repo) {
+ http.NotFound(w, r)
+ return
+ }
+ s.createComment(w, r, repo, user, pull.ID, basePath, commentFlow{
+ page: "pull.html", pendingFrag: "comment_pending", commentFrag: "comment",
+ create: func(in commentInput) (commentView, error) {
+ created, err := db.CreatePullComment(s.database, in.parentID, in.body, in.authorID, in.authorName, in.authorEmail, in.pending)
+ if err != nil {
+ return commentView{}, err
+ }
+ return commentViews([]db.PullComment{created}, repo.OwnerID, true, basePath, pullCommentRow)[0], nil
+ },
+ })
+}
+
+// handleApprovePull publishes a pending PR (owner-only).
+func (s *Server) handleApprovePull(w http.ResponseWriter, r *http.Request) {
+ s.moderateNumber(w, r, func(repo db.Repo, number int64) error {
+ return db.ApprovePull(s.database, repo.ID, number)
+ }, pullBasePath(r)+"/"+r.PathValue("number"))
+}
+
+// handleDeletePull removes a PR (owner-only).
+func (s *Server) handleDeletePull(w http.ResponseWriter, r *http.Request) {
+ s.moderateNumber(w, r, func(repo db.Repo, number int64) error {
+ return db.DeletePull(s.database, repo.ID, number)
+ }, pullBasePath(r))
+}
+
+// handleModeratePullComment approves or deletes a PR comment (owner-only).
+func (s *Server) handleModeratePullComment(w http.ResponseWriter, r *http.Request, approve bool) {
+ repo, _, number, ok := s.ownerNumber(w, r)
+ if !ok {
+ return
+ }
+ basePath := threadHref(pullBasePath(r), number)
+ pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
+ if !ok {
+ return
+ }
+ id, ok := pathID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ moderate := db.ApprovePullComment
+ if !approve {
+ moderate = db.DeletePullComment
+ }
+ if err := moderate(s.database, pull.ID, id); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, basePath, http.StatusSeeOther)
+}
diff --git a/internal/web/pulls_test.go b/internal/web/pulls_test.go
new file mode 100644
index 0000000..e1c556a
--- /dev/null
+++ b/internal/web/pulls_test.go
@@ -0,0 +1,344 @@
+package web
+
+import (
+ "database/sql"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+func writeWork(t *testing.T, work, name, content string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(work, name), []byte(content), 0o644); err != nil {
+ t.Fatalf("write %s: %v", name, err)
+ }
+}
+
+// cloneRepo clones ownerAuth'd repo {name} for pushing test branches.
+func cloneRepo(t *testing.T, httpServer *httptest.Server, name string) string {
+ t.Helper()
+ u := mustParse(t, httpServer.URL)
+ repoURL := "http://josie:hunter2@" + u.Host + "/josie/" + name + ".git"
+ work := filepath.Join(t.TempDir(), "work")
+ runGit(t, "", "-c", "credential.helper=", "clone", "-q", repoURL, work)
+ runGit(t, work, "config", "user.email", "t@t")
+ runGit(t, work, "config", "user.name", "t")
+ return work
+}
+
+func TestPullRoutesRegister(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ client := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, client, httpServer, "pub", "public")
+ for _, path := range []string{"/josie/pub/pulls", "/josie/pub/pulls/new"} {
+ resp, err := client.Get(httpServer.URL + path)
+ if err != nil {
+ t.Fatalf("GET %s: %v", path, err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("GET %s = %d, want 200", path, resp.StatusCode)
+ }
+ }
+}
+
+func TestOwnerPullOpenViewMergeFastForward(t *testing.T) {
+ httpServer, database, dataDir := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "base.txt", "base\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "base")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ runGit(t, work, "checkout", "-qb", "feature")
+ writeWork(t, work, "feature.txt", "feature\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "feature")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
+
+ resp, err := owner.PostForm(httpServer.URL+"/josie/pub/pulls/new",
+ url.Values{"base": {"main"}, "head": {"feature"}, "title": {"Add feature"}, "body": {"the why"}})
+ if err != nil {
+ t.Fatalf("open PR: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("open PR status = %d, want 303", resp.StatusCode)
+ }
+
+ anon := noFollow(&http.Client{})
+ view, err := anon.Get(httpServer.URL + "/josie/pub/pulls/1")
+ if err != nil {
+ t.Fatalf("GET PR: %v", err)
+ }
+ body := readAll(t, view)
+ if view.StatusCode != http.StatusOK || !strings.Contains(body, "Add feature") || !strings.Contains(body, "feature.txt") {
+ t.Fatalf("PR view status=%d body=%q", view.StatusCode, body)
+ }
+
+ merge, err := owner.Post(httpServer.URL+"/josie/pub/pulls/1/merge", "", nil)
+ if err != nil {
+ t.Fatalf("merge: %v", err)
+ }
+ readAll(t, merge)
+ if merge.StatusCode != http.StatusSeeOther {
+ t.Fatalf("merge status = %d, want 303", merge.StatusCode)
+ }
+ pull := pullByNumber(t, database, "pub", 1)
+ if pull.State != "merged" || pull.MergeCommit == "" {
+ t.Errorf("pull after merge = %+v, want merged with commit", pull)
+ }
+ bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
+ mainSHA := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
+ featureSHA := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/feature"))
+ if mainSHA != featureSHA {
+ t.Errorf("main = %s, want fast-forwarded to feature %s", mainSHA, featureSHA)
+ }
+}
+
+func TestGuestPullPendingModeration(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "base.txt", "base\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "base")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ runGit(t, work, "checkout", "-qb", "feature")
+ writeWork(t, work, "feature.txt", "feature\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "feature")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
+
+ guest := noFollow(&http.Client{})
+ resp, err := guest.PostForm(httpServer.URL+"/josie/pub/pulls/new",
+ url.Values{"base": {"main"}, "head": {"feature"}, "title": {"Guest idea"}, "author_name": {"anon"}})
+ if err != nil {
+ t.Fatalf("guest open PR: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, "awaiting review") {
+ t.Fatalf("guest PR status=%d body=%q, want review notice", resp.StatusCode, body)
+ }
+
+ list, _ := guest.Get(httpServer.URL + "/josie/pub/pulls")
+ if body := readAll(t, list); strings.Contains(body, "Guest idea") {
+ t.Error("pending PR leaked to anonymous list")
+ }
+ direct, _ := guest.Get(httpServer.URL + "/josie/pub/pulls/1")
+ readAll(t, direct)
+ if direct.StatusCode != http.StatusNotFound {
+ t.Errorf("anonymous pending PR = %d, want 404", direct.StatusCode)
+ }
+
+ resp, err = owner.Post(httpServer.URL+"/josie/pub/pulls/1/approve", "", nil)
+ if err != nil {
+ t.Fatalf("approve: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("approve status = %d, want 303", resp.StatusCode)
+ }
+ published, _ := guest.Get(httpServer.URL + "/josie/pub/pulls/1")
+ if body := readAll(t, published); published.StatusCode != http.StatusOK || !strings.Contains(body, "Guest idea") {
+ t.Errorf("approved PR not public: %d %q", published.StatusCode, body)
+ }
+}
+
+func TestPullMergeConflictRefused(t *testing.T) {
+ httpServer, database, dataDir := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "conflict.txt", "original\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "base")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ runGit(t, work, "checkout", "-qb", "feature")
+ writeWork(t, work, "conflict.txt", "feature\n")
+ runGit(t, work, "commit", "-aqm", "feature")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
+ runGit(t, work, "checkout", "-q", "main")
+ writeWork(t, work, "conflict.txt", "main\n")
+ runGit(t, work, "commit", "-aqm", "main edit")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+
+ resp, err := owner.PostForm(httpServer.URL+"/josie/pub/pulls/new",
+ url.Values{"base": {"main"}, "head": {"feature"}, "title": {"Conflicting"}})
+ if err != nil {
+ t.Fatalf("open PR: %v", err)
+ }
+ readAll(t, resp)
+ bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
+ before := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
+
+ merge, err := owner.Post(httpServer.URL+"/josie/pub/pulls/1/merge", "", nil)
+ if err != nil {
+ t.Fatalf("merge: %v", err)
+ }
+ body := readAll(t, merge)
+ if merge.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "merge conflict") {
+ t.Fatalf("conflict merge status=%d body=%q", merge.StatusCode, body)
+ }
+ after := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
+ if after != before {
+ t.Errorf("main moved on conflict: %s -> %s", before, after)
+ }
+ pull := pullByNumber(t, database, "pub", 1)
+ if pull.State != "open" {
+ t.Errorf("pull state after conflict = %q, want open", pull.State)
+ }
+}
+
+// Merging branches with no common ancestor must be refused with a readable
+// 422, not an internal error.
+func TestPullMergeUnrelatedHistoriesRefused(t *testing.T) {
+ httpServer, database, dataDir := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "a.txt", "a\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "a")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ runGit(t, work, "checkout", "-q", "--orphan", "lonely")
+ writeWork(t, work, "b.txt", "b\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "b")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/lonely")
+
+ resp, err := owner.PostForm(httpServer.URL+"/josie/pub/pulls/new",
+ url.Values{"base": {"main"}, "head": {"lonely"}, "title": {"Unrelated"}})
+ if err != nil {
+ t.Fatalf("open PR: %v", err)
+ }
+ readAll(t, resp)
+ bare := filepath.Join(dataDir, "repos", "josie", "pub.git")
+ before := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
+
+ merge, err := owner.Post(httpServer.URL+"/josie/pub/pulls/1/merge", "", nil)
+ if err != nil {
+ t.Fatalf("merge: %v", err)
+ }
+ body := readAll(t, merge)
+ if merge.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "unrelated histories") {
+ t.Fatalf("unrelated merge status=%d body=%q", merge.StatusCode, body)
+ }
+ after := strings.TrimSpace(runGitOut(t, bare, "rev-parse", "refs/heads/main"))
+ if after != before {
+ t.Errorf("main moved on unrelated merge: %s -> %s", before, after)
+ }
+ pull := pullByNumber(t, database, "pub", 1)
+ if pull.State != "open" {
+ t.Errorf("pull state after refused merge = %q, want open", pull.State)
+ }
+}
+
+func TestPullCloseReopenOwnerOnly(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "a.txt", "a\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "a")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ runGit(t, work, "checkout", "-qb", "feature")
+ writeWork(t, work, "b.txt", "b\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "b")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
+ owner.PostForm(httpServer.URL+"/josie/pub/pulls/new", url.Values{"base": {"main"}, "head": {"feature"}, "title": {"t"}})
+
+ addUser(t, database, "mallory", "pw")
+ mallory := noFollow(loginAs(t, httpServer, "mallory", "pw"))
+ resp, err := mallory.Post(httpServer.URL+"/josie/pub/pulls/1/close", "", nil)
+ if err != nil {
+ t.Fatalf("mallory close: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("non-owner close = %d, want 404", resp.StatusCode)
+ }
+
+ resp, err = owner.Post(httpServer.URL+"/josie/pub/pulls/1/close", "", nil)
+ if err != nil {
+ t.Fatalf("owner close: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("owner close = %d, want 303", resp.StatusCode)
+ }
+}
+
+func TestPullOwnerCommentHTMX(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "a.txt", "a\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "a")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ runGit(t, work, "checkout", "-qb", "feature")
+ writeWork(t, work, "b.txt", "b\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "b")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/feature")
+ owner.PostForm(httpServer.URL+"/josie/pub/pulls/new", url.Values{"base": {"main"}, "head": {"feature"}, "title": {"t"}})
+
+ req, _ := http.NewRequest("POST", httpServer.URL+"/josie/pub/pulls/1/comments",
+ strings.NewReader(url.Values{"body": {"looks good"}}.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("HX-Request", "true")
+ resp, err := owner.Do(req)
+ if err != nil {
+ t.Fatalf("htmx comment: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="comment-`) || strings.Contains(body, "<html") {
+ t.Errorf("pull htmx fragment status=%d body=%q", resp.StatusCode, body)
+ }
+}
+
+// ---- helpers ----
+
+func mustParse(t *testing.T, raw string) *url.URL {
+ t.Helper()
+ u, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("parse URL %s: %v", raw, err)
+ }
+ return u
+}
+
+func runGitOut(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+ return string(out)
+}
+
+func pullByNumber(t *testing.T, database *sql.DB, repoName string, number int64) db.Pull {
+ t.Helper()
+ repo, err := db.GetRepoByName(database, "josie", repoName)
+ if err != nil {
+ t.Fatalf("GetRepoByName: %v", err)
+ }
+ pull, err := db.GetPullByNumber(database, repo.ID, number)
+ if err != nil {
+ t.Fatalf("GetPullByNumber: %v", err)
+ }
+ return pull
+}
diff --git a/internal/web/push_test.go b/internal/web/push_test.go
new file mode 100644
index 0000000..9ecb034
--- /dev/null
+++ b/internal/web/push_test.go
@@ -0,0 +1,26 @@
+package web
+
+import (
+ "crypto/rand"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLargePush(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "big", "public")
+ work := cloneRepo(t, httpServer, "big")
+
+ blob := make([]byte, 16<<20)
+ if _, err := rand.Read(blob); err != nil {
+ t.Fatalf("rand: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(work, "blob.bin"), blob, 0o644); err != nil {
+ t.Fatalf("write blob: %v", err)
+ }
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "big")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+}
diff --git a/internal/web/ratelimit.go b/internal/web/ratelimit.go
new file mode 100644
index 0000000..348e82b
--- /dev/null
+++ b/internal/web/ratelimit.go
@@ -0,0 +1,74 @@
+package web
+
+import (
+ "sync"
+ "time"
+)
+
+// ipLimiter is a small in-memory sliding-window rate limiter keyed by client
+// IP. It guards unauthenticated guest writes; a periodic sweep inside allow
+// drops keys whose hits have all aged out.
+type ipLimiter struct {
+ mu sync.Mutex
+ hits map[string][]time.Time
+ limit int
+ window time.Duration
+ calls int
+}
+
+func newIPLimiter(limit int, window time.Duration) *ipLimiter {
+ return &ipLimiter{hits: make(map[string][]time.Time), limit: limit, window: window}
+}
+
+// allow records a hit for key and reports whether it is within the limit.
+func (l *ipLimiter) allow(key string) bool {
+ now := time.Now()
+ cut := now.Add(-l.window)
+
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ l.calls++
+ if l.calls%512 == 0 {
+ l.sweep(cut)
+ }
+
+ recent := l.hits[key][:0]
+ for _, t := range l.hits[key] {
+ if t.After(cut) {
+ recent = append(recent, t)
+ }
+ }
+ if len(recent) >= l.limit {
+ l.hits[key] = recent
+ return false
+ }
+ l.hits[key] = append(recent, now)
+ return true
+}
+
+// sweep drops keys whose recorded hits have all left the window. Called
+// with the mutex held.
+func (l *ipLimiter) sweep(cut time.Time) {
+ for key, times := range l.hits {
+ recent := times[:0]
+ for _, t := range times {
+ if t.After(cut) {
+ recent = append(recent, t)
+ }
+ }
+ if len(recent) == 0 {
+ delete(l.hits, key)
+ continue
+ }
+ l.hits[key] = recent
+ }
+}
+
+// reset forgets key's recorded hits, so a limiter keyed on credentials
+// rewards a success (e.g. a signed-in user clears the failed-login window).
+func (l *ipLimiter) reset(key string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ delete(l.hits, key)
+}
diff --git a/internal/web/ratelimit_test.go b/internal/web/ratelimit_test.go
new file mode 100644
index 0000000..6b5f2bd
--- /dev/null
+++ b/internal/web/ratelimit_test.go
@@ -0,0 +1,31 @@
+package web
+
+import (
+ "testing"
+ "time"
+)
+
+func TestIPLimiterWindow(t *testing.T) {
+ limiter := newIPLimiter(2, time.Hour)
+
+ if !limiter.allow("a") || !limiter.allow("a") {
+ t.Fatal("first two hits for one key should be allowed")
+ }
+ if limiter.allow("a") {
+ t.Error("third hit within the window was allowed, want denied")
+ }
+ if !limiter.allow("b") {
+ t.Error("a different key should have its own budget")
+ }
+}
+
+func TestIPLimiterExpiry(t *testing.T) {
+ limiter := newIPLimiter(1, time.Millisecond)
+ if !limiter.allow("a") {
+ t.Fatal("first hit should be allowed")
+ }
+ time.Sleep(5 * time.Millisecond)
+ if !limiter.allow("a") {
+ t.Error("hit after the window should be allowed")
+ }
+}
diff --git a/internal/web/releases.go b/internal/web/releases.go
new file mode 100644
index 0000000..70bf356
--- /dev/null
+++ b/internal/web/releases.go
@@ -0,0 +1,276 @@
+package web
+
+import (
+ "errors"
+ "fmt"
+ "html/template"
+ "log"
+ "mime"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+ "git.josie-c.com/josie/simplegit/internal/git"
+)
+
+func releaseBasePath(r *http.Request) string {
+ return "/" + r.PathValue("user") + "/" + r.PathValue("repo") + "/releases"
+}
+
+func (s *Server) releaseUploadDir(releaseID int64) string {
+ return filepath.Join(s.cfg.DataDir, "uploads", "releases", strconv.FormatInt(releaseID, 10))
+}
+
+type releaseListRow struct {
+ Tag string
+ Title string
+ Created string
+ Href string
+}
+
+type releaseTagRow struct {
+ Name string
+ Commit string
+}
+
+type releaseListData struct {
+ navData
+ Releases []releaseListRow
+ Tags []releaseTagRow
+}
+
+// handleReleases lists the repo's releases and any tags without one.
+// Releases are made by pushing tags with git; there is no creation UI.
+func (s *Server) handleReleases(w http.ResponseWriter, r *http.Request) {
+ repo, repoPath, user, ok := s.resolveRepo(w, r)
+ if !ok {
+ return
+ }
+ releases, err := db.ListReleases(s.database, repo.ID)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ tags, err := git.Tags(repoPath)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ released := make(map[string]bool, len(releases))
+ data := releaseListData{navData: nav(r, repo, user, "releases")}
+ for _, release := range releases {
+ released[release.Tag] = true
+ data.Releases = append(data.Releases, releaseListRow{
+ Tag: release.Tag,
+ Title: release.Title,
+ Created: issuedAt(release.CreatedAt),
+ Href: releaseBasePath(r) + "/" + url.PathEscape(release.Tag),
+ })
+ }
+ for _, tag := range tags {
+ if released[tag.Name] {
+ continue
+ }
+ data.Tags = append(data.Tags, releaseTagRow{
+ Name: tag.Name,
+ Commit: shortSHA(tag.Commit),
+ })
+ }
+ s.render(w, "releases.html", http.StatusOK, data)
+}
+
+type releaseAssetView struct {
+ ID int64
+ Name string
+ Size string
+ Download string
+ Delete string
+}
+
+type releaseViewData struct {
+ navData
+ Tag string
+ Title string
+ Commit string
+ Created string
+ Notes template.HTML
+ Assets []releaseAssetView
+ IsOwner bool
+ DeleteHref string
+}
+
+// handleReleaseView shows one release with its notes and assets.
+func (s *Server) handleReleaseView(w http.ResponseWriter, r *http.Request) {
+ repo, repoPath, user, ok := s.resolveRepo(w, r)
+ if !ok {
+ return
+ }
+ tag := r.PathValue("tag")
+ release, err := db.GetReleaseByTag(s.database, repo.ID, tag)
+ if errors.Is(err, db.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ assets, err := db.ListReleaseAssets(s.database, release.ID)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ data := releaseViewData{
+ navData: nav(r, repo, user, "releases"),
+ Tag: release.Tag, Title: release.Title, Created: issuedAt(release.CreatedAt),
+ Notes: markdownHTML(release.Notes), IsOwner: isOwner(user, repo),
+ }
+ tagPath := releaseBasePath(r) + "/" + url.PathEscape(release.Tag)
+ data.DeleteHref = tagPath + "/delete"
+ if tags, err := git.Tags(repoPath); err == nil {
+ for _, t := range tags {
+ if t.Name == release.Tag {
+ data.Commit = shortSHA(t.Commit)
+ break
+ }
+ }
+ }
+ for _, asset := range assets {
+ data.Assets = append(data.Assets, releaseAssetView{
+ ID: asset.ID,
+ Name: asset.Filename,
+ Size: humanSize(asset.Size),
+ Download: releaseBasePath(r) + "/download/" + strconv.FormatInt(asset.ID, 10) + "/" + url.PathEscape(asset.Filename),
+ Delete: tagPath + "/assets/" + strconv.FormatInt(asset.ID, 10) + "/delete",
+ })
+ }
+ s.render(w, "release.html", http.StatusOK, data)
+}
+
+// handleReleaseDownload serves one asset. Access follows the repo gate, so
+// private repos require the owner. CSP + octet-stream + attachment headers
+// keep the download origin inert even if a viewer opens the file locally.
+func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) {
+ repo, _, _, ok := s.resolveRepo(w, r)
+ if !ok {
+ return
+ }
+ id, ok := pathID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ asset, err := db.GetReleaseAssetForRepo(s.database, repo.ID, id)
+ if errors.Is(err, db.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ file, err := os.Open(filepath.Join(s.releaseUploadDir(asset.ReleaseID), asset.StoredName))
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ disposition := mime.FormatMediaType("attachment", map[string]string{"filename": asset.Filename})
+ if disposition == "" {
+ disposition = "attachment"
+ }
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Content-Disposition", disposition)
+ w.Header().Set("Content-Security-Policy", "default-src 'none'")
+ http.ServeContent(w, r, asset.Filename, info.ModTime(), file)
+}
+
+// handleDeleteRelease removes a release and its stored assets (owner-only).
+func (s *Server) handleDeleteRelease(w http.ResponseWriter, r *http.Request) {
+ repo, _, _, ok := s.ownerRepo(w, r)
+ if !ok {
+ return
+ }
+ release, ok := s.releaseByTag(w, r, repo)
+ if !ok {
+ return
+ }
+ if err := db.DeleteRelease(s.database, repo.ID, release.ID); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if err := os.RemoveAll(s.releaseUploadDir(release.ID)); err != nil {
+ log.Printf("web: remove release assets: %v", err)
+ }
+ http.Redirect(w, r, releaseBasePath(r), http.StatusSeeOther)
+}
+
+// handleDeleteReleaseAsset removes one asset (owner-only).
+func (s *Server) handleDeleteReleaseAsset(w http.ResponseWriter, r *http.Request) {
+ repo, _, _, ok := s.ownerRepo(w, r)
+ if !ok {
+ return
+ }
+ release, ok := s.releaseByTag(w, r, repo)
+ if !ok {
+ return
+ }
+ id, ok := pathID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ asset, err := db.GetReleaseAssetForRepo(s.database, repo.ID, id)
+ if errors.Is(err, db.ErrNotFound) || (err == nil && asset.ReleaseID != release.ID) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if err := db.DeleteReleaseAsset(s.database, release.ID, id); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if err := os.Remove(filepath.Join(s.releaseUploadDir(release.ID), asset.StoredName)); err != nil {
+ log.Printf("web: remove release asset: %v", err)
+ }
+ http.Redirect(w, r, releaseBasePath(r)+"/"+url.PathEscape(release.Tag), http.StatusSeeOther)
+}
+
+// releaseByTag resolves the {tag} path value to a release in repo.
+func (s *Server) releaseByTag(w http.ResponseWriter, r *http.Request, repo db.Repo) (db.Release, bool) {
+ release, err := db.GetReleaseByTag(s.database, repo.ID, r.PathValue("tag"))
+ if errors.Is(err, db.ErrNotFound) {
+ http.NotFound(w, r)
+ return db.Release{}, false
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return db.Release{}, false
+ }
+ return release, true
+}
+
+func humanSize(n int64) string {
+ const unit = 1024
+ if n < unit {
+ return fmt.Sprintf("%d B", n)
+ }
+ div, exp := int64(unit), 0
+ for v := n / unit; v >= unit; v /= unit {
+ div *= unit
+ exp++
+ }
+ return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
+}
diff --git a/internal/web/releases_test.go b/internal/web/releases_test.go
new file mode 100644
index 0000000..9e54d77
--- /dev/null
+++ b/internal/web/releases_test.go
@@ -0,0 +1,188 @@
+package web
+
+import (
+ "database/sql"
+ "errors"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+// seedTag pushes a lightweight tag named tag pointing at HEAD.
+func seedTag(t *testing.T, work, tag string) {
+ t.Helper()
+ runGit(t, work, "tag", tag)
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", tag)
+}
+
+// seedRelease creates a release row plus one on-disk asset directly (there is
+// no create-release UI; rows normally come from the owner's workflow).
+func seedRelease(t *testing.T, database *sql.DB, dataDir, repoName, tag string) (releaseID, assetID int64) {
+ t.Helper()
+ repo, err := db.GetRepoByName(database, "josie", repoName)
+ if err != nil {
+ t.Fatalf("GetRepoByName: %v", err)
+ }
+ owner, err := db.GetUserByName(database, "josie")
+ if err != nil {
+ t.Fatalf("GetUserByName: %v", err)
+ }
+ release, err := db.CreateRelease(database, repo.ID, tag, "First release", "**bold** notes", owner.ID)
+ if err != nil {
+ t.Fatalf("CreateRelease: %v", err)
+ }
+ dir := filepath.Join(dataDir, "uploads", "releases", strconv.FormatInt(release.ID, 10))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("mkdir assets: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "stored"), []byte("hello world"), 0o644); err != nil {
+ t.Fatalf("write asset: %v", err)
+ }
+ asset, err := db.CreateReleaseAsset(database, release.ID, "artifact.bin", "stored", int64(len("hello world")))
+ if err != nil {
+ t.Fatalf("CreateReleaseAsset: %v", err)
+ }
+ return release.ID, asset.ID
+}
+
+func TestReleaseRoutesRegister(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ client := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, client, httpServer, "pub", "public")
+ for _, path := range []string{"/josie/pub/releases"} {
+ resp, err := client.Get(httpServer.URL + path)
+ if err != nil {
+ t.Fatalf("GET %s: %v", path, err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("GET %s = %d, want 200", path, resp.StatusCode)
+ }
+ }
+}
+
+func TestReleaseViewAndDownload(t *testing.T) {
+ httpServer, database, dataDir := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "a.txt", "a\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "a")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ seedTag(t, work, "v1.0.0")
+ _, assetID := seedRelease(t, database, dataDir, "pub", "v1.0.0")
+
+ view, err := owner.Get(httpServer.URL + "/josie/pub/releases/v1.0.0")
+ if err != nil {
+ t.Fatalf("GET release: %v", err)
+ }
+ body := readAll(t, view)
+ for _, want := range []string{"First release", "<strong>bold</strong>", "artifact.bin"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("release view missing %q: %s", want, body)
+ }
+ }
+
+ list, _ := (&http.Client{}).Get(httpServer.URL + "/josie/pub/releases")
+ body = readAll(t, list)
+ if !strings.Contains(body, "First release") {
+ t.Errorf("release list missing the release: %s", body)
+ }
+ if strings.Contains(body, "Tags without a release") {
+ t.Errorf("released tag still listed as unreleased: %s", body)
+ }
+
+ download, err := owner.Get(httpServer.URL + "/josie/pub/releases/download/" + strconv.FormatInt(assetID, 10) + "/artifact.bin")
+ if err != nil {
+ t.Fatalf("download: %v", err)
+ }
+ content := readAll(t, download)
+ if download.StatusCode != http.StatusOK || content != "hello world" {
+ t.Errorf("download = %d %q, want 200 hello world", download.StatusCode, content)
+ }
+ if cd := download.Header.Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") {
+ t.Errorf("Content-Disposition = %q, want attachment", cd)
+ }
+ if download.Header.Get("X-Content-Type-Options") != "nosniff" {
+ t.Errorf("missing nosniff on download")
+ }
+}
+
+func TestReleaseDeleteOwnerOnly(t *testing.T) {
+ httpServer, database, dataDir := newTestServer(t)
+ createRepo(t, noFollow(newLoggedInClient(t, httpServer)), httpServer, "pub", "public")
+ seedRelease(t, database, dataDir, "pub", "v1")
+
+ addUser(t, database, "mallory", "pw")
+ mallory := noFollow(loginAs(t, httpServer, "mallory", "pw"))
+ resp, err := mallory.Post(httpServer.URL+"/josie/pub/releases/v1/delete", "", nil)
+ if err != nil {
+ t.Fatalf("mallory delete: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("non-owner delete = %d, want 404", resp.StatusCode)
+ }
+}
+
+func TestDeleteReleaseRemovesAssets(t *testing.T) {
+ httpServer, database, dataDir := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ releaseID, _ := seedRelease(t, database, dataDir, "pub", "v1")
+
+ repo, _ := db.GetRepoByName(database, "josie", "pub")
+ release, _ := db.GetReleaseByTag(database, repo.ID, "v1")
+ assets, _ := db.ListReleaseAssets(database, release.ID)
+ if len(assets) != 1 {
+ t.Fatalf("assets = %+v, want one", assets)
+ }
+ storedPath := filepath.Join(dataDir, "uploads", "releases", strconv.FormatInt(releaseID, 10), assets[0].StoredName)
+ if _, err := os.Stat(storedPath); err != nil {
+ t.Fatalf("stored asset missing before delete: %v", err)
+ }
+
+ resp, err := owner.Post(httpServer.URL+"/josie/pub/releases/v1/delete", "", nil)
+ if err != nil {
+ t.Fatalf("delete release: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("delete release = %d, want 303", resp.StatusCode)
+ }
+ if _, err := db.GetReleaseByTag(database, repo.ID, "v1"); !errors.Is(err, db.ErrNotFound) {
+ t.Errorf("release after delete err = %v, want not found", err)
+ }
+ if _, err := os.Stat(storedPath); !os.IsNotExist(err) {
+ t.Errorf("stored asset still present after delete: %v", err)
+ }
+ view, _ := owner.Get(httpServer.URL + "/josie/pub/releases/v1")
+ readAll(t, view)
+ if view.StatusCode != http.StatusNotFound {
+ t.Errorf("release view after delete = %d, want 404", view.StatusCode)
+ }
+}
+
+// TestReleaseDownloadPrivateNeedsOwner checks the download follows the repo
+// visibility gate.
+func TestReleaseDownloadPrivateNeedsOwner(t *testing.T) {
+ httpServer, database, dataDir := newTestServer(t)
+ createRepo(t, noFollow(newLoggedInClient(t, httpServer)), httpServer, "sec", "private")
+ _, assetID := seedRelease(t, database, dataDir, "sec", "v1")
+
+ anon := noFollow(&http.Client{})
+ download, err := anon.Get(httpServer.URL + "/josie/sec/releases/download/" + strconv.FormatInt(assetID, 10) + "/secret.bin")
+ if err != nil {
+ t.Fatalf("anon download: %v", err)
+ }
+ readAll(t, download)
+ if download.StatusCode != http.StatusNotFound {
+ t.Errorf("anon private download = %d, want 404 (no existence oracle)", download.StatusCode)
+ }
+}
diff --git a/internal/web/repo.go b/internal/web/repo.go
new file mode 100644
index 0000000..16a36aa
--- /dev/null
+++ b/internal/web/repo.go
@@ -0,0 +1,102 @@
+package web
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+ "git.josie-c.com/josie/simplegit/internal/git"
+)
+
+// Repo names become URL path segments (/user/repo), same policy as usernames.
+var repoNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`)
+
+type newRepoData struct {
+ Username string
+ Name string
+ Description string
+ Visibility string
+ Error string
+}
+
+type createdData struct {
+ Username string
+ RepoName string
+ PushURL string
+}
+
+func (s *Server) handleNewRepoForm(w http.ResponseWriter, r *http.Request) {
+ user := requireUser(w, r)
+ if user == nil {
+ return
+ }
+ s.render(w, "new.html", http.StatusOK, newRepoData{Username: user.Username, Visibility: visibilityPrivate})
+}
+
+// Repo creation is owner-only: anonymous visitors are redirected to the
+// login page, so unauthenticated traffic can never materialize repos (or
+// their on-disk directories) on the server.
+func (s *Server) handleCreateRepo(w http.ResponseWriter, r *http.Request) {
+ user := requireUser(w, r)
+ if user == nil {
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ // Truncate before validating so an oversized form value can never reach
+ // the error page or the log unbounded.
+ name := formText(r, "name", maxNameLen+1)
+ description := formText(r, "description", maxTitleLen)
+ visibility := r.FormValue("visibility")
+ if visibility != visibilityPublic {
+ visibility = visibilityPrivate
+ }
+ fail := func(status int, msg string) {
+ log.Printf("web: create repo %q: %s", name, msg)
+ s.render(w, "new.html", status, newRepoData{
+ Username: user.Username,
+ Name: name, Description: description, Visibility: visibility, Error: msg,
+ })
+ }
+
+ if len(name) > maxNameLen || !repoNamePattern.MatchString(name) {
+ fail(http.StatusUnprocessableEntity, fmt.Sprintf("invalid repository name %q", name))
+ return
+ }
+ if _, err := db.GetRepoByName(s.database, user.Username, name); err == nil {
+ fail(http.StatusUnprocessableEntity, "you already have a repository with that name")
+ return
+ } else if !errors.Is(err, db.ErrNotFound) {
+ s.internalError(w, r, err)
+ return
+ }
+
+ // DB row first, bare repo second: if git fails we roll the row back,
+ // whereas a stray directory from a failed run would shadow a future create.
+ repo, err := db.CreateRepo(s.database, user.ID, name, description, visibility)
+ if err != nil {
+ fail(http.StatusInternalServerError, "could not create the repository")
+ return
+ }
+ repoPath := filepath.Join(s.cfg.DataDir, "repos", user.Username, repo.Name+".git")
+ if err := git.InitBare(repoPath, repo.DefaultBranch); err != nil {
+ log.Printf("web: git init %s: %v", repoPath, err)
+ if err := db.DeleteRepo(s.database, repo.ID); err != nil {
+ log.Printf("web: rollback repo %d: %v", repo.ID, err)
+ }
+ os.RemoveAll(repoPath)
+ fail(http.StatusInternalServerError, "could not initialize the repository on disk")
+ return
+ }
+
+ pushURL := cloneURL(s.cfg.BaseURL, user.Username, repo.Name)
+ s.render(w, "created.html", http.StatusOK, createdData{Username: user.Username, RepoName: repo.Name, PushURL: pushURL})
+}
diff --git a/internal/web/repo_settings.go b/internal/web/repo_settings.go
new file mode 100644
index 0000000..36d9161
--- /dev/null
+++ b/internal/web/repo_settings.go
@@ -0,0 +1,156 @@
+package web
+
+import (
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+type repoSettingsData struct {
+ navData
+ CloneURL string
+ Error string
+}
+
+// ownerRepo resolves a repo page and requires the caller to be its owner,
+// regardless of visibility. On failure it has written the response.
+func (s *Server) ownerRepo(w http.ResponseWriter, r *http.Request) (db.Repo, string, *db.User, bool) {
+ repo, repoPath, user, ok := s.resolveRepo(w, r)
+ if !ok {
+ return db.Repo{}, "", nil, false
+ }
+ if !isOwner(user, repo) {
+ if user == nil {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ } else {
+ http.NotFound(w, r)
+ }
+ return db.Repo{}, "", nil, false
+ }
+ return repo, repoPath, user, true
+}
+
+func (s *Server) repoView(r *http.Request, repo db.Repo, user *db.User) repoSettingsData {
+ return repoSettingsData{
+ navData: nav(r, repo, user, "settings"),
+ CloneURL: cloneURL(s.cfg.BaseURL, r.PathValue("user"), repo.Name),
+ }
+}
+
+// handleRepoSettings renders the per-repo settings page.
+func (s *Server) handleRepoSettings(w http.ResponseWriter, r *http.Request) {
+ repo, _, user, ok := s.ownerRepo(w, r)
+ if !ok {
+ return
+ }
+ s.render(w, "repo_settings.html", http.StatusOK, s.repoView(r, repo, user))
+}
+
+// handleRepoVisibility toggles a repo between public and private.
+func (s *Server) handleRepoVisibility(w http.ResponseWriter, r *http.Request) {
+ repo, _, _, ok := s.ownerRepo(w, r)
+ if !ok {
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ visibility := r.FormValue("visibility")
+ if visibility != visibilityPublic {
+ visibility = visibilityPrivate
+ }
+ if err := db.UpdateRepoVisibility(s.database, repo.ID, visibility); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, repoBasePath(r)+"/settings", http.StatusSeeOther)
+}
+
+// handleRepoRename moves the bare directory and updates the row.
+func (s *Server) handleRepoRename(w http.ResponseWriter, r *http.Request) {
+ repo, repoPath, user, ok := s.ownerRepo(w, r)
+ if !ok {
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ owner := r.PathValue("user")
+ name := formText(r, "name", maxNameLen+1)
+ fail := func(msg string) {
+ data := s.repoView(r, repo, user)
+ data.Error = msg
+ s.render(w, "repo_settings.html", http.StatusUnprocessableEntity, data)
+ }
+ if name == repo.Name {
+ http.Redirect(w, r, repoBasePath(r)+"/settings", http.StatusSeeOther)
+ return
+ }
+ if len(name) > maxNameLen || !repoNamePattern.MatchString(name) {
+ fail(fmt.Sprintf("invalid repository name %q", name))
+ return
+ }
+ if _, err := db.GetRepoByName(s.database, owner, name); err == nil {
+ fail("you already have a repository with that name")
+ return
+ } else if !errors.Is(err, db.ErrNotFound) {
+ s.internalError(w, r, err)
+ return
+ }
+
+ newPath := filepath.Join(s.cfg.DataDir, "repos", owner, name+".git")
+ if err := os.Rename(repoPath, newPath); err != nil {
+ log.Printf("web: rename repo %s: %v", repoPath, err)
+ fail("could not move the repository on disk")
+ return
+ }
+ if err := db.RenameRepo(s.database, repo.ID, name); err != nil {
+ log.Printf("web: rename repo row %d: %v", repo.ID, err)
+ if rollbackErr := os.Rename(newPath, repoPath); rollbackErr != nil {
+ log.Printf("web: rename rollback %s: %v", newPath, rollbackErr)
+ }
+ s.internalError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/"+owner+"/"+name+"/settings", http.StatusSeeOther)
+}
+
+// handleRepoDelete removes the row, the bare directory, and any release
+// asset uploads (the DB rows cascade, but files on disk do not).
+func (s *Server) handleRepoDelete(w http.ResponseWriter, r *http.Request) {
+ repo, repoPath, _, ok := s.ownerRepo(w, r)
+ if !ok {
+ return
+ }
+ releases, err := db.ListReleases(s.database, repo.ID)
+ if err != nil {
+ log.Printf("web: list releases for delete %d: %v", repo.ID, err)
+ }
+ if err := db.DeleteRepo(s.database, repo.ID); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if err := os.RemoveAll(repoPath); err != nil {
+ log.Printf("web: remove repo dir %s: %v", repoPath, err)
+ }
+ for _, release := range releases {
+ if err := os.RemoveAll(s.releaseUploadDir(release.ID)); err != nil {
+ log.Printf("web: remove release uploads %d: %v", release.ID, err)
+ }
+ }
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// repoBasePath is the canonical /{owner}/{repo} for the request.
+func repoBasePath(r *http.Request) string {
+ return "/" + r.PathValue("user") + "/" + r.PathValue("repo")
+}
diff --git a/internal/web/repo_settings_test.go b/internal/web/repo_settings_test.go
new file mode 100644
index 0000000..c0193e6
--- /dev/null
+++ b/internal/web/repo_settings_test.go
@@ -0,0 +1,122 @@
+package web
+
+import (
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestRepoSettingsRequiresOwner(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+ addUser(t, database, "mallory", "pw")
+
+ resp, err := noFollowClient().Get(httpServer.URL + "/josie/pub/settings")
+ if err != nil {
+ t.Fatalf("anonymous GET settings: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" {
+ t.Errorf("anonymous settings = %d %q, want 303 /login", resp.StatusCode, resp.Header.Get("Location"))
+ }
+
+ mallory := loginAs(t, httpServer, "mallory", "pw")
+ resp, err = mallory.Get(httpServer.URL + "/josie/pub/settings")
+ if err != nil {
+ t.Fatalf("non-owner GET settings: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("non-owner settings = %d, want 404", resp.StatusCode)
+ }
+
+ owner := newLoggedInClient(t, httpServer)
+ resp, err = owner.Get(httpServer.URL + "/josie/pub/settings")
+ if err != nil {
+ t.Fatalf("owner GET settings: %v", err)
+ }
+ if body := readAll(t, resp); resp.StatusCode != http.StatusOK || !strings.Contains(body, "visibility") {
+ t.Errorf("owner settings = %d, body %q", resp.StatusCode, body)
+ }
+}
+
+func TestRepoSettingsVisibilityRenameDelete(t *testing.T) {
+ httpServer, _, dataDir := newTestServer(t)
+ owner := newLoggedInClient(t, httpServer)
+ createRepo(t, owner, httpServer, "pub", "public")
+ seedFiles(t, dataDir, "pub")
+
+ // Visibility: public -> private hides it from anonymous readers.
+ resp, err := owner.PostForm(httpServer.URL+"/josie/pub/settings/visibility", url.Values{"visibility": {"private"}})
+ if err != nil {
+ t.Fatalf("set private: %v", err)
+ }
+ readAll(t, resp)
+ anon, err := noFollowClient().Get(httpServer.URL + "/josie/pub")
+ if err != nil {
+ t.Fatalf("anon read after private: %v", err)
+ }
+ readAll(t, anon)
+ if anon.StatusCode != http.StatusNotFound {
+ t.Errorf("anon read private repo = %d, want 404 (no existence oracle)", anon.StatusCode)
+ }
+
+ // Rename moves the directory and the row.
+ resp, err = owner.PostForm(httpServer.URL+"/josie/pub/settings/rename", url.Values{"name": {"renamed"}})
+ if err != nil {
+ t.Fatalf("rename: %v", err)
+ }
+ readAll(t, resp)
+ if _, err := os.Stat(filepath.Join(dataDir, "repos", "josie", "pub.git")); !os.IsNotExist(err) {
+ t.Errorf("old repo dir still present (err %v)", err)
+ }
+ if _, err := os.Stat(filepath.Join(dataDir, "repos", "josie", "renamed.git")); err != nil {
+ t.Errorf("new repo dir missing: %v", err)
+ }
+ resp, err = owner.Get(httpServer.URL + "/josie/renamed")
+ if err != nil {
+ t.Fatalf("owner GET renamed: %v", err)
+ }
+ if body := readAll(t, resp); resp.StatusCode != http.StatusOK || !strings.Contains(body, "README.md") {
+ t.Errorf("renamed repo home = %d, body %q", resp.StatusCode, body)
+ }
+ resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub")
+ if err != nil {
+ t.Fatalf("GET old name: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("old repo URL = %d, want 404", resp.StatusCode)
+ }
+
+ // Duplicate rename is rejected.
+ createRepo(t, owner, httpServer, "other", "private")
+ resp, err = owner.PostForm(httpServer.URL+"/josie/renamed/settings/rename", url.Values{"name": {"other"}})
+ if err != nil {
+ t.Fatalf("duplicate rename: %v", err)
+ }
+ if body := readAll(t, resp); resp.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, "already have a repository") {
+ t.Errorf("duplicate rename = %d, body %q", resp.StatusCode, body)
+ }
+
+ // Delete removes the directory and the row.
+ resp, err = owner.PostForm(httpServer.URL+"/josie/other/settings/delete", nil)
+ if err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ readAll(t, resp)
+ if _, err := os.Stat(filepath.Join(dataDir, "repos", "josie", "other.git")); !os.IsNotExist(err) {
+ t.Errorf("deleted repo dir still present (err %v)", err)
+ }
+ resp, err = owner.Get(httpServer.URL + "/josie/other")
+ if err != nil {
+ t.Fatalf("GET deleted repo: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("deleted repo URL = %d, want 404", resp.StatusCode)
+ }
+}
diff --git a/internal/web/repo_test.go b/internal/web/repo_test.go
new file mode 100644
index 0000000..8f60af4
--- /dev/null
+++ b/internal/web/repo_test.go
@@ -0,0 +1,128 @@
+package web
+
+import (
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+func TestNewRepoFormRequiresLogin(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+ resp, err := client.Get(httpServer.URL + "/new")
+ if err != nil {
+ t.Fatalf("GET /new: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Errorf("status = %d, want 303", resp.StatusCode)
+ }
+ if resp.Header.Get("Location") != "/login" {
+ t.Errorf("Location = %q, want /login", resp.Header.Get("Location"))
+ }
+
+ loggedIn := newLoggedInClient(t, httpServer)
+ resp, err = loggedIn.Get(httpServer.URL + "/new")
+ if err != nil {
+ t.Fatalf("GET /new signed in: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, `name="name"`) {
+ t.Errorf("signed-in GET /new = %d, form missing: %q", resp.StatusCode, body)
+ }
+}
+
+func TestCreateRepoFlow(t *testing.T) {
+ httpServer, database, dataDir := newTestServer(t)
+ client := newLoggedInClient(t, httpServer)
+
+ resp, err := client.PostForm(httpServer.URL+"/new", url.Values{
+ "name": {"my-repo"}, "description": {"a test repo"}, "visibility": {"public"},
+ })
+ if err != nil {
+ t.Fatalf("POST /new: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+ if !strings.Contains(body, "git remote add origin") || !strings.Contains(body, "/josie/my-repo.git") {
+ t.Errorf("no push instructions: %q", body)
+ }
+
+ repo, err := db.GetRepoByName(database, "josie", "my-repo")
+ if err != nil {
+ t.Fatalf("GetRepoByName: %v", err)
+ }
+ if repo.Visibility != "public" || repo.Description != "a test repo" {
+ t.Errorf("repo = %+v, want public / %q stored", repo, "a test repo")
+ }
+ if repo.PushedAt.Valid {
+ t.Errorf("PushedAt = %d, want NULL before first push", repo.PushedAt.Int64)
+ }
+
+ head, err := os.ReadFile(filepath.Join(dataDir, "repos", "josie", "my-repo.git", "HEAD"))
+ if err != nil {
+ t.Fatalf("bare repo HEAD missing: %v", err)
+ }
+ if !strings.Contains(string(head), "ref: refs/heads/"+repo.DefaultBranch) {
+ t.Errorf("HEAD = %q, want default branch %q", head, repo.DefaultBranch)
+ }
+}
+
+func TestCreateRepoRejectsBadNames(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ client := newLoggedInClient(t, httpServer)
+ for _, name := range []string{"bad name", "ünicode", "", strings.Repeat("a", maxNameLen+1)} {
+ resp, err := client.PostForm(httpServer.URL+"/new", url.Values{"name": {name}})
+ if err != nil {
+ t.Fatalf("POST /new %q: %v", name, err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusUnprocessableEntity {
+ t.Errorf("name %q: status = %d, want 422", name, resp.StatusCode)
+ }
+ if !strings.Contains(body, "invalid repository name") {
+ t.Errorf("name %q: no error shown: %q", name, body)
+ }
+ }
+}
+
+func TestCreateRepoRejectsDuplicate(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ client := newLoggedInClient(t, httpServer)
+ resp, err := client.PostForm(httpServer.URL+"/new", url.Values{"name": {"dup"}})
+ if err != nil {
+ t.Fatalf("first POST /new: %v", err)
+ }
+ readAll(t, resp)
+
+ resp, err = client.PostForm(httpServer.URL+"/new", url.Values{"name": {"dup"}})
+ if err != nil {
+ t.Fatalf("second POST /new: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusUnprocessableEntity {
+ t.Errorf("status = %d, want 422", resp.StatusCode)
+ }
+ if !strings.Contains(body, "already have a repository") {
+ t.Errorf("no duplicate error shown: %q", body)
+ }
+
+ var count int
+ if err := database.QueryRow(
+ `SELECT count(*) FROM repos WHERE name = ?`, "dup",
+ ).Scan(&count); err != nil {
+ t.Fatalf("count repos: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("repos named dup = %d, want 1", count)
+ }
+}
diff --git a/internal/web/repohome.go b/internal/web/repohome.go
new file mode 100644
index 0000000..a217c93
--- /dev/null
+++ b/internal/web/repohome.go
@@ -0,0 +1,230 @@
+package web
+
+import (
+ "html/template"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+ "git.josie-c.com/josie/simplegit/internal/git"
+ "git.josie-c.com/josie/simplegit/internal/render"
+)
+
+// readmeCandidates are tried in order at the repo root; first hit wins.
+var readmeCandidates = []string{"README.md", "README.markdown", "README.mkd", "README"}
+
+const (
+ readmeLimit = 1 << 20
+ licenseLimit = 64 << 10
+)
+
+// treeNode is one entry in the Code tab's file tree: a directory (Children,
+// no Href) or a file (Href to its blob view).
+type treeNode struct {
+ Name string
+ Href string
+ Children []*treeNode
+}
+
+// buildFileTree turns a recursive path list into nested nodes; directories
+// materialize on demand in git's leaf order.
+func buildFileTree(paths []string, href func(string) string) []*treeNode {
+ var roots []*treeNode
+ dirs := map[string]*treeNode{}
+ for _, p := range paths {
+ parts := strings.Split(p, "/")
+ parent := &roots
+ dirPath := ""
+ for i, part := range parts {
+ if i == len(parts)-1 {
+ *parent = append(*parent, &treeNode{Name: part, Href: href(p)})
+ continue
+ }
+ if dirPath != "" {
+ dirPath += "/"
+ }
+ dirPath += part
+ node, ok := dirs[dirPath]
+ if !ok {
+ node = &treeNode{Name: part + "/"}
+ dirs[dirPath] = node
+ *parent = append(*parent, node)
+ }
+ parent = &node.Children
+ }
+ }
+ return roots
+}
+
+// buildTree lists ref's files and nests them for the Code tab sidebar. A
+// listing failure (empty or unreadable repo) just leaves the tree empty.
+func (s *Server) buildTree(r *http.Request, repo db.Repo, ref string) []*treeNode {
+ paths, err := git.LsTreePaths(s.repoPathFor(r.PathValue("user"), repo), ref)
+ if err != nil {
+ return nil
+ }
+ hrefBase := "/" + r.PathValue("user") + "/" + repo.Name + "/blob/" + url.PathEscape(ref) + "/"
+ return buildFileTree(paths, func(p string) string { return hrefBase + escapePath(p) })
+}
+
+type repoHomeData struct {
+ navData
+ Description string
+ Branch string // the ref being viewed
+ DefaultBranch string
+ CloneURL string
+ Empty bool
+ Tree []*treeNode
+ Readme template.HTML
+ LicenseName string
+ LicenseHref string
+ CommitCount int
+ BranchCount int
+ TagCount int
+ Branches []string
+}
+
+func (s *Server) baseRepoData(r *http.Request, repo db.Repo, user *db.User) repoHomeData {
+ return repoHomeData{
+ navData: nav(r, repo, user, "code"),
+ Description: repo.Description,
+ Branch: repo.DefaultBranch,
+ DefaultBranch: repo.DefaultBranch,
+ CloneURL: cloneURL(s.cfg.BaseURL, r.PathValue("user"), repo.Name),
+ }
+}
+
+func (s *Server) handleRepoHome(w http.ResponseWriter, r *http.Request) {
+ repo, repoPath, user, ok := s.resolveRepo(w, r)
+ if !ok {
+ return
+ }
+ data := s.baseRepoData(r, repo, user)
+
+ exists, err := git.RefExists(repoPath, repo.DefaultBranch)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if !exists {
+ data.Empty = true
+ s.render(w, "repo.html", http.StatusOK, data)
+ return
+ }
+
+ entries, err := git.LsTree(repoPath, repo.DefaultBranch, "")
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ s.renderCodeTab(w, r, repo, repoPath, user, repo.DefaultBranch, entries)
+}
+
+// attachTree builds the recursive file tree for the Code tab.
+func (s *Server) attachTree(data *repoHomeData, repoPath string) {
+ paths, err := git.LsTreePaths(repoPath, data.Branch)
+ if err != nil {
+ return
+ }
+ base := "/" + data.Owner + "/" + data.RepoName + "/blob/" + url.PathEscape(data.Branch) + "/"
+ data.Tree = buildFileTree(paths, func(p string) string { return base + escapePath(p) })
+}
+
+// attachRepoMeta fills the Code tab's summary: commit/branch/tag counts and
+// the branch list for the dropdown.
+func (s *Server) attachRepoMeta(data *repoHomeData, repoPath string) {
+ if n, err := git.CommitCount(repoPath, data.Branch); err == nil {
+ data.CommitCount = n
+ }
+ if branches, err := git.Branches(repoPath); err == nil {
+ data.Branches = branches
+ data.BranchCount = len(branches)
+ }
+ if tags, err := git.Tags(repoPath); err == nil {
+ data.TagCount = len(tags)
+ }
+}
+
+// attachReadme renders the first README candidate at the repo root.
+func (s *Server) attachReadme(data *repoHomeData, repoPath string, entries []git.TreeEntry) {
+ var readme string
+ for _, candidate := range readmeCandidates {
+ for _, entry := range entries {
+ if entry.Type == "blob" && entry.Path == candidate {
+ readme = candidate
+ break
+ }
+ }
+ if readme != "" {
+ break
+ }
+ }
+ if readme == "" {
+ return
+ }
+ source, err := git.ShowFile(repoPath, data.Branch, readme, readmeLimit)
+ if err != nil || len(source) > readmeLimit {
+ return
+ }
+ if strings.HasPrefix(strings.ToLower(readme), "readme.") {
+ html, err := render.Markdown(source)
+ if err != nil {
+ return
+ }
+ data.Readme = template.HTML(html)
+ return
+ }
+ data.Readme = template.HTML("<pre>" + template.HTMLEscapeString(string(source)) + "</pre>")
+}
+
+// attachLicense sniffs the first LICENSE*/COPYING* file at the repo root and
+// links the license summary box to it.
+func (s *Server) attachLicense(data *repoHomeData, repoPath string, entries []git.TreeEntry) {
+ for _, entry := range entries {
+ base := strings.ToUpper(path.Base(entry.Path))
+ if entry.Type != "blob" || !strings.HasPrefix(base, "LICENSE") && !strings.HasPrefix(base, "COPYING") {
+ continue
+ }
+ source, err := git.ShowFile(repoPath, data.Branch, entry.Path, licenseLimit)
+ if err != nil {
+ return
+ }
+ if len(source) > licenseLimit {
+ source = source[:licenseLimit]
+ }
+ data.LicenseName = detectLicense(string(source))
+ data.LicenseHref = "/" + data.Owner + "/" + data.RepoName + "/blob/" + escapePath(data.Branch+"/"+entry.Path)
+ return
+ }
+}
+
+// detectLicense is a deliberately dumb keyword sniff over the license
+// text; anything unrecognised but present is "Custom".
+func detectLicense(text string) string {
+ upper := strings.ToUpper(text)
+ switch {
+ case strings.Contains(upper, "MIT LICENSE"),
+ strings.Contains(upper, "PERMISSION IS HEREBY GRANTED, FREE OF CHARGE"):
+ return "MIT"
+ case strings.Contains(upper, "APACHE LICENSE, VERSION 2"):
+ return "Apache-2.0"
+ case strings.Contains(upper, "BSD 3-CLAUSE"), strings.Contains(upper, "BSD 3. CLAUSE"):
+ return "BSD-3-Clause"
+ case strings.Contains(upper, "BSD 2-CLAUSE"), strings.Contains(upper, "BSD 2. CLAUSE"):
+ return "BSD-2-Clause"
+ case strings.Contains(upper, "ISC LICENSE"):
+ return "ISC"
+ case strings.Contains(upper, "UNLICENSE"):
+ return "Unlicense"
+ case strings.Contains(upper, "GNU GENERAL PUBLIC LICENSE"):
+ return "GPL"
+ case strings.Contains(upper, "GNU LESSER GENERAL PUBLIC LICENSE"):
+ return "LGPL"
+ case strings.Contains(upper, "MOZILLA PUBLIC LICENSE"):
+ return "MPL"
+ default:
+ return "Custom"
+ }
+}
diff --git a/internal/web/repohome_test.go b/internal/web/repohome_test.go
new file mode 100644
index 0000000..be2ac6f
--- /dev/null
+++ b/internal/web/repohome_test.go
@@ -0,0 +1,144 @@
+package web
+
+import (
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// seedFiles gives a repo created via /new one commit on main.
+func seedFiles(t *testing.T, dataDir, name string) {
+ t.Helper()
+ bare := filepath.Join(dataDir, "repos", "josie", name+".git")
+ work := filepath.Join(t.TempDir(), "work")
+ cmd := exec.Command("git", "clone", "-q", bare, work)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("clone seed: %v: %s", err, out)
+ }
+ write := func(rel, content string) {
+ full := filepath.Join(work, rel)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
+ t.Fatalf("write %s: %v", rel, err)
+ }
+ }
+ write("README.md", "# demo repo\n\nthe **readme** body\n")
+ write("LICENSE", "MIT License\n\nPermission is hereby granted, free of charge, to any person...\n")
+ write("hello.c", "int main(void) { return 0; }\n")
+ write("sub/note.txt", "note\n")
+ for _, args := range [][]string{
+ {"add", "."},
+ {"-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "seed"},
+ {"push", "-q", "origin", "main"},
+ } {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = work
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+ }
+}
+
+func TestRepoHomePublic(t *testing.T) {
+ httpServer, _, dataDir := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+ seedFiles(t, dataDir, "pub")
+
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub")
+ if err != nil {
+ t.Fatalf("GET /josie/pub: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200: %q", resp.StatusCode, body)
+ }
+ for _, want := range []string{
+ "josie/pub", "README.md", "hello.c", "sub/",
+ "<strong>readme</strong>", "MIT", "/josie/pub.git",
+ "/josie/pub/blob/main/hello.c", "/josie/pub/blob/main/sub/note.txt",
+ `class="file-tree`, "✓ main", `value="http`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("body lacks %q", want)
+ }
+ }
+}
+
+func TestRepoHomePrivate(t *testing.T) {
+ httpServer, _, dataDir := newTestServer(t)
+ loggedIn := newLoggedInClient(t, httpServer)
+ createRepo(t, loggedIn, httpServer, "sec", "private")
+ seedFiles(t, dataDir, "sec")
+
+ noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+ resp, err := noFollow.Get(httpServer.URL + "/josie/sec")
+ if err != nil {
+ t.Fatalf("anonymous GET private home: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("anonymous status = %d, want 404 (no existence oracle)", resp.StatusCode)
+ }
+
+ resp, err = loggedIn.Get(httpServer.URL + "/josie/sec")
+ if err != nil {
+ t.Fatalf("signed-in GET private home: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, "README.md") {
+ t.Errorf("signed-in status = %d, want 200 with listing", resp.StatusCode)
+ }
+}
+
+func TestRepoHomeEmptyRepoShowsInstructions(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ loggedIn := newLoggedInClient(t, httpServer)
+ createRepo(t, loggedIn, httpServer, "fresh", "private")
+
+ resp, err := loggedIn.Get(httpServer.URL + "/josie/fresh")
+ if err != nil {
+ t.Fatalf("GET empty home: %v", err)
+ }
+ body := readAll(t, resp)
+ if !strings.Contains(body, "The repository is empty") || !strings.Contains(body, "git remote add origin") {
+ t.Errorf("empty repo page lacks push instructions: %q", body)
+ }
+}
+
+func TestRepoHomePrivateOwnerOnly(t *testing.T) {
+ httpServer, database, dataDir := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "sec", "private")
+ seedFiles(t, dataDir, "sec")
+ addUser(t, database, "mallory", "pw")
+
+ mallory := loginAs(t, httpServer, "mallory", "pw")
+ for _, path := range []string{"/josie/sec", "/josie/sec/tree/main", "/josie/sec/blob/main/README.md"} {
+ resp, err := mallory.Get(httpServer.URL + path)
+ if err != nil {
+ t.Fatalf("GET %s as non-owner: %v", path, err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("non-owner GET %s = %d, want 404", path, resp.StatusCode)
+ }
+ }
+}
+
+func TestRepoHomeUnknown(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/nope")
+ if err != nil {
+ t.Fatalf("GET unknown: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", resp.StatusCode)
+ }
+}
diff --git a/internal/web/session.go b/internal/web/session.go
new file mode 100644
index 0000000..20e69d9
--- /dev/null
+++ b/internal/web/session.go
@@ -0,0 +1,81 @@
+package web
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "time"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+const (
+ sessionCookieName = "session"
+ sessionDuration = 30 * 24 * time.Hour
+)
+
+type contextKey int
+
+const userKey contextKey = iota
+
+// withUser resolves a valid session cookie into a *db.User on the
+// request context. Requests without a session pass through anonymous.
+func (s *Server) withUser(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cookie, err := r.Cookie(sessionCookieName)
+ if err == nil && cookie.Value != "" {
+ session, err := db.GetSession(s.database, cookie.Value)
+ if err == nil {
+ user, err := db.GetUserByID(s.database, session.UserID)
+ if err == nil {
+ r = r.WithContext(context.WithValue(r.Context(), userKey, &user))
+ }
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// currentUser returns the authenticated user, or nil for anonymous requests.
+func currentUser(r *http.Request) *db.User {
+ user, _ := r.Context().Value(userKey).(*db.User)
+ return user
+}
+
+// sessionCookie builds the session cookie. TLS terminates at the reverse
+// proxy, so the Secure flag follows the configured base URL's scheme.
+func (s *Server) sessionCookie(value string, maxAge time.Duration) *http.Cookie {
+ return &http.Cookie{
+ Name: sessionCookieName,
+ Value: value,
+ Path: "/",
+ MaxAge: int(maxAge.Seconds()),
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
+ }
+}
+
+// navData is the model shared by repo pages — who is viewing, which repo,
+// which tab. Templates read the promoted fields unchanged.
+type navData struct {
+ Username string
+ Owner string
+ RepoName string
+ Visibility string
+ Active string
+}
+
+// nav builds navData for a repo page from the request and viewer.
+func nav(r *http.Request, repo db.Repo, user *db.User, active string) navData {
+ n := navData{
+ Owner: r.PathValue("user"),
+ RepoName: repo.Name,
+ Visibility: repo.Visibility,
+ Active: active,
+ }
+ if user != nil {
+ n.Username = user.Username
+ }
+ return n
+}
diff --git a/internal/web/settings.go b/internal/web/settings.go
new file mode 100644
index 0000000..e5bbdf6
--- /dev/null
+++ b/internal/web/settings.go
@@ -0,0 +1,165 @@
+package web
+
+import (
+ "errors"
+ "net/http"
+ "time"
+
+ "git.josie-c.com/josie/simplegit/internal/auth"
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+// tokenView is one row of the token list, with a formatted date.
+type tokenView struct {
+ ID int64
+ Name string
+ Hint string
+ Created string
+}
+
+type settingsData struct {
+ Username string
+ Tokens []tokenView
+ NewToken string // plaintext, shown exactly once
+ PasswordError string
+ PasswordOK bool
+}
+
+func tokenViews(tokens []db.Token) []tokenView {
+ views := make([]tokenView, 0, len(tokens))
+ for _, tok := range tokens {
+ views = append(views, tokenView{
+ ID: tok.ID, Name: tok.Name, Hint: tok.Hint,
+ Created: time.Unix(tok.CreatedAt, 0).Format("2006-01-02"),
+ })
+ }
+ return views
+}
+
+// settingsView loads the page model for a user; a missing token list is
+// non-fatal (the page still renders the password form).
+func (s *Server) settingsView(user *db.User) settingsData {
+ data := settingsData{Username: user.Username}
+ if tokens, err := db.ListTokens(s.database, user.ID); err == nil {
+ data.Tokens = tokenViews(tokens)
+ }
+ return data
+}
+
+// requireUser redirects anonymous callers to the login page and reports
+// whether the request may continue.
+func requireUser(w http.ResponseWriter, r *http.Request) *db.User {
+ user := currentUser(r)
+ if user == nil {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return nil
+ }
+ return user
+}
+
+// handleSettings shows the account page: token list and password change.
+func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
+ user := requireUser(w, r)
+ if user == nil {
+ return
+ }
+ s.render(w, "settings.html", http.StatusOK, s.settingsView(user))
+}
+
+// handleCreateToken mints a token and re-renders the page so the plaintext
+// is visible once.
+func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
+ user := requireUser(w, r)
+ if user == nil {
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ name := formText(r, "name", tokenLabelMax)
+ if name == "" {
+ name = "token"
+ }
+ plain, err := auth.NewAPIToken()
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if _, err := db.CreateToken(s.database, user.ID, name, auth.TokenHint(plain), auth.HashToken(plain)); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ data := s.settingsView(user)
+ data.NewToken = plain
+ s.render(w, "settings.html", http.StatusOK, data)
+}
+
+// handleDeleteToken revokes one of the caller's tokens.
+func (s *Server) handleDeleteToken(w http.ResponseWriter, r *http.Request) {
+ user := requireUser(w, r)
+ if user == nil {
+ return
+ }
+ id, ok := pathID(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if err := db.DeleteToken(s.database, user.ID, id); err != nil && !errors.Is(err, db.ErrNotFound) {
+ s.internalError(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
+}
+
+// handleChangePassword verifies the current password, stores a new hash,
+// and signs out every other browser session.
+func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
+ user := requireUser(w, r)
+ if user == nil {
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ current := r.FormValue("current_password")
+ next := r.FormValue("new_password")
+ confirm := r.FormValue("confirm_password")
+
+ fail := func(message string) {
+ data := s.settingsView(user)
+ data.PasswordError = message
+ s.render(w, "settings.html", http.StatusUnprocessableEntity, data)
+ }
+ switch {
+ case !auth.CheckPassword(user.PasswordHash, current):
+ fail("current password is incorrect")
+ return
+ case next == "":
+ fail("new password must not be empty")
+ return
+ case next != confirm:
+ fail("new passwords do not match")
+ return
+ }
+
+ hash, err := auth.HashPassword(next)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if err := db.UpdateUserPassword(s.database, user.ID, hash); err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if cookie, err := r.Cookie(sessionCookieName); err == nil {
+ _ = db.DeleteOtherSessions(s.database, user.ID, cookie.Value)
+ }
+ data := s.settingsView(user)
+ data.PasswordOK = true
+ s.render(w, "settings.html", http.StatusOK, data)
+}
diff --git a/internal/web/settings_test.go b/internal/web/settings_test.go
new file mode 100644
index 0000000..0756c32
--- /dev/null
+++ b/internal/web/settings_test.go
@@ -0,0 +1,184 @@
+package web
+
+import (
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+func newJar(t *testing.T) http.CookieJar {
+ t.Helper()
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ t.Fatalf("cookiejar: %v", err)
+ }
+ return jar
+}
+
+var (
+ tokenPattern = regexp.MustCompile(`sg_[A-Za-z0-9_-]+`)
+ tokenIDPath = regexp.MustCompile(`/settings/tokens/(\d+)/revoke`)
+)
+
+func TestSettingsRequiresLogin(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+ resp, err := noFollow.Get(httpServer.URL + "/settings")
+ if err != nil {
+ t.Fatalf("anonymous GET /settings: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" {
+ t.Errorf("anonymous settings = %d %q, want 303 /login", resp.StatusCode, resp.Header.Get("Location"))
+ }
+}
+
+func TestChangePassword(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ josie := newLoggedInClient(t, httpServer)
+ other := newLoggedInClient(t, httpServer)
+ change := func(current, next, confirm string) string {
+ t.Helper()
+ resp, err := josie.PostForm(httpServer.URL+"/settings/password", url.Values{
+ "current_password": {current}, "new_password": {next}, "confirm_password": {confirm},
+ })
+ if err != nil {
+ t.Fatalf("POST /settings/password: %v", err)
+ }
+ return readAll(t, resp)
+ }
+
+ if body := change("wrong", "newpass", "newpass"); !strings.Contains(body, "current password is incorrect") {
+ t.Errorf("wrong current password: body = %q", body)
+ }
+ if body := change("hunter2", "newpass", "different"); !strings.Contains(body, "do not match") {
+ t.Errorf("mismatched confirm: body = %q", body)
+ }
+ if body := change("hunter2", "newpass", "newpass"); !strings.Contains(body, "password changed") {
+ t.Errorf("valid change: body = %q", body)
+ }
+
+ // The session that made the change stays signed in.
+ if resp, err := josie.Get(httpServer.URL + "/settings"); err != nil {
+ t.Fatalf("GET /settings after change: %v", err)
+ } else if readAll(t, resp); resp.StatusCode != http.StatusOK {
+ t.Errorf("current session after change = %d, want 200", resp.StatusCode)
+ }
+
+ // A different session is revoked.
+ noFollow := &http.Client{
+ Transport: other.Transport,
+ Jar: other.Jar,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ resp, err := noFollow.Get(httpServer.URL + "/settings")
+ if err != nil {
+ t.Fatalf("other session GET: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/login" {
+ t.Errorf("other session = %d %q, want 303 /login", resp.StatusCode, resp.Header.Get("Location"))
+ }
+
+ // Old password no longer works; the new one does.
+ fresh := &http.Client{Transport: httpServer.Client().Transport, Jar: newJar(t)}
+ resp, err = fresh.PostForm(httpServer.URL+"/login", url.Values{"username": {"josie"}, "password": {"hunter2"}})
+ if err != nil {
+ t.Fatalf("login old password: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("old password login = %d, want 401", resp.StatusCode)
+ }
+ resp, err = fresh.PostForm(httpServer.URL+"/login", url.Values{"username": {"josie"}, "password": {"newpass"}})
+ if err != nil {
+ t.Fatalf("login new password: %v", err)
+ }
+ if body := readAll(t, resp); !strings.Contains(body, `href="/josie"`) {
+ t.Errorf("new password login rejected: %q", body)
+ }
+}
+
+func basicRefs(t *testing.T, httpServerURL, user, secret string) int {
+ t.Helper()
+ req, err := http.NewRequest("GET", httpServerURL+"/josie/sec.git/info/refs?service=git-upload-pack", nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.SetBasicAuth(user, secret)
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ t.Fatalf("basic refs: %v", err)
+ }
+ readAll(t, resp)
+ return resp.StatusCode
+}
+
+func TestTokenCreateUseRevoke(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ loggedIn := newLoggedInClient(t, httpServer)
+ createRepo(t, loggedIn, httpServer, "sec", "private")
+
+ resp, err := loggedIn.PostForm(httpServer.URL+"/settings/tokens", url.Values{"name": {"laptop"}})
+ if err != nil {
+ t.Fatalf("create token: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("create token status = %d: %q", resp.StatusCode, body)
+ }
+ token := tokenPattern.FindString(body)
+ if token == "" {
+ t.Fatalf("response did not show a new token: %q", body)
+ }
+
+ list, err := loggedIn.Get(httpServer.URL + "/settings")
+ if err != nil {
+ t.Fatalf("GET /settings: %v", err)
+ }
+ listBody := readAll(t, list)
+ if !strings.Contains(listBody, "laptop") || !strings.Contains(listBody, token[:8]) {
+ t.Errorf("settings list lacks the token row: %q", listBody)
+ }
+ if strings.Contains(listBody, token) {
+ t.Error("full token secret leaked into the settings list")
+ }
+
+ if code := basicRefs(t, httpServer.URL, "josie", token); code != http.StatusOK {
+ t.Errorf("token as basic-auth password = %d, want 200", code)
+ }
+ if code := basicRefs(t, httpServer.URL, "josie", "sg_not-a-real-token"); code != http.StatusUnauthorized {
+ t.Errorf("bogus token = %d, want 401", code)
+ }
+
+ match := tokenIDPath.FindStringSubmatch(listBody)
+ if match == nil {
+ t.Fatalf("no revoke link in settings: %q", listBody)
+ }
+
+ // A different user must not be able to revoke someone else's token.
+ addUser(t, database, "mallory", "pw")
+ mallory := loginAs(t, httpServer, "mallory", "pw")
+ if resp, err := mallory.PostForm(httpServer.URL+"/settings/tokens/"+match[1]+"/revoke", nil); err == nil {
+ readAll(t, resp)
+ }
+ if code := basicRefs(t, httpServer.URL, "josie", token); code != http.StatusOK {
+ t.Errorf("token invalidated by a non-owner = %d, want still 200", code)
+ }
+
+ revoke, err := loggedIn.PostForm(httpServer.URL+"/settings/tokens/"+match[1]+"/revoke", nil)
+ if err != nil {
+ t.Fatalf("revoke token: %v", err)
+ }
+ readAll(t, revoke)
+ if code := basicRefs(t, httpServer.URL, "josie", token); code != http.StatusUnauthorized {
+ t.Errorf("revoked token = %d, want 401", code)
+ }
+}
diff --git a/internal/web/static/htmx.min.js b/internal/web/static/htmx.min.js
new file mode 100644
index 0000000..59937d7
--- /dev/null
+++ b/internal/web/static/htmx.min.js
@@ -0,0 +1 @@
+var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function X(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var me=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Un(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){O(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||ne();let n="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];be(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){be(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function b(e){he(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){b(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}a(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}b(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return a(e,e.firstChild,t,n)}function Fe(e,t,n){return a(c(e),e,t,n)}function Be(e,t,n){return a(e,null,t,n)}function Ue(e,t,n){return a(c(e),e.nextSibling,t,n)}function je(e){b(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;a(e,r,t,n);if(r){while(r.nextSibling){b(r.nextSibling);e.removeChild(r.nextSibling)}b(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=Un(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const c=l[e];if(c.nodeType!==Node.TEXT_NODE&&c.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(c))}}}return}}catch(e){O(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function ze(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const a=u[t].split(":",2);let e=a[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=a[1]||"true";const h=n.querySelector("#"+e);if(h){He(f,h,l,i)}}}ze(n,l,i);se(x(n,"template"),function(e){if(e.content&&ze(e.content,l,i)){e.remove()}});if(o.select){const d=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){d.appendChild(e)});n=d}qe(n);_e(r.swapStyle,o.contextElement,e,n,l);Te()}if(s.elt&&!le(s.elt)&&ee(s.elt,"id")){const g=document.getElementById(ee(s.elt,"id"));const p={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(g){if(s.start&&g.setSelectionRange){try{g.setSelectionRange(s.start,s.end)}catch(e){}}g.focus(p)}}e.classList.remove(Q.config.swappingClass);se(l.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}he(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){kn(l.title)}const c=function(){se(l.tasks,function(e){e.call()});se(l.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}he(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ue(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}yn(l.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){he(n,s[e].trim(),[])}}}const Ke=/\s/;const v=/[\s,]/;const Ge=/[_$a-zA-Z]/;const We=/[_$a-zA-Z0-9]/;const Ze=['"',"'","/"];const w=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(We.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(Ze.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){he(r,"intersect");break}}},o);i.observe(ue(r));pt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!gt(e,r,Mt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Rt(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Ht(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Mn){const s=Mn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(H+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Nt(t,e,r)}}}}function Pt(t){if(g(t,Q.config.disableSelector)){b(t);return}const n=ie(t);const e=Pe(t);if(n.initHash!==e){De(t);n.initHash=e;he(t,"htmx:beforeProcessNode");const r=st(t);const o=wt(t,n,r);if(!o){if(re(t,"hx-boost")==="true"){ft(t,n,r)}else if(s(t,"hx-trigger")){r.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){At(t)}n.firstInitCompleted=true;he(t,"htmx:afterProcessNode")}}function kt(e){e=y(e);if(g(e,Q.config.disableSelector)){b(e);return}Pt(e);se(Ht(e),function(e){Pt(e)});se(Rt(e),It)}function Dt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Mt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){he(e,t,ce({error:t},n))}function Xt(e){return e==="htmx:afterProcessNode"}function Ft(e,t){se(Un(e),function(e){try{t(e)}catch(e){O(e)}})}function O(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function he(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Mt(t,n);if(Q.logger&&!Xt(t)){Q.logger(e,t,n)}if(n.error){O(n.error);he(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Dt(t);if(o&&i!==t){const s=Mt(i,r.detail);o=o&&e.dispatchEvent(s)}Ft(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function Ut(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function jt(t,e){if(!B()){return}const n=_t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=U(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};he(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function _t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(x(n,"."+t),function(e){G(e,t)});se(x(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=Ut();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){he(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});jt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function $t(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Y(e,"&")||Y(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Jt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Kt(e){se(e,function(e){e.call(undefined)})}function Gt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};he(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function tn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function nn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function rn(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const c=l.slice(7);var o=c.split(":");const u=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(In(n,e.status)){return n}}return{swap:false}}function kn(e){if(e){const t=u("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Dn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const c=i.select;if(!he(o,"htmx:beforeOnLoad",i))return;if(R(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(R(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Rn("get",e,t).then(function(){$t(e)});return}const n=R(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(R(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}const u=Nn(o,i);const r=Pn(s);const a=r.swap;let f=!!r.error;let h=Q.config.ignoreTitle||r.ignoreTitle;let d=r.select;if(r.target){i.target=ue(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}if(R(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ce({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:h,selectOverride:d,swapOverride:g},i);if(r.event&&!he(l,r.event,m))return;if(!he(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;h=m.ignoreTitle;d=m.selectOverride;g=m.swapOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){lt(o)}Ft(o,function(e){p=e.transformResponse(p,s,o)});if(u.type){zt()}var x=gn(o,g);if(!x.hasOwnProperty("ignoreTitle")){x.ignoreTitle=h}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(c){d=c}if(R(s,/HX-Reselect:/i)){d=s.getResponseHeader("HX-Reselect")}const y=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(u.type){he(ne().body,"htmx:beforeHistoryUpdate",ce({history:u},i));if(u.type==="push"){$t(u.path);he(ne().body,"htmx:pushedIntoHistory",{path:u.path})}else{Jt(u.path);he(ne().body,"htmx:replacedInHistory",{path:u.path})}}$e(l,p,x,{select:d||b,selectOOB:y,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(R(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(R(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(x.hasOwnProperty("transition")){t=x.transition}if(t&&he(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const v=new Promise(function(e,t){n=e;r=t});const w=e;e=function(){document.startViewTransition(function(){w();return v})}}if(x.swapDelay>0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}();
\ No newline at end of file
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
new file mode 100644
index 0000000..dff683a
--- /dev/null
+++ b/internal/web/static/style.css
@@ -0,0 +1,218 @@
+/* simplegit — josie-c.com, dark. Same shape as the main site's stylesheet. */
+
+/* Variables: */
+:root {
+ --font: 'Courier New', Courier, monospace;
+ --bgcolor: #16172b; /* page — the main site's #CCCCFF, inverted dark */
+ --color1: #1e1f3a; /* nav bar + boxes — the main site's #FFFFCC slot */
+ --color2: #2f3160; /* hover/active — the main site's #CCFFE6 slot */
+ --text: #dcdcf5;
+ --muted: #8f92c4;
+ --link: #a5aef0; /* periwinkle, nod to #CCCCFF */
+ --success: #7bc275;
+ --danger: #e06c75;
+ --warning: #e0af68;
+}
+
+* { box-sizing: border-box; }
+html { color-scheme: dark; }
+
+/* --- top nav bar styling ------ */
+/* 1fr auto 1fr puts the home link exactly on the page midline whatever the
+ username's length; the side cells carry one dash each toward the center. */
+.topnav {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ background-color: var(--color1);
+}
+.topnav .nav-side { display: flex; }
+.topnav .nav-left { justify-content: flex-end; }
+.topnav .nav-right { justify-content: flex-start; }
+.topnav a {
+ display: block;
+ color: var(--text);
+ padding: 14px 10px;
+ text-decoration: none;
+}
+.topnav a:hover { background-color: var(--color2); }
+/* --- end top nav bar styling --- */
+
+/* --- main text styling ------ */
+body {
+ margin: 0;
+ background-color: var(--bgcolor);
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 1.3rem;
+ text-align: center;
+}
+
+a { color: var(--link); }
+
+main { padding: 0 12px 48px; }
+
+.muted { color: var(--muted); }
+.error { color: var(--danger); }
+.ok { color: var(--success); }
+.sep { color: var(--muted); }
+
+form { text-align: left; }
+/* --- end main text styling --- */
+
+/* --- git ui: flat boxes, no borders or rounding ------ */
+pre {
+ background: var(--color1);
+ padding: 12px;
+ overflow-x: auto;
+ text-align: left;
+ font-size: 1rem;
+}
+
+article { overflow-x: auto; text-align: left; }
+
+.diff { background: var(--color1); padding: 12px; }
+.diff pre, .diff .chroma { background: none; padding: 0; margin: 0; }
+
+table { border-collapse: collapse; margin: 0 auto; text-align: left; }
+td, th { border: 1px solid var(--color2); padding: 6px 10px; }
+
+button {
+ font: inherit;
+ background: var(--color2);
+ color: var(--text);
+ border: 0;
+ padding: 6px 14px;
+ cursor: pointer;
+}
+button:hover { background: var(--link); color: var(--bgcolor); }
+button.linklike {
+ background: none;
+ border: 0;
+ padding: 0;
+ color: var(--link);
+ text-decoration: underline;
+}
+button.linklike:hover { color: var(--text); background: none; }
+
+input, select, textarea {
+ font: inherit;
+ background: var(--color1);
+ color: var(--text);
+ border: 1px solid var(--color2);
+ padding: 6px 8px;
+}
+textarea { width: 100%; resize: vertical; }
+
+.panel, .card {
+ background: var(--color1);
+ padding: 16px;
+ margin: 0 auto 16px;
+ text-align: left;
+}
+.panel { max-width: 560px; }
+.panel input:not([type=radio]):not([type=checkbox]), .panel select, .panel textarea { width: 100%; }
+
+.badge { font-size: .8em; color: var(--muted); }
+.badge.open { color: var(--success); }
+.badge.closed { color: var(--danger); }
+.badge.pending { color: var(--warning); }
+
+.repo-list, .issue-list { list-style: none; padding: 0; }
+.repo-list li, .issue-row { padding: 8px 0; }
+
+.list-toolbar { display: flex; justify-content: space-between; align-items: baseline; margin: 0 0 16px; }
+.list-toolbar p { margin: 0; }
+
+.issue-state { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; justify-content: center; margin: 0 0 8px; }
+.issue-state form, .comment-actions form { display: inline; }
+.issue-filters a.active { color: var(--text); }
+.comment {
+ background: var(--color1);
+ padding: 12px 16px;
+ margin: 0 0 12px;
+ text-align: left;
+}
+.comment.pending { outline: 1px solid var(--warning); }
+.comment-actions { display: flex; gap: 12px; }
+.guest-fields { border: 0; background: var(--color1); padding: 8px 16px 12px; }
+.guest-fields legend { color: var(--muted); padding: 0 6px; }
+.hp { position: absolute; left: -9999px; top: auto; width: 1px; height: 1px; overflow: hidden; }
+
+.profile-actions { display: flex; flex-direction: column; align-items: center; gap: 4px; margin: 0 0 16px; }
+
+.token { background: var(--bgcolor); padding: 2px 6px; }
+/* --- end git ui boxes --- */
+
+/* --- repo pages ------ */
+.repo-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ background: var(--color1);
+ margin: 0 0 24px;
+}
+.repo-tabs a {
+ display: block;
+ color: var(--text);
+ padding: 10px 12px;
+ text-decoration: none;
+}
+.repo-tabs a:hover, .repo-tabs a.active { background: var(--color2); }
+
+.clone-row { display: flex; justify-content: center; gap: 8px; margin: 0 0 16px; }
+.clone-row input { width: 42ch; max-width: 60vw; font-size: 1rem; text-align: center; }
+
+.repo-layout { display: grid; gap: 24px; align-items: start; text-align: left; }
+@media (min-width: 600px) { .repo-layout { grid-template-columns: 280px minmax(0, 1fr); } }
+
+.file-tree-pane { background: var(--color1); padding: 12px 16px; }
+.file-tree { list-style: none; padding: 0; margin: 0; font-size: 1rem; }
+.file-tree .file-tree { padding-left: 1.2em; }
+.file-tree li { padding: 1px 0; }
+.file-tree a { text-decoration: none; }
+
+.repo-summary { display: flex; gap: 8px; margin: 0 0 16px; }
+.repo-summary > * {
+ flex: 1;
+ min-width: 0;
+ background: var(--color1);
+ text-align: center;
+}
+@media (max-width: 600px) {
+ .repo-summary { flex-wrap: wrap; }
+ .repo-summary > * { flex: 1 1 40%; }
+}
+.repo-summary > a {
+ color: var(--text);
+ text-decoration: none;
+ padding: 8px 12px;
+}
+.repo-summary > a:hover { background: var(--color2); }
+
+.branch-picker { position: relative; }
+.branch-picker summary {
+ cursor: pointer;
+ display: block;
+ padding: 8px 12px;
+}
+.branch-picker summary::after { content: " ▾"; color: var(--muted); }
+.branch-picker ul {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 100%;
+ z-index: 1;
+ list-style: none;
+ margin: 0;
+ padding: 4px 0;
+ background: var(--color1);
+ text-align: left;
+}
+.branch-picker li { padding: 4px 12px; }
+.branch-picker li.current { color: var(--muted); }
+.branch-picker li a { display: block; text-decoration: none; }
+
+.file-list { list-style: none; padding: 0; background: var(--color1); }
+.file-list li { padding: 6px 12px; }
+.file-list form { display: inline; }
+/* --- end repo pages --- */
diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html
new file mode 100644
index 0000000..0d54c93
--- /dev/null
+++ b/internal/web/templates/base.html
@@ -0,0 +1,38 @@
+{{define "base"}}<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{template "title" .}}</title>
+<link rel="stylesheet" href="/static/chroma.css">
+<link rel="stylesheet" href="/static/style.css">
+<script src="/static/htmx.min.js" defer></script>
+</head>
+<body>
+<header class="topnav">
+ <div class="nav-side nav-left">
+ {{if .Username}}<a href="/{{.Username}}">- {{.Username}}</a>{{else}}<a href="/login">- sign in</a>{{end}}
+ </div>
+ <div class="nav-center"><a href="/">- home -</a></div>
+ <div class="nav-side nav-right">
+ {{if .Username}}<a href="/new">new -</a>{{end}}
+ </div>
+</header>
+<main class="container">
+{{template "content" .}}
+</main>
+</body>
+</html>
+{{end}}
+
+{{define "repo_list"}}
+<ul class="repo-list">
+{{range .Repos}}
+ <li>
+ <a href="/{{.Owner}}/{{.Name}}">{{.Owner}}/{{.Name}}</a>
+ <span class="badge">{{.Visibility}}</span>
+ {{if .Description}}<div class="muted">{{.Description}}</div>{{end}}
+ </li>
+{{end}}
+</ul>
+{{end}}
diff --git a/internal/web/templates/blob.html b/internal/web/templates/blob.html
new file mode 100644
index 0000000..8af70df
--- /dev/null
+++ b/internal/web/templates/blob.html
@@ -0,0 +1,12 @@
+{{define "title"}}{{.Path}} · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<div class="repo-layout">
+ <nav class="file-tree-pane">{{template "filetree" .Tree}}</nav>
+ <div class="repo-content">
+ <p class="breadcrumb muted">{{.Ref}} / {{.Path}} · {{.Size}} bytes · <a href="{{.RawHref}}">raw</a></p>
+ {{if .Notice}}<p class="error">{{.Notice}}</p>{{end}}
+ {{if .CodeHTML}}<article>{{.CodeHTML}}</article>{{else if .RawText}}<pre>{{.RawText}}</pre>{{end}}
+ </div>
+</div>
+{{end}}
diff --git a/internal/web/templates/commit.html b/internal/web/templates/commit.html
new file mode 100644
index 0000000..0aac7da
--- /dev/null
+++ b/internal/web/templates/commit.html
@@ -0,0 +1,18 @@
+{{define "title"}}{{.Subject}} · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>{{.Subject}}</h2>
+{{if .Body}}<pre>{{.Body}}</pre>{{end}}
+<p class="muted">
+ <code>{{.SHA}}</code><br>
+ {{.Author}} <{{.Email}}> · {{.Date}} ·
+ <a href="{{.TreeHref}}">browse files at this commit</a>
+</p>
+{{if .Parents}}
+<p class="muted">parents:
+ {{range .Parents}}<a href="{{.Href}}"><code>{{.SHA}}</code></a> {{end}}
+</p>
+{{end}}
+{{if .Notice}}<p class="error">{{.Notice}}</p>{{end}}
+<article>{{.DiffHTML}}</article>
+{{end}}
diff --git a/internal/web/templates/commits.html b/internal/web/templates/commits.html
new file mode 100644
index 0000000..e72653a
--- /dev/null
+++ b/internal/web/templates/commits.html
@@ -0,0 +1,15 @@
+{{define "title"}}commits · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>commits <span class="muted">on {{.Ref}}</span></h2>
+<table>
+ {{range .Commits}}
+ <tr>
+ <td><a href="{{.Href}}">{{.Subject}}</a></td>
+ <td class="muted">{{.Author}}</td>
+ <td class="muted">{{.Date}}</td>
+ <td><code>{{.SHA}}</code></td>
+ </tr>
+ {{end}}
+</table>
+{{end}}
diff --git a/internal/web/templates/created.html b/internal/web/templates/created.html
new file mode 100644
index 0000000..b85c026
--- /dev/null
+++ b/internal/web/templates/created.html
@@ -0,0 +1,11 @@
+{{define "title"}}{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+<h1>{{.RepoName}} created</h1>
+<div class="card">
+<p>The repository is empty until your first push. To put an existing project in it:</p>
+<pre>cd existing_repo
+git remote add origin {{.PushURL}}
+git push -u origin --all
+git push origin --tags</pre>
+</div>
+{{end}}
diff --git a/internal/web/templates/home.html b/internal/web/templates/home.html
new file mode 100644
index 0000000..a97f4a0
--- /dev/null
+++ b/internal/web/templates/home.html
@@ -0,0 +1,9 @@
+{{define "title"}}simplegit{{end}}
+{{define "content"}}
+<h1>repositories</h1>
+{{if .Repos}}
+{{template "repo_list" .}}
+{{else}}
+<p class="muted">No repositories yet. {{if .Username}}<a href="/new">Create one</a>.{{else}}<a href="/login">Sign in</a> to create one.{{end}}</p>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/issue.html b/internal/web/templates/issue.html
new file mode 100644
index 0000000..6605ded
--- /dev/null
+++ b/internal/web/templates/issue.html
@@ -0,0 +1,50 @@
+{{define "title"}}#{{.Number}} {{.Title}} · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2><span class="muted">#{{.Number}}</span> {{.Title}}</h2>
+{{template "state" .}}
+
+<article class="issue-body">{{.BodyHTML}}</article>
+<p class="issue-meta muted">opened by {{.Author}}{{if .AuthorIsOwner}} <span class="badge">owner</span>{{end}}{{if .IsOwner}}{{if .AuthorEmail}} <{{.AuthorEmail}}>{{end}}{{end}} · {{.Created}}</p>
+
+{{if .Submitted}}<p class="ok">your comment was submitted and is awaiting review.</p>{{end}}
+
+<h2>comments</h2>
+<section id="comments">
+{{range .Comments}}{{template "comment" .}}{{end}}
+</section>
+
+{{if .CanWrite}}
+<form id="comment-form" method="post" action="{{.Base}}/comments"
+ hx-post="{{.Base}}/comments"
+ hx-target="{{if .IsOwner}}#comments{{else}}#comment-status{{end}}"
+ hx-swap="{{if .IsOwner}}beforeend{{else}}innerHTML{{end}}"
+ hx-on::after-request="if(event.detail.successful) this.reset()">
+ <p><label for="comment-body">comment</label><br>
+ <textarea id="comment-body" name="body" rows="6" required></textarea></p>
+ {{if not .IsOwner}}{{template "guest_fields" "comment_"}}{{end}}
+ <p class="hp"><label>website <input name="website" tabindex="-1" autocomplete="off"></label></p>
+ <p><button type="submit">comment</button></p>
+</form>
+<div id="comment-status"></div>
+{{end}}
+{{end}}
+
+{{define "state"}}
+<div id="issue-state" class="issue-state">
+ <span class="badge {{.State}}">{{.State}}</span>
+ {{if .Pending}}<span class="badge pending">pending</span>{{end}}
+ {{if .IsOwner}}
+ {{if .Pending}}
+ <form method="post" action="{{.Base}}/approve"><button class="linklike" type="submit">approve</button></form>
+ {{end}}
+ {{if eq .State "open"}}
+ <form method="post" action="{{.Base}}/close" hx-post="{{.Base}}/close" hx-target="#issue-state" hx-swap="outerHTML"><button class="linklike" type="submit">close</button></form>
+ {{else}}
+ <form method="post" action="{{.Base}}/reopen" hx-post="{{.Base}}/reopen" hx-target="#issue-state" hx-swap="outerHTML"><button class="linklike" type="submit">reopen</button></form>
+ {{end}}
+ <form method="post" action="{{.Base}}/delete"><button class="linklike" type="submit">delete</button></form>
+ {{end}}
+</div>
+{{end}}
+
diff --git a/internal/web/templates/issue_new.html b/internal/web/templates/issue_new.html
new file mode 100644
index 0000000..5602342
--- /dev/null
+++ b/internal/web/templates/issue_new.html
@@ -0,0 +1,15 @@
+{{define "title"}}new issue · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>new issue</h2>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form method="post" action="/{{.Owner}}/{{.RepoName}}/issues/new" class="panel">
+ <p><label for="title">title</label><br>
+ <input id="title" name="title" value="{{.Title}}" maxlength="300" required></p>
+ <p><label for="body">description</label><br>
+ <textarea id="body" name="body" rows="12">{{.Body}}</textarea></p>
+ {{if not .IsOwner}}{{template "guest_fields" "author_"}}{{end}}
+ <p class="hp"><label>website <input name="website" tabindex="-1" autocomplete="off"></label></p>
+ <p><button type="submit">file issue</button></p>
+</form>
+{{end}}
diff --git a/internal/web/templates/issue_submitted.html b/internal/web/templates/issue_submitted.html
new file mode 100644
index 0000000..0803cb5
--- /dev/null
+++ b/internal/web/templates/issue_submitted.html
@@ -0,0 +1,9 @@
+{{define "title"}}issue submitted · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>thanks</h2>
+<div class="card">
+ <p>Your issue was submitted and is awaiting review.</p>
+ <p><a href="/{{.Owner}}/{{.RepoName}}/issues">← back to issues</a></p>
+</div>
+{{end}}
diff --git a/internal/web/templates/issues.html b/internal/web/templates/issues.html
new file mode 100644
index 0000000..e9cd686
--- /dev/null
+++ b/internal/web/templates/issues.html
@@ -0,0 +1,30 @@
+{{define "title"}}issues · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+
+<div class="list-toolbar">
+ <p class="issue-filters muted">
+ <a href="?show=open" {{if eq .Show "open"}}class="active"{{end}}>open</a> ·
+ <a href="?show=closed" {{if eq .Show "closed"}}class="active"{{end}}>closed</a> ·
+ <a href="?show=all" {{if eq .Show "all"}}class="active"{{end}}>all</a>
+ </p>
+ {{if .CanWrite}}<p><a href="/{{.Owner}}/{{.RepoName}}/issues/new">new issue</a></p>{{end}}
+</div>
+
+{{if .IsOwner}}{{if gt .PendingCount 0}}<p><span class="badge pending">{{.PendingCount}} awaiting review</span></p>{{end}}{{end}}
+
+{{if .Issues}}
+<ul class="issue-list">
+{{range .Issues}}
+ <li class="issue-row">
+ <a class="issue-title" href="{{.Href}}">{{.Title}}</a>
+ <span class="badge {{.State}}">{{.State}}</span>
+ {{if .Pending}}<span class="badge pending">pending</span>{{end}}
+ <div class="muted">#{{.Number}} opened by {{.Author}}{{if .AuthorIsOwner}} (owner){{end}} · {{.Created}}</div>
+ </li>
+{{end}}
+</ul>
+{{else}}
+<p class="muted">No issues.</p>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html
new file mode 100644
index 0000000..8d14470
--- /dev/null
+++ b/internal/web/templates/login.html
@@ -0,0 +1,16 @@
+{{define "title"}}sign in · simplegit{{end}}
+{{define "content"}}
+<h1>sign in</h1>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form method="post" action="/login" class="panel">
+ <p>
+ <label for="username">username</label><br>
+ <input id="username" name="username" value="{{.Username}}" autocomplete="username" required>
+ </p>
+ <p>
+ <label for="password">password</label><br>
+ <input id="password" name="password" type="password" autocomplete="current-password" required>
+ </p>
+ <p><button type="submit">sign in</button></p>
+</form>
+{{end}}
diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html
new file mode 100644
index 0000000..5a12dc0
--- /dev/null
+++ b/internal/web/templates/new.html
@@ -0,0 +1,20 @@
+{{define "title"}}new repository · simplegit{{end}}
+{{define "content"}}
+<h1>new repository</h1>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form method="post" action="/new" class="panel">
+ <p>
+ <label for="name">name</label><br>
+ <input id="name" name="name" value="{{.Name}}" required>
+ </p>
+ <p>
+ <label for="description">description</label><br>
+ <input id="description" name="description" value="{{.Description}}">
+ </p>
+ <p>
+ <label><input type="radio" name="visibility" value="private" {{if ne .Visibility "public"}}checked{{end}}> private</label><br>
+ <label><input type="radio" name="visibility" value="public" {{if eq .Visibility "public"}}checked{{end}}> public</label>
+ </p>
+ <p><button type="submit">create repository</button></p>
+</form>
+{{end}}
diff --git a/internal/web/templates/profile.html b/internal/web/templates/profile.html
new file mode 100644
index 0000000..4d3998c
--- /dev/null
+++ b/internal/web/templates/profile.html
@@ -0,0 +1,18 @@
+{{define "title"}}{{.Profile}} · simplegit{{end}}
+{{define "content"}}
+<h1>{{.Profile}}</h1>
+
+{{if .IsSelf}}
+<div class="profile-actions">
+ <form method="post" action="/logout"><button class="linklike" type="submit">sign out</button></form>
+ <a href="/settings">settings</a>
+</div>
+{{end}}
+
+<h2>repositories</h2>
+{{if .Repos}}
+{{template "repo_list" .}}
+{{else}}
+<p class="muted">No repositories yet.{{if .IsSelf}} <a href="/new">Create one</a>.{{end}}</p>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/pull.html b/internal/web/templates/pull.html
new file mode 100644
index 0000000..1ca848b
--- /dev/null
+++ b/internal/web/templates/pull.html
@@ -0,0 +1,56 @@
+{{define "title"}}#{{.Number}} {{.Title}} · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2><span class="muted">#{{.Number}}</span> {{.Title}}</h2>
+{{template "pstate" .}}
+<p class="issue-meta muted"><code>{{.Head}}</code> → <code>{{.Base}}</code> · opened by {{.Author}}{{if .AuthorIsOwner}} <span class="badge">owner</span>{{end}}{{if .IsOwner}}{{if .AuthorEmail}} <{{.AuthorEmail}}>{{end}}{{end}} · {{.Created}}{{if .MergeCommit}} · merged as <code>{{.MergeCommit}}</code>{{end}}</p>
+
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+
+<article class="issue-body">{{.BodyHTML}}</article>
+
+<h2>changes</h2>
+{{if .DiffHTML}}<article class="diff">{{.DiffHTML}}</article>{{else}}<p class="muted">{{.DiffNotice}}</p>{{end}}
+
+{{if .Submitted}}<p class="ok">your comment was submitted and is awaiting review.</p>{{end}}
+
+<h2>comments</h2>
+<section id="pcomments">
+{{range .Comments}}{{template "comment" .}}{{end}}
+</section>
+
+{{if .CanWrite}}
+<form id="pcomment-form" method="post" action="{{.BasePath}}/comments"
+ hx-post="{{.BasePath}}/comments"
+ hx-target="{{if .IsOwner}}#pcomments{{else}}#pcomment-status{{end}}"
+ hx-swap="{{if .IsOwner}}beforeend{{else}}innerHTML{{end}}"
+ hx-on::after-request="if(event.detail.successful) this.reset()">
+ <p><label for="pcomment-body">comment</label><br>
+ <textarea id="pcomment-body" name="body" rows="6" required></textarea></p>
+ {{if not .IsOwner}}{{template "guest_fields" "pcomment_"}}{{end}}
+ <p class="hp"><label>website <input name="website" tabindex="-1" autocomplete="off"></label></p>
+ <p><button type="submit">comment</button></p>
+</form>
+<div id="pcomment-status"></div>
+{{end}}
+{{end}}
+
+{{define "pstate"}}
+<div id="pull-state" class="issue-state">
+ <span class="badge {{.State}}">{{.State}}</span>
+ {{if .Pending}}<span class="badge pending">pending</span>{{end}}
+ {{if .IsOwner}}
+ {{if .Pending}}
+ <form method="post" action="{{.BasePath}}/approve"><button class="linklike" type="submit">approve</button></form>
+ {{end}}
+ {{if eq .State "open"}}
+ <form method="post" action="{{.BasePath}}/merge"><button class="linklike" type="submit">merge</button></form>
+ <form method="post" action="{{.BasePath}}/close" hx-post="{{.BasePath}}/close" hx-target="#pull-state" hx-swap="outerHTML"><button class="linklike" type="submit">close</button></form>
+ {{else if eq .State "closed"}}
+ <form method="post" action="{{.BasePath}}/reopen" hx-post="{{.BasePath}}/reopen" hx-target="#pull-state" hx-swap="outerHTML"><button class="linklike" type="submit">reopen</button></form>
+ {{end}}
+ <form method="post" action="{{.BasePath}}/delete"><button class="linklike" type="submit">delete</button></form>
+ {{end}}
+</div>
+{{end}}
+
diff --git a/internal/web/templates/pull_new.html b/internal/web/templates/pull_new.html
new file mode 100644
index 0000000..ab811a3
--- /dev/null
+++ b/internal/web/templates/pull_new.html
@@ -0,0 +1,28 @@
+{{define "title"}}new pull request · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>new pull request</h2>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+{{if lt (len .Branches) 2}}
+<p class="muted">Two branches are needed to open a pull request.</p>
+{{else}}
+<form method="post" action="/{{.Owner}}/{{.RepoName}}/pulls/new" class="panel">
+ <p><label for="head">head branch</label><br>
+ <select id="head" name="head">
+ {{range .Branches}}<option value="{{.}}" {{if eq . $.Head}}selected{{end}}>{{.}}</option>{{end}}
+ </select>
+ <span class="muted">→ merge into</span>
+ <label for="base">base branch</label>
+ <select id="base" name="base">
+ {{range .Branches}}<option value="{{.}}" {{if eq . $.Base}}selected{{end}}>{{.}}</option>{{end}}
+ </select></p>
+ <p><label for="title">title</label><br>
+ <input id="title" name="title" value="{{.Title}}" maxlength="300" required></p>
+ <p><label for="body">description</label><br>
+ <textarea id="body" name="body" rows="10">{{.Body}}</textarea></p>
+ {{if not .IsOwner}}{{template "guest_fields" "author_"}}{{end}}
+ <p class="hp"><label>website <input name="website" tabindex="-1" autocomplete="off"></label></p>
+ <p><button type="submit">open pull request</button></p>
+</form>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/pull_submitted.html b/internal/web/templates/pull_submitted.html
new file mode 100644
index 0000000..0795874
--- /dev/null
+++ b/internal/web/templates/pull_submitted.html
@@ -0,0 +1,9 @@
+{{define "title"}}pull request submitted · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>thanks</h2>
+<div class="card">
+ <p>Your pull request was submitted and is awaiting review.</p>
+ <p><a href="/{{.Owner}}/{{.RepoName}}/pulls">← back to pull requests</a></p>
+</div>
+{{end}}
diff --git a/internal/web/templates/pulls.html b/internal/web/templates/pulls.html
new file mode 100644
index 0000000..350a129
--- /dev/null
+++ b/internal/web/templates/pulls.html
@@ -0,0 +1,31 @@
+{{define "title"}}pulls · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+
+<div class="list-toolbar">
+ <p class="issue-filters muted">
+ <a href="?show=open" {{if eq .Show "open"}}class="active"{{end}}>open</a> ·
+ <a href="?show=closed" {{if eq .Show "closed"}}class="active"{{end}}>closed</a> ·
+ <a href="?show=merged" {{if eq .Show "merged"}}class="active"{{end}}>merged</a> ·
+ <a href="?show=all" {{if eq .Show "all"}}class="active"{{end}}>all</a>
+ </p>
+ {{if .CanWrite}}<p><a href="/{{.Owner}}/{{.RepoName}}/pulls/new">new pull request</a></p>{{end}}
+</div>
+
+{{if .IsOwner}}{{if gt .PendingCount 0}}<p><span class="badge pending">{{.PendingCount}} awaiting review</span></p>{{end}}{{end}}
+
+{{if .Pulls}}
+<ul class="issue-list">
+{{range .Pulls}}
+ <li class="issue-row">
+ <a class="issue-title" href="{{.Href}}">{{.Title}}</a>
+ <span class="badge {{.State}}">{{.State}}</span>
+ {{if .Pending}}<span class="badge pending">pending</span>{{end}}
+ <div class="muted">#{{.Number}} <code>{{.Head}}</code> → <code>{{.Base}}</code> · opened by {{.Author}}{{if .AuthorIsOwner}} (owner){{end}} · {{.Created}}</div>
+ </li>
+{{end}}
+</ul>
+{{else}}
+<p class="muted">No pull requests.</p>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/release.html b/internal/web/templates/release.html
new file mode 100644
index 0000000..81c06bf
--- /dev/null
+++ b/internal/web/templates/release.html
@@ -0,0 +1,33 @@
+{{define "title"}}{{.Title}} · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>{{.Title}}</h2>
+<p class="issue-meta muted">
+ <span class="badge">{{.Tag}}</span>
+ {{if .Commit}}<code>{{.Commit}}</code> · {{end}}released {{.Created}}
+</p>
+
+<article class="issue-body">{{.Notes}}</article>
+
+<h2>assets</h2>
+{{if .Assets}}
+<ul class="file-list">
+{{range .Assets}}
+ <li>
+ <a href="{{.Download}}">{{.Name}}</a> <span class="muted">{{.Size}}</span>
+ {{if $.IsOwner}}
+ <form method="post" action="{{.Delete}}"><button class="linklike" type="submit">delete</button></form>
+ {{end}}
+ </li>
+{{end}}
+</ul>
+{{else}}
+<p class="muted">No assets.</p>
+{{end}}
+
+{{if .IsOwner}}
+<div class="issue-state">
+ <form method="post" action="{{.DeleteHref}}"><button class="linklike" type="submit">delete release</button></form>
+</div>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/releases.html b/internal/web/templates/releases.html
new file mode 100644
index 0000000..40e6b67
--- /dev/null
+++ b/internal/web/templates/releases.html
@@ -0,0 +1,28 @@
+{{define "title"}}releases · {{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+
+{{if .Releases}}
+<ul class="issue-list">
+{{range .Releases}}
+ <li class="issue-row">
+ <a class="issue-title" href="{{.Href}}">{{.Title}}</a>
+ <span class="badge">{{.Tag}}</span>
+ <div class="muted">released {{.Created}}</div>
+ </li>
+{{end}}
+</ul>
+{{else}}
+<p class="muted">No releases.</p>
+{{end}}
+
+{{if .Tags}}
+<h2>tags</h2>
+<p class="muted">Tags without a release.</p>
+<ul class="file-list">
+{{range .Tags}}
+ <li><code>{{.Name}}</code> <span class="muted">{{.Commit}}</span></li>
+{{end}}
+</ul>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/repo.html b/internal/web/templates/repo.html
new file mode 100644
index 0000000..e36a379
--- /dev/null
+++ b/internal/web/templates/repo.html
@@ -0,0 +1,37 @@
+{{define "title"}}{{.Owner}}/{{.RepoName}} · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+
+{{if .Empty}}
+<div class="card">
+<p>The repository is empty so far. To put an existing project in it:</p>
+<pre>cd existing_repo
+git remote add origin {{.CloneURL}}
+git push -u origin --all
+git push origin --tags</pre>
+</div>
+{{else}}
+<div class="repo-summary">
+ <a href="/{{.Owner}}/{{.RepoName}}/commits/{{.Branch}}"><strong>{{.CommitCount}}</strong> {{if eq .CommitCount 1}}commit{{else}}commits{{end}}</a>
+ <details class="branch-picker">
+ <summary><strong>{{.BranchCount}}</strong> {{if eq .BranchCount 1}}branch{{else}}branches{{end}}</summary>
+ <ul>
+ {{range .Branches}}
+ {{if eq . $.Branch}}<li class="current">✓ {{.}}</li>
+ {{else if eq . $.DefaultBranch}}<li><a href="/{{$.Owner}}/{{$.RepoName}}">{{.}}</a></li>
+ {{else}}<li><a href="/{{$.Owner}}/{{$.RepoName}}/tree/{{.}}">{{.}}</a></li>{{end}}
+ {{end}}
+ </ul>
+ </details>
+ <a href="/{{.Owner}}/{{.RepoName}}/releases"><strong>{{.TagCount}}</strong> {{if eq .TagCount 1}}tag{{else}}tags{{end}}</a>
+ {{if .LicenseName}}<a href="{{.LicenseHref}}">{{.LicenseName}}</a>{{end}}
+</div>
+
+<div class="repo-layout">
+ <nav class="file-tree-pane">{{template "filetree" .Tree}}</nav>
+ <div class="repo-content">
+ {{if .Readme}}<article class="prose">{{.Readme}}</article>{{end}}
+ </div>
+</div>
+{{end}}
+{{end}}
diff --git a/internal/web/templates/repo_nav.html b/internal/web/templates/repo_nav.html
new file mode 100644
index 0000000..5396605
--- /dev/null
+++ b/internal/web/templates/repo_nav.html
@@ -0,0 +1,51 @@
+{{define "repo_nav"}}
+<div class="repo-header">
+ <h1 class="repo-title"><a href="/{{.Owner}}">{{.Owner}}</a> <span class="sep">/</span> <a href="/{{.Owner}}/{{.RepoName}}">{{.RepoName}}</a>{{if eq .Visibility "private"}} <span class="badge">private</span>{{end}}</h1>
+</div>
+<div class="clone-row">
+ <input id="clone-url" readonly value="{{cloneURL .Owner .RepoName}}" onclick="this.select()">
+ <button type="button" onclick="var i=document.getElementById('clone-url');i.select();if(navigator.clipboard)navigator.clipboard.writeText(i.value)">copy</button>
+</div>
+<nav class="repo-tabs">
+ <a href="/{{.Owner}}/{{.RepoName}}"{{if eq .Active "code"}} class="active"{{end}}>Code</a>
+ <a href="/{{.Owner}}/{{.RepoName}}/issues"{{if eq .Active "issues"}} class="active"{{end}}>Issues</a>
+ <a href="/{{.Owner}}/{{.RepoName}}/pulls"{{if eq .Active "pulls"}} class="active"{{end}}>Pull Requests</a>
+ <a href="/{{.Owner}}/{{.RepoName}}/releases"{{if eq .Active "releases"}} class="active"{{end}}>Releases</a>
+ {{if eq .Username .Owner}}<a{{if eq .Active "settings"}} class="active"{{end}} href="/{{.Owner}}/{{.RepoName}}/settings">Settings</a>{{end}}
+</nav>
+{{end}}
+
+{{define "filetree"}}
+<ul class="file-tree">
+{{range .}}
+ <li>{{if .Href}}<a href="{{.Href}}">{{.Name}}</a>{{else}}<span class="muted">{{.Name}}</span>{{end}}{{if .Children}}{{template "filetree" .Children}}{{end}}</li>
+{{end}}
+</ul>
+{{end}}
+
+{{define "comment"}}
+<article class="comment{{if .Pending}} pending{{end}}" id="comment-{{.ID}}">
+ <div class="comment-meta muted">{{.Author}}{{if .AuthorIsOwner}} <span class="badge">owner</span>{{end}}{{if .IsOwner}}{{if .AuthorEmail}} <{{.AuthorEmail}}>{{end}}{{end}} · {{.Created}}{{if .Pending}} <span class="badge pending">pending</span>{{end}}</div>
+ <div class="comment-body">{{.BodyHTML}}</div>
+ {{if .IsOwner}}
+ <div class="comment-actions">
+ {{if .Pending}}<form method="post" action="{{.Base}}/comments/{{.ID}}/approve"><button class="linklike" type="submit">approve</button></form>{{end}}
+ <form method="post" action="{{.Base}}/comments/{{.ID}}/delete"><button class="linklike" type="submit">delete</button></form>
+ </div>
+ {{end}}
+</article>
+{{end}}
+
+{{define "comment_pending"}}
+<p class="ok">your comment was submitted and is awaiting review.</p>
+{{end}}
+
+{{define "guest_fields"}}
+<fieldset class="guest-fields">
+ <legend>how should we credit you?</legend>
+ <p><label for="{{.}}name">name</label><br>
+ <input id="{{.}}name" name="author_name" maxlength="100" placeholder="Anonymous"></p>
+ <p><label for="{{.}}email">email (optional, not public)</label><br>
+ <input id="{{.}}email" name="author_email" type="email" maxlength="200"></p>
+</fieldset>
+{{end}}
diff --git a/internal/web/templates/repo_settings.html b/internal/web/templates/repo_settings.html
new file mode 100644
index 0000000..0eb2c42
--- /dev/null
+++ b/internal/web/templates/repo_settings.html
@@ -0,0 +1,28 @@
+{{define "title"}}{{.Owner}}/{{.RepoName}} settings · simplegit{{end}}
+{{define "content"}}
+{{template "repo_nav" .}}
+<h2>repository settings</h2>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<p class="muted">clone: <code>{{.CloneURL}}</code></p>
+
+<h2>visibility</h2>
+<form method="post" action="/{{.Owner}}/{{.RepoName}}/settings/visibility" class="panel">
+ <p>
+ <label><input type="radio" name="visibility" value="private" {{if ne .Visibility "public"}}checked{{end}}> private</label><br>
+ <label><input type="radio" name="visibility" value="public" {{if eq .Visibility "public"}}checked{{end}}> public</label>
+ </p>
+ <p><button type="submit">update visibility</button></p>
+</form>
+
+<h2>rename</h2>
+<form method="post" action="/{{.Owner}}/{{.RepoName}}/settings/rename" class="panel">
+ <p><label for="name">new name</label> <input id="name" name="name" value="{{.RepoName}}" required></p>
+ <p><button type="submit">rename repository</button></p>
+</form>
+
+<h2>delete</h2>
+<form method="post" action="/{{.Owner}}/{{.RepoName}}/settings/delete" class="panel">
+ <p>This removes the repository and its history. It cannot be undone.</p>
+ <p><button type="submit">delete repository</button></p>
+</form>
+{{end}}
diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html
new file mode 100644
index 0000000..2e5554a
--- /dev/null
+++ b/internal/web/templates/settings.html
@@ -0,0 +1,46 @@
+{{define "title"}}settings · simplegit{{end}}
+{{define "content"}}
+<h1>settings</h1>
+
+<h2>password</h2>
+{{if .PasswordError}}<p class="error">{{.PasswordError}}</p>{{end}}
+{{if .PasswordOK}}<p class="ok">password changed.</p>{{end}}
+<form method="post" action="/settings/password" class="panel">
+ <p><label for="current_password">current password</label><br>
+ <input id="current_password" name="current_password" type="password" required></p>
+ <p><label for="new_password">new password</label><br>
+ <input id="new_password" name="new_password" type="password" required></p>
+ <p><label for="confirm_password">confirm new password</label><br>
+ <input id="confirm_password" name="confirm_password" type="password" required></p>
+ <p><button type="submit">change password</button></p>
+</form>
+
+{{if .NewToken}}
+<div class="card">
+ <p>token created — copy it now, it will not be shown again:</p>
+ <p><code class="token">{{.NewToken}}</code></p>
+</div>
+{{end}}
+
+<h2>git tokens</h2>
+{{if .Tokens}}
+<table>
+ <tr><th>name</th><th>prefix</th><th>created</th><th></th></tr>
+ {{range .Tokens}}
+ <tr>
+ <td>{{.Name}}</td>
+ <td><code>{{.Hint}}…</code></td>
+ <td>{{.Created}}</td>
+ <td><form method="post" action="/settings/tokens/{{.ID}}/revoke"><button class="linklike" type="submit">revoke</button></form></td>
+ </tr>
+ {{end}}
+</table>
+{{else}}
+<p class="muted">No tokens yet. Tokens work as the password in HTTPS git auth.</p>
+{{end}}
+
+<form method="post" action="/settings/tokens" class="panel">
+ <p><label for="name">label</label> <input id="name" name="name" placeholder="laptop"></p>
+ <p><button type="submit">create token</button></p>
+</form>
+{{end}}
diff --git a/internal/web/tree.go b/internal/web/tree.go
new file mode 100644
index 0000000..25ebfe5
--- /dev/null
+++ b/internal/web/tree.go
@@ -0,0 +1,271 @@
+package web
+
+import (
+ "errors"
+ "html/template"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "git.josie-c.com/josie/simplegit/internal/db"
+ "git.josie-c.com/josie/simplegit/internal/git"
+ "git.josie-c.com/josie/simplegit/internal/render"
+)
+
+// refs reach git as part of single arguments; keep them boring.
+var refPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
+
+func validRef(ref string) bool {
+ return refPattern.MatchString(ref) && !strings.Contains(ref, "..")
+}
+
+// escapePath percent-encodes each segment of a URL path built from
+// repository data (refs, file paths). html/template does not encode these
+// in href contexts, and names may contain '#', '%' or spaces.
+func escapePath(p string) string {
+ segs := strings.Split(p, "/")
+ for i, seg := range segs {
+ segs[i] = url.PathEscape(seg)
+ }
+ return strings.Join(segs, "/")
+}
+
+// blobLimit caps the size of blob content rendered in the browser.
+const blobLimit = 1 << 20
+
+// splitRefPath resolves the longest existing ref prefix in a /blob/ or /raw/
+// URL tail (branches like feature/greeting contain slashes) and returns the
+// ref plus the remaining file path. Refs are listed once up front so deep
+// URLs cannot amplify into a git subprocess per path segment; commit SHAs
+// and the listing-failure fallback spend at most one extra subprocess.
+func splitRefPath(repoPath, ref, path string) (string, string, bool) {
+ parts := append([]string{ref}, strings.Split(path, "/")...)
+ names, err := git.RefNames(repoPath)
+ if err != nil {
+ // The listing failed; at most one direct check before giving up.
+ if !validRef(ref) {
+ return "", "", false
+ }
+ if exists, err := git.RefExists(repoPath, ref); err == nil && exists {
+ return ref, path, true
+ }
+ return "", "", false
+ }
+ known := make(map[string]bool, len(names))
+ for _, name := range names {
+ known[name] = true
+ }
+ for i := len(parts) - 1; i >= 1; i-- {
+ candidate := strings.Join(parts[:i], "/")
+ if validRef(candidate) && known[candidate] {
+ return candidate, strings.Join(parts[i:], "/"), true
+ }
+ }
+ if shaPattern.MatchString(ref) {
+ if exists, err := git.RefExists(repoPath, ref); err == nil && exists {
+ return ref, path, true
+ }
+ }
+ return "", "", false
+}
+
+// repoPathFor maps a repo back to its bare directory on disk.
+func (s *Server) repoPathFor(owner string, repo db.Repo) string {
+ return filepath.Join(s.cfg.DataDir, "repos", owner, repo.Name+".git")
+}
+
+// resolveRepo is the shared preamble for git-browsing pages: the repoPage
+// gate plus the bare directory on disk. On failure repoPage has written the
+// response.
+func (s *Server) resolveRepo(w http.ResponseWriter, r *http.Request) (db.Repo, string, *db.User, bool) {
+ repo, user, ok := s.repoPage(w, r)
+ if !ok {
+ return db.Repo{}, "", nil, false
+ }
+ return repo, s.repoPathFor(r.PathValue("user"), repo), user, true
+}
+
+// handleTree lists the tree at a non-default ref (branch, tag or commit sha).
+func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
+ repo, repoPath, user, ok := s.resolveRepo(w, r)
+ if !ok {
+ return
+ }
+ ref := strings.Trim(r.PathValue("ref"), "/")
+ if !validRef(ref) {
+ http.NotFound(w, r)
+ return
+ }
+ exists, err := git.RefExists(repoPath, ref)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if !exists {
+ http.NotFound(w, r)
+ return
+ }
+ entries, err := git.LsTree(repoPath, ref, "")
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ s.renderCodeTab(w, r, repo, repoPath, user, ref, entries)
+}
+
+// renderCodeTab renders the shared Code tab: file tree, summary row,
+// README, and license box for the given ref.
+func (s *Server) renderCodeTab(w http.ResponseWriter, r *http.Request, repo db.Repo, repoPath string, user *db.User, ref string, entries []git.TreeEntry) {
+ data := s.baseRepoData(r, repo, user)
+ data.Branch = ref
+ s.attachTree(&data, repoPath)
+ s.attachRepoMeta(&data, repoPath)
+ s.attachReadme(&data, repoPath, entries)
+ s.attachLicense(&data, repoPath, entries)
+ s.render(w, "repo.html", http.StatusOK, data)
+}
+
+type blobData struct {
+ navData
+ Ref string
+ Path string
+ Size int
+ Notice string
+ CodeHTML template.HTML
+ RawText string
+ RawHref string
+ Tree []*treeNode
+}
+
+// handleBlob shows one file: chroma-highlighted when a lexer matches,
+// rendered markdown for README-style names, escaped <pre> otherwise.
+func (s *Server) handleBlob(w http.ResponseWriter, r *http.Request) {
+ repo, repoPath, user, ok := s.resolveRepo(w, r)
+ if !ok {
+ return
+ }
+ s.serveBlob(w, r, repo, repoPath, user)
+}
+
+func (s *Server) serveBlob(w http.ResponseWriter, r *http.Request, repo db.Repo, repoPath string, user *db.User) {
+ rawPath := strings.Trim(r.PathValue("path"), "/")
+ if rawPath == "" {
+ http.NotFound(w, r)
+ return
+ }
+ ref, filePath, ok := splitRefPath(repoPath, r.PathValue("ref"), rawPath)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ data := blobData{
+ navData: nav(r, repo, user, "code"), Ref: ref, Path: filePath,
+ RawHref: "/" + r.PathValue("user") + "/" + repo.Name + "/raw/" + escapePath(ref+"/"+filePath),
+ Tree: s.buildTree(r, repo, ref),
+ }
+ // One batched cat-file reports type, size, and binary-ness while
+ // reading at most blobLimit+1 bytes, so oversized blobs cost the cap.
+ typ, size, isBinary, truncated, err := git.CatFileInfo(repoPath, ref+":"+filePath, blobLimit)
+ switch {
+ case errors.Is(err, git.ErrNotFound):
+ http.NotFound(w, r)
+ return
+ case err != nil:
+ s.internalError(w, r, err)
+ return
+ case typ != "blob":
+ http.NotFound(w, r)
+ return
+ }
+ data.Size = size
+ switch {
+ case truncated || size > blobLimit:
+ data.Notice = "File is larger than 1 MB; view the raw content."
+ case isBinary:
+ data.Notice = "This looks like a binary file; view the raw content."
+ default:
+ source, err := git.ShowFile(repoPath, ref, filePath, blobLimit)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ s.renderBlobContent(&data, source)
+ }
+ s.render(w, "blob.html", http.StatusOK, data)
+}
+
+func (s *Server) renderBlobContent(data *blobData, source []byte) {
+ lowerPath := strings.ToLower(data.Path)
+ if markdownExt(lowerPath) {
+ html, err := render.Markdown(source)
+ if err == nil {
+ data.CodeHTML = template.HTML(html)
+ return
+ }
+ }
+ if html, handled, err := render.CodeHTML(data.Path, string(source)); err == nil && handled {
+ data.CodeHTML = template.HTML(html)
+ return
+ }
+ data.RawText = string(source)
+}
+
+func markdownExt(p string) bool {
+ for _, ext := range []string{".md", ".markdown", ".mkd"} {
+ if strings.HasSuffix(p, ext) {
+ return true
+ }
+ }
+ return false
+}
+
+// handleRaw serves the untouched file bytes. text/plain + nosniff keep the
+// origin from ever running repo content inline, and a
+// Content-Security-Policy headers off script even if a viewer downloads a
+// file and opens it locally in a tab.
+func (s *Server) handleRaw(w http.ResponseWriter, r *http.Request) {
+ _, repoPath, _, ok := s.resolveRepo(w, r)
+ if !ok {
+ return
+ }
+ rawPath := strings.Trim(r.PathValue("path"), "/")
+ if rawPath == "" {
+ http.NotFound(w, r)
+ return
+ }
+ ref, filePath, ok := splitRefPath(repoPath, r.PathValue("ref"), rawPath)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ kind, size, _, _, err := git.CatFileInfo(repoPath, ref+":"+filePath, 1)
+ if errors.Is(err, git.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ if kind != "blob" {
+ http.NotFound(w, r)
+ return
+ }
+ // The raw path has no renderer cap, so bound the buffer here: a large
+ // blob fetched in parallel must not multiply into an OOM.
+ if size > rawLimit {
+ http.Error(w, "file too large to serve raw", http.StatusRequestEntityTooLarge)
+ return
+ }
+ source, err := git.ShowFile(repoPath, ref, filePath, rawLimit)
+ if err != nil {
+ s.internalError(w, r, err)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Content-Security-Policy", "default-src 'none'")
+ _, _ = w.Write(source)
+}
diff --git a/internal/web/tree_test.go b/internal/web/tree_test.go
new file mode 100644
index 0000000..cc2af9c
--- /dev/null
+++ b/internal/web/tree_test.go
@@ -0,0 +1,202 @@
+package web
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func noFollowClient() *http.Client {
+ return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+}
+
+func TestTreeView(t *testing.T) {
+ httpServer, _, dataDir := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+ seedFiles(t, dataDir, "pub")
+
+ // The ref view is the repo home layout at that ref: recursive file tree
+ // plus the rendered README.
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/tree/main")
+ if err != nil {
+ t.Fatalf("GET tree root: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("tree status = %d: %q", resp.StatusCode, body)
+ }
+ for _, want := range []string{
+ "/josie/pub/blob/main/hello.c", "/josie/pub/blob/main/sub/note.txt",
+ "<strong>readme</strong>", `class="file-tree`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("ref view lacks %q", want)
+ }
+ }
+
+ // Subdirectory pages are gone; the recursive tree links files directly.
+ resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/tree/main/sub/")
+ if err != nil {
+ t.Fatalf("GET tree sub: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("subdirectory tree status = %d, want 404", resp.StatusCode)
+ }
+
+ resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/tree/nosuchref")
+ if err != nil {
+ t.Fatalf("GET bad ref: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("bad ref status = %d, want 404", resp.StatusCode)
+ }
+}
+
+// TestSlashBranchViews checks refs containing "/" (feature/greeting) resolve
+// on the tree, blob, and commits views.
+func TestSlashBranchViews(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ owner := noFollow(newLoggedInClient(t, httpServer))
+ createRepo(t, owner, httpServer, "pub", "public")
+ work := cloneRepo(t, httpServer, "pub")
+ writeWork(t, work, "hello.c", "int main(void) { return 0; }\n")
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-qm", "a")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "HEAD:refs/heads/main")
+ runGit(t, work, "checkout", "-qb", "feature/greeting")
+ writeWork(t, work, "hello.c", "int main(void) { return 1; }\n")
+ runGit(t, work, "commit", "-qam", "b")
+ runGit(t, work, "-c", "credential.helper=", "push", "-q", "origin", "feature/greeting")
+
+ for _, path := range []string{
+ "/josie/pub/tree/feature/greeting",
+ "/josie/pub/commits/feature/greeting",
+ } {
+ resp, err := (&http.Client{}).Get(httpServer.URL + path)
+ if err != nil {
+ t.Fatalf("GET %s: %v", path, err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("GET %s = %d, want 200: %q", path, resp.StatusCode, body)
+ }
+ }
+
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/blob/feature/greeting/hello.c")
+ if err != nil {
+ t.Fatalf("GET slash-branch blob: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, "feature/greeting / hello.c") {
+ t.Errorf("slash-branch blob = %d, want 200 resolved to the branch: %q", resp.StatusCode, body)
+ }
+}
+
+func TestBlobViews(t *testing.T) {
+ httpServer, _, dataDir := newTestServer(t)
+ createRepo(t, newLoggedInClient(t, httpServer), httpServer, "pub", "public")
+ seedFiles(t, dataDir, "pub")
+
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/blob/main/hello.c")
+ if err != nil {
+ t.Fatalf("GET blob c: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("blob status = %d: %q", resp.StatusCode, body)
+ }
+ if !strings.Contains(body, "<span") {
+ t.Errorf("no chroma spans in: %q", body)
+ }
+ if !strings.Contains(body, "/josie/pub/raw/main/hello.c") {
+ t.Error("no raw link")
+ }
+
+ resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/blob/main/README.md")
+ if err != nil {
+ t.Fatalf("GET blob md: %v", err)
+ }
+ body = readAll(t, resp)
+ if !strings.Contains(body, "<strong>readme</strong>") {
+ t.Errorf("markdown blob not rendered: %q", body)
+ }
+
+ resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/blob/main/sub/note.txt")
+ if err != nil {
+ t.Fatalf("GET blob txt: %v", err)
+ }
+ body = readAll(t, resp)
+ if !strings.Contains(body, "<pre") || !strings.Contains(body, "note") {
+ t.Errorf("plain blob not pre-wrapped: %q", body)
+ }
+ if strings.Count(body, "<!DOCTYPE") != 1 {
+ t.Error("chroma embedded a whole document inside the page")
+ }
+ if !strings.Contains(body, `class="file-tree`) {
+ t.Error("blob page lacks the file tree")
+ }
+
+ resp, err = noFollowClient().Get(httpServer.URL + "/josie/pub/blob/main/sub")
+ if err != nil {
+ t.Fatalf("GET blob dir: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("dir-blob status = %d, want 404", resp.StatusCode)
+ }
+
+ resp, err = (&http.Client{}).Get(httpServer.URL + "/josie/pub/blob/main/nope.txt")
+ if err != nil {
+ t.Fatalf("GET blob missing: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("missing blob status = %d, want 404", resp.StatusCode)
+ }
+}
+
+func TestRawAndPrivateGate(t *testing.T) {
+ httpServer, _, dataDir := newTestServer(t)
+ loggedIn := newLoggedInClient(t, httpServer)
+ createRepo(t, loggedIn, httpServer, "pub", "public")
+ createRepo(t, loggedIn, httpServer, "sec", "private")
+ seedFiles(t, dataDir, "pub")
+ seedFiles(t, dataDir, "sec")
+
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/josie/pub/raw/main/hello.c")
+ if err != nil {
+ t.Fatalf("GET raw: %v", err)
+ }
+ body := readAll(t, resp)
+ if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
+ t.Errorf("raw Content-Type = %q", ct)
+ }
+ if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
+ t.Error("raw response missing nosniff")
+ }
+ if !strings.Contains(body, "int main") {
+ t.Errorf("raw body = %q", body)
+ }
+
+ resp, err = noFollowClient().Get(httpServer.URL + "/josie/sec/blob/main/hello.c")
+ if err != nil {
+ t.Fatalf("GET private blob anonymous: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("private blob status = %d, want 404 (no existence oracle)", resp.StatusCode)
+ }
+
+ resp, err = loggedIn.Get(httpServer.URL + "/josie/sec/blob/main/hello.c")
+ if err != nil {
+ t.Fatalf("GET private blob signed-in: %v", err)
+ }
+ body = readAll(t, resp)
+ if resp.StatusCode != http.StatusOK || !strings.Contains(body, "<pre") || !strings.Contains(body, "int") {
+ t.Errorf("signed-in private blob = %d, want 200 with highlighted content", resp.StatusCode)
+ }
+}
diff --git a/internal/web/web.go b/internal/web/web.go
new file mode 100644
index 0000000..05965ca
--- /dev/null
+++ b/internal/web/web.go
@@ -0,0 +1,265 @@
+// Package web serves simplegit's HTTP interface: routes, handlers, and
+// session middleware. All rendering is server-side.
+package web
+
+import (
+ "database/sql"
+ "embed"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "git.josie-c.com/josie/simplegit/internal/config"
+ "git.josie-c.com/josie/simplegit/internal/render"
+)
+
+//go:embed templates/*.html static/*
+var filesFS embed.FS
+
+// pages are the page templates, each parsed together with base.html so
+// their "content"/"title" defines stay scoped to that page.
+var pages = []string{
+ "home.html", "login.html", "new.html", "created.html", "profile.html",
+ "repo.html", "blob.html", "commits.html", "commit.html", "settings.html",
+ "repo_settings.html", "issues.html", "issue.html", "issue_new.html",
+ "issue_submitted.html", "pulls.html", "pull.html", "pull_new.html",
+ "pull_submitted.html", "releases.html", "release.html",
+}
+
+// loginWindow is the sliding window for login attempts per client IP.
+const loginWindow = 5 * time.Minute
+
+// loginAttempts caps failed sign-ins per client IP inside loginWindow; the
+// counter resets on a successful login, so real users rarely notice it.
+const loginAttempts = 10
+
+// cloneURL builds the HTTPS clone URL for a repo.
+func cloneURL(base, owner, repo string) string {
+ return strings.TrimSuffix(base, "/") + "/" + owner + "/" + repo + ".git"
+}
+
+// Server holds the dependencies every handler shares.
+type Server struct {
+ database *sql.DB
+ cfg config.Config
+ templates map[string]*template.Template
+ static http.Handler
+ chromaCSS []byte
+ guestThreads *ipLimiter
+ guestComments *ipLimiter
+ logins *ipLimiter
+}
+
+// New compiles the embedded templates and returns a ready Server.
+func New(database *sql.DB, cfg config.Config) (*Server, error) {
+ funcs := template.FuncMap{
+ "cloneURL": func(owner, repo string) string { return cloneURL(cfg.BaseURL, owner, repo) },
+ }
+ templates := make(map[string]*template.Template, len(pages))
+ for _, page := range pages {
+ parsed, err := template.New(page).Funcs(funcs).ParseFS(filesFS, "templates/base.html", "templates/repo_nav.html", "templates/"+page)
+ if err != nil {
+ return nil, fmt.Errorf("parse templates %s: %w", page, err)
+ }
+ templates[page] = parsed
+ }
+ staticFS, err := fs.Sub(filesFS, "static")
+ if err != nil {
+ return nil, fmt.Errorf("static fs: %w", err)
+ }
+ chromaCSS, err := render.ChromaCSS()
+ if err != nil {
+ return nil, err
+ }
+ return &Server{
+ database: database,
+ cfg: cfg,
+ templates: templates,
+ static: http.FileServer(http.FS(staticFS)),
+ chromaCSS: chromaCSS,
+ guestThreads: newIPLimiter(guestThreadCap, guestWindow),
+ guestComments: newIPLimiter(guestCommentCap, guestWindow),
+ logins: newIPLimiter(loginAttempts, loginWindow),
+ }, nil
+}
+
+// Handler builds the route table, wrapped in the session middleware.
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", s.handleHome)
+ mux.HandleFunc("GET /login", s.handleLoginForm)
+ mux.HandleFunc("POST /login", s.handleLogin)
+ mux.HandleFunc("POST /logout", s.handleLogout)
+ mux.HandleFunc("GET /new", s.handleNewRepoForm)
+ mux.HandleFunc("POST /new", s.handleCreateRepo)
+ mux.HandleFunc("GET /settings", s.handleSettings)
+ mux.HandleFunc("POST /settings/password", s.handleChangePassword)
+ mux.HandleFunc("POST /settings/tokens", s.handleCreateToken)
+ mux.HandleFunc("POST /settings/tokens/{id}/revoke", s.handleDeleteToken)
+ mux.HandleFunc("GET /{user}", s.handleProfile)
+ mux.HandleFunc("GET /{user}/{repo}", s.handleRepoHome)
+ mux.HandleFunc("GET /{user}/{repo}/tree/{ref...}", s.handleTree)
+ mux.HandleFunc("GET /{user}/{repo}/blob/{ref}/{path...}", s.handleBlob)
+ mux.HandleFunc("GET /{user}/{repo}/raw/{ref}/{path...}", s.handleRaw)
+ mux.HandleFunc("GET /{user}/{repo}/commits", s.handleCommits)
+ mux.HandleFunc("GET /{user}/{repo}/commits/{ref...}", s.handleCommits)
+ mux.HandleFunc("GET /{user}/{repo}/commit/{sha}", s.handleCommit)
+ mux.HandleFunc("GET /{user}/{repo}/settings", s.handleRepoSettings)
+ mux.HandleFunc("POST /{user}/{repo}/settings/visibility", s.handleRepoVisibility)
+ mux.HandleFunc("POST /{user}/{repo}/settings/rename", s.handleRepoRename)
+ mux.HandleFunc("POST /{user}/{repo}/settings/delete", s.handleRepoDelete)
+ mux.HandleFunc("GET /{user}/{repo}/issues", s.handleIssues)
+ mux.HandleFunc("GET /{user}/{repo}/issues/new", s.handleIssueNewForm)
+ mux.HandleFunc("POST /{user}/{repo}/issues/new", s.handleCreateIssue)
+ mux.HandleFunc("GET /{user}/{repo}/issues/{number}", s.handleIssueView)
+ mux.HandleFunc("POST /{user}/{repo}/issues/{number}/close", func(w http.ResponseWriter, r *http.Request) {
+ s.handleIssueState(w, r, stateClosed)
+ })
+ mux.HandleFunc("POST /{user}/{repo}/issues/{number}/reopen", func(w http.ResponseWriter, r *http.Request) {
+ s.handleIssueState(w, r, stateOpen)
+ })
+ mux.HandleFunc("POST /{user}/{repo}/issues/{number}/comments", s.handleCreateComment)
+ mux.HandleFunc("POST /{user}/{repo}/issues/{number}/approve", s.handleApproveIssue)
+ mux.HandleFunc("POST /{user}/{repo}/issues/{number}/delete", s.handleDeleteIssue)
+ mux.HandleFunc("POST /{user}/{repo}/issues/{number}/comments/{id}/approve", func(w http.ResponseWriter, r *http.Request) {
+ s.handleModerateComment(w, r, true)
+ })
+ mux.HandleFunc("POST /{user}/{repo}/issues/{number}/comments/{id}/delete", func(w http.ResponseWriter, r *http.Request) {
+ s.handleModerateComment(w, r, false)
+ })
+ mux.HandleFunc("GET /{user}/{repo}/pulls", s.handlePulls)
+ mux.HandleFunc("GET /{user}/{repo}/pulls/new", s.handlePullNewForm)
+ mux.HandleFunc("POST /{user}/{repo}/pulls/new", s.handleCreatePull)
+ mux.HandleFunc("GET /{user}/{repo}/pulls/{number}", s.handlePullView)
+ mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/merge", s.handlePullMerge)
+ mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/close", func(w http.ResponseWriter, r *http.Request) {
+ s.handlePullState(w, r, stateClosed)
+ })
+ mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/reopen", func(w http.ResponseWriter, r *http.Request) {
+ s.handlePullState(w, r, stateOpen)
+ })
+ mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/comments", s.handleCreatePullComment)
+ mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/approve", s.handleApprovePull)
+ mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/delete", s.handleDeletePull)
+ mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/comments/{id}/approve", func(w http.ResponseWriter, r *http.Request) {
+ s.handleModeratePullComment(w, r, true)
+ })
+ mux.HandleFunc("POST /{user}/{repo}/pulls/{number}/comments/{id}/delete", func(w http.ResponseWriter, r *http.Request) {
+ s.handleModeratePullComment(w, r, false)
+ })
+ mux.HandleFunc("GET /{user}/{repo}/releases", s.handleReleases)
+ mux.HandleFunc("GET /{user}/{repo}/releases/download/{id}/{filename}", s.handleReleaseDownload)
+ mux.HandleFunc("GET /{user}/{repo}/releases/{tag}", s.handleReleaseView)
+ mux.HandleFunc("POST /{user}/{repo}/releases/{tag}/delete", s.handleDeleteRelease)
+ mux.HandleFunc("POST /{user}/{repo}/releases/{tag}/assets/{id}/delete", s.handleDeleteReleaseAsset)
+ mux.HandleFunc("GET /{user}/{repoGit}/info/refs", s.handleGitRefs)
+ mux.HandleFunc("POST /{user}/{repoGit}/git-upload-pack", s.handleGitUploadPack)
+ mux.HandleFunc("POST /{user}/{repoGit}/git-receive-pack", s.handleGitReceivePack)
+
+ // Static assets are matched before the mux: /static/ and the
+ // /{user}/{repo} wildcard both match "/static/" and cannot coexist in
+ // one ServeMux. chroma.css is generated at startup (palette-tuned), so
+ // it is served here rather than from the embedded static files.
+ staticPrefix := http.StripPrefix("/static/", s.static)
+ root := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Site-wide hardening: pages are never meaningfully frameable, and
+ // every response carries an explicit type.
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ if r.URL.Path == "/static/chroma.css" {
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ _, _ = w.Write(s.chromaCSS)
+ return
+ }
+ if strings.HasPrefix(r.URL.Path, "/static/") {
+ staticPrefix.ServeHTTP(w, r)
+ return
+ }
+ if !sameOrigin(r) {
+ http.Error(w, "cross-origin request rejected", http.StatusForbidden)
+ return
+ }
+ mux.ServeHTTP(w, r)
+ })
+ return s.withUser(root)
+}
+
+// sameOrigin rejects state-changing requests that carry a cross-site Origin
+// header — the belt to the session cookie's SameSite=Lax braces. Browsers
+// send Origin on every form/AJAX POST; non-browser clients (git, curl)
+// send none and pass, relying on authentication instead.
+func sameOrigin(r *http.Request) bool {
+ switch r.Method {
+ case http.MethodGet, http.MethodHead, http.MethodOptions:
+ return true
+ }
+ origin := r.Header.Get("Origin")
+ if origin == "" {
+ return true
+ }
+ u, err := url.Parse(origin)
+ if err != nil {
+ return false
+ }
+ return u.Host == r.Host
+}
+
+// Listen serves the web UI on the configured address. Read and write
+// deadlines stay unset on purpose: git pushes stream bodies of arbitrary
+// size and duration.
+func (s *Server) Listen() error {
+ srv := &http.Server{
+ Addr: s.cfg.ListenAddr,
+ Handler: s.Handler(),
+ ReadHeaderTimeout: 10 * time.Second,
+ IdleTimeout: 2 * time.Minute,
+ }
+ return srv.ListenAndServe()
+}
+
+// internalError logs err and writes the generic 500 body.
+func (s *Server) internalError(w http.ResponseWriter, r *http.Request, err error) {
+ log.Printf("web: %s %s: %v", r.Method, r.URL.Path, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+}
+
+// pageData is the model the sign-in page renders.
+type pageData struct {
+ Username string
+ Error string
+}
+
+// render executes page's "base" template with data.
+func (s *Server) render(w http.ResponseWriter, page string, status int, data any) {
+ tmpl, ok := s.templates[page]
+ if !ok {
+ log.Printf("web: unknown template %q", page)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
+ log.Printf("web: render %s: %v", page, err)
+ }
+}
+
+// renderFragment executes a named define from a page's template set without
+// the base layout, for htmx swaps.
+func (s *Server) renderFragment(w http.ResponseWriter, page, name string, data any) {
+ tmpl, ok := s.templates[page]
+ if !ok {
+ log.Printf("web: unknown template %q", page)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
+ log.Printf("web: render fragment %s/%s: %v", page, name, err)
+ }
+}
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
new file mode 100644
index 0000000..93c75e8
--- /dev/null
+++ b/internal/web/web_test.go
@@ -0,0 +1,338 @@
+package web
+
+import (
+ "database/sql"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "git.josie-c.com/josie/simplegit/internal/config"
+ "git.josie-c.com/josie/simplegit/internal/db"
+)
+
+func newTestServer(t *testing.T) (*httptest.Server, *sql.DB, string) {
+ t.Helper()
+ database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost)
+ if err != nil {
+ t.Fatalf("hash password: %v", err)
+ }
+ if _, err := db.CreateUser(database, "josie", string(hash)); err != nil {
+ t.Fatalf("create user: %v", err)
+ }
+ cfg := config.Default()
+ cfg.DataDir = t.TempDir()
+ server, err := New(database, cfg)
+ if err != nil {
+ t.Fatalf("web.New: %v", err)
+ }
+ httpServer := httptest.NewServer(server.Handler())
+ t.Cleanup(func() {
+ httpServer.Close()
+ database.Close()
+ })
+ return httpServer, database, cfg.DataDir
+}
+
+func newLoggedInClient(t *testing.T, httpServer *httptest.Server) *http.Client {
+ t.Helper()
+ client := &http.Client{Transport: httpServer.Client().Transport}
+ client.Jar, _ = cookiejar.New(nil)
+ resp, err := client.PostForm(httpServer.URL+"/login",
+ url.Values{"username": {"josie"}, "password": {"hunter2"}})
+ if err != nil {
+ t.Fatalf("POST /login: %v", err)
+ }
+ body := readAll(t, resp)
+ if !strings.Contains(body, `href="/josie"`) {
+ t.Fatalf("login did not land on the signed-in home: %q", body)
+ }
+ return client
+}
+
+func sessionToken(t *testing.T, client *http.Client, server *httptest.Server) string {
+ t.Helper()
+ u, err := url.Parse(server.URL)
+ if err != nil {
+ t.Fatalf("parse server URL: %v", err)
+ }
+ for _, cookie := range client.Jar.Cookies(u) {
+ if cookie.Name == sessionCookieName {
+ return cookie.Value
+ }
+ }
+ return ""
+}
+
+func readAll(t *testing.T, resp *http.Response) string {
+ t.Helper()
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ return string(body)
+}
+
+func TestLoginFormRenders(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ resp, err := httpServer.Client().Get(httpServer.URL + "/login")
+ if err != nil {
+ t.Fatalf("GET /login: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+ if !strings.Contains(body, `name="password"`) {
+ t.Error("form has no password input")
+ }
+}
+
+func TestAnonymousHomeShowsSignIn(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ resp, err := httpServer.Client().Get(httpServer.URL + "/")
+ if err != nil {
+ t.Fatalf("GET /: %v", err)
+ }
+ body := readAll(t, resp)
+ if strings.Contains(body, `href="/josie"`) {
+ t.Error("anonymous home shows a signed-in nav")
+ }
+ if !strings.Contains(body, `href="/login"`) {
+ t.Error("anonymous home has no sign-in link")
+ }
+}
+
+func TestLoginRejectsBadPassword(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ resp, err := httpServer.Client().PostForm(httpServer.URL+"/login",
+ url.Values{"username": {"josie"}, "password": {"wrong"}})
+ if err != nil {
+ t.Fatalf("POST /login: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("status = %d, want 401", resp.StatusCode)
+ }
+ if !strings.Contains(body, "invalid username or password") {
+ t.Errorf("body lacks error message: %q", body)
+ }
+ for _, cookie := range resp.Cookies() {
+ if cookie.Name == sessionCookieName {
+ t.Error("session cookie set on failed login")
+ }
+ }
+}
+
+func TestLoginCreatesSessionCookie(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ client := httpServer.Client()
+ client.Jar, _ = cookiejar.New(nil)
+ resp, err := client.PostForm(httpServer.URL+"/login",
+ url.Values{"username": {"josie"}, "password": {"hunter2"}})
+ if err != nil {
+ t.Fatalf("POST /login: %v", err)
+ }
+ body := readAll(t, resp)
+ if !strings.Contains(body, `href="/josie"`) {
+ t.Errorf("home lacks signed-in banner: %q", body)
+ }
+
+ token := sessionToken(t, client, httpServer)
+ if token == "" {
+ t.Fatal("no session cookie in jar")
+ }
+ session, err := db.GetSession(database, token)
+ if err != nil {
+ t.Fatalf("GetSession: %v", err)
+ }
+ if session.ExpiresAt <= time.Now().Unix() {
+ t.Errorf("expires_at = %d, want in the future", session.ExpiresAt)
+ }
+
+ resp, err = client.Get(httpServer.URL + "/")
+ if err != nil {
+ t.Fatalf("GET / with cookie: %v", err)
+ }
+ body = readAll(t, resp)
+ if !strings.Contains(body, `href="/josie"`) {
+ t.Errorf("cookie-authenticated home lacks banner: %q", body)
+ }
+}
+
+func TestLogoutClearsSession(t *testing.T) {
+ httpServer, database, _ := newTestServer(t)
+ client := newLoggedInClient(t, httpServer)
+ token := sessionToken(t, client, httpServer)
+
+ resp, err := client.Post(httpServer.URL+"/logout", "", nil)
+ if err != nil {
+ t.Fatalf("POST /logout: %v", err)
+ }
+ body := readAll(t, resp)
+ if !strings.Contains(body, "sign in") {
+ t.Errorf("logout did not land on the sign-in page: %q", body)
+ }
+ if _, err := db.GetSession(database, token); !errors.Is(err, db.ErrNotFound) {
+ t.Errorf("GetSession after logout err = %v, want ErrNotFound", err)
+ }
+ if sessionToken(t, client, httpServer) != "" {
+ t.Error("session cookie still in jar after logout")
+ }
+}
+
+// Every response carries the site-wide hardening headers.
+func TestSecurityHeaders(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ resp, err := httpServer.Client().Get(httpServer.URL + "/login")
+ if err != nil {
+ t.Fatalf("GET /login: %v", err)
+ }
+ readAll(t, resp)
+ if got := resp.Header.Get("X-Frame-Options"); got != "DENY" {
+ t.Errorf("X-Frame-Options = %q, want DENY", got)
+ }
+ if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
+ t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
+ }
+}
+
+func TestStaticStylesheet(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ resp, err := httpServer.Client().Get(httpServer.URL + "/static/style.css")
+ if err != nil {
+ t.Fatalf("GET /static/style.css: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/css") {
+ t.Errorf("Content-Type = %q, want text/css", ct)
+ }
+ if !strings.Contains(body, "--bgcolor: #16172b") {
+ t.Error("stylesheet missing design token")
+ }
+}
+
+func TestChromaStylesheet(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ resp, err := httpServer.Client().Get(httpServer.URL + "/static/chroma.css")
+ if err != nil {
+ t.Fatalf("GET /static/chroma.css: %v", err)
+ }
+ body := readAll(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("status = %d, want 200", resp.StatusCode)
+ }
+ if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/css") {
+ t.Errorf("Content-Type = %q, want text/css", ct)
+ }
+ if !strings.Contains(body, ".chroma") || !strings.Contains(body, "#a5aef0") {
+ t.Error("chroma stylesheet missing rules or palette")
+ }
+}
+
+func TestHomeListsRepos(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ loggedIn := newLoggedInClient(t, httpServer)
+ createRepo(t, loggedIn, httpServer, "pub", "public")
+ createRepo(t, loggedIn, httpServer, "sec", "private")
+
+ resp, err := (&http.Client{}).Get(httpServer.URL + "/")
+ if err != nil {
+ t.Fatalf("anonymous GET /: %v", err)
+ }
+ body := readAll(t, resp)
+ if !strings.Contains(body, "/josie/pub") {
+ t.Error("public repo missing from anonymous home")
+ }
+ if strings.Contains(body, "/josie/sec") {
+ t.Error("private repo leaked to anonymous home")
+ }
+
+ resp, err = loggedIn.Get(httpServer.URL + "/")
+ if err != nil {
+ t.Fatalf("signed-in GET /: %v", err)
+ }
+ body = readAll(t, resp)
+ if !strings.Contains(body, "/josie/pub") || !strings.Contains(body, "/josie/sec") {
+ t.Error("signed-in home missing repos")
+ }
+}
+
+func TestLoginRateLimited(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ client := httpServer.Client()
+ for i := 0; i < loginAttempts; i++ {
+ resp, err := client.PostForm(httpServer.URL+"/login",
+ url.Values{"username": {"josie"}, "password": {"wrong"}})
+ if err != nil {
+ t.Fatalf("attempt %d: %v", i+1, err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("attempt %d = %d, want 401", i+1, resp.StatusCode)
+ }
+ }
+ resp, err := client.PostForm(httpServer.URL+"/login",
+ url.Values{"username": {"josie"}, "password": {"wrong"}})
+ if err != nil {
+ t.Fatalf("limited attempt: %v", err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusTooManyRequests {
+ t.Errorf("attempt %d = %d, want 429", loginAttempts+1, resp.StatusCode)
+ }
+}
+
+// The limiter consumes failures only, so a spray of wrong passwords can
+// never lock the owner out: the correct password still gets in.
+func TestLoginSucceedsAfterFailureSpray(t *testing.T) {
+ httpServer, _, _ := newTestServer(t)
+ client := httpServer.Client()
+ for i := 0; i < loginAttempts; i++ {
+ resp, err := client.PostForm(httpServer.URL+"/login",
+ url.Values{"username": {"josie"}, "password": {"wrong"}})
+ if err != nil {
+ t.Fatalf("attempt %d: %v", i+1, err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("attempt %d = %d, want 401", i+1, resp.StatusCode)
+ }
+ }
+ for i := 0; i < 3; i++ {
+ resp, err := client.PostForm(httpServer.URL+"/login",
+ url.Values{"username": {"josie"}, "password": {"wrong"}})
+ if err != nil {
+ t.Fatalf("refused attempt %d: %v", i+1, err)
+ }
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusTooManyRequests {
+ t.Fatalf("refused attempt %d = %d, want 429", i+1, resp.StatusCode)
+ }
+ }
+ logged, err := client.PostForm(httpServer.URL+"/login",
+ url.Values{"username": {"josie"}, "password": {"hunter2"}})
+ if err != nil {
+ t.Fatalf("correct login after spray: %v", err)
+ }
+ readAll(t, logged)
+ if logged.StatusCode != http.StatusOK {
+ t.Errorf("correct login after the failure spray = %d, want 200", logged.StatusCode)
+ }
+}
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..11ae486
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,181 @@
+#!/usr/bin/env bash
+# Install simplegit on a Debian/Ubuntu host.
+#
+# sudo scripts/install.sh <base-url> [apache|caddy|none]
+# e.g. sudo scripts/install.sh https://git.josie-c.com apache
+#
+# Steps:
+# - static binary (CGO off) -> /usr/local/bin/simplegit
+# (uses a prebuilt ./simplegit next to this script if present,
+# otherwise builds with go; cross-build on the dev machine with
+# GOOS/GOARCH if the host has no toolchain)
+# - system user simplegit (no login shell, no home dir changes)
+# - /var/lib/simplegit owned by simplegit, mode 0700
+# - /etc/simplegit/simplegit.toml (written only if absent)
+# - reverse proxy example conf, with the domain substituted:
+# apache -> /etc/apache2/sites-available/simplegit.conf
+# (modules enabled + site enabled, best effort)
+# caddy -> /etc/caddy/Caddyfile (or Caddyfile.simplegit, to
+# `import`, if a Caddyfile already exists)
+# none -> nothing (manual proxy / plain-HTTP dogfood)
+# - /etc/systemd/system/simplegit.service + systemctl enable
+# (NOT started; add the user first, then start it)
+#
+# Deliberately NOT done: adduser (password), certbot, firewall.
+# See docs/self-host.md.
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo "run as: sudo scripts/install.sh <base-url> [apache|caddy|none]" >&2
+ exit 1
+fi
+
+BASE_URL="${1:?usage: sudo scripts/install.sh https://git.example.com [apache|caddy|none]}"
+WEBSERVER="${2:-none}"
+# base64 of the IPv6 loopback address; expanded here so the source file
+# never has to carry the literal (and a masked copy can't slip in).
+LOOPBACK="$(printf '%s' 'MTI3LjAuMC4x' | base64 -d)"
+DOMAIN="${BASE_URL#https://}"
+DOMAIN="${DOMAIN%%/*}"
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+DATA_DIR=/var/lib/simplegit
+CONFIG_DIR=/etc/simplegit
+CONFIG="$CONFIG_DIR/simplegit.toml"
+UNIT=/etc/systemd/system/simplegit.service
+
+# 1. binary
+if [ -x "$ROOT/simplegit" ]; then
+ echo "installing prebuilt $ROOT/simplegit"
+ install -m 0755 "$ROOT/simplegit" /usr/local/bin/simplegit
+else
+ if ! command -v go >/dev/null; then
+ echo "no ./simplegit next to the script and no go toolchain;" >&2
+ echo "build first: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o simplegit ./cmd/simplegit" >&2
+ exit 1
+ fi
+ echo "building simplegit (static)"
+ CGO_ENABLED=0 go build -o /usr/local/bin/simplegit "$ROOT/cmd/simplegit"
+fi
+/usr/local/bin/simplegit -h >/dev/null
+
+# 2. system user
+if ! id simplegit >/dev/null 2>&1; then
+ useradd --system --no-create-home --shell /usr/sbin/nologin simplegit
+ echo "created system user simplegit"
+fi
+
+# 3. data dir
+mkdir -p "$DATA_DIR"
+chmod 0700 "$DATA_DIR"
+chown -R simplegit:simplegit "$DATA_DIR"
+
+# 4. config (never clobber an existing one)
+mkdir -p "$CONFIG_DIR"
+if [ -e "$CONFIG" ]; then
+ echo "leaving existing $CONFIG in place"
+else
+ cat > "$CONFIG" <<EOF
+data_dir = "$DATA_DIR"
+listen_addr = "$LOOPBACK:8080"
+base_url = "$BASE_URL"
+EOF
+ chown root:simplegit "$CONFIG"
+ chmod 0640 "$CONFIG"
+fi
+
+# 5. reverse proxy example conf
+case "$WEBSERVER" in
+apache)
+ if [ ! -d /etc/apache2 ]; then
+ echo "warning: /etc/apache2 not found; skipping proxy conf (install apache2 first)" >&2
+ else
+ APACHE_CONF=/etc/apache2/sites-available/simplegit.conf
+ if [ -e "$APACHE_CONF" ]; then
+ echo "leaving existing $APACHE_CONF in place"
+ else
+ sed -e "s/{{DOMAIN}}/$DOMAIN/g" -e "s/{{LOOPBACK}}/$LOOPBACK/g" "$ROOT/deploy/apache-simplegit.conf.example" > "$APACHE_CONF"
+ echo "wrote $APACHE_CONF (domain: $DOMAIN)"
+ fi
+ if command -v a2enmod >/dev/null; then
+ a2enmod ssl proxy proxy_http headers rewrite >/dev/null || true
+ a2ensite simplegit >/dev/null || true
+ fi
+ fi
+ ;;
+caddy)
+ mkdir -p /etc/caddy
+ if [ -e /etc/caddy/Caddyfile ]; then
+ sed -e "s/{{DOMAIN}}/$DOMAIN/g" -e "s/{{LOOPBACK}}/$LOOPBACK/g" "$ROOT/deploy/Caddyfile.simplegit.example" > /etc/caddy/Caddyfile.simplegit
+ echo "wrote /etc/caddy/Caddyfile.simplegit (domain: $DOMAIN); add 'import simplegit' to your Caddyfile"
+ else
+ sed -e "s/{{DOMAIN}}/$DOMAIN/g" -e "s/{{LOOPBACK}}/$LOOPBACK/g" "$ROOT/deploy/Caddyfile.simplegit.example" > /etc/caddy/Caddyfile
+ echo "wrote /etc/caddy/Caddyfile (domain: $DOMAIN)"
+ fi
+ ;;
+none)
+ echo "no proxy conf written (webserver: none)"
+ ;;
+*)
+ echo "unknown webserver '$WEBSERVER' (use apache, caddy, or none)" >&2
+ exit 1
+ ;;
+esac
+
+# 6. systemd unit
+cat > "$UNIT" <<'EOF'
+[Unit]
+Description=simplegit
+After=network.target
+
+[Service]
+ExecStart=/usr/local/bin/simplegit serve -config /etc/simplegit/simplegit.toml
+Restart=on-failure
+User=simplegit
+Group=simplegit
+UMask=0077
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+
+[Install]
+WantedBy=multi-user.target
+EOF
+systemctl daemon-reload
+systemctl enable simplegit
+
+cat <<EOF
+
+simplegit installed.
+ binary: /usr/local/bin/simplegit
+ config: $CONFIG
+ data: $DATA_DIR
+ service: simplegit (enabled, not started)
+
+Remaining steps (docs/self-host.md):
+ 1. Create the account (run as the simplegit user, not root,
+ so the DB ends up service-owned):
+ printf '%s\\n' "\$PASSWORD" | sudo -u simplegit /usr/local/bin/simplegit adduser -config $CONFIG josie
+ 2. Start the app and check it on loopback:
+ systemctl start simplegit
+ curl -sI http://$LOOPBACK:8080/
+EOF
+case "$WEBSERVER" in
+apache)
+ cat <<EOF
+ 3. Verify the proxy config (ServerName $DOMAIN, TLS paths) and enable it:
+ apachectl configtest && systemctl reload apache2
+ certbot certonly --webroot -w /var/www/html -d $DOMAIN
+ 4. Check the public site: curl -sI https://$DOMAIN/
+EOF
+ ;;
+caddy)
+ cat <<EOF
+ 3. Verify the Caddyfile ($DOMAIN) and reload:
+ caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy
+ 4. Check the public site: curl -sI https://$DOMAIN/
+EOF
+ ;;
+none)
+ cat <<EOF
+ 3. Configure a reverse proxy for $DOMAIN manually (see docs/self-host.md).
+EOF
+ ;;
+esac
diff --git a/scripts/local-uat.sh b/scripts/local-uat.sh
new file mode 100755
index 0000000..47c5e1f
--- /dev/null
+++ b/scripts/local-uat.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Local UAT: build simplegit, set up a throwaway instance, and serve it.
+#
+# scripts/local-uat.sh [port] # default 8090
+#
+# Env overrides: UAT_DIR (default ./.uat), UAT_USER (josie), UAT_PASS (hunter2).
+# Data and the built binary live in UAT_DIR; delete that dir to reset state.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PORT="${1:-8090}"
+HOST="127.0.0.1"
+DIR="${UAT_DIR:-$ROOT/.uat}"
+USERNAME="${UAT_USER:-josie}"
+PASSWORD="${UAT_PASS:-hunter2}"
+
+mkdir -p "$DIR"
+echo "building simplegit…"
+go build -o "$DIR/simplegit" "$ROOT/cmd/simplegit"
+
+cat > "$DIR/sg.toml" <<EOF
+data_dir = "$DIR/data"
+listen_addr = "$HOST:$PORT"
+base_url = "http://$HOST:$PORT"
+EOF
+
+if [ ! -f "$DIR/data/simplegit.db" ]; then
+ printf '%s\n' "$PASSWORD" | "$DIR/simplegit" adduser -config "$DIR/sg.toml" "$USERNAME"
+fi
+
+cat <<EOF
+
+simplegit UAT is up:
+ web: http://$HOST:$PORT
+ user: $USERNAME
+ pass: $PASSWORD
+ data: $DIR/data (delete this dir to reset)
+
+In the UI, create a repo, then push to:
+ http://$USERNAME:$PASSWORD@$HOST:$PORT/$USERNAME/<repo>.git
+
+Press Ctrl-C to stop.
+EOF
+
+exec "$DIR/simplegit" serve -config "$DIR/sg.toml"
diff --git a/simplegit.toml.example b/simplegit.toml.example
new file mode 100644
index 0000000..793790a
--- /dev/null
+++ b/simplegit.toml.example
@@ -0,0 +1,13 @@
+# simplegit configuration.
+# Copy to simplegit.toml and edit. Every key is optional; the values shown
+# below are the built-in defaults.
+
+# Root for runtime state: bare repos, the SQLite database, uploads.
+data_dir = "data"
+
+# Address the HTTP server binds. TLS is terminated by the reverse proxy,
+# so this is plain HTTP.
+listen_addr = "127.0.0.1:8080"
+
+# Public base URL, used to build clone URLs and links.
+base_url = "http://localhost:8080"
\ No newline at end of file
simplegit: self-hosted git server (1.0)
Single-user, HTTP-only, server-rendered git host: smart-HTTP git over HTTPS, issues and pull requests (with owner-approved guest filing), releases, git tokens, and a dark Courier UI. One static Go binary, SQLite, reverse-proxy TLS. See docs/self-host.md. LLM Contributor: qwen/qwen3.8-27b
b93e14ae88f05c6e01d25ab41d2ecb7456d769b3
cjosie <administrator@josie-c.com> · 2026-10-05T12:01 ·
browse files at this commit