Commit d69226d

Eric Bower  ·  2026-03-05 23:27:44 -0500 EST
parent 0c37db1
feat(pgs): auto-forms
15 files changed,  +445, -10
+124, -0
......@@ -0,0 +1,124 @@
1+# Auto-Form Feature for pgs.sh
2+
3+## Overview
4+
5+The auto-form feature allows users to create HTML forms on their pgs.sh sites that automatically submit data to the pgs database. Form submissions can then be retrieved via the pgs CLI in JSON format.
6+
7+## How It Works
8+
9+1. Form submissions are automatically captured and stored in PostgreSQL
10+2. Users can retrieve form data via the pgs CLI
11+
12+## Database Schema
13+
14+```sql
15+CREATE TABLE form_entries (
16+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
17+ user_id uuid NOT NULL,
18+ name VARCHAR(255) NOT NULL,
19+ data jsonb NOT NULL,
20+ created_at timestamp without time zone NOT NULL DEFAULT NOW(),
21+ CONSTRAINT form_entries_pkey PRIMARY KEY (id),
22+ CONSTRAINT fk_form_entries_users
23+ FOREIGN KEY(user_id)
24+ REFERENCES app_users(id)
25+ ON DELETE CASCADE
26+);
27+
28+CREATE INDEX IF NOT EXISTS idx_form_entries_user ON form_entries(user_id);
29+CREATE INDEX IF NOT EXISTS idx_form_entries_name ON form_entries(name);
30+```
31+
32+### Migration
33+
34+Run the migration:
35+```bash
36+make latest
37+```
38+
39+Or run all migrations:
40+```bash
41+make migrate
42+```
43+
44+## HTML Form Example
45+
46+```html
47+<form method="POST" action="/forms/example" data-pgs="true">
48+ <p>
49+ <label>Your Name: <input type="text" name="name" /></label>
50+ </p>
51+ <p>
52+ <label>Your Email: <input type="email" name="email" /></label>
53+ </p>
54+ <p>
55+ <label>Your Role: <select name="role[]" multiple>
56+ <option value="leader">Leader</option>
57+ <option value="follower">Follower</option>
58+ </select></label>
59+ </p>
60+ <p>
61+ <label>Message: <textarea name="message"></textarea></label>
62+ </p>
63+ <p>
64+ <button type="submit">Send</button>
65+ </p>
66+</form>
67+```
68+
69+## CLI Commands
70+
71+### List all form names for a user
72+```bash
73+ssh pgs.sh forms ls
74+```
75+
76+### Get form submissions for a specific form
77+```bash
78+ssh pgs.sh forms show example
79+```
80+
81+Output (JSON):
82+```json
83+[
84+ {
85+ "id": "uuid",
86+ "name": "contact",
87+ "data": {
88+ "name": "John Doe",
89+ "email": "john@example.com",
90+ "role": ["leader", "follower"],
91+ "message": "Hello!"
92+ },
93+ "created_at": "2026-03-05T12:00:00Z"
94+ }
95+]
96+```
97+
98+### Delete all submissions for a form
99+```bash
100+ssh pgs.sh forms rm example --write
101+```
102+
103+## Data Storage
104+
105+- Form data is stored in PostgreSQL `form_entries` table
106+- Each submission is a JSON object with form field names as keys
107+- Data is associated with the user, not the project
108+- Form data is deleted when the user account is deleted (CASCADE)
109+
110+## Implementation Details
111+
112+### Database Methods
113+
114+```go
115+InsertFormEntry(userID, name string, data map[string]interface{}) error
116+FindFormEntriesByUserAndName(userID, name string) ([]*db.FormEntry, error)
117+FindFormNamesByUser(userID string) ([]string, error)
118+RemoveFormEntriesByUserAndName(userID, name string) error
119+```
120+
121+## More features
122+
123+- Form validation and confirmation pages
124+- CSRF token in form and validated in post handler
+2, -1
......@@ -145,10 +145,11 @@ migrate:
145145 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20251217_add_access_logs_table.sql
146146 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20251226_add_pipe_monitoring.sql
147147 $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260116_add_analytics_filter_indexes.sql
148+ $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260305_add_forms_table.sql
148149 .PHONY: migrate
149150
150151 latest:
151- $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260116_add_analytics_filter_indexes.sql
152+ $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260305_add_forms_table.sql
152153 .PHONY: latest
153154
154155 psql:
+81, -0
......@@ -0,0 +1,81 @@
1+package pgs
2+
3+import (
4+ "net/http"
5+
6+ "github.com/picosh/pico/pkg/shared/router"
7+)
8+
9+func handleAutoForm(w http.ResponseWriter, r *http.Request, cfg *PgsConfig) {
10+ formName := r.PathValue("fname")
11+ if r.Method != http.MethodPost {
12+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
13+ return
14+ }
15+
16+ err := r.ParseForm()
17+ if err != nil {
18+ cfg.Logger.Error("failed to parse auto form", "err", err)
19+ http.Error(w, "failed to parse auto form", http.StatusBadRequest)
20+ return
21+ }
22+
23+ formValues := make(map[string]interface{})
24+ for key, values := range r.PostForm {
25+ if len(values) == 1 {
26+ formValues[key] = values[0]
27+ } else if len(values) > 1 {
28+ formValues[key] = values
29+ }
30+ }
31+
32+ subdomain := router.GetSubdomainFromRequest(r, cfg.Domain, cfg.TxtPrefix)
33+ props, err := router.GetProjectFromSubdomain(subdomain)
34+ if err != nil {
35+ cfg.Logger.Error("could not get project from subdomain", "subdomain", subdomain, "err", err)
36+ http.Error(w, "not found", http.StatusNotFound)
37+ return
38+ }
39+
40+ user, err := cfg.DB.FindUserByName(props.Username)
41+ if err != nil {
42+ cfg.Logger.Error("user not found", "username", props.Username)
43+ http.Error(w, "not found", http.StatusNotFound)
44+ return
45+ }
46+
47+ err = cfg.DB.InsertFormEntry(user.ID, formName, formValues)
48+ if err != nil {
49+ cfg.Logger.Error("failed to save form data", "err", err)
50+ http.Error(w, "failed to save form data", http.StatusInternalServerError)
51+ return
52+ }
53+
54+ serveAutoFormSubmitted(w, r, cfg)
55+}
56+
57+type FormData struct {
58+ Error string
59+}
60+
61+func serveAutoFormSubmitted(w http.ResponseWriter, r *http.Request, cfg *PgsConfig) {
62+ errorMsg := r.URL.Query().Get("error")
63+ data := loginFormData{
64+ Error: errorMsg,
65+ }
66+
67+ w.WriteHeader(http.StatusUnprocessableEntity)
68+
69+ ts, err := renderTemplate(cfg, []string{cfg.StaticPath("html/auto_form.page.tmpl")})
70+ if err != nil {
71+ cfg.Logger.Error("could not render auto form template", "err", err.Error())
72+ http.Error(w, "Server error", http.StatusInternalServerError)
73+ return
74+ }
75+
76+ err = ts.Execute(w, data)
77+ if err != nil {
78+ cfg.Logger.Error("could not execute login template", "err", err.Error())
79+ http.Error(w, "Server error", http.StatusInternalServerError)
80+ }
81+}
+37, -0
......@@ -1,6 +1,7 @@
11 package pgs
22
33 import (
4+ "encoding/json"
45 "errors"
56 "fmt"
67 "io"
......@@ -561,3 +562,39 @@ func (c *Cmd) cacheAll() error {
561562 }
562563 return nil
563564 }
565+
566+func (c *Cmd) formsLs() error {
567+ forms, err := c.Dbpool.FindFormNamesByUser(c.User.ID)
568+ if err != nil {
569+ return err
570+ }
571+ if len(forms) == 0 {
572+ c.output("no forms found")
573+ return nil
574+ }
575+ for _, name := range forms {
576+ c.output(name)
577+ }
578+ return nil
579+}
580+
581+func (c *Cmd) formData(formName string) error {
582+ formData, err := c.Dbpool.FindFormEntriesByUserAndName(c.User.ID, formName)
583+ if err != nil {
584+ return err
585+ }
586+ data, err := json.Marshal(formData)
587+ if err != nil {
588+ return err
589+ }
590+ c.output(string(data))
591+ return nil
592+}
593+
594+func (c *Cmd) formRm(formName string) error {
595+ c.output(fmt.Sprintf("removing all data associated with form: %s", formName))
596+ if c.Write {
597+ return c.Dbpool.RemoveFormEntriesByUserAndName(c.User.ID, formName)
598+ }
599+ return nil
600+}
+23, -0
......@@ -212,6 +212,29 @@ func Middleware(handler *UploadAssetHandler) pssh.SSHServerMiddleware {
212212
213213 err := opts.cache(projectName)
214214 opts.notice()
215+ opts.bail(err)
216+ return err
217+ case "forms":
218+ formName := projectName
219+ formsCmd, write := flagSet("forms", sesh)
220+ rmForm := formsCmd.Bool("rm", false, "delete form data")
221+ if !flagCheck(formsCmd, formName, cmdArgs) {
222+ return nil
223+ }
224+ opts.Write = *write
225+
226+ var err error
227+ if formName == "ls" {
228+ err = opts.formsLs()
229+ } else {
230+ if *rmForm {
231+ err = opts.formRm(formName)
232+ opts.notice()
233+ } else {
234+ err = opts.formData(formName)
235+ }
236+ }
237+
215238 opts.bail(err)
216239 return err
217240 case "acl":
+5, -0
......@@ -27,6 +27,11 @@ type PgsDB interface {
2727 FindProjectsByPrefix(userID, name string) ([]*db.Project, error)
2828 FindProjects(by string) ([]*db.Project, error)
2929
30+ InsertFormEntry(userID, name string, data map[string]interface{}) error
31+ FindFormEntriesByUserAndName(userID, name string) ([]*db.FormEntry, error)
32+ FindFormNamesByUser(userID string) ([]string, error)
33+ RemoveFormEntriesByUserAndName(userID, name string) error
34+
3035 RegisterAdmin(username, pubkey, pubkeyName string) error
3136
3237 Close() error
+55, -5
......@@ -11,11 +11,12 @@ import (
1111 )
1212
1313 type MemoryDB struct {
14- Logger *slog.Logger
15- Users []*db.User
16- Projects []*db.Project
17- Pubkeys []*db.PublicKey
18- Feature *db.FeatureFlag
14+ Logger *slog.Logger
15+ Users []*db.User
16+ Projects []*db.Project
17+ Pubkeys []*db.PublicKey
18+ Feature *db.FeatureFlag
19+ FormEntries []*db.FormEntry
1920 }
2021
2122 var _ PgsDB = (*MemoryDB)(nil)
......@@ -195,3 +196,52 @@ func (me *MemoryDB) RegisterAdmin(username, pubkey, pubkeyName string) error {
195196 func (me *MemoryDB) InsertAccessLog(*db.AccessLog) error {
196197 return errNotImpl
197198 }
199+
200+func (me *MemoryDB) InsertFormEntry(userID, name string, data map[string]interface{}) error {
201+ id := uuid.NewString()
202+ now := time.Now()
203+ entry := &db.FormEntry{
204+ ID: id,
205+ UserID: userID,
206+ Name: name,
207+ Data: data,
208+ CreatedAt: &now,
209+ }
210+ me.FormEntries = append(me.FormEntries, entry)
211+ return nil
212+}
213+
214+func (me *MemoryDB) FindFormEntriesByUserAndName(userID, name string) ([]*db.FormEntry, error) {
215+ entries := []*db.FormEntry{}
216+ for _, entry := range me.FormEntries {
217+ if entry.UserID == userID && entry.Name == name {
218+ entries = append(entries, entry)
219+ }
220+ }
221+ return entries, nil
222+}
223+
224+func (me *MemoryDB) FindFormNamesByUser(userID string) ([]string, error) {
225+ names := make(map[string]bool)
226+ for _, entry := range me.FormEntries {
227+ if entry.UserID == userID {
228+ names[entry.Name] = true
229+ }
230+ }
231+ result := []string{}
232+ for name := range names {
233+ result = append(result, name)
234+ }
235+ return result, nil
236+}
237+
238+func (me *MemoryDB) RemoveFormEntriesByUserAndName(userID, name string) error {
239+ filtered := []*db.FormEntry{}
240+ for _, entry := range me.FormEntries {
241+ if entry.UserID != userID || entry.Name != name {
242+ filtered = append(filtered, entry)
243+ }
244+ }
245+ me.FormEntries = filtered
246+ return nil
247+}
+39, -0
......@@ -1,6 +1,7 @@
11 package pgsdb
22
33 import (
4+ "encoding/json"
45 "fmt"
56 "log/slog"
67 "time"
......@@ -295,3 +296,41 @@ func (me *PgsPsqlDB) RegisterAdmin(username, pubkey, pubkeyName string) error {
295296 _, err = me.Db.Exec("INSERT INTO feature_flags (user_id, name, expires_at) VALUES (1, 'admin', '2100-01-01')", userID)
296297 return err
297298 }
299+
300+func (me *PgsPsqlDB) InsertFormEntry(userID, name string, data map[string]interface{}) error {
301+ dataJSON, err := json.Marshal(data)
302+ if err != nil {
303+ return err
304+ }
305+
306+ _, err = me.Db.Exec(
307+ "INSERT INTO form_entries (user_id, name, data) VALUES ($1, $2, $3)",
308+ userID, name, dataJSON,
309+ )
310+ return err
311+}
312+
313+func (me *PgsPsqlDB) FindFormEntriesByUserAndName(userID, name string) ([]*db.FormEntry, error) {
314+ entries := []*db.FormEntry{}
315+ err := me.Db.Select(
316+ &entries,
317+ "SELECT * FROM form_entries WHERE user_id=$1 AND name=$2 ORDER BY created_at DESC",
318+ userID, name,
319+ )
320+ return entries, err
321+}
322+
323+func (me *PgsPsqlDB) FindFormNamesByUser(userID string) ([]string, error) {
324+ names := []string{}
325+ err := me.Db.Select(
326+ &names,
327+ "SELECT DISTINCT name FROM form_entries WHERE user_id=$1 ORDER BY name ASC",
328+ userID,
329+ )
330+ return names, err
331+}
332+
333+func (me *PgsPsqlDB) RemoveFormEntriesByUserAndName(userID, name string) error {
334+ _, err := me.Db.Exec("DELETE FROM form_entries WHERE user_id=$1 AND name=$2", userID, name)
335+ return err
336+}
+12, -0
......@@ -58,6 +58,18 @@ CREATE TABLE IF NOT EXISTS feature_flags (
5858 ON DELETE CASCADE
5959 ON UPDATE CASCADE
6060 );
61+
62+CREATE TABLE IF NOT EXISTS form_entries (
63+ id INTEGER PRIMARY KEY AUTOINCREMENT,
64+ user_id INTEGER NOT NULL,
65+ name TEXT NOT NULL,
66+ data BLOB NOT NULL,
67+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
68+ CONSTRAINT form_entries_user_id_fk
69+ FOREIGN KEY(user_id) REFERENCES app_users(id)
70+ ON DELETE CASCADE
71+ ON UPDATE CASCADE
72+);
6173 `
6274
6375 var sqliteMigrations = []string{
+20, -0
......@@ -0,0 +1,20 @@
1+{{define "title"}}Form Submitted{{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">Form Submitted</h1>
12+
13+ {{if .Error}}
14+ <div style="color: tomato;">{{.Error}}</div>
15+ {{else}}
16+ <div>Form submitted successfully!</div>
17+ {{end}}
18+ </div>
19+</div>
20+{{end}}
+1, -1
......@@ -16,7 +16,7 @@
1616 <div style="color: tomato;">{{.Error}}</div>
1717 {{end}}
1818
19- <form method="POST" action="/auth/login">
19+ <form method="POST" action="/pgs/login">
2020 <input type="hidden" name="project" value="{{.ProjectName}}">
2121 <label for="password">Enter password:</label>
2222 <div class="flex gap">
+6, -1
......@@ -153,7 +153,8 @@ func (web *WebRouter) initRouters() {
153153
154154 // subdomain or custom domains
155155 userRouter := http.NewServeMux()
156- userRouter.HandleFunc("POST /auth/login", web.handleLogin)
156+ userRouter.HandleFunc("POST /pgs/login", web.handleLogin)
157+ userRouter.HandleFunc("POST /pgs/forms/{fname...}", web.handleAutoForm)
157158 userRouter.HandleFunc("GET /{fname...}", web.AssetRequest(WebPerm))
158159 userRouter.HandleFunc("GET /{$}", web.AssetRequest(WebPerm))
159160 web.UserRouter = userRouter
......@@ -570,6 +571,10 @@ func (web *WebRouter) handleLogin(w http.ResponseWriter, r *http.Request) {
570571 handleLogin(w, r, web.Cfg)
571572 }
572573
574+func (web *WebRouter) handleAutoForm(w http.ResponseWriter, r *http.Request) {
575+ handleAutoForm(w, r, web.Cfg)
576+}
577+
573578 func (web *WebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
574579 subdomain := router.GetSubdomainFromRequest(r, web.Cfg.Domain, web.Cfg.TxtPrefix)
575580 if web.RootRouter == nil || web.UserRouter == nil {
+24, -0
......@@ -249,6 +249,30 @@ type Token struct {
249249 ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
250250 }
251251
252+type FormEntry struct {
253+ ID string `json:"id" db:"id"`
254+ UserID string `json:"-" db:"user_id"`
255+ Name string `json:"-" db:"name"`
256+ Data FormEntryData `json:"data" db:"data"`
257+ CreatedAt *time.Time `json:"created_at" db:"created_at"`
258+}
259+
260+type FormEntryData map[string]interface{}
261+
262+// Make the FormEntry struct implement the driver.Valuer interface.
263+func (f FormEntryData) Value() (driver.Value, error) {
264+ return json.Marshal(f)
265+}
266+
267+// Make the FormEntry struct implement the sql.Scanner interface.
268+func (f *FormEntryData) Scan(value any) error {
269+ b, err := tcast(value)
270+ if err != nil {
271+ return err
272+ }
273+ return json.Unmarshal(b, &f)
274+}
275+
252276 type FeatureFlag struct {
253277 ID string `json:"id" db:"id"`
254278 UserID string `json:"user_id" db:"user_id"`
+1, -2
......@@ -6,11 +6,10 @@ import (
66 "fmt"
77 "log/slog"
88 "math"
9+ "slices"
910 "strings"
1011 "time"
1112
12- "slices"
13-
1413 "github.com/jmoiron/sqlx"
1514 _ "github.com/lib/pq"
1615 "github.com/picosh/pico/pkg/db"
+15, -0
......@@ -0,0 +1,15 @@
1+CREATE TABLE IF NOT EXISTS form_entries (
2+ id uuid NOT NULL DEFAULT uuid_generate_v4(),
3+ user_id uuid NOT NULL,
4+ name VARCHAR(255) NOT NULL,
5+ data jsonb NOT NULL,
6+ created_at timestamp without time zone NOT NULL DEFAULT NOW(),
7+ CONSTRAINT form_entries_pkey PRIMARY KEY (id),
8+ CONSTRAINT fk_form_entries_users
9+ FOREIGN KEY(user_id)
10+ REFERENCES app_users(id)
11+ ON DELETE CASCADE
12+);
13+
14+CREATE INDEX IF NOT EXISTS idx_form_entries_user ON form_entries(user_id);
15+CREATE INDEX IF NOT EXISTS idx_form_entries_name ON form_entries(name);