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}