josie / simplegit

package web

import (
	"errors"
	"log"
	"net/http"
	"time"

	"git.josie-c.com/josie/simplegit/internal/auth"
	"git.josie-c.com/josie/simplegit/internal/db"
)

// loginFormMax caps the login POST body; credentials are always tiny.
const loginFormMax = 1 << 16

type repoItem struct {
	Owner       string
	Name        string
	Description string
	Visibility  string
}

type homeData struct {
	Username string
	Repos    []repoItem
}

// repoItems projects a repo listing for a page; owner filters to one
// account's repos ("" keeps everything).
func repoItems(repos []db.RepoView, owner string) []repoItem {
	var items []repoItem
	for _, repo := range repos {
		if owner != "" && repo.OwnerName != owner {
			continue
		}
		items = append(items, repoItem{
			Owner: repo.OwnerName, Name: repo.Name,
			Description: repo.Description, Visibility: repo.Visibility,
		})
	}
	return items
}

func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
	// "GET /" is also the mux catch-all, so serve only the root here;
	// repo browsing registers its own patterns.
	if r.URL.Path != "/" {
		http.NotFound(w, r)
		return
	}
	user := currentUser(r)
	data := homeData{}
	var viewerID int64
	if user != nil {
		data.Username = user.Username
		viewerID = user.ID
	}
	repos, err := db.ListRepos(s.database, viewerID)
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	data.Repos = repoItems(repos, "")
	s.render(w, "home.html", http.StatusOK, data)
}

func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
	s.render(w, "login.html", http.StatusOK, pageData{})
}

// handleLogin authenticates with the stored bcrypt hash, minting a fresh
// random session on success. Only failed attempts consume the per-IP budget
// (refused outright once the window is full), so a failure spray can never
// lock out a correct password; a dummy bcrypt runs on the unknown-user path
// so all failures cost the same.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
	ip := clientIP(r)
	r.Body = http.MaxBytesReader(w, r.Body, loginFormMax)
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad form", http.StatusBadRequest)
		return
	}
	username := r.FormValue("username")
	password := r.FormValue("password")

	fail := func() {
		if !s.logins.allow(ip) {
			http.Error(w, "too many login attempts; try again later", http.StatusTooManyRequests)
			return
		}
		s.render(w, "login.html", http.StatusUnauthorized,
			pageData{Username: username, Error: "invalid username or password"})
	}
	user, err := db.GetUserByName(s.database, username)
	if errors.Is(err, db.ErrNotFound) {
		// Keep the response timing uniform with the wrong-password path.
		_, _ = auth.HashPassword(password)
		fail()
		return
	}
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	if !auth.CheckPassword(user.PasswordHash, password) {
		fail()
		return
	}

	token, err := auth.NewToken()
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	// Opportunistic housekeeping: sessions only leave the table at logout,
	// so without this the expired rows would accumulate forever.
	if err := db.DeleteExpiredSessions(s.database); err != nil {
		log.Printf("web: login purge sessions: %v", err)
	}
	if err := db.CreateSession(s.database, token, user.ID, time.Now().Add(sessionDuration).Unix()); err != nil {
		s.internalError(w, r, err)
		return
	}
	s.logins.reset(ip)
	http.SetCookie(w, s.sessionCookie(token, sessionDuration))
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
	if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
		if err := db.DeleteSession(s.database, cookie.Value); err != nil {
			log.Printf("web: logout: %v", err)
		}
	}
	http.SetCookie(w, s.sessionCookie("", -1))
	http.Redirect(w, r, "/login", http.StatusSeeOther)
}