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}