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