- commit
- 45aaf05
- parent
- a83183c
- author
- Eric Bower
- date
- 2026-03-02 10:14:33 -0500 EST
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
1@@ -227,7 +227,7 @@ func Middleware(handler *UploadAssetHandler) pssh.SSHServerMiddleware {
2 }
3 opts.Write = *write
4
5- if !slices.Contains([]string{"public", "pubkeys", "pico"}, *aclType) {
6+ if !slices.Contains([]string{"public", "pubkeys", "pico", "http-pass"}, *aclType) {
7 err := fmt.Errorf(
8 "acl type must be one of the following: [public, pubkeys, pico], found %s",
9 *aclType,
+29,
-0
1@@ -0,0 +1,29 @@
2+{{define "title"}}Password Protected Site{{end}}
3+
4+{{define "meta"}}
5+{{end}}
6+
7+{{define "attrs"}}class="container" style="height: 100vh;"{{end}}
8+
9+{{define "body"}}
10+<div class="container flex justify-center">
11+ <div style="max-width: 450px;" class="mt-4 border py-4 px-4 flex flex-col gap">
12+ <h1 class="text-lg">Password protected site</h1>
13+
14+ <div>The site admin must share the password for this site in order to access it. Access is then granted for 24 hours.</div>
15+
16+ {{if .Error}}
17+ <div style="color: tomato;">{{.Error}}</div>
18+ {{end}}
19+
20+ <form method="POST" action="/auth/login">
21+ <input type="hidden" name="project" value="{{.ProjectName}}">
22+ <label for="password">Enter password:</label>
23+ <div class="flex gap">
24+ <input type="password" id="password" name="password" placeholder="Password" required autofocus>
25+ <button type="submit">Access</button>
26+ </div>
27+ </form>
28+ </div>
29+</div>
30+{{end}}
+147,
-0
1@@ -0,0 +1,147 @@
2+package pgs
3+
4+import (
5+ "log/slog"
6+ "net/http"
7+ "strings"
8+ "time"
9+
10+ "github.com/picosh/pico/pkg/db"
11+ "github.com/picosh/pico/pkg/shared/router"
12+)
13+
14+func validatePassword(expectedPass, actualPass string) bool {
15+ return expectedPass == actualPass
16+}
17+
18+func getCookieName(projectName string) string {
19+ prefix := "pgs_session_"
20+ return prefix + projectName
21+}
22+
23+// loginFormData holds data for rendering the login form template.
24+type loginFormData struct {
25+ ProjectName string
26+ Error string
27+}
28+
29+// serveLoginFormWithConfig renders and serves the login form using templates.
30+func serveLoginFormWithConfig(w http.ResponseWriter, r *http.Request, project *db.Project, cfg *PgsConfig, logger *slog.Logger) {
31+ // Determine error message from query params
32+ errorMsg := ""
33+ if r.URL.Query().Get("error") == "invalid" {
34+ errorMsg = "Invalid password"
35+ }
36+
37+ data := loginFormData{
38+ ProjectName: project.Name,
39+ Error: errorMsg,
40+ }
41+
42+ w.WriteHeader(http.StatusForbidden)
43+
44+ ts, err := renderTemplate(cfg, []string{cfg.StaticPath("html/login.page.tmpl")})
45+ if err != nil {
46+ logger.Error("could not render login template", "err", err.Error())
47+ http.Error(w, "Server error", http.StatusInternalServerError)
48+ return
49+ }
50+
51+ err = ts.Execute(w, data)
52+ if err != nil {
53+ logger.Error("could not execute login template", "err", err.Error())
54+ http.Error(w, "Server error", http.StatusInternalServerError)
55+ }
56+}
57+
58+// handleLogin processes the login form submission.
59+func handleLogin(w http.ResponseWriter, r *http.Request, cfg *PgsConfig) {
60+ if r.Method != http.MethodPost {
61+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
62+ return
63+ }
64+
65+ err := r.ParseForm()
66+ if err != nil {
67+ cfg.Logger.Error("failed to parse login form", "err", err.Error())
68+ http.Error(w, "bad request", http.StatusBadRequest)
69+ return
70+ }
71+
72+ projectName := strings.TrimSpace(r.FormValue("project"))
73+ password := r.FormValue("password")
74+
75+ if projectName == "" {
76+ http.Error(w, "missing project name", http.StatusBadRequest)
77+ return
78+ }
79+
80+ if password == "" {
81+ // Redirect back with error
82+ http.Redirect(w, r, "/?error=invalid", http.StatusSeeOther)
83+ return
84+ }
85+
86+ subdomain := router.GetSubdomainFromRequest(r, cfg.Domain, cfg.TxtPrefix)
87+ props, err := router.GetProjectFromSubdomain(subdomain)
88+ if err != nil {
89+ cfg.Logger.Error("could not get project from subdomain", "subdomain", subdomain, "err", err)
90+ http.Error(w, "not found", http.StatusNotFound)
91+ return
92+ }
93+
94+ user, err := cfg.DB.FindUserByName(props.Username)
95+ if err != nil {
96+ cfg.Logger.Error("user not found", "username", props.Username)
97+ http.Error(w, "not found", http.StatusNotFound)
98+ return
99+ }
100+
101+ project, err := cfg.DB.FindProjectByName(user.ID, projectName)
102+ if err != nil {
103+ cfg.Logger.Error("project not found", "username", props.Username, "projectName", projectName)
104+ http.Error(w, "not found", http.StatusNotFound)
105+ return
106+ }
107+
108+ if project.Acl.Type != "http-pass" {
109+ cfg.Logger.Error("project is not password protected", "projectName", projectName)
110+ http.Error(w, "not found", http.StatusNotFound)
111+ return
112+ }
113+
114+ if len(project.Acl.Data) == 0 {
115+ cfg.Logger.Error("password-protected project has no password hash", "projectName", projectName)
116+ http.Error(w, "server error", http.StatusInternalServerError)
117+ return
118+ }
119+
120+ storedPass := project.Acl.Data[0]
121+ if !validatePassword(storedPass, password) {
122+ cfg.Logger.Info("invalid password attempt", "projectName", projectName)
123+ http.Redirect(w, r, "/?error=invalid", http.StatusSeeOther)
124+ return
125+ }
126+
127+ expiresAt := time.Hour * 24
128+ cookieName := getCookieName(projectName)
129+ cookie := &http.Cookie{
130+ Name: cookieName,
131+ Value: project.ID,
132+ Path: "/",
133+ HttpOnly: true,
134+ Secure: cfg.WebProtocol == "https",
135+ SameSite: http.SameSiteStrictMode,
136+ Expires: time.Now().Add(expiresAt),
137+ MaxAge: int(expiresAt.Seconds()),
138+ }
139+ http.SetCookie(w, cookie)
140+
141+ redirectPath := r.Header.Get("X-PGS-Referer")
142+ if redirectPath == "" || !strings.HasPrefix(redirectPath, "/") {
143+ redirectPath = "/"
144+ }
145+
146+ cfg.Logger.Info("successful login", "projectName", projectName, "username", props.Username)
147+ http.Redirect(w, r, redirectPath, http.StatusSeeOther)
148+}
+30,
-1
1@@ -3,6 +3,7 @@ package pgs
2 import (
3 "bufio"
4 "context"
5+ "errors"
6 "fmt"
7 "html/template"
8 "log/slog"
9@@ -152,6 +153,7 @@ func (web *WebRouter) initRouters() {
10
11 // subdomain or custom domains
12 userRouter := http.NewServeMux()
13+ userRouter.HandleFunc("POST /auth/login", web.handleLogin)
14 userRouter.HandleFunc("GET /{fname...}", web.AssetRequest(WebPerm))
15 userRouter.HandleFunc("GET /{$}", web.AssetRequest(WebPerm))
16 web.UserRouter = userRouter
17@@ -503,7 +505,26 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
18 return
19 }
20
21- if !hasPerm(project) {
22+ if project.Acl.Type == "http-pass" {
23+ cookie, err := r.Cookie(getCookieName(project.Name))
24+ if err == nil {
25+ if cookie.Valid() != nil || cookie.Value != project.ID {
26+ logger.Error("cookie not valid", "err", err)
27+ web.serveLoginForm(w, r, project, logger)
28+ return
29+ }
30+ } else {
31+ if errors.Is(err, http.ErrNoCookie) {
32+ web.serveLoginForm(w, r, project, logger)
33+ return
34+ } else {
35+ // Some other error occurred
36+ logger.Error("failed to fetch cookie", "err", err)
37+ http.Error(w, err.Error(), http.StatusInternalServerError)
38+ return
39+ }
40+ }
41+ } else if !hasPerm(project) {
42 logger.Error("You do not have access to this site")
43 http.Error(w, "You do not have access to this site", http.StatusUnauthorized)
44 return
45@@ -541,6 +562,14 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
46 asset.ServeHTTP(w, r)
47 }
48
49+func (web *WebRouter) serveLoginForm(w http.ResponseWriter, r *http.Request, project *db.Project, logger *slog.Logger) {
50+ serveLoginFormWithConfig(w, r, project, web.Cfg, logger)
51+}
52+
53+func (web *WebRouter) handleLogin(w http.ResponseWriter, r *http.Request) {
54+ handleLogin(w, r, web.Cfg)
55+}
56+
57 func (web *WebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
58 subdomain := router.GetSubdomainFromRequest(r, web.Cfg.Domain, web.Cfg.TxtPrefix)
59 if web.RootRouter == nil || web.UserRouter == nil {
+1,
-1
1@@ -82,7 +82,7 @@ type Project struct {
2 }
3
4 type ProjectAcl struct {
5- Type string `json:"type" db:"type"` // public, pico, pubkeys, private
6+ Type string `json:"type" db:"type"` // public, pico, pubkeys, private, http-pass
7 Data []string `json:"data" db:"data"`
8 }
9