Skip to content

Commit d69226d

Browse files
committed
feat(pgs): auto-forms
1 parent 0c37db1 commit d69226d

15 files changed

Lines changed: 445 additions & 10 deletions

File tree

AUTO_FORM.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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:

pkg/apps/pgs/auto_form.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

pkg/apps/pgs/cli.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

pkg/apps/pgs/cli_middleware.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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":

pkg/apps/pgs/db/db.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

pkg/apps/pgs/db/memory.go

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)