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