Antonio Mika
·
2026-10-10
1package pgs
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "path/filepath"
10 "slices"
11 "strings"
12 "text/tabwriter"
13 "time"
14
15 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
16 "github.com/picosh/pico/pkg/db"
17 "github.com/picosh/pico/pkg/shared"
18 "github.com/picosh/pico/pkg/storage"
19)
20
21func NewTabWriter(out io.Writer) *tabwriter.Writer {
22 return tabwriter.NewWriter(out, 0, 0, 2, ' ', tabwriter.TabIndent)
23}
24
25func projectTable(sesh io.Writer, projects []*db.Project) {
26 writer := NewTabWriter(sesh)
27 _, _ = fmt.Fprintln(writer, "Name\tLast Updated\tLinks To\tACL Type\tACL\tBlocked")
28
29 for _, project := range projects {
30 links := ""
31 if project.ProjectDir != project.Name {
32 links = project.ProjectDir
33 }
34 _, _ = fmt.Fprintf(
35 writer,
36 "%s\t%s\t%s\t%s\t%s\t%s\r\n",
37 project.Name,
38 project.UpdatedAt.Format("2006-01-02 15:04:05"),
39 links,
40 project.Acl.Type,
41 strings.Join(project.Acl.Data, " "),
42 project.Blocked,
43 )
44 }
45 _ = writer.Flush()
46}
47
48type Cmd struct {
49 User *db.User
50 Session shared.CmdSession
51 Log *slog.Logger
52 Store storage.StorageServe
53 Dbpool pgsdb.PgsDB
54 Write bool
55 Width int
56 Height int
57 Cfg *Config
58}
59
60func (c *Cmd) output(out string) {
61 _, _ = c.Session.Write([]byte(out + "\r\n"))
62}
63
64func (c *Cmd) error(err error) {
65 _, _ = fmt.Fprint(c.Session.Stderr(), err, "\r\n")
66 _ = c.Session.Exit(1)
67 _ = c.Session.Close()
68}
69
70func (c *Cmd) bail(err error) {
71 if err == nil {
72 return
73 }
74 c.Log.Error(err.Error())
75 c.error(err)
76}
77
78func (c *Cmd) notice() {
79 if !c.Write {
80 c.output("\nNOTICE: changes not commited, use `--write` to save operation")
81 }
82}
83
84// purge clears what the web servers cache about a project after a command
85// changed it.
86func (c *Cmd) purge(projectName string) {
87 c.Cfg.CacheClearingQueue <- getSurrogateKey(c.User.Name, projectName)
88}
89
90func (c *Cmd) RmProjectAssets(projectName string) error {
91 bucketName := shared.GetAssetBucketName(c.User.ID)
92 bucket, err := c.Store.GetBucket(bucketName)
93 if err != nil {
94 return err
95 }
96 c.output(fmt.Sprintf("removing project assets (%s)", projectName))
97
98 fileList, err := c.Store.ListObjects(bucket, projectName+"/", true)
99 if err != nil {
100 return err
101 }
102
103 if len(fileList) == 0 {
104 c.output(fmt.Sprintf("no assets found for project (%s)", projectName))
105 if c.Write {
106 return c.Store.DeleteObject(bucket, projectName)
107 }
108 return nil
109 }
110 c.output(fmt.Sprintf("found (%d) assets for project (%s), removing", len(fileList), projectName))
111 if c.Write {
112 defer c.purge(projectName)
113 }
114
115 for _, file := range fileList {
116 if file.IsDir() {
117 continue
118 }
119 intent := fmt.Sprintf("deleted (%s)", file.Name())
120 c.Log.Info(
121 "attempting to delete file",
122 "user", c.User.Name,
123 "bucket", bucket.Name,
124 "filename", file.Name(),
125 )
126 if c.Write {
127 err = c.Store.DeleteObject(
128 bucket,
129 filepath.Join(projectName, file.Name()),
130 )
131 if err == nil {
132 c.output(intent)
133 } else {
134 return err
135 }
136 } else {
137 c.output(intent)
138 }
139 }
140 if !c.Write {
141 return nil
142 }
143
144 // Directories outlive their files, so remove them deepest first.
145 dirs := []string{projectName}
146 for _, file := range fileList {
147 if file.IsDir() {
148 dirs = append(dirs, filepath.Join(projectName, file.Name()))
149 }
150 }
151 slices.SortStableFunc(dirs, func(a, b string) int {
152 return strings.Count(b, "/") - strings.Count(a, "/")
153 })
154 for _, dir := range dirs {
155 if err := c.Store.DeleteObject(bucket, dir); err != nil {
156 return err
157 }
158 }
159 return nil
160}
161
162func (c *Cmd) help() {
163 helpStr := `pgs.sh
164======
165
166Deploy static sites with a single command. No passwords. No config files. No CI setup. Just your SSH key and rsync.
167
168 rsync --delete -rv ./public/ pgs.sh:/mysite
169 # => https://erock-mysite.pgs.sh
170
171That's the entire workflow. Your SSH key is your identity and every deploy is instant.
172
173You can fetch project assets with the same command in reverse:
174
175 rsync -rv pgs.sh:/mysite/ ./public/
176
177You can also use unix pipes to directly upload files by providing the project name as part of the path:
178
179 echo "<body>hello world!</body>" | ssh pgs.sh /mysite/index.html
180 # => https://erock-mysite.pgs.sh/index.html
181
182The leading "/" is important.
183
184You can also create private projects when you prefix the project name with 'private':
185
186 rsync -rv ./public/ pgs.sh:/private-site/
187
188This means only you can access the site through a web tunnel or by downloading the files.
189`
190 helpStr += "\r\nCommands: [help, stats, ls, fzf, rm, link, unlink, prune, retain, depends, acl, cache, forms]\r\n"
191 helpStr += "For most of these commands you can provide a `-h` to learn about its usage.\r\n"
192 helpStr += "\r\n> NOTICE:" + " *must* append with `--write` for the changes to persist.\r\n"
193 c.output(helpStr)
194 projectName := "{project}"
195
196 data := [][]string{
197 {
198 "help",
199 "Prints this screen",
200 },
201 {
202 "stats",
203 "Usage statistics (quota, % quota used, number of projects)",
204 },
205 {
206 "ls",
207 "Lists all projects and meta data",
208 },
209 {
210 fmt.Sprintf("fzf %s", projectName),
211 "Lists urls of all assets in project",
212 },
213 {
214 fmt.Sprintf("rm %s", projectName),
215 "Removes all files in project and then deletes the project",
216 },
217 {
218 fmt.Sprintf("link %s --to projB", projectName),
219 fmt.Sprintf("Instant promotion and rollback mechanism that symbolic links %s to `projB`", projectName),
220 },
221 {
222 fmt.Sprintf("unlink %s", projectName),
223 "Removes symbolic link",
224 },
225 {
226 fmt.Sprintf("prune %s", projectName),
227 "Delete all projects matching a prefix (except projects with linked projects)",
228 },
229 {
230 fmt.Sprintf("retain %s", projectName),
231 "Delete all projects matching a prefix except the last N recently updated projects.",
232 },
233 {
234 fmt.Sprintf("depends %s", projectName),
235 "Lists all projects linked to project",
236 },
237 {
238 fmt.Sprintf("acl %s", projectName),
239 "Access control for project",
240 },
241 {
242 fmt.Sprintf("cache %s", projectName),
243 "Clear http cache",
244 },
245 {
246 "forms ls",
247 "Print list of forms",
248 },
249 {
250 fmt.Sprintf("forms %s", projectName),
251 "Print form submissions in json",
252 },
253 }
254
255 writer := NewTabWriter(c.Session)
256 _, _ = fmt.Fprintln(writer, "Cmd\tDescription")
257 _, _ = fmt.Fprintf(writer, "===\t===========\r\n")
258 for _, dat := range data {
259 _, _ = fmt.Fprintf(writer, "%s\t%s\r\n", dat[0], dat[1])
260 }
261 _ = writer.Flush()
262}
263
264func (c *Cmd) stats(cfgMaxSize uint64) error {
265 ff, err := findFeatureFlag(c.Dbpool, c.Cfg, c.User.ID)
266 if err != nil {
267 ff = db.NewFeatureFlag(c.User.ID, "pgs", cfgMaxSize, 0, 0)
268 }
269 storageMax := ff.FindStorageMax(cfgMaxSize)
270
271 bucketName := shared.GetAssetBucketName(c.User.ID)
272 bucket, err := c.Store.GetBucket(bucketName)
273 if err != nil {
274 return err
275 }
276
277 totalFileSize, err := c.Store.GetBucketQuota(bucket)
278 if err != nil {
279 return err
280 }
281
282 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
283 if err != nil {
284 return err
285 }
286
287 writer := NewTabWriter(c.Session)
288 _, _ = fmt.Fprintln(writer, "Used (GB)\tQuota (GB)\tUsed (%)\tProjects (#)")
289 _, _ = fmt.Fprintf(
290 writer,
291 "%.4f\t%.4f\t%.4f\t%d\r\n",
292 shared.BytesToGB(int(totalFileSize)),
293 shared.BytesToGB(int(storageMax)),
294 (float32(totalFileSize)/float32(storageMax))*100,
295 len(projects),
296 )
297 return writer.Flush()
298}
299
300func (c *Cmd) ls() error {
301 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
302 if err != nil {
303 return err
304 }
305
306 if len(projects) == 0 {
307 c.output("no projects found")
308 }
309
310 projectTable(c.Session, projects)
311
312 return nil
313}
314
315func (c *Cmd) unlink(projectName string) error {
316 c.Log.Info("user running `unlink` command", "user", c.User.Name, "project", projectName)
317 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
318 if err != nil {
319 return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
320 }
321
322 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, project.Name, c.Write)
323 if err != nil {
324 return err
325 }
326 if c.Write {
327 c.purge(project.Name)
328 }
329 c.output(fmt.Sprintf("(%s) unlinked", project.Name))
330
331 return nil
332}
333
334func (c *Cmd) fzf(projectName string) error {
335 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
336 if err != nil {
337 return err
338 }
339
340 bucket, err := c.Store.GetBucket(shared.GetAssetBucketName(c.User.ID))
341 if err != nil {
342 return err
343 }
344
345 objs, err := c.Store.ListObjects(bucket, project.ProjectDir+"/", true)
346 if err != nil {
347 return err
348 }
349
350 for _, obj := range objs {
351 url := c.Cfg.AssetURL(
352 c.User.Name,
353 project.Name,
354 strings.TrimPrefix(obj.Name(), "/"),
355 )
356 c.output(url)
357 }
358
359 return nil
360}
361
362func (c *Cmd) link(projectName, linkTo string) error {
363 c.Log.Info("user running `link` command", "user", c.User.Name, "project", projectName, "link", linkTo)
364
365 projectDir := linkTo
366 _, err := c.Dbpool.FindProjectByName(c.User.ID, linkTo)
367 if err != nil {
368 e := fmt.Errorf("(%s) project doesn't exist", linkTo)
369 return e
370 }
371
372 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
373 projectID := ""
374 if err == nil {
375 projectID = project.ID
376 c.Log.Info("user already has project, updating", "user", c.User.Name, "project", projectName)
377 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, projectDir, c.Write)
378 if err != nil {
379 return err
380 }
381 } else {
382 c.Log.Info("user has no project record, creating", "user", c.User.Name, "project", projectName)
383 if !c.Write {
384 out := fmt.Sprintf("(%s) cannot create a new project without `--write` permission, aborting", projectName)
385 c.output(out)
386 return nil
387 }
388 id, err := c.Dbpool.InsertProject(c.User.ID, projectName, projectName)
389 if err != nil {
390 return err
391 }
392 projectID = id
393 }
394
395 c.Log.Info("user linking", "user", c.User.Name, "project", projectName, "projectDir", projectDir)
396 err = c.Dbpool.LinkToProject(c.User.ID, projectID, projectDir, c.Write)
397 if err != nil {
398 return err
399 }
400 if c.Write {
401 c.purge(projectName)
402 }
403
404 out := fmt.Sprintf("(%s) might have orphaned assets, removing", projectName)
405 c.output(out)
406
407 err = c.RmProjectAssets(projectName)
408 if err != nil {
409 return err
410 }
411
412 out = fmt.Sprintf("(%s) now points to (%s)", projectName, linkTo)
413 c.output(out)
414 return nil
415}
416
417func (c *Cmd) depends(projectName string) error {
418 projects, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
419 if err != nil {
420 return err
421 }
422
423 if len(projects) == 0 {
424 out := fmt.Sprintf("no projects linked to (%s)", projectName)
425 c.output(out)
426 return nil
427 }
428
429 projectTable(c.Session, projects)
430 return nil
431}
432
433// delete all the projects and associated assets matching prefix
434// but keep the latest N records.
435func (c *Cmd) prune(prefix string, keepNumLatest int) error {
436 c.Log.Info("user running `clean` command", "user", c.User.Name, "prefix", prefix)
437 c.output(fmt.Sprintf("searching for projects that match prefix (%s) and are not linked to other projects", prefix))
438
439 if prefix == "" || prefix == "*" {
440 e := fmt.Errorf("must provide valid prefix")
441 return e
442 }
443
444 projects, err := c.Dbpool.FindProjectsByPrefix(c.User.ID, prefix)
445 if err != nil {
446 return err
447 }
448
449 if len(projects) == 0 {
450 c.output(fmt.Sprintf("no projects found matching prefix (%s)", prefix))
451 return nil
452 }
453
454 rmProjects := []*db.Project{}
455 for _, project := range projects {
456 links, err := c.Dbpool.FindProjectLinks(c.User.ID, project.Name)
457 if err != nil {
458 return err
459 }
460
461 if len(links) == 0 {
462 rmProjects = append(rmProjects, project)
463 } else {
464 out := fmt.Sprintf("project (%s) has (%d) projects linked to it, cannot prune", project.Name, len(links))
465 c.output(out)
466 }
467 }
468
469 goodbye := rmProjects
470 if keepNumLatest > 0 {
471 pmax := len(rmProjects) - (keepNumLatest)
472 if pmax <= 0 {
473 out := fmt.Sprintf(
474 "no projects available to prune (retention policy: %d, total: %d)",
475 keepNumLatest,
476 len(rmProjects),
477 )
478 c.output(out)
479 return nil
480 }
481 goodbye = rmProjects[:pmax]
482 }
483
484 for _, project := range goodbye {
485 out := fmt.Sprintf("project (%s) is available to be pruned", project.Name)
486 c.output(out)
487 err = c.RmProjectAssets(project.Name)
488 if err != nil {
489 return err
490 }
491
492 out = fmt.Sprintf("(%s) removing", project.Name)
493 c.output(out)
494
495 if c.Write {
496 c.Log.Info("removing project", "project", project.Name)
497 err = c.Dbpool.RemoveProject(project.ID)
498 if err != nil {
499 return err
500 }
501 c.purge(project.Name)
502 }
503 }
504
505 c.output("\r\nsummary")
506 c.output("=======")
507 for _, project := range goodbye {
508 c.output(fmt.Sprintf("project (%s) removed", project.Name))
509 }
510
511 return nil
512}
513
514func (c *Cmd) rm(projectName string) error {
515 c.Log.Info("user running `rm` command", "user", c.User.Name, "project", projectName)
516
517 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
518 if err == nil {
519 c.Log.Info("found project, checking dependencies", "project", projectName, "projectID", project.ID)
520
521 links, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
522 if err != nil {
523 return err
524 }
525
526 if len(links) > 0 {
527 e := fmt.Errorf("project (%s) has (%d) projects linking to it, cannot delete project until they have been unlinked or removed, aborting", projectName, len(links))
528 return e
529 }
530
531 out := fmt.Sprintf("(%s) removing", project.Name)
532 c.output(out)
533 if c.Write {
534 c.Log.Info("removing project", "project", project.Name)
535 err = c.Dbpool.RemoveProject(project.ID)
536 if err != nil {
537 return err
538 }
539 c.purge(project.Name)
540 }
541 } else {
542 msg := fmt.Sprintf("(%s) project record not found for user (%s)", projectName, c.User.Name)
543 c.output(msg)
544 }
545
546 err = c.RmProjectAssets(projectName)
547 return err
548}
549
550func (c *Cmd) acl(projectName, aclType string, acls []string) error {
551 c.Log.Info(
552 "user running `acl` command",
553 "user", c.User.Name,
554 "project", projectName,
555 "actType", aclType,
556 "acls", acls,
557 )
558 c.output(fmt.Sprintf("setting acl for %s to %s (%s)", projectName, aclType, strings.Join(acls, ",")))
559 acl := db.ProjectAcl{
560 Type: aclType,
561 Data: acls,
562 }
563 if c.Write {
564 if err := c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl); err != nil {
565 return err
566 }
567 c.purge(projectName)
568 }
569 return nil
570}
571
572func (c *Cmd) cache(projectName string) error {
573 c.Log.Info(
574 "user running `cache` command",
575 "user", c.User.Name,
576 "project", projectName,
577 )
578
579 c.output(fmt.Sprintf("clearing http cache for %s", projectName))
580
581 if c.Write {
582 surrogate := getSurrogateKey(c.User.Name, projectName)
583 return purgeCache(c.Cfg, c.Cfg.Pubsub, surrogate)
584 }
585 return nil
586}
587
588func (c *Cmd) cacheAll() error {
589 isAdmin := false
590 ff, _ := c.Dbpool.FindFeature(c.User.ID, "admin")
591 if ff != nil {
592 if ff.ExpiresAt.After(time.Now()) {
593 isAdmin = true
594 }
595 }
596
597 if !isAdmin {
598 return fmt.Errorf("must be admin to use this command")
599 }
600
601 c.Log.Info(
602 "admin running `cache-all` command",
603 "user", c.User.Name,
604 )
605 c.output("clearing http cache for all sites")
606 if c.Write {
607 return purgeAllCache(c.Cfg, c.Cfg.Pubsub)
608 }
609 return nil
610}
611
612func (c *Cmd) formsLs() error {
613 forms, err := c.Dbpool.FindFormNamesByUser(c.User.ID)
614 if err != nil {
615 return err
616 }
617 if len(forms) == 0 {
618 c.output("no forms found")
619 return nil
620 }
621 for _, name := range forms {
622 c.output(name)
623 }
624 return nil
625}
626
627func (c *Cmd) formData(formName string) error {
628 formData, err := c.Dbpool.FindFormEntriesByUserAndName(c.User.ID, formName)
629 if err != nil {
630 return err
631 }
632 data, err := json.Marshal(formData)
633 if err != nil {
634 return err
635 }
636 c.output(string(data))
637 return nil
638}
639
640func (c *Cmd) formRm(formName string) error {
641 c.output(fmt.Sprintf("removing all data associated with form: %s", formName))
642 if c.Write {
643 return c.Dbpool.RemoveFormEntriesByUserAndName(c.User.ID, formName)
644 }
645 return nil
646}