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