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