Commit d69226d
Eric Bower
·
2026-03-05 23:27:44 -0500 EST
parent 0c37db1
feat(pgs): auto-forms
15 files changed,
+445,
-10
M
Makefile
+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 |
M
Makefile
+2,
-1
| ... | ... | @@ -145,10 +145,11 @@ migrate: | |
| 145 | 145 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20251217_add_access_logs_table.sql | |
| 146 | 146 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20251226_add_pipe_monitoring.sql | |
| 147 | 147 | $(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 | |
| 148 | 149 | .PHONY: migrate | |
| 149 | 150 | ||
| 150 | 151 | 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 | |
| 152 | 153 | .PHONY: latest | |
| 153 | 154 | ||
| 154 | 155 | 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
| ... | ... | @@ -561,3 +562,39 @@ func (c *Cmd) cacheAll() error { | |
| 561 | 562 | } | |
| 562 | 563 | return nil | |
| 563 | 564 | } | |
| 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 { | |
| 212 | 212 | ||
| 213 | 213 | err := opts.cache(projectName) | |
| 214 | 214 | 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 | + | ||
| 215 | 238 | opts.bail(err) | |
| 216 | 239 | return err | |
| 217 | 240 | case "acl": |
+5,
-0
| ... | ... | @@ -27,6 +27,11 @@ type PgsDB interface { | |
| 27 | 27 | FindProjectsByPrefix(userID, name string) ([]*db.Project, error) | |
| 28 | 28 | FindProjects(by string) ([]*db.Project, error) | |
| 29 | 29 | ||
| 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 | + | ||
| 30 | 35 | RegisterAdmin(username, pubkey, pubkeyName string) error | |
| 31 | 36 | ||
| 32 | 37 | Close() error |
+55,
-5
| ... | ... | @@ -11,11 +11,12 @@ import ( | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | 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 | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | var _ PgsDB = (*MemoryDB)(nil) |
| ... | ... | @@ -195,3 +196,52 @@ func (me *MemoryDB) RegisterAdmin(username, pubkey, pubkeyName string) error { | |
| 195 | 196 | func (me *MemoryDB) InsertAccessLog(*db.AccessLog) error { | |
| 196 | 197 | return errNotImpl | |
| 197 | 198 | } | |
| 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 @@ | |
| 1 | 1 | package pgsdb | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/json" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "log/slog" | |
| 6 | 7 | "time" |
| ... | ... | @@ -295,3 +296,41 @@ func (me *PgsPsqlDB) RegisterAdmin(username, pubkey, pubkeyName string) error { | |
| 295 | 296 | _, err = me.Db.Exec("INSERT INTO feature_flags (user_id, name, expires_at) VALUES (1, 'admin', '2100-01-01')", userID) | |
| 296 | 297 | return err | |
| 297 | 298 | } | |
| 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 ( | |
| 58 | 58 | ON DELETE CASCADE | |
| 59 | 59 | ON UPDATE CASCADE | |
| 60 | 60 | ); | |
| 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 | + | ); | |
| 61 | 73 | ` | |
| 62 | 74 | ||
| 63 | 75 | 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 @@ | |
| 16 | 16 | <div style="color: tomato;">{{.Error}}</div> | |
| 17 | 17 | {{end}} | |
| 18 | 18 | ||
| 19 | - | <form method="POST" action="/auth/login"> | |
| 19 | + | <form method="POST" action="/pgs/login"> | |
| 20 | 20 | <input type="hidden" name="project" value="{{.ProjectName}}"> | |
| 21 | 21 | <label for="password">Enter password:</label> | |
| 22 | 22 | <div class="flex gap"> |
+6,
-1
| ... | ... | @@ -153,7 +153,8 @@ func (web *WebRouter) initRouters() { | |
| 153 | 153 | ||
| 154 | 154 | // subdomain or custom domains | |
| 155 | 155 | 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) | |
| 157 | 158 | userRouter.HandleFunc("GET /{fname...}", web.AssetRequest(WebPerm)) | |
| 158 | 159 | userRouter.HandleFunc("GET /{$}", web.AssetRequest(WebPerm)) | |
| 159 | 160 | web.UserRouter = userRouter |
| ... | ... | @@ -570,6 +571,10 @@ func (web *WebRouter) handleLogin(w http.ResponseWriter, r *http.Request) { | |
| 570 | 571 | handleLogin(w, r, web.Cfg) | |
| 571 | 572 | } | |
| 572 | 573 | ||
| 574 | + | func (web *WebRouter) handleAutoForm(w http.ResponseWriter, r *http.Request) { | |
| 575 | + | handleAutoForm(w, r, web.Cfg) | |
| 576 | + | } | |
| 577 | + | ||
| 573 | 578 | func (web *WebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 574 | 579 | subdomain := router.GetSubdomainFromRequest(r, web.Cfg.Domain, web.Cfg.TxtPrefix) | |
| 575 | 580 | if web.RootRouter == nil || web.UserRouter == nil { |
+24,
-0
| ... | ... | @@ -249,6 +249,30 @@ type Token struct { | |
| 249 | 249 | ExpiresAt *time.Time `json:"expires_at" db:"expires_at"` | |
| 250 | 250 | } | |
| 251 | 251 | ||
| 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 | + | ||
| 252 | 276 | type FeatureFlag struct { | |
| 253 | 277 | ID string `json:"id" db:"id"` | |
| 254 | 278 | UserID string `json:"user_id" db:"user_id"` |
+1,
-2
| ... | ... | @@ -6,11 +6,10 @@ import ( | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "log/slog" | |
| 8 | 8 | "math" | |
| 9 | + | "slices" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 12 | - | "slices" | |
| 13 | - | ||
| 14 | 13 | "github.com/jmoiron/sqlx" | |
| 15 | 14 | _ "github.com/lib/pq" | |
| 16 | 15 | "github.com/picosh/pico/pkg/db" |
| ... | ... | @@ -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); |