repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / pgs
Eric Bower  ·  2026-03-02

http_auth.go

  1package pgs
  2
  3import (
  4	"log/slog"
  5	"net/http"
  6	"strings"
  7	"time"
  8
  9	"github.com/picosh/pico/pkg/db"
 10	"github.com/picosh/pico/pkg/shared/router"
 11)
 12
 13func validatePassword(expectedPass, actualPass string) bool {
 14	return expectedPass == actualPass
 15}
 16
 17func getCookieName(projectName string) string {
 18	prefix := "pgs_session_"
 19	return prefix + projectName
 20}
 21
 22// loginFormData holds data for rendering the login form template.
 23type loginFormData struct {
 24	ProjectName string
 25	Error       string
 26}
 27
 28// serveLoginFormWithConfig renders and serves the login form using templates.
 29func serveLoginFormWithConfig(w http.ResponseWriter, r *http.Request, project *db.Project, cfg *PgsConfig, logger *slog.Logger) {
 30	// Determine error message from query params
 31	errorMsg := ""
 32	if r.URL.Query().Get("error") == "invalid" {
 33		errorMsg = "Invalid password"
 34	}
 35
 36	data := loginFormData{
 37		ProjectName: project.Name,
 38		Error:       errorMsg,
 39	}
 40
 41	w.WriteHeader(http.StatusForbidden)
 42
 43	ts, err := renderTemplate(cfg, []string{cfg.StaticPath("html/login.page.tmpl")})
 44	if err != nil {
 45		logger.Error("could not render login template", "err", err.Error())
 46		http.Error(w, "Server error", http.StatusInternalServerError)
 47		return
 48	}
 49
 50	err = ts.Execute(w, data)
 51	if err != nil {
 52		logger.Error("could not execute login template", "err", err.Error())
 53		http.Error(w, "Server error", http.StatusInternalServerError)
 54	}
 55}
 56
 57// handleLogin processes the login form submission.
 58func handleLogin(w http.ResponseWriter, r *http.Request, cfg *PgsConfig) {
 59	if r.Method != http.MethodPost {
 60		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
 61		return
 62	}
 63
 64	err := r.ParseForm()
 65	if err != nil {
 66		cfg.Logger.Error("failed to parse login form", "err", err.Error())
 67		http.Error(w, "bad request", http.StatusBadRequest)
 68		return
 69	}
 70
 71	projectName := strings.TrimSpace(r.FormValue("project"))
 72	password := r.FormValue("password")
 73
 74	if projectName == "" {
 75		http.Error(w, "missing project name", http.StatusBadRequest)
 76		return
 77	}
 78
 79	if password == "" {
 80		// Redirect back with error
 81		http.Redirect(w, r, "/?error=invalid", http.StatusSeeOther)
 82		return
 83	}
 84
 85	subdomain := router.GetSubdomainFromRequest(r, cfg.Domain, cfg.TxtPrefix)
 86	props, err := router.GetProjectFromSubdomain(subdomain)
 87	if err != nil {
 88		cfg.Logger.Error("could not get project from subdomain", "subdomain", subdomain, "err", err)
 89		http.Error(w, "not found", http.StatusNotFound)
 90		return
 91	}
 92
 93	user, err := cfg.DB.FindUserByName(props.Username)
 94	if err != nil {
 95		cfg.Logger.Error("user not found", "username", props.Username)
 96		http.Error(w, "not found", http.StatusNotFound)
 97		return
 98	}
 99
100	project, err := cfg.DB.FindProjectByName(user.ID, projectName)
101	if err != nil {
102		cfg.Logger.Error("project not found", "username", props.Username, "projectName", projectName)
103		http.Error(w, "not found", http.StatusNotFound)
104		return
105	}
106
107	if project.Acl.Type != "http-pass" {
108		cfg.Logger.Error("project is not password protected", "projectName", projectName)
109		http.Error(w, "not found", http.StatusNotFound)
110		return
111	}
112
113	if len(project.Acl.Data) == 0 {
114		cfg.Logger.Error("password-protected project has no password hash", "projectName", projectName)
115		http.Error(w, "server error", http.StatusInternalServerError)
116		return
117	}
118
119	storedPass := project.Acl.Data[0]
120	if !validatePassword(storedPass, password) {
121		cfg.Logger.Info("invalid password attempt", "projectName", projectName)
122		http.Redirect(w, r, "/?error=invalid", http.StatusSeeOther)
123		return
124	}
125
126	expiresAt := time.Hour * 24
127	cookieName := getCookieName(projectName)
128	cookie := &http.Cookie{
129		Name:     cookieName,
130		Value:    project.ID,
131		Path:     "/",
132		HttpOnly: true,
133		Secure:   cfg.WebProtocol == "https",
134		SameSite: http.SameSiteStrictMode,
135		Expires:  time.Now().Add(expiresAt),
136		MaxAge:   int(expiresAt.Seconds()),
137	}
138	http.SetCookie(w, cookie)
139
140	redirectPath := r.Header.Get("X-PGS-Referer")
141	if redirectPath == "" || !strings.HasPrefix(redirectPath, "/") {
142		redirectPath = "/"
143	}
144
145	cfg.Logger.Info("successful login", "projectName", projectName, "username", props.Username)
146	http.Redirect(w, r, redirectPath, http.StatusSeeOther)
147}