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