Commit 45aaf05

Eric Bower  ·  2026-03-02 10:14:33 -0500 EST
parent a83183c
feat(pgs): http-pass ACL

This feature enables users to set their project to "http-pass" which requires a password to access the site.

The user can set the acl via:

`ssh pgs.sh acl {project} --type http-pass --acl {pass}`

When accessing the project the user will have to enter the password to access the site.  When access has been granted we set a cookie in the user's browser.  That cookie is valid for 24 hours.
5 files changed,  +208, -3
+1, -1
......@@ -227,7 +227,7 @@ func Middleware(handler *UploadAssetHandler) pssh.SSHServerMiddleware {
227227 }
228228 opts.Write = *write
229229
230- if !slices.Contains([]string{"public", "pubkeys", "pico"}, *aclType) {
230+ if !slices.Contains([]string{"public", "pubkeys", "pico", "http-pass"}, *aclType) {
231231 err := fmt.Errorf(
232232 "acl type must be one of the following: [public, pubkeys, pico], found %s",
233233 *aclType,
+29, -0
......@@ -0,0 +1,29 @@
1+{{define "title"}}Password Protected Site{{end}}
2+
3+{{define "meta"}}
4+{{end}}
5+
6+{{define "attrs"}}class="container" style="height: 100vh;"{{end}}
7+
8+{{define "body"}}
9+<div class="container flex justify-center">
10+ <div style="max-width: 450px;" class="mt-4 border py-4 px-4 flex flex-col gap">
11+ <h1 class="text-lg">Password protected site</h1>
12+
13+ <div>The site admin must share the password for this site in order to access it. Access is then granted for 24 hours.</div>
14+
15+ {{if .Error}}
16+ <div style="color: tomato;">{{.Error}}</div>
17+ {{end}}
18+
19+ <form method="POST" action="/auth/login">
20+ <input type="hidden" name="project" value="{{.ProjectName}}">
21+ <label for="password">Enter password:</label>
22+ <div class="flex gap">
23+ <input type="password" id="password" name="password" placeholder="Password" required autofocus>
24+ <button type="submit">Access</button>
25+ </div>
26+ </form>
27+ </div>
28+</div>
29+{{end}}
+147, -0
......@@ -0,0 +1,147 @@
1+package pgs
2+
3+import (
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+
13+func validatePassword(expectedPass, actualPass string) bool {
14+ return expectedPass == actualPass
15+}
16+
17+func getCookieName(projectName string) string {
18+ prefix := "pgs_session_"
19+ return prefix + projectName
20+}
21+
22+// loginFormData holds data for rendering the login form template.
23+type loginFormData struct {
24+ ProjectName string
25+ Error string
26+}
27+
28+// serveLoginFormWithConfig renders and serves the login form using templates.
29+func 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.
58+func 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+}
+30, -1
......@@ -3,6 +3,7 @@ package pgs
33 import (
44 "bufio"
55 "context"
6+ "errors"
67 "fmt"
78 "html/template"
89 "log/slog"
......@@ -152,6 +153,7 @@ func (web *WebRouter) initRouters() {
152153
153154 // subdomain or custom domains
154155 userRouter := http.NewServeMux()
156+ userRouter.HandleFunc("POST /auth/login", web.handleLogin)
155157 userRouter.HandleFunc("GET /{fname...}", web.AssetRequest(WebPerm))
156158 userRouter.HandleFunc("GET /{$}", web.AssetRequest(WebPerm))
157159 web.UserRouter = userRouter
......@@ -503,7 +505,26 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
503505 return
504506 }
505507
506- if !hasPerm(project) {
508+ if project.Acl.Type == "http-pass" {
509+ cookie, err := r.Cookie(getCookieName(project.Name))
510+ if err == nil {
511+ if cookie.Valid() != nil || cookie.Value != project.ID {
512+ logger.Error("cookie not valid", "err", err)
513+ web.serveLoginForm(w, r, project, logger)
514+ return
515+ }
516+ } else {
517+ if errors.Is(err, http.ErrNoCookie) {
518+ web.serveLoginForm(w, r, project, logger)
519+ return
520+ } else {
521+ // Some other error occurred
522+ logger.Error("failed to fetch cookie", "err", err)
523+ http.Error(w, err.Error(), http.StatusInternalServerError)
524+ return
525+ }
526+ }
527+ } else if !hasPerm(project) {
507528 logger.Error("You do not have access to this site")
508529 http.Error(w, "You do not have access to this site", http.StatusUnauthorized)
509530 return
......@@ -541,6 +562,14 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
541562 asset.ServeHTTP(w, r)
542563 }
543564
565+func (web *WebRouter) serveLoginForm(w http.ResponseWriter, r *http.Request, project *db.Project, logger *slog.Logger) {
566+ serveLoginFormWithConfig(w, r, project, web.Cfg, logger)
567+}
568+
569+func (web *WebRouter) handleLogin(w http.ResponseWriter, r *http.Request) {
570+ handleLogin(w, r, web.Cfg)
571+}
572+
544573 func (web *WebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
545574 subdomain := router.GetSubdomainFromRequest(r, web.Cfg.Domain, web.Cfg.TxtPrefix)
546575 if web.RootRouter == nil || web.UserRouter == nil {
+1, -1
......@@ -82,7 +82,7 @@ type Project struct {
8282 }
8383
8484 type ProjectAcl struct {
85- Type string `json:"type" db:"type"` // public, pico, pubkeys, private
85+ Type string `json:"type" db:"type"` // public, pico, pubkeys, private, http-pass
8686 Data []string `json:"data" db:"data"`
8787 }
8888