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