| README.md |
| | # gitweb |
| | |
| | Serves git repositories stored in [upspin](https://upspin.io) over HTTPS at |
| | `https://git.cosmocr.at`: a read-only browse UI **and** the git smart-HTTP |
| | protocol for `clone`/`fetch`/`push` with stock `git` (no git extensions). |
| | |
| | gitweb runs as one upspin user (`git@cosmocr.at`) and reads/writes repos on the |
| | owner's behalf. All access control lives in upspin **Access files** — gitweb |
| | never has its own permission database. |
| | |
| | The URL path is the upspin path, so a bare repo at upspin path |
| | `u@example.com/proj.git` is served at |
| | `https://git.cosmocr.at/u@example.com/proj.git`. |
| | |
| | ## Public vs private |
| | |
| | The repo's **governing Access file** (the nearest `Access` file at or above the |
| | repo directory) decides visibility: |
| | |
| | - **Public** — grants read to all (`read: all`). Anyone may browse and clone, |
| | no credentials. |
| | - **Private** — readable by `git@cosmocr.at` but not by all. Browsing and |
| | cloning require authentication; pushing requires `write`. |
| | |
| | Because gitweb reads as `git@cosmocr.at`, a private repo's Access file must |
| | grant that user read (and, for push, `create,write,delete`) in addition to the |
| | human collaborators. |
| | |
| | Example Access files (in the repo directory, e.g. `u@example.com/proj.git/Access`): |
| | |
| | ``` |
| | # public, read-only to the world; owner keeps full control |
| | read,list: all |
| | *: u@example.com |
| | ``` |
| | |
| | ``` |
| | # private; alice can push, bob can only read; the service can serve+write |
| | *: u@example.com |
| | read,list,create,write,delete: alice@example.com, git@cosmocr.at |
| | read,list: bob@example.com |
| | ``` |
| | |
| | ## Authenticating (clients) |
| | |
| | Credentials go over HTTPS Basic auth. There are two ways to get them. |
| | |
| | ### Passkey login via the credential helper (recommended) |
| | |
| | Install the helper and point git at it once: |
| | |
| | ``` |
| | # clone the repo somewhere: |
| | # $ git clone https://git.cosmocr.at/git@cosmocr.at/gitweb.git $REPO && cd $REPO |
| | $ make helper && cp bin/git-credential-cosmocr ~/bin/ # anywhere on $PATH |
| | $ git config --global credential.https://git.cosmocr.at.helper cosmocr |
| | ``` |
| | |
| | Now `git clone https://git.cosmocr.at/u@example.com/proj.git` opens your browser |
| | to `vault.cosmocr.at`, you approve with your passkey, and git receives a |
| | short-lived token automatically. On a headless machine (no browser) the helper |
| | prints a URL and code to approve from another device (device-code flow); force |
| | it with `COSMOCR_DEVICE=1`. |
| | |
| | The token is a `cosmocr.at` SSO token minted by vault and signed with vault's |
| | upspin key; gitweb verifies it against the upspin keyserver. Tokens live ~12h |
| | and are cached in `~/.config/cosmocr/tokens.json` (mode 0600). |
| | |
| | ### Personal access token (CI / automation) |
| | |
| | For non-interactive use, generate a token and store its SHA-256 hash in your own |
| | upspin tree: |
| | |
| | ``` |
| | tok=$(openssl rand -hex 32) |
| | printf '%s laptop-ci\n' "$(printf %s "$tok" | sha256sum | cut -d' ' -f1)" \ |
| | | upspin put u@example.com/gitweb/tokens |
| | # let the service read your token file |
| | printf 'read: git@cosmocr.at\n' | upspin put u@example.com/gitweb/Access |
| | echo "$tok" # use as the Basic-auth password |
| | ``` |
| | |
| | Then clone with the token as the password (username = your upspin user): |
| | |
| | ``` |
| | git clone https://u%40example.com:$tok@git.cosmocr.at/u@example.com/proj.git |
| | ``` |
| | |
| | Revoke by deleting the line from `u@example.com/gitweb/tokens` (effective within |
| | ~1 minute). |
| | |
| | ## Creating a repo |
| | |
| | gitweb serves existing bare repos; it does not create them. Bootstrap one, then |
| | push to it: |
| | |
| | ``` |
| | git init --bare proj.git |
| | upspin mkdir u@example.com/proj.git |
| | upspin cp -R proj.git/ u@example.com/proj.git/ |
| | printf '*: u@example.com\nread,list,create,write,delete: git@cosmocr.at\n' \ |
| | | upspin put u@example.com/proj.git/Access |
| | git remote add origin https://git.cosmocr.at/u@example.com/proj.git |
| | git push -u origin main |
| | ``` |
| | |
| | ## Markdown & diagrams |
| | |
| | READMEs and `.md` files are rendered (GitHub-flavored markdown, class-based |
| | syntax highlighting, sanitized with bluemonday). Fenced ` ```mermaid ` blocks |
| | are drawn client-side: the block becomes `<pre class="mermaid">` and a small |
| | same-origin script (`web/mermaid-init.js`) lazy-loads a **vendored** mermaid |
| | bundle (`web/mermaid.min.js`, no CDN) only on pages that contain a diagram, then |
| | renders it with mermaid's `securityLevel: "strict"`. |
| | |
| | This is why the Content-Security-Policy allows `script-src 'self'` (our own |
| | scripts only — no `'unsafe-inline'`, so markdown-injected inline script still |
| | cannot run) and `style-src 'self' 'unsafe-inline'` (mermaid injects diagram |
| | styles at render time). See `docs/ARCHITECTURE.md`, whose diagrams render when |
| | this file is browsed through gitweb itself. |
| | |
| | ## Limitations |
| | |
| | - `git clone --depth N` (shallow) is not supported by the underlying go-git |
| | server; clone fully. |
| | - `fetch` re-sends the full history (the server ignores client "have" lines); |
| | fine for clone, wasteful for incremental fetch. |
| | - Push is not atomic across refs; each ref updates independently. |
| | - A repo/push is capped at 256 MiB. |
| | |
| | ## Running |
| | |
| | - `make run-dev` — local file store (`./devdata`), no upspin/TLS, for hacking. |
| | - Production: `-upspin_config`, `-tls_cert`, `-tls_key`, listens on `:8446`. |
| - | See `DEPLOY.md`. SSO verification is enabled by `-vault_issuer` |
| - | (default `vault@cosmocr.at`); set it to `""` to accept only PATs. |
| + | See `DEPLOY.md`. SSO token verification is enabled by `-sso_issuer` |
| + | (default `vault@cosmocr.at`); set it to `""` to accept only PATs. The token's |
| + | signature is checked against the issuer's upspin keyserver public key, so |
| + | gitweb never calls the issuer at runtime. |
| + | - Browser SSO ("Sign in" for private repos): additionally set `-sso_authorize_url` |
| + | (default `https://vault.cosmocr.at/authorize`) and `-base_url` (default |
| + | `https://git.cosmocr.at`). The browser is redirected there to log in with a |
| + | passkey; the issuer returns a token in the callback URL fragment, which gitweb |
| + | verifies and stores in an HttpOnly session cookie. The issuer must allow-list |
| + | `<base_url>/_auth/callback` (for vault: `-sso_redirect gitweb=…/_auth/callback`). |
| | |
| | ## Layout |
| | |
| | - `internal/store` — read/write access to upspin (or a local dir in dev). |
| | - `internal/gitrepo` — materialize bare repos into memory, cache, push write-back. |
| | - `internal/githttp` — git smart-HTTP wire protocol over go-git sessions. |
| | - `internal/auth` — Access-file authorization; PAT and vault-token verification. |
| | - `internal/server` — routing, browse UI, git endpoints, auth gating. |
| | - `cmd/git-credential-cosmocr` — the passkey/device git credential helper. |
| internal/server/auth.go |
| | package server |
| | |
| | import ( |
| | "net/http" |
| | "strings" |
| | |
| | "gitweb/internal/auth" |
| | "gitweb/internal/store" |
| | ) |
| | |
| | // authenticate identifies the caller from HTTP Basic auth. It returns the |
| | // verified upspin user name, or "" for an anonymous request (no credentials). |
| | // |
| | // The second result is false when credentials were supplied but invalid; in |
| | // that case a 401 challenge has already been written and the caller must stop. |
| | // |
| | // The Basic-auth password is either a vault-issued SSO bearer token (from the |
| | // passkey login flow) or a personal access token. Vault tokens are self- |
| | // describing, so the username field is ignored for them; for PATs the username |
| | // is the upspin user whose token file is consulted. |
| | func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) { |
| + | // Browser session: a gitweb_session cookie carries an issuer-signed SSO |
| + | // token from the passkey login flow. It is verified statelessly (same as a |
| + | // Basic-auth token), so a browser never needs to send Basic credentials. An |
| + | // expired or tampered cookie is treated as anonymous — never a hard failure — |
| + | // and cleared, so the next request re-prompts login rather than 401-looping. |
| + | if s.vault != nil { |
| + | if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" { |
| + | if sub, err := s.vault.Verify(c.Value); err == nil { |
| + | return sub, true |
| + | } |
| + | clearCookie(w, sessionCookie) |
| + | } |
| + | } |
| + | |
| | user, pass, ok := r.BasicAuth() |
| | if !ok || pass == "" { |
| | return "", true // anonymous |
| | } |
| | |
| | if s.vault != nil && auth.LooksLikeVaultToken(pass) { |
| | sub, err := s.vault.Verify(pass) |
| | if err != nil { |
| | s.challenge(w) |
| | return "", false |
| | } |
| | return sub, true |
| | } |
| | |
| | // Personal access token: the username names the token file's owner. |
| | if !store.ValidUser(user) { |
| | s.challenge(w) |
| | return "", false |
| | } |
| | valid, err := s.pat.Verify(user, pass) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return "", false |
| | } |
| | if !valid { |
| | s.challenge(w) |
| | return "", false |
| | } |
| | return user, true |
| | } |
| | |
| | // parseUpspinPath splits a URL path "/user@domain/rest..." into the upspin user |
| | // and a cleaned remainder beginning with "/". ok is false if the user element |
| | // is missing or malformed. |
| | func parseUpspinPath(urlPath string) (user, clean string, ok bool) { |
| | raw := strings.TrimPrefix(urlPath, "/") |
| | user, rest, _ := strings.Cut(raw, "/") |
| | if !store.ValidUser(user) { |
| | return "", "", false |
| | } |
| | return user, cleanRest(rest), true |
| | } |
| internal/server/git.go |
| | package server |
| | |
| | import ( |
| | "bytes" |
| | "errors" |
| | "html/template" |
| | "net/http" |
| | "net/url" |
| | "path" |
| | "strconv" |
| | "strings" |
| | |
| | "github.com/go-git/go-git/v5" |
| | "github.com/go-git/go-git/v5/plumbing" |
| | "github.com/go-git/go-git/v5/plumbing/filemode" |
| | "github.com/go-git/go-git/v5/plumbing/object" |
| | |
| | "gitweb/internal/gitrepo" |
| | "gitweb/internal/render" |
| | ) |
| | |
| | var gitTmpl = template.Must(template.ParseFS(tmplFS, "templates/git.html")) |
| | |
| | const logLimit = 100 |
| | |
| | // serveGit handles a request that Locate resolved to a bare repo. repoPath is |
| | // the upspin repo root (e.g. "u@d.com/proj.git"); rest is the remaining URL |
| | // after it (e.g. "blob/main/src/x.go"). base is "/"+repoPath. |
| | func (s *Server) serveGit(w http.ResponseWriter, r *http.Request, repoPath, rest string) { |
| | repo, release, err := s.repos.Get(repoPath) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | defer release() |
| | base := "/" + repoPath |
| | rest = strings.Trim(rest, "/") |
| | verb, rem, _ := strings.Cut(rest, "/") |
| | |
| | switch verb { |
| | case "": |
| - | s.gitSummary(w, repo, repoPath, base) |
| + | s.gitSummary(w, r, repo, repoPath, base) |
| | case "refs": |
| - | s.gitRefs(w, repo, repoPath, base) |
| + | s.gitRefs(w, r, repo, repoPath, base) |
| | case "tree": |
| - | s.gitTree(w, repo, repoPath, base, rem) |
| + | s.gitTree(w, r, repo, repoPath, base, rem) |
| | case "blob": |
| - | s.gitBlob(w, repo, repoPath, base, rem, false, r.URL.Query()) |
| + | s.gitBlob(w, r, repo, repoPath, base, rem, false, r.URL.Query()) |
| | case "raw": |
| - | s.gitBlob(w, repo, repoPath, base, rem, true, nil) |
| + | s.gitBlob(w, r, repo, repoPath, base, rem, true, nil) |
| | case "log": |
| - | s.gitLog(w, repo, repoPath, base, rem) |
| + | s.gitLog(w, r, repo, repoPath, base, rem) |
| | case "commit": |
| - | s.gitCommit(w, repo, repoPath, base, rem) |
| + | s.gitCommit(w, r, repo, repoPath, base, rem) |
| | default: |
| | s.notFound(w) |
| | } |
| | } |
| | |
| | // --- view models --- |
| | |
| | type gitNav struct { |
| | RepoName string |
| | Base string |
| | Ref string |
| | LogURL string |
| | } |
| | |
| | func newNav(repoName, base, ref string) gitNav { |
| | n := gitNav{RepoName: repoName, Base: base, Ref: ref} |
| | if ref != "" { |
| | n.LogURL = base + "/log/" + escapePath(ref) |
| | } |
| | return n |
| | } |
| | |
| | type treeEntry struct { |
| | Name string |
| | IsDir bool |
| | URL string |
| | Size string |
| | } |
| | |
| | type crumb struct { |
| | Name string |
| | URL string // empty for the last crumb |
| | } |
| | |
| | type refLink struct { |
| | Name string |
| | Short string |
| | URL string |
| | LogURL string |
| | } |
| | |
| | // --- summary --- |
| | |
| - | func (s *Server) gitSummary(w http.ResponseWriter, repo *git.Repository, repoName, base string) { |
| + | func (s *Server) gitSummary(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base string) { |
| | hash, ref, _, err := gitrepo.ResolveRef(repo, "") |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | nav := newNav(repoName, base, ref) |
| | |
| | tree, err := treeAt(repo, hash, "") |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | entries := treeEntries(repo, tree, base, ref, "") |
| | |
| | branches, tags, err := gitrepo.Refs(repo) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | |
| | var readme template.HTML |
| | if name := findReadme(tree); name != "" { |
| | if f, err := tree.File(name); err == nil { |
| | if data, err := f.Contents(); err == nil { |
| | if h, err := s.renderer.ToHTML([]byte(data)); err == nil { |
| | readme = rebaseRepoHTML(template.HTML(h), base, ref, path.Dir(name)) |
| | } |
| | } |
| | } |
| | } |
| | |
| - | s.renderGit(w, repoName, "summary", struct { |
| + | s.renderGit(w, r, repoName, "summary", struct { |
| | Nav gitNav |
| | Branches []refLink |
| | Tags []refLink |
| | Entries []treeEntry |
| | Readme template.HTML |
| | }{ |
| | Nav: nav, |
| | Branches: refLinks(branches, base, "tree"), |
| | Tags: refLinks(tags, base, "tree"), |
| | Entries: entries, |
| | Readme: readme, |
| | }) |
| | } |
| | |
| | // --- tree --- |
| | |
| - | func (s *Server) gitTree(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) { |
| + | func (s *Server) gitTree(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base, rem string) { |
| | hash, ref, sub, err := gitrepo.ResolveRef(repo, rem) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | tree, err := treeAt(repo, hash, sub) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | nav := newNav(repoName, base, ref) |
| | title := repoName |
| | if sub != "" { |
| | title = repoName + "/" + sub |
| | } |
| - | s.renderGit(w, repoName, "tree", struct { |
| + | s.renderGit(w, r, repoName, "tree", struct { |
| | Nav gitNav |
| | Title string |
| | Crumbs []crumb |
| | Entries []treeEntry |
| | }{ |
| | Nav: nav, |
| | Title: title, |
| | Crumbs: buildCrumbs(base, ref, sub, true), |
| | Entries: treeEntries(repo, tree, base, ref, sub), |
| | }) |
| | } |
| | |
| | // --- blob / raw --- |
| | |
| - | func (s *Server) gitBlob(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string, raw bool, q url.Values) { |
| + | func (s *Server) gitBlob(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base, rem string, raw bool, q url.Values) { |
| | hash, ref, sub, err := gitrepo.ResolveRef(repo, rem) |
| | if err != nil || sub == "" { |
| | s.notFound(w) |
| | return |
| | } |
| | if isAccessFile(path.Base(sub)) { |
| | s.notFound(w) // never render an upspin ACL, blob or raw |
| | return |
| | } |
| | tree, err := treeAt(repo, hash, "") |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | file, err := tree.File(sub) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | data, err := file.Contents() |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | content := []byte(data) |
| | |
| | if raw { |
| | ctype := http.DetectContentType(content) |
| | w.Header().Set("Content-Type", ctype) |
| | w.Write(content) |
| | return |
| | } |
| | |
| | rawURL := base + "/raw/" + escapePath(ref) + "/" + escapePath(sub) |
| | binary := render.IsBinary(content) |
| | name := path.Base(sub) |
| | force := q.Get("force") == "1" |
| | selected := s.renderers.Select(name, content, q.Get("render"), force) |
| | |
| | var body template.HTML |
| | var choices []rendererChoice |
| | var forceURL string |
| | if !binary { |
| | body, err = selected.Render(name, content) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | if selected.ID == "markdown" { |
| | // Rewrite relative links/images so they resolve to the file's |
| | // directory in the repo rather than the current URL. |
| | body = rebaseRepoHTML(body, base, ref, path.Dir(sub)) |
| | } |
| | blobURL := base + "/blob/" + escapePath(ref) + "/" + escapePath(sub) |
| | choices, forceURL = renderControls(s.renderers, blobURL, name, content, selected.ID, force) |
| | } |
| | |
| - | s.renderGit(w, repoName, "blob", blobView{ |
| + | s.renderGit(w, r, repoName, "blob", blobView{ |
| | Nav: newNav(repoName, base, ref), |
| | Crumbs: buildCrumbs(base, ref, sub, true), |
| | Path: sub, |
| | Size: humanSize(file.Size), |
| | RawURL: rawURL, |
| | Binary: binary, |
| | Body: body, |
| | Renderers: choices, |
| | Force: force, |
| | ForceURL: forceURL, |
| | }) |
| | } |
| | |
| | // blobView is the file-view model, including the renderer control bar. |
| | type blobView struct { |
| | Nav gitNav |
| | Crumbs []crumb |
| | Path string |
| | Size string |
| | RawURL string |
| | Binary bool |
| | Body template.HTML |
| | Renderers []rendererChoice |
| | Force bool |
| | ForceURL string |
| | } |
| | |
| | // rendererChoice is one selectable renderer link in the control bar. |
| | type rendererChoice struct { |
| | Label string |
| | URL string |
| | Active bool |
| | } |
| | |
| | // renderControls builds the renderer switch links and the force-toggle link for |
| | // a blob. With force on, every registered renderer is offered; otherwise only |
| | // the eligible ones. The default renderer's link carries no ?render= so its URL |
| | // stays clean. |
| | func renderControls(reg *render.Registry, blobURL, name string, content []byte, selectedID string, force bool) ([]rendererChoice, string) { |
| | mods := reg.Eligible(name, content) |
| | if force { |
| | mods = reg.All() |
| | } |
| | defID := reg.Default().ID |
| | |
| | choices := make([]rendererChoice, 0, len(mods)) |
| | for _, m := range mods { |
| | choices = append(choices, rendererChoice{ |
| | Label: m.Label, |
| | URL: blobURL + blobQuery(m.ID, defID, force), |
| | Active: m.ID == selectedID, |
| | }) |
| | } |
| | |
| | var forceURL string |
| | if force { |
| | // Turning force off: keep the selection only if it stays eligible. |
| | keep := selectedID |
| | if _, ok := find(reg.Eligible(name, content), keep); !ok { |
| | keep = defID |
| | } |
| | forceURL = blobURL + blobQuery(keep, defID, false) |
| | } else { |
| | forceURL = blobURL + blobQuery(selectedID, defID, true) |
| | } |
| | return choices, forceURL |
| | } |
| | |
| | // blobQuery builds the query string for a blob link with the given renderer and |
| | // force state. The default renderer is encoded as no ?render= param. |
| | func blobQuery(id, defID string, force bool) string { |
| | v := url.Values{} |
| | if id != "" && id != defID { |
| | v.Set("render", id) |
| | } |
| | if force { |
| | v.Set("force", "1") |
| | } |
| | if len(v) == 0 { |
| | return "" |
| | } |
| | return "?" + v.Encode() |
| | } |
| | |
| | func find(mods []render.Module, id string) (render.Module, bool) { |
| | for _, m := range mods { |
| | if m.ID == id { |
| | return m, true |
| | } |
| | } |
| | return render.Module{}, false |
| | } |
| | |
| | // --- log --- |
| | |
| - | func (s *Server) gitLog(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) { |
| + | func (s *Server) gitLog(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base, rem string) { |
| | hash, ref, _, err := gitrepo.ResolveRef(repo, rem) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | iter, err := repo.Log(&git.LogOptions{From: hash}) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | type commitRow struct { |
| | Subject, Author, When, Short, URL string |
| | } |
| | var commits []commitRow |
| | _ = iter.ForEach(func(c *object.Commit) error { |
| | if len(commits) >= logLimit { |
| | return errStopIter |
| | } |
| | commits = append(commits, commitRow{ |
| | Subject: subjectOf(c.Message), |
| | Author: c.Author.Name, |
| | When: c.Author.When.Format("2006-01-02"), |
| | Short: c.Hash.String()[:8], |
| | URL: base + "/commit/" + c.Hash.String(), |
| | }) |
| | return nil |
| | }) |
| - | s.renderGit(w, repoName, "log", struct { |
| + | s.renderGit(w, r, repoName, "log", struct { |
| | Nav gitNav |
| | Commits interface{} |
| | }{ |
| | Nav: newNav(repoName, base, ref), |
| | Commits: commits, |
| | }) |
| | } |
| | |
| | // --- commit --- |
| | |
| - | func (s *Server) gitCommit(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) { |
| + | func (s *Server) gitCommit(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base, rem string) { |
| | rem = strings.Trim(rem, "/") |
| | h, err := repo.ResolveRevision(plumbing.Revision(rem)) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | c, err := repo.CommitObject(*h) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | |
| | var fromTree *object.Tree |
| | var parents []struct{ Short, URL string } |
| | if c.NumParents() > 0 { |
| | p, err := c.Parent(0) |
| | if err == nil { |
| | fromTree, _ = p.Tree() |
| | parents = append(parents, struct{ Short, URL string }{ |
| | Short: p.Hash.String()[:8], |
| | URL: base + "/commit/" + p.Hash.String(), |
| | }) |
| | } |
| | } |
| | toTree, err := c.Tree() |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | changes, err := object.DiffTree(fromTree, toTree) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | patch, err := changes.Patch() |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | |
| - | s.renderGit(w, repoName, "commit", struct { |
| + | s.renderGit(w, r, repoName, "commit", struct { |
| | Nav gitNav |
| | Subject string |
| | Body string |
| | Hash string |
| | Author string |
| | When string |
| | Parents []struct{ Short, URL string } |
| | Diff template.HTML |
| | }{ |
| | Nav: newNav(repoName, base, ""), |
| | Subject: subjectOf(c.Message), |
| | Body: bodyOf(c.Message), |
| | Hash: c.Hash.String(), |
| | Author: c.Author.Name + " <" + c.Author.Email + ">", |
| | When: c.Author.When.Format("2006-01-02 15:04 -0700"), |
| | Parents: parents, |
| | Diff: render.Diff(patch), |
| | }) |
| | } |
| | |
| | // --- refs --- |
| | |
| - | func (s *Server) gitRefs(w http.ResponseWriter, repo *git.Repository, repoName, base string) { |
| + | func (s *Server) gitRefs(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base string) { |
| | branches, tags, err := gitrepo.Refs(repo) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| - | s.renderGit(w, repoName, "refs", struct { |
| + | s.renderGit(w, r, repoName, "refs", struct { |
| | Nav gitNav |
| | Branches []refLink |
| | Tags []refLink |
| | }{ |
| | Nav: newNav(repoName, base, ""), |
| | Branches: refLinks(branches, base, "tree"), |
| | Tags: refLinks(tags, base, "tree"), |
| | }) |
| | } |
| | |
| | // --- helpers --- |
| | |
| | // errStopIter is a sentinel to stop a commit ForEach early. |
| | var errStopIter = errors.New("stop") |
| | |
| - | func (s *Server) renderGit(w http.ResponseWriter, repoName, name string, data interface{}) { |
| + | func (s *Server) renderGit(w http.ResponseWriter, r *http.Request, repoName, name string, data interface{}) { |
| | var body bytes.Buffer |
| | if err := gitTmpl.ExecuteTemplate(&body, name, data); err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| - | s.renderPage(w, repoName, template.HTML(body.String())) |
| + | s.renderPage(w, r, repoName, template.HTML(body.String())) |
| | } |
| | |
| | // treeAt returns the tree at sub within the commit identified by hash. |
| | func treeAt(repo *git.Repository, hash plumbing.Hash, sub string) (*object.Tree, error) { |
| | c, err := repo.CommitObject(hash) |
| | if err != nil { |
| | return nil, err |
| | } |
| | tree, err := c.Tree() |
| | if err != nil { |
| | return nil, err |
| | } |
| | if sub == "" { |
| | return tree, nil |
| | } |
| | return tree.Tree(sub) |
| | } |
| | |
| | func treeEntries(repo *git.Repository, tree *object.Tree, base, ref, sub string) []treeEntry { |
| | out := make([]treeEntry, 0, len(tree.Entries)) |
| | for _, e := range tree.Entries { |
| | if isAccessFile(e.Name) { |
| | continue // don't list an upspin ACL committed into the tree |
| | } |
| | child := e.Name |
| | if sub != "" { |
| | child = sub + "/" + e.Name |
| | } |
| | isDir := e.Mode == filemode.Dir |
| | te := treeEntry{Name: e.Name, IsDir: isDir} |
| | if isDir { |
| | te.URL = base + "/tree/" + escapePath(ref) + "/" + escapePath(child) |
| | } else { |
| | te.URL = base + "/blob/" + escapePath(ref) + "/" + escapePath(child) |
| | if b, err := repo.BlobObject(e.Hash); err == nil { |
| | te.Size = humanSize(b.Size) |
| | } |
| | } |
| | out = append(out, te) |
| | } |
| | // Directories first, then by name. |
| | sortTreeEntries(out) |
| | return out |
| | } |
| | |
| | func sortTreeEntries(es []treeEntry) { |
| | // Simple insertion sort keeps dirs first then name (lists are small). |
| | for i := 1; i < len(es); i++ { |
| | for j := i; j > 0 && less(es[j], es[j-1]); j-- { |
| | es[j], es[j-1] = es[j-1], es[j] |
| | } |
| | } |
| | } |
| | |
| | func less(a, b treeEntry) bool { |
| | if a.IsDir != b.IsDir { |
| | return a.IsDir |
| | } |
| | return a.Name < b.Name |
| | } |
| | |
| | func buildCrumbs(base, ref, sub string, dirLinks bool) []crumb { |
| | crumbs := []crumb{{Name: ref, URL: base + "/tree/" + escapePath(ref)}} |
| | if sub == "" { |
| | return crumbs |
| | } |
| | parts := strings.Split(sub, "/") |
| | acc := "" |
| | for i, p := range parts { |
| | if acc == "" { |
| | acc = p |
| | } else { |
| | acc = acc + "/" + p |
| | } |
| | c := crumb{Name: p} |
| | last := i == len(parts)-1 |
| | if !last || dirLinks { |
| | c.URL = base + "/tree/" + escapePath(ref) + "/" + escapePath(acc) |
| | } |
| | if last { |
| | c.URL = "" // current location, not a link |
| | } |
| | crumbs = append(crumbs, c) |
| | } |
| | return crumbs |
| | } |
| | |
| | func refLinks(refs []gitrepo.RefName, base, verb string) []refLink { |
| | out := make([]refLink, 0, len(refs)) |
| | for _, r := range refs { |
| | out = append(out, refLink{ |
| | Name: r.Name, |
| | Short: r.Hash.String()[:8], |
| | URL: base + "/" + verb + "/" + escapePath(r.Name), |
| | LogURL: base + "/log/" + escapePath(r.Name), |
| | }) |
| | } |
| | return out |
| | } |
| | |
| | func findReadme(tree *object.Tree) string { |
| | for _, name := range []string{"README.md", "readme.md", "README.markdown", "README"} { |
| | if _, err := tree.File(name); err == nil { |
| | return name |
| | } |
| | } |
| | return "" |
| | } |
| | |
| | // rebaseRepoHTML rewrites relative links/images in rendered repo markdown so |
| | // they resolve to the source file's location in the repo (blob view for links, |
| | // raw for images) at ref, given the markdown file's directory dir within the |
| | // tree ("" or "." for the repo root). |
| | func rebaseRepoHTML(h template.HTML, base, ref, dir string) template.HTML { |
| | prefix := escapePath(ref) + "/" |
| | if dir != "" && dir != "." { |
| | prefix += escapePath(dir) + "/" |
| | } |
| | return render.Rebase(h, base+"/blob/"+prefix, base+"/raw/"+prefix) |
| | } |
| | |
| | // escapePath URL-escapes each slash-separated segment, preserving slashes. |
| | func escapePath(p string) string { |
| | parts := strings.Split(p, "/") |
| | for i, s := range parts { |
| | parts[i] = url.PathEscape(s) |
| | } |
| | return strings.Join(parts, "/") |
| | } |
| | |
| | func subjectOf(msg string) string { |
| | if i := strings.IndexByte(msg, '\n'); i >= 0 { |
| | return strings.TrimSpace(msg[:i]) |
| | } |
| | return strings.TrimSpace(msg) |
| | } |
| | |
| | func bodyOf(msg string) string { |
| | if i := strings.IndexByte(msg, '\n'); i >= 0 { |
| | return strings.TrimSpace(msg[i+1:]) |
| | } |
| | return "" |
| | } |
| | |
| | func humanSize(n int64) string { |
| | const unit = 1024 |
| | if n < unit { |
| | return strconv.FormatInt(n, 10) + " B" |
| | } |
| | div, exp := int64(unit), 0 |
| | for m := n / unit; m >= unit; m /= unit { |
| | div *= unit |
| | exp++ |
| | } |
| | return strconv.FormatFloat(float64(n)/float64(div), 'f', 1, 64) + " " + string("KMGT"[exp]) + "iB" |
| | } |
| internal/server/githttp.go |
| | package server |
| | |
| | import ( |
| | "log" |
| | "net/http" |
| | "path" |
| | "strings" |
| | |
| | "gitweb/internal/githttp" |
| | "gitweb/internal/gitrepo" |
| | ) |
| | |
| | // gitKind identifies which git smart-HTTP endpoint a request hit. |
| | type gitKind int |
| | |
| | const ( |
| | gitInfoRefs gitKind = iota |
| | gitUploadPack |
| | gitReceivePack |
| | ) |
| | |
| | // gitReq describes a matched git smart-HTTP request. |
| | type gitReq struct { |
| | kind gitKind |
| | service githttp.Service // the effective service (upload-pack or receive-pack) |
| | urlPath string // URL path with the git suffix stripped, e.g. "/u@d.com/x.git" |
| | } |
| | |
| | // gitEndpoint recognizes the three git smart-HTTP endpoints by method and path |
| | // suffix. It returns ok=false for ordinary browse requests. |
| | func gitEndpoint(r *http.Request) (gitReq, bool) { |
| | p := r.URL.Path |
| | switch { |
| | case r.Method == http.MethodGet && strings.HasSuffix(p, "/info/refs"): |
| | svc := r.URL.Query().Get("service") |
| | switch githttp.Service(svc) { |
| | case githttp.UploadPack, githttp.ReceivePack: |
| | return gitReq{gitInfoRefs, githttp.Service(svc), strings.TrimSuffix(p, "/info/refs")}, true |
| | } |
| | // info/refs without a smart service param: fall through to browse (this |
| | // also lets someone view a repo's literal info/refs file). |
| | return gitReq{}, false |
| | case r.Method == http.MethodPost && strings.HasSuffix(p, "/git-upload-pack"): |
| | return gitReq{gitUploadPack, githttp.UploadPack, strings.TrimSuffix(p, "/git-upload-pack")}, true |
| | case r.Method == http.MethodPost && strings.HasSuffix(p, "/git-receive-pack"): |
| | return gitReq{gitReceivePack, githttp.ReceivePack, strings.TrimSuffix(p, "/git-receive-pack")}, true |
| | } |
| | return gitReq{}, false |
| | } |
| | |
| | // handleGitHTTP serves a git smart-HTTP request: authenticate, resolve the repo, |
| | // authorize (read for clone/fetch, write for push), then run the protocol. |
| | func (s *Server) handleGitHTTP(w http.ResponseWriter, r *http.Request, ep gitReq) { |
| | user, clean, ok := parseUpspinPath(ep.urlPath) |
| | if !ok { |
| | s.notFound(w) |
| | return |
| | } |
| | |
| | authUser, ok := s.authenticate(w, r) |
| | if !ok { |
| | return // 401 already written |
| | } |
| | |
| | repoPath, rest, isRepo, err := gitrepo.Locate(s.store, user, clean) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | // Endpoints live only at a repo root. |
| | if !isRepo || rest != "" { |
| - | s.denyRead(w, authUser) |
| + | s.denyRead(w, r, authUser) |
| | return |
| | } |
| | |
| | writing := ep.service == githttp.ReceivePack |
| | if writing { |
| | // Push always requires an authenticated, authorized writer. |
| | if authUser == "" { |
| | s.challenge(w) |
| | return |
| | } |
| | can, err := s.authz.CanWrite(authUser, repoPath) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | if !can { |
| | // The caller is known; distinguish "can't read" (hide it) from |
| | // "can read but not write" (explicit 403). |
| | readable, rerr := s.authz.CanRead(authUser, repoPath) |
| | if rerr != nil { |
| | s.serverErr(w, rerr) |
| | return |
| | } |
| | if !readable { |
| | s.notFound(w) |
| | return |
| | } |
| | http.Error(w, "403 forbidden: no write access", http.StatusForbidden) |
| | return |
| | } |
| | } else { |
| | can, err := s.authz.CanRead(authUser, repoPath) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | if !can { |
| - | s.denyRead(w, authUser) |
| + | s.denyRead(w, r, authUser) |
| | return |
| | } |
| | } |
| | |
| | switch ep.kind { |
| | case gitInfoRefs: |
| | storer, release, err := s.repos.GetStorer(repoPath) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | defer release() |
| | if err := githttp.InfoRefs(w, storer, ep.service); err != nil { |
| | log.Printf("gitweb: info/refs %s: %v", repoPath, err) |
| | } |
| | case gitUploadPack: |
| | // upload-pack streams the packfile from the storer for the whole |
| | // response; the deferred release keeps the repo's on-disk backing |
| | // alive until ServeUploadPack returns. |
| | storer, release, err := s.repos.GetStorer(repoPath) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | defer release() |
| | if err := githttp.ServeUploadPack(w, r, storer); err != nil { |
| | log.Printf("gitweb: upload-pack %s: %v", repoPath, err) |
| | } |
| | case gitReceivePack: |
| | req, err := githttp.DecodeReceivePack(r) |
| | if err != nil { |
| | http.Error(w, "400 bad request", http.StatusBadRequest) |
| | return |
| | } |
| | rs, err := s.pusher.ReceivePack(r.Context(), repoPath, req) |
| | if err != nil { |
| | log.Printf("gitweb: receive-pack %s: %v", repoPath, err) |
| | if rs == nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | // rs carries per-ref failure detail; report it to the client. |
| | } |
| | if err := githttp.WriteReceivePackResult(w, rs); err != nil { |
| | log.Printf("gitweb: receive-pack result %s: %v", repoPath, err) |
| | } |
| | } |
| | } |
| | |
| | // cleanRest cleans a URL remainder into an upspin path suffix beginning with |
| | // "/". path.Clean collapses any ".." so it cannot escape the user's tree. |
| | func cleanRest(rest string) string { |
| | return path.Clean("/" + rest) |
| | } |
| internal/server/server.go |
| | // Package server is the HTTP layer of the git browser. The upspin path is the |
| | // URL path: GET /<user@domain>/<path...>. If the path resolves inside a bare |
| | // git repository the request is handled by the git browser (summary, tree, |
| | // blob, log, commit, refs); otherwise it falls back to plain upspin namespace |
| | // browsing (directory listings and raw files) so users can drill down to repos. |
| | package server |
| | |
| | import ( |
| | "bytes" |
| + | "context" |
| | "embed" |
| | "errors" |
| | "html/template" |
| | "io" |
| | "log" |
| | "mime" |
| | "net/http" |
| + | "net/url" |
| | "os" |
| | "path" |
| | "strings" |
| | |
| | "gitweb/internal/auth" |
| | "gitweb/internal/gitrepo" |
| | "gitweb/internal/render" |
| | "gitweb/internal/store" |
| | ) |
| | |
| | //go:embed templates/*.html |
| | var tmplFS embed.FS |
| | |
| | var pageTmpl = template.Must(template.ParseFS(tmplFS, "templates/page.html")) |
| | |
| | // Config configures a Server. |
| | type Config struct { |
| | Store store.WriteStore // read for browse/clone; write for push |
| | Web http.FileSystem // static assets (style.css, landing index.html) |
| | Realm string // HTTP Basic auth realm; defaults to "gitweb" |
| - | // Vault, if non-nil, verifies vault-issued SSO bearer tokens (the passkey |
| + | // Vault, if non-nil, verifies issuer-signed SSO bearer tokens (the passkey |
| | // login path). When nil, only PATs are accepted. |
| | Vault *auth.VaultVerifier |
| + | // SSOAuthorizeURL is the issuer's browser login endpoint (e.g. |
| + | // https://vault.cosmocr.at/authorize). When set (with Vault non-nil), the |
| + | // browser "Sign in" flow at /_auth/login is enabled. Empty disables it. |
| + | SSOAuthorizeURL string |
| + | // SSOAudience is the audience requested in the browser SSO flow (e.g. |
| + | // "gitweb"); it must match the audience Vault accepts. |
| + | SSOAudience string |
| + | // BaseURL is this service's public origin (e.g. https://git.cosmocr.at), |
| + | // used to build the SSO redirect_uri. Not derived from the Host header, so a |
| + | // spoofed Host cannot redirect the token elsewhere. |
| + | BaseURL string |
| | // CacheDir is where repos are materialized on disk. Its contents are |
| | // deleted at startup. Empty means an ephemeral directory under the system |
| | // temp dir (dev and tests). |
| | CacheDir string |
| | // CacheBudgetBytes and MaxRepoBytes bound the repo cache; 0 means the |
| | // gitrepo defaults (512 MiB / 256 MiB). |
| | CacheBudgetBytes int64 |
| | MaxRepoBytes int64 |
| | } |
| | |
| | // Server serves upspin-stored git repositories over HTTP: a browse UI, plus the |
| | // git smart-HTTP protocol for clone and push. Access is gated by upspin Access |
| | // files; private repos require Basic-auth credentials (a vault SSO token or a |
| | // personal access token). |
| | type Server struct { |
| - | store store.Store |
| - | web http.FileSystem |
| - | renderer *render.Renderer |
| - | renderers *render.Registry |
| - | repos *gitrepo.Cache |
| - | authz *auth.Authorizer |
| - | pat *auth.PATVerifier |
| - | vault *auth.VaultVerifier // may be nil |
| - | pusher *gitrepo.Pusher |
| - | realm string |
| - | chroma []byte // generated highlighter stylesheet |
| - | landing []byte // /index.html, served at "/" |
| + | store store.Store |
| + | web http.FileSystem |
| + | renderer *render.Renderer |
| + | renderers *render.Registry |
| + | repos *gitrepo.Cache |
| + | authz *auth.Authorizer |
| + | pat *auth.PATVerifier |
| + | vault *auth.VaultVerifier // may be nil |
| + | pusher *gitrepo.Pusher |
| + | realm string |
| + | ssoAuthURL string // issuer login endpoint; "" disables browser SSO |
| + | ssoAud string // audience requested in the browser SSO flow |
| + | baseURL string // this service's public origin, for redirect_uri |
| + | chroma []byte // generated highlighter stylesheet |
| + | landing []byte // /index.html, served at "/" |
| | } |
| | |
| | // New builds a Server. |
| | func New(c Config) (*Server, error) { |
| | css, err := render.CSS() |
| | if err != nil { |
| | return nil, err |
| | } |
| | rnd := render.New() |
| | cacheDir := c.CacheDir |
| | if cacheDir == "" { |
| | cacheDir, err = os.MkdirTemp("", "gitweb-cache-") |
| | if err != nil { |
| | return nil, err |
| | } |
| | } |
| | repos, err := gitrepo.NewCache(c.Store, gitrepo.CacheOptions{ |
| | Dir: cacheDir, |
| | BudgetBytes: c.CacheBudgetBytes, |
| | MaxRepoBytes: c.MaxRepoBytes, |
| | }) |
| | if err != nil { |
| | return nil, err |
| | } |
| | realm := c.Realm |
| | if realm == "" { |
| | realm = "gitweb" |
| | } |
| | s := &Server{ |
| - | store: c.Store, |
| - | web: c.Web, |
| - | renderer: rnd, |
| - | renderers: render.NewRegistry(rnd), |
| - | repos: repos, |
| - | authz: auth.NewAuthorizer(c.Store), |
| - | pat: auth.NewPATVerifier(c.Store), |
| - | vault: c.Vault, |
| - | pusher: gitrepo.NewPusher(c.Store, repos), |
| - | realm: realm, |
| - | chroma: css, |
| + | store: c.Store, |
| + | web: c.Web, |
| + | renderer: rnd, |
| + | renderers: render.NewRegistry(rnd), |
| + | repos: repos, |
| + | authz: auth.NewAuthorizer(c.Store), |
| + | pat: auth.NewPATVerifier(c.Store), |
| + | vault: c.Vault, |
| + | pusher: gitrepo.NewPusher(c.Store, repos), |
| + | realm: realm, |
| + | chroma: css, |
| + | ssoAuthURL: c.SSOAuthorizeURL, |
| + | ssoAud: c.SSOAudience, |
| + | baseURL: strings.TrimRight(c.BaseURL, "/"), |
| | } |
| | if f, err := c.Web.Open("index.html"); err == nil { |
| | s.landing, _ = io.ReadAll(f) |
| | f.Close() |
| | } |
| | return s, nil |
| | } |
| | |
| | // Handler returns the HTTP handler. |
| | func (s *Server) Handler() http.Handler { |
| | mux := http.NewServeMux() |
| | mux.HandleFunc("GET /_assets/chroma.css", s.handleChromaCSS) |
| | mux.Handle("GET /_assets/", http.StripPrefix("/_assets/", http.FileServer(s.web))) |
| + | // Browser SSO: /_auth/* cannot collide with an upspin path (users are |
| + | // user@domain). Enabled only when an issuer login endpoint is configured. |
| + | if s.ssoEnabled() { |
| + | mux.HandleFunc("GET /_auth/login", s.handleLogin) |
| + | mux.HandleFunc("GET /_auth/callback", s.handleCallback) |
| + | mux.HandleFunc("POST /_auth/session", s.handleSession) |
| + | mux.HandleFunc("GET /_auth/logout", s.handleLogout) |
| + | } |
| | // The catch-all accepts every method: git smart-HTTP needs POST, and |
| | // ServeMux cannot match the "/info/refs", "/git-upload-pack", |
| | // "/git-receive-pack" suffixes, so handleRoot dispatches by suffix itself. |
| | mux.HandleFunc("/", s.handleRoot) |
| | return securityHeaders(mux) |
| | } |
| | |
| | // handleRoot dispatches between the git smart-HTTP endpoints and the browse UI. |
| | func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { |
| | if ep, ok := gitEndpoint(r); ok { |
| | s.handleGitHTTP(w, r, ep) |
| | return |
| | } |
| | if r.Method != http.MethodGet { |
| | http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed) |
| | return |
| | } |
| | s.handlePath(w, r) |
| | } |
| | |
| | func (s *Server) handleChromaCSS(w http.ResponseWriter, r *http.Request) { |
| | w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| | w.Write(s.chroma) |
| | } |
| | |
| | // handlePath dispatches a request whose URL path is an upspin path. |
| | func (s *Server) handlePath(w http.ResponseWriter, r *http.Request) { |
| | raw := strings.TrimPrefix(r.URL.Path, "/") |
| | if raw == "" { |
| | s.serveLanding(w) |
| | return |
| | } |
| | |
| | // First element is the upspin user; validate before building any path. |
| | user, rest, _ := strings.Cut(raw, "/") |
| | if !store.ValidUser(user) { |
| | s.notFound(w) |
| | return |
| | } |
| | // Clean the remainder; path.Clean collapses any ".." so it cannot escape |
| | // the user's tree. clean always begins with "/". |
| | clean := path.Clean("/" + rest) |
| | upath := user + clean // e.g. "u@d.com/proj.git"; root is "u@d.com/" |
| | |
| | // Identify the caller (anonymous if no/blank credentials) before deciding |
| | // what they may see. |
| | authUser, ok := s.authenticate(w, r) |
| | if !ok { |
| | return // 401 already written |
| | } |
| + | // Carry the viewer identity so page rendering can show sign-in/out state. |
| + | r = r.WithContext(context.WithValue(r.Context(), viewerKey{}, authUser)) |
| | |
| | // If the path lies inside a bare git repo, hand off to the git browser; |
| | // otherwise fall back to plain upspin namespace browsing. |
| | repoPath, gitRest, isRepo, err := gitrepo.Locate(s.store, user, clean) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | |
| | // Gate the browse UI by the same public/private rule as clone: anonymous |
| | // users see only world-readable paths; others must be granted read. |
| | resource := upath |
| | if isRepo { |
| | resource = repoPath |
| | } |
| | allowed, err := s.authz.CanRead(authUser, resource) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | if !allowed { |
| - | s.denyRead(w, authUser) |
| + | s.denyRead(w, r, authUser) |
| | return |
| | } |
| | |
| | if isRepo { |
| | s.serveGit(w, r, repoPath, gitRest) |
| | return |
| | } |
| | |
| | isDir, err := s.store.Stat(upath) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | |
| | if isDir { |
| | s.serveDir(w, r, user, clean, upath) |
| | return |
| | } |
| - | s.serveFile(w, upath, clean) |
| + | s.serveFile(w, r, upath, clean) |
| | } |
| | |
| | // serveDir renders a directory: README.md/index.md if present, else a listing. |
| | func (s *Server) serveDir(w http.ResponseWriter, r *http.Request, user, clean, upath string) { |
| | // A trailing slash is required so relative links resolve against the dir. |
| | if !strings.HasSuffix(r.URL.Path, "/") { |
| | http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently) |
| | return |
| | } |
| | |
| | entries, err := s.store.List(upath) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | |
| | // Prefer an index document if the directory has one. |
| | for _, name := range []string{"index.md", "README.md", "readme.md"} { |
| | if hasEntry(entries, name) { |
| - | s.serveFile(w, strings.TrimRight(upath, "/")+"/"+name, clean) |
| + | s.serveFile(w, r, strings.TrimRight(upath, "/")+"/"+name, clean) |
| | return |
| | } |
| | } |
| | |
| | var body bytes.Buffer |
| | body.WriteString(`<h1>Index of `) |
| | body.WriteString(template.HTMLEscapeString(displayPath(user, clean))) |
| | body.WriteString("</h1>\n<ul class=\"dir\">\n") |
| | if clean != "/" { |
| | body.WriteString(`<li><a href="../">../</a></li>` + "\n") |
| | } |
| | for _, e := range entries { |
| | if isAccessFile(e.Name) { |
| | continue // never expose upspin ACLs in a listing |
| | } |
| | name := e.Name |
| | if e.IsDir { |
| | name += "/" |
| | } |
| | esc := template.HTMLEscapeString(name) |
| | body.WriteString(`<li><a href="` + esc + `">` + esc + "</a></li>\n") |
| | } |
| | body.WriteString("</ul>\n") |
| - | s.renderPage(w, displayPath(user, clean), template.HTML(body.String())) |
| + | s.renderPage(w, r, displayPath(user, clean), template.HTML(body.String())) |
| | } |
| | |
| | // serveFile renders a markdown file, or serves any other file raw. |
| - | func (s *Server) serveFile(w http.ResponseWriter, upath, clean string) { |
| + | func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, upath, clean string) { |
| | // Never serve an upspin Access file's contents (its user/group list), |
| | // even when requested directly by path. |
| | if isAccessFile(path.Base(upath)) { |
| | s.notFound(w) |
| | return |
| | } |
| | data, err := s.store.Get(upath) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | |
| | if isMarkdown(upath) { |
| | html, err := s.renderer.ToHTML(data) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| - | s.renderPage(w, path.Base(clean), template.HTML(html)) |
| + | s.renderPage(w, r, path.Base(clean), template.HTML(html)) |
| | return |
| | } |
| | |
| | // Raw asset (images referenced by markdown, etc.). |
| | ctype := mime.TypeByExtension(path.Ext(upath)) |
| | if ctype == "" { |
| | ctype = http.DetectContentType(data) |
| | } |
| | w.Header().Set("Content-Type", ctype) |
| | w.Write(data) |
| | } |
| | |
| - | func (s *Server) renderPage(w http.ResponseWriter, title string, body template.HTML) { |
| + | func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, title string, body template.HTML) { |
| | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| | err := pageTmpl.Execute(w, struct { |
| | Title string |
| | Body template.HTML |
| - | }{Title: title, Body: body}) |
| + | Nav template.HTML |
| + | }{Title: title, Body: body, Nav: s.navHTML(r)}) |
| | if err != nil { |
| | log.Printf("gitweb: template: %v", err) |
| | } |
| | } |
| | |
| + | // viewerKey stashes the authenticated viewer (or "") on the request context, so |
| + | // renderPage can show sign-in state without every render path taking it as an |
| + | // argument. |
| + | type viewerKey struct{} |
| + | |
| + | // navHTML builds the account nav shown on every rendered page: sign-out when the |
| + | // viewer is authenticated, sign-in when SSO is available, nothing otherwise. |
| + | func (s *Server) navHTML(r *http.Request) template.HTML { |
| + | if r == nil { |
| + | return "" |
| + | } |
| + | viewer, _ := r.Context().Value(viewerKey{}).(string) |
| + | if viewer != "" { |
| + | return template.HTML(`Signed in as <strong>` + template.HTMLEscapeString(viewer) + |
| + | `</strong> · <a href="/_auth/logout">Sign out</a>`) |
| + | } |
| + | if s.ssoEnabled() { |
| + | return template.HTML(`<a href="/_auth/login?next=` + |
| + | template.HTMLEscapeString(url.QueryEscape(r.URL.Path)) + `">Sign in</a>`) |
| + | } |
| + | return "" |
| + | } |
| + | |
| | func (s *Server) serveLanding(w http.ResponseWriter) { |
| | if s.landing == nil { |
| | s.notFound(w) |
| | return |
| | } |
| | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| | w.Write(s.landing) |
| | } |
| | |
| | // --- errors --- |
| | |
| | func (s *Server) storeErr(w http.ResponseWriter, err error) { |
| | if errors.Is(err, store.ErrNotFound) { |
| | s.notFound(w) |
| | return |
| | } |
| | s.serverErr(w, err) |
| | } |
| | |
| | func (s *Server) notFound(w http.ResponseWriter) { |
| | http.Error(w, "404 not found", http.StatusNotFound) |
| | } |
| | |
| | // denyRead responds to a caller who may not read the requested resource. An |
| - | // anonymous caller gets a Basic-auth challenge (so a git/browser client can |
| - | // retry with credentials); an already-authenticated caller gets a 404, which |
| - | // does not reveal whether the resource exists. |
| - | func (s *Server) denyRead(w http.ResponseWriter, authUser string) { |
| - | if authUser == "" { |
| - | s.challenge(w) |
| + | // already-authenticated caller gets a 404, which does not reveal whether the |
| + | // resource exists. An anonymous caller gets either a redirect into the browser |
| + | // SSO flow (if this looks like a browser and SSO is enabled) or a Basic-auth |
| + | // challenge (so a git/curl client can retry with credentials). |
| + | func (s *Server) denyRead(w http.ResponseWriter, r *http.Request, authUser string) { |
| + | if authUser != "" { |
| + | s.notFound(w) |
| + | return |
| + | } |
| + | if s.ssoEnabled() && wantsHTML(r) { |
| + | http.Redirect(w, r, "/_auth/login?next="+url.QueryEscape(r.URL.Path), http.StatusSeeOther) |
| | return |
| | } |
| - | s.notFound(w) |
| + | s.challenge(w) |
| + | } |
| + | |
| + | // wantsHTML reports whether the request looks like a browser navigation (as |
| + | // opposed to git/curl), so we can redirect it to the login page rather than |
| + | // issue a Basic-auth challenge it can't satisfy interactively. |
| + | func wantsHTML(r *http.Request) bool { |
| + | return strings.Contains(r.Header.Get("Accept"), "text/html") |
| | } |
| | |
| | // challenge writes a 401 with a Basic-auth challenge. The body is deliberately |
| | // vague so anonymous callers cannot distinguish "private" from "absent". |
| | func (s *Server) challenge(w http.ResponseWriter) { |
| | w.Header().Set("WWW-Authenticate", `Basic realm="`+s.realm+`", charset="UTF-8"`) |
| | http.Error(w, "not found, or authentication required", http.StatusUnauthorized) |
| | } |
| | |
| | func (s *Server) serverErr(w http.ResponseWriter, err error) { |
| | log.Printf("gitweb: 500: %v", err) |
| | http.Error(w, "500 internal server error", http.StatusInternalServerError) |
| | } |
| | |
| | // --- helpers --- |
| | |
| | func isMarkdown(p string) bool { |
| | switch strings.ToLower(path.Ext(p)) { |
| | case ".md", ".markdown": |
| | return true |
| | } |
| | return false |
| | } |
| | |
| | // isAccessFile reports whether name is an upspin Access file. gitweb never lists |
| | // or renders these: their contents enumerate the upspin users and groups granted |
| | // access, which must not be exposed to browsers. |
| | func isAccessFile(name string) bool { return name == "Access" } |
| | |
| | func hasEntry(entries []store.Entry, name string) bool { |
| | for _, e := range entries { |
| | if !e.IsDir && e.Name == name { |
| | return true |
| | } |
| | } |
| | return false |
| | } |
| | |
| | // displayPath renders the upspin path for a heading: "u@d.com/docs/". |
| | func displayPath(user, clean string) string { |
| | if clean == "/" { |
| | return user + "/" |
| | } |
| | return user + clean |
| | } |
| | |
| | func securityHeaders(h http.Handler) http.Handler { |
| | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| | w.Header().Set("X-Content-Type-Options", "nosniff") |
| | w.Header().Set("X-Frame-Options", "DENY") |
| | w.Header().Set("Referrer-Policy", "no-referrer") |
| | // script-src 'self': the only scripts are our own, same-origin mermaid |
| | // files (mermaid-init.js and the vendored bundle). No 'unsafe-inline', so |
| | // script injected via rendered markdown cannot execute. style-src allows |
| | // 'unsafe-inline' because mermaid injects diagram styles at render time; |
| | // this permits inline styles but not scripts. Markdown may embed external |
| | // https images; everything else is same-origin. |
| | w.Header().Set("Content-Security-Policy", |
| | "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+ |
| | "img-src 'self' data: https:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") |
| | h.ServeHTTP(w, r) |
| | }) |
| | } |
| added internal/server/sso.go |
| + | package server |
| + | |
| + | import ( |
| + | "crypto/rand" |
| + | "crypto/subtle" |
| + | "encoding/base64" |
| + | "encoding/json" |
| + | "net/http" |
| + | "net/url" |
| + | "strings" |
| + | "time" |
| + | ) |
| + | |
| + | // Browser single-sign-on against an external issuer (vault). gitweb never calls |
| + | // the issuer at runtime: it redirects the browser to the issuer's login page, |
| + | // receives an issuer-signed SSO token back in the callback URL *fragment*, and |
| + | // verifies it statelessly with the same VaultVerifier used for Basic-auth |
| + | // tokens (the signature roots in the issuer's upspin keyserver key). The token |
| + | // then lives in a session cookie. |
| + | // |
| + | // Flow: /_auth/login -> issuer /authorize -> /_auth/callback#token=…&state=… |
| + | // -> (page JS) POST /_auth/session -> gitweb_session cookie set. |
| + | |
| + | const ( |
| + | sessionCookie = "gitweb_session" // holds the verified SSO token |
| + | oauthCookie = "gitweb_oauth" // holds {state, next} across the redirect |
| + | oauthTTL = 10 * time.Minute |
| + | sessionTTL = 12 * time.Hour // matches the issuer token TTL; expiry is |
| + | // ultimately enforced by verifying the token, not by the cookie MaxAge. |
| + | ) |
| + | |
| + | // ssoEnabled reports whether the browser login flow is configured. |
| + | func (s *Server) ssoEnabled() bool { |
| + | return s.vault != nil && s.ssoAuthURL != "" && s.baseURL != "" |
| + | } |
| + | |
| + | // handleLogin starts the browser SSO flow: it remembers where to return to, |
| + | // then redirects to the issuer's login page with our callback as redirect_uri. |
| + | func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { |
| + | next := sanitizeNext(r.URL.Query().Get("next")) |
| + | state := randToken() |
| + | |
| + | // Remember state (CSRF) and the post-login destination in a short-lived, |
| + | // same-origin cookie. SameSite=Lax so it survives the top-level GET |
| + | // navigation back from the issuer. |
| + | setCookie(w, oauthCookie, url.Values{"state": {state}, "next": {next}}.Encode(), oauthTTL) |
| + | |
| + | q := url.Values{ |
| + | "redirect_uri": {s.baseURL + "/_auth/callback"}, |
| + | "state": {state}, |
| + | "aud": {s.ssoAud}, |
| + | } |
| + | http.Redirect(w, r, s.ssoAuthURL+"?"+q.Encode(), http.StatusSeeOther) |
| + | } |
| + | |
| + | // handleCallback serves the page that the issuer redirects back to. The SSO |
| + | // token arrives in the URL fragment (never sent to a server), so a tiny |
| + | // same-origin script reads it and POSTs it to /_auth/session. Served as HTML |
| + | // referencing an external script because the CSP forbids inline scripts. |
| + | func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { |
| + | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + | w.Write([]byte(`<!doctype html> |
| + | <html lang="en"><head><meta charset="utf-8"><title>Signing in…</title> |
| + | <link rel="stylesheet" href="/_assets/style.css"></head> |
| + | <body><main><p id="msg">Completing sign-in…</p></main> |
| + | <script src="/_assets/auth-callback.js"></script></body></html>`)) |
| + | } |
| + | |
| + | // handleSession consumes the token the callback page extracted from the |
| + | // fragment. It checks the CSRF state against the oauth cookie, verifies the |
| + | // token, and on success installs the session cookie. Body: {"token","state"}. |
| + | func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { |
| + | var body struct { |
| + | Token string `json:"token"` |
| + | State string `json:"state"` |
| + | } |
| + | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&body); err != nil { |
| + | http.Error(w, "bad request", http.StatusBadRequest) |
| + | return |
| + | } |
| + | |
| + | c, err := r.Cookie(oauthCookie) |
| + | if err != nil { |
| + | http.Error(w, "no login in progress", http.StatusBadRequest) |
| + | return |
| + | } |
| + | saved, _ := url.ParseQuery(c.Value) |
| + | clearCookie(w, oauthCookie) // one-shot regardless of outcome |
| + | wantState := saved.Get("state") |
| + | if wantState == "" || subtle.ConstantTimeCompare([]byte(wantState), []byte(body.State)) != 1 { |
| + | http.Error(w, "state mismatch", http.StatusBadRequest) |
| + | return |
| + | } |
| + | |
| + | if _, err := s.vault.Verify(body.Token); err != nil { |
| + | http.Error(w, "invalid token", http.StatusUnauthorized) |
| + | return |
| + | } |
| + | setCookie(w, sessionCookie, body.Token, sessionTTL) |
| + | writeJSON(w, map[string]string{"next": sanitizeNext(saved.Get("next"))}) |
| + | } |
| + | |
| + | // handleLogout clears the session cookie and returns to the site root. It is a |
| + | // plain GET link: gitweb's CSP forbids form submissions (form-action 'none'), |
| + | // and forcing a logout is not a meaningful attack. |
| + | func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { |
| + | clearCookie(w, sessionCookie) |
| + | http.Redirect(w, r, "/", http.StatusSeeOther) |
| + | } |
| + | |
| + | // sanitizeNext confines a post-login redirect to a local path, so the flow |
| + | // cannot be turned into an open redirect. Anything not a plain absolute path |
| + | // (including protocol-relative "//host") falls back to the site root. |
| + | func sanitizeNext(next string) string { |
| + | if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { |
| + | return "/" |
| + | } |
| + | return next |
| + | } |
| + | |
| + | func randToken() string { |
| + | b := make([]byte, 16) |
| + | _, _ = rand.Read(b) |
| + | return base64.RawURLEncoding.EncodeToString(b) |
| + | } |
| + | |
| + | func writeJSON(w http.ResponseWriter, v any) { |
| + | w.Header().Set("Content-Type", "application/json") |
| + | _ = json.NewEncoder(w).Encode(v) |
| + | } |
| + | |
| + | func setCookie(w http.ResponseWriter, name, val string, ttl time.Duration) { |
| + | http.SetCookie(w, &http.Cookie{ |
| + | Name: name, Value: val, Path: "/", |
| + | MaxAge: int(ttl.Seconds()), HttpOnly: true, Secure: true, |
| + | SameSite: http.SameSiteLaxMode, |
| + | }) |
| + | } |
| + | |
| + | func clearCookie(w http.ResponseWriter, name string) { |
| + | http.SetCookie(w, &http.Cookie{ |
| + | Name: name, Value: "", Path: "/", MaxAge: -1, |
| + | HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, |
| + | }) |
| + | } |
| added internal/server/sso_test.go |
| + | package server |
| + | |
| + | import ( |
| + | "crypto/ecdsa" |
| + | "crypto/elliptic" |
| + | "crypto/rand" |
| + | "crypto/sha256" |
| + | "encoding/base64" |
| + | "encoding/json" |
| + | "fmt" |
| + | "net/http" |
| + | "net/http/httptest" |
| + | "net/url" |
| + | "os" |
| + | "strings" |
| + | "testing" |
| + | "time" |
| + | |
| + | "gitweb/internal/auth" |
| + | "gitweb/internal/store" |
| + | |
| + | "upspin.io/upspin" |
| + | ) |
| + | |
| + | // newSSOTestServer builds a Server with browser SSO enabled and returns a |
| + | // function that mints issuer-signed tokens the server will accept. |
| + | func newSSOTestServer(t *testing.T) (*Server, func(sub string, exp time.Time) string) { |
| + | t.Helper() |
| + | priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| + | if err != nil { |
| + | t.Fatal(err) |
| + | } |
| + | pub := upspin.PublicKey(fmt.Sprintf("p256\n%s\n%s\n", priv.X.String(), priv.Y.String())) |
| + | const issuer = "vault@cosmocr.at" |
| + | verifier := auth.NewVaultVerifier(issuer, "gitweb", |
| + | func(upspin.UserName) (upspin.PublicKey, error) { return pub, nil }) |
| + | |
| + | s, err := New(Config{ |
| + | Store: store.NewLocalStore(t.TempDir()), |
| + | Web: http.FS(os.DirFS("../../web")), |
| + | CacheDir: t.TempDir(), |
| + | Vault: verifier, |
| + | SSOAuthorizeURL: "https://vault.cosmocr.at/authorize", |
| + | SSOAudience: "gitweb", |
| + | BaseURL: "https://git.cosmocr.at", |
| + | }) |
| + | if err != nil { |
| + | t.Fatal(err) |
| + | } |
| + | |
| + | mint := func(sub string, exp time.Time) string { |
| + | claims := map[string]any{ |
| + | "iss": issuer, "sub": sub, "aud": "gitweb", |
| + | "iat": time.Now().Unix(), "exp": exp.Unix(), |
| + | } |
| + | payload, _ := json.Marshal(claims) |
| + | p := base64.RawURLEncoding.EncodeToString(payload) |
| + | hash := sha256.Sum256([]byte("v1." + p)) |
| + | r, ss, err := ecdsa.Sign(rand.Reader, priv, hash[:]) |
| + | if err != nil { |
| + | t.Fatal(err) |
| + | } |
| + | return "v1." + p + "." + |
| + | base64.RawURLEncoding.EncodeToString(r.Bytes()) + "." + |
| + | base64.RawURLEncoding.EncodeToString(ss.Bytes()) |
| + | } |
| + | return s, mint |
| + | } |
| + | |
| + | // TestAuthenticateSessionCookie: a valid session cookie authenticates the |
| + | // browser; an expired one is ignored (anonymous) and cleared. |
| + | func TestAuthenticateSessionCookie(t *testing.T) { |
| + | s, mint := newSSOTestServer(t) |
| + | |
| + | // Valid cookie -> authenticated subject. |
| + | r := httptest.NewRequest("GET", "/u@d.com/x", nil) |
| + | r.AddCookie(&http.Cookie{Name: sessionCookie, Value: mint("alice@d.com", time.Now().Add(time.Hour))}) |
| + | w := httptest.NewRecorder() |
| + | user, ok := s.authenticate(w, r) |
| + | if !ok || user != "alice@d.com" { |
| + | t.Fatalf("authenticate = (%q, %v), want (alice@d.com, true)", user, ok) |
| + | } |
| + | |
| + | // Expired cookie -> anonymous, and the stale cookie is cleared. |
| + | r = httptest.NewRequest("GET", "/u@d.com/x", nil) |
| + | r.AddCookie(&http.Cookie{Name: sessionCookie, Value: mint("alice@d.com", time.Now().Add(-time.Minute))}) |
| + | w = httptest.NewRecorder() |
| + | user, ok = s.authenticate(w, r) |
| + | if !ok || user != "" { |
| + | t.Fatalf("expired cookie: authenticate = (%q, %v), want (\"\", true)", user, ok) |
| + | } |
| + | if !clearsCookie(w, sessionCookie) { |
| + | t.Errorf("expired cookie was not cleared: %v", w.Result().Cookies()) |
| + | } |
| + | } |
| + | |
| + | // TestHandleSession covers the callback token-exchange endpoint: state must |
| + | // match the oauth cookie and the token must verify before a session is set. |
| + | func TestHandleSession(t *testing.T) { |
| + | s, mint := newSSOTestServer(t) |
| + | token := mint("bob@d.com", time.Now().Add(time.Hour)) |
| + | |
| + | post := func(oauthState, bodyState, tok string) *httptest.ResponseRecorder { |
| + | body, _ := json.Marshal(map[string]string{"token": tok, "state": bodyState}) |
| + | r := httptest.NewRequest("POST", "/_auth/session", strings.NewReader(string(body))) |
| + | r.AddCookie(&http.Cookie{Name: oauthCookie, |
| + | Value: url.Values{"state": {oauthState}, "next": {"/u@d.com/priv.git"}}.Encode()}) |
| + | w := httptest.NewRecorder() |
| + | s.handleSession(w, r) |
| + | return w |
| + | } |
| + | |
| + | // Happy path: matching state + valid token -> 200, session cookie, next. |
| + | w := post("s123", "s123", token) |
| + | if w.Code != 200 { |
| + | t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body) |
| + | } |
| + | if !setsCookie(w, sessionCookie, token) { |
| + | t.Errorf("session cookie not set to the token") |
| + | } |
| + | var out struct{ Next string } |
| + | json.Unmarshal(w.Body.Bytes(), &out) |
| + | if out.Next != "/u@d.com/priv.git" { |
| + | t.Errorf("next = %q, want /u@d.com/priv.git", out.Next) |
| + | } |
| + | |
| + | // State mismatch -> 400, no session. |
| + | if w := post("s123", "WRONG", token); w.Code != 400 { |
| + | t.Errorf("state mismatch status = %d, want 400", w.Code) |
| + | } |
| + | // Invalid token -> 401, no session. |
| + | if w := post("s123", "s123", "v1.garbage.a.b"); w.Code != 401 { |
| + | t.Errorf("invalid token status = %d, want 401", w.Code) |
| + | } |
| + | } |
| + | |
| + | // TestDenyReadRoutes: an anonymous browser is redirected to login, a git/curl |
| + | // client gets a Basic-auth challenge, an authenticated caller gets 404. |
| + | func TestDenyReadRoutes(t *testing.T) { |
| + | s, _ := newSSOTestServer(t) |
| + | |
| + | // Browser (Accept: text/html), anonymous -> 303 to /_auth/login. |
| + | r := httptest.NewRequest("GET", "/u@d.com/priv.git", nil) |
| + | r.Header.Set("Accept", "text/html") |
| + | w := httptest.NewRecorder() |
| + | s.denyRead(w, r, "") |
| + | if w.Code != http.StatusSeeOther { |
| + | t.Fatalf("browser deny status = %d, want 303", w.Code) |
| + | } |
| + | if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/_auth/login?next=") { |
| + | t.Errorf("Location = %q, want /_auth/login?next=…", loc) |
| + | } |
| + | |
| + | // git/curl (no text/html), anonymous -> 401 challenge. |
| + | r = httptest.NewRequest("GET", "/u@d.com/priv.git/info/refs", nil) |
| + | w = httptest.NewRecorder() |
| + | s.denyRead(w, r, "") |
| + | if w.Code != http.StatusUnauthorized { |
| + | t.Errorf("cli deny status = %d, want 401", w.Code) |
| + | } |
| + | |
| + | // Authenticated but unauthorized -> 404 (does not reveal existence). |
| + | r = httptest.NewRequest("GET", "/u@d.com/priv.git", nil) |
| + | r.Header.Set("Accept", "text/html") |
| + | w = httptest.NewRecorder() |
| + | s.denyRead(w, r, "eve@d.com") |
| + | if w.Code != http.StatusNotFound { |
| + | t.Errorf("authed deny status = %d, want 404", w.Code) |
| + | } |
| + | } |
| + | |
| + | func setsCookie(w *httptest.ResponseRecorder, name, val string) bool { |
| + | for _, c := range w.Result().Cookies() { |
| + | if c.Name == name && c.Value == val && c.MaxAge >= 0 { |
| + | return true |
| + | } |
| + | } |
| + | return false |
| + | } |
| + | |
| + | func clearsCookie(w *httptest.ResponseRecorder, name string) bool { |
| + | for _, c := range w.Result().Cookies() { |
| + | if c.Name == name && c.MaxAge < 0 { |
| + | return true |
| + | } |
| + | } |
| + | return false |
| + | } |
| internal/server/templates/page.html |
| | <!doctype html> |
| | <html lang="en"> |
| | <head> |
| | <meta charset="utf-8"> |
| | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| | <title>{{.Title}}</title> |
| | <link rel="stylesheet" href="/_assets/style.css"> |
| | <link rel="stylesheet" href="/_assets/chroma.css"> |
| | <script src="/_assets/mermaid-init.js" defer></script> |
| | </head> |
| | <body> |
| + | {{if .Nav}}<nav class="account-nav">{{.Nav}}</nav>{{end}} |
| | <main class="markdown-body"> |
| | {{.Body}} |
| | </main> |
| | </body> |
| | </html> |
| main.go |
| | // Command gitweb serves git repositories stored in upspin over HTTPS: a browse |
| | // UI plus the git smart-HTTP protocol for clone and push. It runs as a single |
| | // upspin user (git@cosmocr.at); a repo owner publishes a path by granting that |
| | // user access in an upspin Access file. The upspin path is the URL path, so a |
| | // repo lives at https://git.cosmocr.at/<user@domain>/<path-to-repo>. |
| | // |
| | // Public vs private is derived from the repo's governing Access file: a repo |
| | // readable by all is public (browse and clone anonymously); otherwise callers |
| | // must authenticate with HTTP Basic auth, supplying either a vault-issued SSO |
| | // token (see cmd/git-credential-cosmocr) or a personal access token. Push |
| | // requires write access in the Access file. See README.md. |
| | package main |
| | |
| | import ( |
| | "crypto/tls" |
| | "embed" |
| | "flag" |
| | "io/fs" |
| | "log" |
| | "net/http" |
| | "os" |
| | "sync" |
| | "time" |
| | |
| | "gitweb/internal/auth" |
| | "gitweb/internal/server" |
| | "gitweb/internal/store" |
| | |
| | "upspin.io/config" |
| | "upspin.io/rpc" |
| | "upspin.io/transports" |
| | "upspin.io/upspin" |
| | |
| | // Packers used by the upspin client when reading packed files. |
| | _ "upspin.io/pack/ee" |
| | _ "upspin.io/pack/eeintegrity" |
| | _ "upspin.io/pack/plain" |
| | ) |
| | |
| | //go:embed all:web |
| | var embeddedWeb embed.FS |
| | |
| | func main() { |
| | var ( |
| | addr = flag.String("addr", ":8446", "listen address") |
| | certFile = flag.String("tls_cert", "", "TLS certificate (PEM)") |
| | keyFile = flag.String("tls_key", "", "TLS key (PEM)") |
| | upspinConf = flag.String("upspin_config", "/gitweb/config", "upspin config file for the gitweb user") |
| | webDir = flag.String("web", "", "serve static assets from this dir (default: embedded)") |
| | devDir = flag.String("dev", "", "if set, read files from this local dir instead of upspin (dev only)") |
| - | vaultIss = flag.String("vault_issuer", "vault@cosmocr.at", "upspin user that issues SSO tokens ('' disables passkey/vault auth)") |
| - | vaultAud = flag.String("vault_audience", "gitweb", "expected audience of accepted vault SSO tokens") |
| + | ssoIss = flag.String("sso_issuer", "vault@cosmocr.at", "upspin user that issues SSO tokens; its keyserver public key is trusted ('' disables SSO auth)") |
| + | ssoAud = flag.String("sso_audience", "gitweb", "expected audience of accepted SSO tokens") |
| + | ssoAuthURL = flag.String("sso_authorize_url", "https://vault.cosmocr.at/authorize", "issuer's browser login endpoint; the browser SSO 'Sign in' flow is enabled when set") |
| + | baseURL = flag.String("base_url", "https://git.cosmocr.at", "this service's public base URL, used to build the SSO redirect_uri") |
| | cacheDir = flag.String("cache_dir", "", "repo cache directory — ALL its contents are DELETED at startup (default: fresh dir under the system temp dir)") |
| | cacheMB = flag.Int64("cache_budget_mb", 512, "target total size of the on-disk repo cache, in MiB") |
| | maxRepoMB = flag.Int64("max_repo_mb", 256, "reject repos larger than this when materializing, in MiB") |
| | ) |
| | flag.Parse() |
| | |
| | var st store.WriteStore |
| | var vault *auth.VaultVerifier |
| | if *devDir != "" { |
| | st = store.NewLocalStore(*devDir) |
| | log.Printf("gitweb: DEV MODE — local file store at %s", *devDir) |
| | } else { |
| | cfg, err := config.FromFile(*upspinConf) |
| | if err != nil { |
| | log.Fatalf("gitweb: load upspin config: %v", err) |
| | } |
| | transports.Init(cfg) |
| | st = store.NewUpspinStore(cfg) |
| - | if *vaultIss != "" { |
| - | // Verify vault SSO tokens by looking up the issuer's public key |
| - | // through the upspin keyserver. |
| + | if *ssoIss != "" { |
| + | // Verify SSO tokens by looking up the issuer's public key through |
| + | // the upspin keyserver — no runtime call to the issuer service. |
| | lookup := rpc.PublicUserKeyService(cfg) |
| - | vault = auth.NewVaultVerifier(upspin.UserName(*vaultIss), *vaultAud, |
| + | vault = auth.NewVaultVerifier(upspin.UserName(*ssoIss), *ssoAud, |
| | auth.KeyLookup(lookup)) |
| - | log.Printf("gitweb: accepting SSO tokens from %s (aud=%s)", *vaultIss, *vaultAud) |
| + | log.Printf("gitweb: accepting SSO tokens from %s (aud=%s)", *ssoIss, *ssoAud) |
| | } |
| | } |
| | |
| | var webFS http.FileSystem |
| | if *webDir != "" { |
| | webFS = http.Dir(*webDir) |
| | } else { |
| | sub, err := fs.Sub(embeddedWeb, "web") |
| | if err != nil { |
| | log.Fatalf("gitweb: embed web: %v", err) |
| | } |
| | webFS = http.FS(sub) |
| | } |
| | |
| | srv, err := server.New(server.Config{ |
| | Store: st, |
| | Web: webFS, |
| | Vault: vault, |
| + | SSOAuthorizeURL: *ssoAuthURL, |
| + | SSOAudience: *ssoAud, |
| + | BaseURL: *baseURL, |
| | CacheDir: *cacheDir, |
| | CacheBudgetBytes: *cacheMB << 20, |
| | MaxRepoBytes: *maxRepoMB << 20, |
| | }) |
| | if err != nil { |
| | log.Fatalf("gitweb: init server: %v", err) |
| | } |
| | |
| | httpSrv := &http.Server{ |
| | Addr: *addr, |
| | Handler: srv.Handler(), |
| | ReadHeaderTimeout: 10 * time.Second, |
| | } |
| | log.Printf("gitweb: serving on %s", *addr) |
| | if *certFile == "" || *keyFile == "" { |
| | log.Fatal(httpSrv.ListenAndServe()) // dev only (e.g. localhost) |
| | } |
| | httpSrv.TLSConfig = &tls.Config{ |
| | MinVersion: tls.VersionTLS12, |
| | GetCertificate: (&certReloader{certFile: *certFile, keyFile: *keyFile}).get, |
| | } |
| | log.Fatal(httpSrv.ListenAndServeTLS("", "")) |
| | } |
| | |
| | // certReloader serves a file-based TLS certificate, reloading it when the files |
| | // change so certbot renewals are picked up without a restart. (Copied from the |
| | // vault/markdown services, which share this deployment model.) |
| | type certReloader struct { |
| | certFile, keyFile string |
| | mu sync.RWMutex |
| | cert *tls.Certificate |
| | modTime time.Time |
| | } |
| | |
| | func (r *certReloader) get(*tls.ClientHelloInfo) (*tls.Certificate, error) { |
| | mt := newest(r.certFile, r.keyFile) |
| | r.mu.RLock() |
| | cert, loaded := r.cert, r.modTime |
| | r.mu.RUnlock() |
| | if cert == nil || mt.After(loaded) { |
| | c, err := tls.LoadX509KeyPair(r.certFile, r.keyFile) |
| | if err != nil { |
| | if cert != nil { |
| | log.Printf("gitweb: cert reload failed, keeping previous: %v", err) |
| | return cert, nil |
| | } |
| | return nil, err |
| | } |
| | r.mu.Lock() |
| | r.cert, r.modTime = &c, mt |
| | r.mu.Unlock() |
| | cert = &c |
| | } |
| | return cert, nil |
| | } |
| | |
| | func newest(files ...string) time.Time { |
| | var t time.Time |
| | for _, f := range files { |
| | if fi, err := os.Stat(f); err == nil && fi.ModTime().After(t) { |
| | t = fi.ModTime() |
| | } |
| | } |
| | return t |
| | } |
| added web/auth-callback.js |
| + | "use strict"; |
| + | |
| + | // SSO callback shim. The issuer redirects here with the signed token in the URL |
| + | // *fragment* (#token=…&state=…), which the browser never sends to a server. We |
| + | // read it client-side and hand it to /_auth/session, which verifies it and sets |
| + | // the HttpOnly session cookie. Then we navigate to the page the user wanted. |
| + | |
| + | (function () { |
| + | function fail(msg) { |
| + | var el = document.getElementById("msg"); |
| + | if (el) el.textContent = msg; |
| + | } |
| + | |
| + | var params = new URLSearchParams(location.hash.replace(/^#/, "")); |
| + | var token = params.get("token"); |
| + | var state = params.get("state"); |
| + | if (!token) { |
| + | fail("Sign-in failed: no token was returned."); |
| + | return; |
| + | } |
| + | |
| + | fetch("/_auth/session", { |
| + | method: "POST", |
| + | headers: { "Content-Type": "application/json" }, |
| + | credentials: "same-origin", |
| + | body: JSON.stringify({ token: token, state: state }), |
| + | }) |
| + | .then(function (r) { |
| + | if (!r.ok) throw new Error("HTTP " + r.status); |
| + | return r.json(); |
| + | }) |
| + | .then(function (data) { |
| + | // Replace so the callback URL (with its fragment) leaves no history entry. |
| + | location.replace(data.next || "/"); |
| + | }) |
| + | .catch(function () { |
| + | fail("Sign-in failed. Please try again."); |
| + | }); |
| + | })(); |
| web/style.css |
| | :root { |
| | --fg: #1f2328; |
| | --muted: #59636e; |
| | --link: #0969da; |
| | --border: #d1d9e0; |
| | --code-bg: #f6f8fa; |
| | --add-bg: #e6ffec; |
| | --add-border: #2da44e; |
| | --del-bg: #ffebe9; |
| | --del-border: #cf222e; |
| | } |
| | |
| | * { box-sizing: border-box; } |
| | |
| | body { |
| | margin: 0; |
| | color: var(--fg); |
| | font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; |
| | background: #fff; |
| | } |
| | |
| | .markdown-body { |
| | max-width: 980px; |
| | margin: 0 auto; |
| | padding: 2.5rem 1.25rem 4rem; |
| | } |
| | |
| | .markdown-body h1, |
| | .markdown-body h2 { |
| | border-bottom: 1px solid var(--border); |
| | padding-bottom: .3em; |
| | margin-top: 1.6em; |
| | } |
| | .markdown-body h1:first-child { margin-top: 0; } |
| | |
| | .markdown-body a { color: var(--link); text-decoration: none; } |
| | .markdown-body a:hover { text-decoration: underline; } |
| | |
| | .markdown-body img { max-width: 100%; } |
| | |
| | .markdown-body code { |
| | background: var(--code-bg); |
| | padding: .2em .4em; |
| | border-radius: 6px; |
| | font-size: 85%; |
| | font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; |
| | } |
| | |
| | .markdown-body pre { |
| | background: var(--code-bg); |
| | padding: 1rem; |
| | border-radius: 6px; |
| | overflow: auto; |
| | } |
| | .markdown-body pre code { background: none; padding: 0; font-size: 100%; } |
| | |
| | .markdown-body blockquote { |
| | margin: 0; |
| | padding: 0 1em; |
| | color: var(--muted); |
| | border-left: .25em solid var(--border); |
| | } |
| | |
| | .markdown-body table { border-collapse: collapse; } |
| | .markdown-body th, |
| | .markdown-body td { border: 1px solid var(--border); padding: 6px 13px; } |
| | |
| | .markdown-body ul.dir { list-style: none; padding-left: 0; } |
| | .markdown-body ul.dir li { padding: .15em 0; } |
| | .markdown-body ul.dir a { |
| | font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; |
| | } |
| | |
| | /* --- git views --- */ |
| | |
| | .mono, .git-nav, .tree, .log, .refs, .diff { |
| | font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; |
| | } |
| | |
| | .git-nav { |
| | font-size: 90%; |
| | color: var(--muted); |
| | padding-bottom: .8em; |
| | margin-bottom: 1.2em; |
| | border-bottom: 1px solid var(--border); |
| | } |
| | .git-nav a { margin-right: .9em; } |
| | |
| | .crumbs { font-size: 90%; margin: .4em 0 1em; } |
| | |
| | .hash { color: var(--muted); } |
| | |
| | /* tree + log + refs tables */ |
| | .tree, .log, .refs { width: 100%; border-collapse: collapse; } |
| | .tree td, .log td, .refs td { |
| | border: none; |
| | border-bottom: 1px solid var(--border); |
| | padding: 5px 10px 5px 0; |
| | vertical-align: top; |
| | } |
| | .tree td.name a { font-weight: 500; } |
| | .tree td.size, .log td.when, .log td.who { color: var(--muted); white-space: nowrap; } |
| | .log td.subject a { color: var(--fg); } |
| | |
| | /* blob */ |
| | .blob-meta { color: var(--muted); font-size: 90%; margin-bottom: .6em; } |
| | .blob-controls { |
| | display: flex; |
| | align-items: center; |
| | gap: .4rem; |
| | font-size: 85%; |
| | margin-bottom: .6em; |
| | } |
| | .blob-controls .label { color: var(--muted); } |
| | .blob-controls .renderer, |
| | .blob-controls .force-toggle { |
| | text-decoration: none; |
| | color: var(--link); |
| | border: 1px solid var(--border); |
| | border-radius: 6px; |
| | padding: .1rem .5rem; |
| | } |
| | .blob-controls .renderer.active { |
| | color: var(--fg); |
| | background: var(--code-bg); |
| | font-weight: 600; |
| | } |
| | .blob-controls .force-toggle { margin-left: auto; color: var(--muted); } |
| | .blob-controls .force-toggle.on { color: var(--fg); background: var(--code-bg); } |
| | .blob { |
| | background: var(--code-bg); |
| | border-radius: 6px; |
| | padding: 1rem; |
| | overflow: auto; |
| | } |
| | .blob pre { background: none; margin: 0; padding: 0; } |
| | |
| | /* commit + diff */ |
| | .commit-meta { |
| | background: var(--code-bg); |
| | border: 1px solid var(--border); |
| | border-radius: 6px; |
| | padding: .8rem 1rem; |
| | margin-bottom: 1.4em; |
| | } |
| | .commit-meta .subject { font-weight: 600; } |
| | |
| | .diff { |
| | width: 100%; |
| | border-collapse: collapse; |
| | font-size: 85%; |
| | overflow: auto; |
| | display: block; |
| | } |
| | .diff td { border: none; padding: 0 10px; white-space: pre-wrap; } |
| | .diff .file-head td { |
| | background: var(--code-bg); |
| | border-top: 1px solid var(--border); |
| | border-bottom: 1px solid var(--border); |
| | font-weight: 600; |
| | padding: 6px 10px; |
| | } |
| | .diff .hunk td { color: var(--muted); background: #f6f8fa; } |
| | .diff .diff-add { background: var(--add-bg); } |
| | .diff .diff-del { background: var(--del-bg); } |
| | .diff .diff-ctx { background: none; } |
| + | |
| + | /* Account nav (SSO sign-in/out), top-right on every rendered page. */ |
| + | .account-nav { |
| + | font-size: 0.85em; |
| + | text-align: right; |
| + | color: var(--muted); |
| + | margin: 0 auto 1em; |
| + | max-width: 980px; |
| + | } |