repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

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
M pkg/apps/pgs/cli_middleware.go
+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,
A pkg/apps/pgs/html/login.page.tmpl
+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}}
A pkg/apps/pgs/http_auth.go
+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+}
M pkg/apps/pgs/web.go
+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 {
M pkg/db/db.go
+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