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