josie / simplegit

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")
}