Eric Bower
·
2025-06-26
cli.go
1package pgs
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "path/filepath"
10 "strings"
11 "text/tabwriter"
12 "time"
13
14 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
15 "github.com/picosh/pico/pkg/db"
16 sst "github.com/picosh/pico/pkg/pobj/storage"
17 "github.com/picosh/pico/pkg/pssh"
18 "github.com/picosh/pico/pkg/shared"
19 "github.com/picosh/utils"
20)
21
22func NewTabWriter(out io.Writer) *tabwriter.Writer {
23 return tabwriter.NewWriter(out, 0, 0, 2, ' ', tabwriter.TabIndent)
24}
25
26func projectTable(sesh io.Writer, projects []*db.Project) {
27 writer := NewTabWriter(sesh)
28 _, _ = fmt.Fprintln(writer, "Name\tLast Updated\tLinks To\tACL Type\tACL\tBlocked")
29
30 for _, project := range projects {
31 links := ""
32 if project.ProjectDir != project.Name {
33 links = project.ProjectDir
34 }
35 _, _ = fmt.Fprintf(
36 writer,
37 "%s\t%s\t%s\t%s\t%s\t%s\r\n",
38 project.Name,
39 project.UpdatedAt.Format("2006-01-02 15:04:05"),
40 links,
41 project.Acl.Type,
42 strings.Join(project.Acl.Data, " "),
43 project.Blocked,
44 )
45 }
46 _ = writer.Flush()
47}
48
49type Cmd struct {
50 User *db.User
51 Session utils.CmdSession
52 Log *slog.Logger
53 Store sst.ObjectStorage
54 Dbpool pgsdb.PgsDB
55 Write bool
56 Width int
57 Height int
58 Cfg *PgsConfig
59}
60
61func (c *Cmd) output(out string) {
62 _, _ = c.Session.Write([]byte(out + "\r\n"))
63}
64
65func (c *Cmd) error(err error) {
66 _, _ = fmt.Fprint(c.Session.Stderr(), err, "\r\n")
67 _ = c.Session.Exit(1)
68 _ = c.Session.Close()
69}
70
71func (c *Cmd) bail(err error) {
72 if err == nil {
73 return
74 }
75 c.Log.Error(err.Error())
76 c.error(err)
77}
78
79func (c *Cmd) notice() {
80 if !c.Write {
81 c.output("\nNOTICE: changes not commited, use `--write` to save operation")
82 }
83}
84
85func (c *Cmd) RmProjectAssets(projectName string) error {
86 bucketName := shared.GetAssetBucketName(c.User.ID)
87 bucket, err := c.Store.GetBucket(bucketName)
88 if err != nil {
89 return err
90 }
91 c.output(fmt.Sprintf("removing project assets (%s)", projectName))
92
93 fileList, err := c.Store.ListObjects(bucket, projectName+"/", true)
94 if err != nil {
95 return err
96 }
97
98 if len(fileList) == 0 {
99 c.output(fmt.Sprintf("no assets found for project (%s)", projectName))
100 return nil
101 }
102 c.output(fmt.Sprintf("found (%d) assets for project (%s), removing", len(fileList), projectName))
103
104 for _, file := range fileList {
105 if file.IsDir() {
106 continue
107 }
108 intent := fmt.Sprintf("deleted (%s)", file.Name())
109 c.Log.Info(
110 "attempting to delete file",
111 "user", c.User.Name,
112 "bucket", bucket.Name,
113 "filename", file.Name(),
114 )
115 if c.Write {
116 err = c.Store.DeleteObject(
117 bucket,
118 filepath.Join(projectName, file.Name()),
119 )
120 if err == nil {
121 c.output(intent)
122 } else {
123 return err
124 }
125 } else {
126 c.output(intent)
127 }
128 }
129 return nil
130}
131
132func (c *Cmd) help() {
133 helpStr := "Commands: [help, stats, ls, fzf, rm, link, unlink, prune, retain, depends, acl, cache]\r\n"
134 helpStr += "NOTICE:" + " *must* append with `--write` for the changes to persist.\r\n"
135 c.output(helpStr)
136 projectName := "projA"
137
138 data := [][]string{
139 {
140 "help",
141 "prints this screen",
142 },
143 {
144 "stats",
145 "usage statistics",
146 },
147 {
148 "ls",
149 "lists projects",
150 },
151 {
152 fmt.Sprintf("fzf %s", projectName),
153 fmt.Sprintf("lists urls of all assets in %s", projectName),
154 },
155 {
156 fmt.Sprintf("rm %s", projectName),
157 fmt.Sprintf("delete %s", projectName),
158 },
159 {
160 fmt.Sprintf("link %s --to projB", projectName),
161 fmt.Sprintf("symbolic link `%s` to `projB`", projectName),
162 },
163 {
164 fmt.Sprintf("unlink %s", projectName),
165 fmt.Sprintf("removes symbolic link for `%s`", projectName),
166 },
167 {
168 fmt.Sprintf("prune %s", projectName),
169 fmt.Sprintf("removes projects that match prefix `%s`", projectName),
170 },
171 {
172 fmt.Sprintf("retain %s", projectName),
173 "alias to `prune` but keeps last N projects",
174 },
175 {
176 fmt.Sprintf("depends %s", projectName),
177 fmt.Sprintf("lists all projects linked to `%s`", projectName),
178 },
179 {
180 fmt.Sprintf("acl %s", projectName),
181 fmt.Sprintf("access control for `%s`", projectName),
182 },
183 {
184 fmt.Sprintf("cache %s", projectName),
185 fmt.Sprintf("clear http cache for `%s`", projectName),
186 },
187 }
188
189 writer := NewTabWriter(c.Session)
190 _, _ = fmt.Fprintln(writer, "Cmd\tDescription")
191 for _, dat := range data {
192 _, _ = fmt.Fprintf(writer, "%s\t%s\r\n", dat[0], dat[1])
193 }
194 _ = writer.Flush()
195}
196
197func (c *Cmd) stats(cfgMaxSize uint64) error {
198 ff, err := c.Dbpool.FindFeature(c.User.ID, "plus")
199 if err != nil {
200 ff = db.NewFeatureFlag(c.User.ID, "plus", cfgMaxSize, 0, 0)
201 }
202 // this is jank
203 ff.Data.StorageMax = ff.FindStorageMax(cfgMaxSize)
204 storageMax := ff.Data.StorageMax
205
206 bucketName := shared.GetAssetBucketName(c.User.ID)
207 bucket, err := c.Store.GetBucket(bucketName)
208 if err != nil {
209 return err
210 }
211
212 totalFileSize, err := c.Store.GetBucketQuota(bucket)
213 if err != nil {
214 return err
215 }
216
217 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
218 if err != nil {
219 return err
220 }
221
222 writer := NewTabWriter(c.Session)
223 _, _ = fmt.Fprintln(writer, "Used (GB)\tQuota (GB)\tUsed (%)\tProjects (#)")
224 _, _ = fmt.Fprintf(
225 writer,
226 "%.4f\t%.4f\t%.4f\t%d\r\n",
227 utils.BytesToGB(int(totalFileSize)),
228 utils.BytesToGB(int(storageMax)),
229 (float32(totalFileSize)/float32(storageMax))*100,
230 len(projects),
231 )
232 return writer.Flush()
233}
234
235func (c *Cmd) ls() error {
236 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
237 if err != nil {
238 return err
239 }
240
241 if len(projects) == 0 {
242 c.output("no projects found")
243 }
244
245 projectTable(c.Session, projects)
246
247 return nil
248}
249
250func (c *Cmd) unlink(projectName string) error {
251 c.Log.Info("user running `unlink` command", "user", c.User.Name, "project", projectName)
252 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
253 if err != nil {
254 return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
255 }
256
257 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, project.Name, c.Write)
258 if err != nil {
259 return err
260 }
261 c.output(fmt.Sprintf("(%s) unlinked", project.Name))
262
263 return nil
264}
265
266func (c *Cmd) fzf(projectName string) error {
267 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
268 if err != nil {
269 return err
270 }
271
272 bucket, err := c.Store.GetBucket(shared.GetAssetBucketName(c.User.ID))
273 if err != nil {
274 return err
275 }
276
277 objs, err := c.Store.ListObjects(bucket, project.ProjectDir+"/", true)
278 if err != nil {
279 return err
280 }
281
282 for _, obj := range objs {
283 if strings.Contains(obj.Name(), "._pico_keep_dir") {
284 continue
285 }
286 url := c.Cfg.AssetURL(
287 c.User.Name,
288 project.Name,
289 strings.TrimPrefix(obj.Name(), "/"),
290 )
291 c.output(url)
292 }
293
294 return nil
295}
296
297func (c *Cmd) link(projectName, linkTo string) error {
298 c.Log.Info("user running `link` command", "user", c.User.Name, "project", projectName, "link", linkTo)
299
300 projectDir := linkTo
301 _, err := c.Dbpool.FindProjectByName(c.User.ID, linkTo)
302 if err != nil {
303 e := fmt.Errorf("(%s) project doesn't exist", linkTo)
304 return e
305 }
306
307 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
308 projectID := ""
309 if err == nil {
310 projectID = project.ID
311 c.Log.Info("user already has project, updating", "user", c.User.Name, "project", projectName)
312 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, projectDir, c.Write)
313 if err != nil {
314 return err
315 }
316 } else {
317 c.Log.Info("user has no project record, creating", "user", c.User.Name, "project", projectName)
318 if !c.Write {
319 out := fmt.Sprintf("(%s) cannot create a new project without `--write` permission, aborting", projectName)
320 c.output(out)
321 return nil
322 }
323 id, err := c.Dbpool.InsertProject(c.User.ID, projectName, projectName)
324 if err != nil {
325 return err
326 }
327 projectID = id
328 }
329
330 c.Log.Info("user linking", "user", c.User.Name, "project", projectName, "projectDir", projectDir)
331 err = c.Dbpool.LinkToProject(c.User.ID, projectID, projectDir, c.Write)
332 if err != nil {
333 return err
334 }
335
336 out := fmt.Sprintf("(%s) might have orphaned assets, removing", projectName)
337 c.output(out)
338
339 err = c.RmProjectAssets(projectName)
340 if err != nil {
341 return err
342 }
343
344 out = fmt.Sprintf("(%s) now points to (%s)", projectName, linkTo)
345 c.output(out)
346 return nil
347}
348
349func (c *Cmd) depends(projectName string) error {
350 projects, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
351 if err != nil {
352 return err
353 }
354
355 if len(projects) == 0 {
356 out := fmt.Sprintf("no projects linked to (%s)", projectName)
357 c.output(out)
358 return nil
359 }
360
361 projectTable(c.Session, projects)
362 return nil
363}
364
365// delete all the projects and associated assets matching prefix
366// but keep the latest N records.
367func (c *Cmd) prune(prefix string, keepNumLatest int) error {
368 c.Log.Info("user running `clean` command", "user", c.User.Name, "prefix", prefix)
369 c.output(fmt.Sprintf("searching for projects that match prefix (%s) and are not linked to other projects", prefix))
370
371 if prefix == "prose" {
372 return fmt.Errorf("cannot delete `prose` because it is used by prose.sh and is protected")
373 }
374
375 if prefix == "" || prefix == "*" {
376 e := fmt.Errorf("must provide valid prefix")
377 return e
378 }
379
380 projects, err := c.Dbpool.FindProjectsByPrefix(c.User.ID, prefix)
381 if err != nil {
382 return err
383 }
384
385 if len(projects) == 0 {
386 c.output(fmt.Sprintf("no projects found matching prefix (%s)", prefix))
387 return nil
388 }
389
390 rmProjects := []*db.Project{}
391 for _, project := range projects {
392 links, err := c.Dbpool.FindProjectLinks(c.User.ID, project.Name)
393 if err != nil {
394 return err
395 }
396
397 if len(links) == 0 {
398 rmProjects = append(rmProjects, project)
399 } else {
400 out := fmt.Sprintf("project (%s) has (%d) projects linked to it, cannot prune", project.Name, len(links))
401 c.output(out)
402 }
403 }
404
405 goodbye := rmProjects
406 if keepNumLatest > 0 {
407 pmax := len(rmProjects) - (keepNumLatest)
408 if pmax <= 0 {
409 out := fmt.Sprintf(
410 "no projects available to prune (retention policy: %d, total: %d)",
411 keepNumLatest,
412 len(rmProjects),
413 )
414 c.output(out)
415 return nil
416 }
417 goodbye = rmProjects[:pmax]
418 }
419
420 for _, project := range goodbye {
421 out := fmt.Sprintf("project (%s) is available to be pruned", project.Name)
422 c.output(out)
423 err = c.RmProjectAssets(project.Name)
424 if err != nil {
425 return err
426 }
427
428 out = fmt.Sprintf("(%s) removing", project.Name)
429 c.output(out)
430
431 if c.Write {
432 c.Log.Info("removing project", "project", project.Name)
433 err = c.Dbpool.RemoveProject(project.ID)
434 if err != nil {
435 return err
436 }
437 }
438 }
439
440 c.output("\r\nsummary")
441 c.output("=======")
442 for _, project := range goodbye {
443 c.output(fmt.Sprintf("project (%s) removed", project.Name))
444 }
445
446 return nil
447}
448
449func (c *Cmd) rm(projectName string) error {
450 c.Log.Info("user running `rm` command", "user", c.User.Name, "project", projectName)
451 if projectName == "prose" {
452 return fmt.Errorf("cannot delete `prose` because it is used by prose.sh and is protected")
453 }
454
455 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
456 if err == nil {
457 c.Log.Info("found project, checking dependencies", "project", projectName, "projectID", project.ID)
458
459 links, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
460 if err != nil {
461 return err
462 }
463
464 if len(links) > 0 {
465 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))
466 return e
467 }
468
469 out := fmt.Sprintf("(%s) removing", project.Name)
470 c.output(out)
471 if c.Write {
472 c.Log.Info("removing project", "project", project.Name)
473 err = c.Dbpool.RemoveProject(project.ID)
474 if err != nil {
475 return err
476 }
477 }
478 } else {
479 msg := fmt.Sprintf("(%s) project record not found for user (%s)", projectName, c.User.Name)
480 c.output(msg)
481 }
482
483 err = c.RmProjectAssets(projectName)
484 return err
485}
486
487func (c *Cmd) acl(projectName, aclType string, acls []string) error {
488 c.Log.Info(
489 "user running `acl` command",
490 "user", c.User.Name,
491 "project", projectName,
492 "actType", aclType,
493 "acls", acls,
494 )
495 c.output(fmt.Sprintf("setting acl for %s to %s (%s)", projectName, aclType, strings.Join(acls, ",")))
496 acl := db.ProjectAcl{
497 Type: aclType,
498 Data: acls,
499 }
500 if c.Write {
501 return c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl)
502 }
503 return nil
504}
505
506func (c *Cmd) cache(projectName string) error {
507 c.Log.Info(
508 "user running `cache` command",
509 "user", c.User.Name,
510 "project", projectName,
511 )
512
513 c.output(fmt.Sprintf("clearing http cache for %s", projectName))
514
515 if c.Write {
516 var ctx context.Context
517 if s, ok := c.Session.(*pssh.SSHServerConnSession); ok {
518 ctx = s.Context()
519 } else {
520 ctx = context.Background()
521 }
522
523 ctx, cancel := context.WithCancel(ctx)
524 defer cancel()
525
526 send := createPubCacheDrain(ctx, c.Log)
527
528 surrogate := getSurrogateKey(c.User.Name, projectName)
529 return purgeCache(c.Cfg, send, surrogate)
530 }
531 return nil
532}
533
534func (c *Cmd) cacheAll() error {
535 isAdmin := false
536 ff, _ := c.Dbpool.FindFeature(c.User.ID, "admin")
537 if ff != nil {
538 if ff.ExpiresAt.Before(time.Now()) {
539 isAdmin = true
540 }
541 }
542
543 if !isAdmin {
544 return fmt.Errorf("must be admin to use this command")
545 }
546
547 c.Log.Info(
548 "admin running `cache-all` command",
549 "user", c.User.Name,
550 )
551 c.output("clearing http cache for all sites")
552 if c.Write {
553 var ctx context.Context
554 if s, ok := c.Session.(*pssh.SSHServerConnSession); ok {
555 ctx = s.Context()
556 } else {
557 ctx = context.Background()
558 }
559
560 ctx, cancel := context.WithCancel(ctx)
561 defer cancel()
562
563 send := createPubCacheDrain(ctx, c.Log)
564 return purgeAllCache(c.Cfg, send)
565 }
566 return nil
567}