Eric Bower
·
09 Nov 24
cli.go
1package pgs
2
3import (
4 "cmp"
5 "errors"
6 "fmt"
7 "log/slog"
8 "path/filepath"
9 "slices"
10 "strings"
11
12 "github.com/charmbracelet/lipgloss"
13 "github.com/charmbracelet/lipgloss/table"
14 "github.com/picosh/pico/db"
15 "github.com/picosh/pico/shared"
16 "github.com/picosh/pico/tui/common"
17 sst "github.com/picosh/pobj/storage"
18 "github.com/picosh/utils"
19)
20
21func projectTable(styles common.Styles, projects []*db.Project, width int) *table.Table {
22 headers := []string{
23 "Name",
24 "Last Updated",
25 "Links To",
26 "ACL Type",
27 "ACL",
28 "Blocked",
29 }
30 data := [][]string{}
31 for _, project := range projects {
32 row := []string{
33 project.Name,
34 project.UpdatedAt.Format("2006-01-02 15:04:05"),
35 }
36 links := ""
37 if project.ProjectDir != project.Name {
38 links = project.ProjectDir
39 }
40 row = append(row, links)
41 row = append(row,
42 project.Acl.Type,
43 strings.Join(project.Acl.Data, " "),
44 )
45 row = append(row, project.Blocked)
46 data = append(data, row)
47 }
48
49 t := table.New().
50 Width(width).
51 Headers(headers...).
52 Rows(data...)
53 return t
54}
55
56func getHelpText(styles common.Styles, userName string, width int) string {
57 helpStr := "Commands: [help, stats, ls, rm, link, unlink, prune, retain, depends, acl]\n"
58 helpStr += styles.Note.Render("NOTICE:") + " *must* append with `--write` for the changes to persist.\n"
59
60 projectName := "projA"
61 headers := []string{"Cmd", "Description"}
62 data := [][]string{
63 {
64 "help",
65 "prints this screen",
66 },
67 {
68 "stats",
69 "usage statistics",
70 },
71 {
72 fmt.Sprintf("stats %s", projectName),
73 fmt.Sprintf("site analytics for `%s`", projectName),
74 },
75 {
76 "ls",
77 "lists projects",
78 },
79 {
80 fmt.Sprintf("rm %s", projectName),
81 fmt.Sprintf("delete %s", projectName),
82 },
83 {
84 fmt.Sprintf("link %s --to projB", projectName),
85 fmt.Sprintf("symbolic link `%s` to `projB`", projectName),
86 },
87 {
88 fmt.Sprintf("unlink %s", projectName),
89 fmt.Sprintf("removes symbolic link for `%s`", projectName),
90 },
91 {
92 fmt.Sprintf("prune %s", projectName),
93 fmt.Sprintf("removes projects that match prefix `%s`", projectName),
94 },
95 {
96 fmt.Sprintf("retain %s", projectName),
97 "alias to `prune` but keeps last N projects",
98 },
99 {
100 fmt.Sprintf("depends %s", projectName),
101 fmt.Sprintf("lists all projects linked to `%s`", projectName),
102 },
103 {
104 fmt.Sprintf("acl %s", projectName),
105 fmt.Sprintf("access control for `%s`", projectName),
106 },
107 }
108
109 t := table.New().
110 Width(width).
111 Border(lipgloss.RoundedBorder()).
112 Headers(headers...).
113 Rows(data...)
114
115 helpStr += t.String()
116 return helpStr
117}
118
119type Cmd struct {
120 User *db.User
121 Session utils.CmdSession
122 Log *slog.Logger
123 Store sst.ObjectStorage
124 Dbpool db.DB
125 Write bool
126 Styles common.Styles
127 Width int
128 Height int
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(projectName string) error {
204 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
205 if err != nil {
206 return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
207 }
208
209 opts := &db.SummaryOpts{
210 FkID: project.ID,
211 By: "project_id",
212 Interval: "day",
213 Origin: utils.StartOfMonth(),
214 }
215
216 summary, err := c.Dbpool.VisitSummary(opts)
217 if err != nil {
218 return err
219 }
220
221 c.output("Top URLs")
222 topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls)
223 c.output(topUrlsTbl.Width(c.Width).String())
224
225 c.output("Top Referers")
226 topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
227 c.output(topRefsTbl.Width(c.Width).String())
228
229 uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals)
230 c.output("Unique Visitors this Month")
231 c.output(uniqueTbl.Width(c.Width).String())
232
233 return nil
234}
235
236func (c *Cmd) statsSites() error {
237 opts := &db.SummaryOpts{
238 FkID: c.User.ID,
239 By: "user_id",
240 Interval: "day",
241 Origin: utils.StartOfMonth(),
242 }
243
244 summary, err := c.Dbpool.VisitSummary(opts)
245 if err != nil {
246 return err
247 }
248
249 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
250 if err != nil {
251 return err
252 }
253
254 c.output("\nVisitor Analytics this Month\n")
255
256 c.output("Top URLs")
257 topUrlsTbl := common.VisitUrlsWithProjectTbl(projects, summary.TopUrls)
258
259 c.output(topUrlsTbl.Width(c.Width).String())
260
261 c.output("Top Referers")
262 topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
263 c.output(topRefsTbl.Width(c.Width).String())
264
265 mapper := map[string]*db.VisitInterval{}
266 result := []*db.VisitUrl{}
267 // combine visitors by project_id
268 for _, interval := range summary.Intervals {
269 if interval.ProjectID == "" {
270 continue
271 }
272 if _, ok := mapper[interval.ProjectID]; !ok {
273 mapper[interval.ProjectID] = interval
274 }
275 mapper[interval.ProjectID].Visitors += interval.Visitors
276 }
277
278 for _, val := range mapper {
279 projectName := ""
280 for _, project := range projects {
281 if project.ID == val.ProjectID {
282 projectName = project.Name
283 }
284 }
285 result = append(result, &db.VisitUrl{
286 Url: projectName,
287 Count: val.Visitors,
288 })
289 }
290
291 slices.SortFunc(result, func(a, b *db.VisitUrl) int {
292 return cmp.Compare(b.Count, a.Count)
293 })
294
295 uniqueTbl := common.VisitUrlsTbl(result)
296 c.output("Unique Visitors by Site")
297 c.output(uniqueTbl.Width(c.Width).String())
298
299 return nil
300}
301
302func (c *Cmd) stats(cfgMaxSize uint64) error {
303 ff, err := c.Dbpool.FindFeatureForUser(c.User.ID, "plus")
304 if err != nil {
305 ff = db.NewFeatureFlag(c.User.ID, "plus", cfgMaxSize, 0, 0)
306 }
307 // this is jank
308 ff.Data.StorageMax = ff.FindStorageMax(cfgMaxSize)
309 storageMax := ff.Data.StorageMax
310
311 bucketName := shared.GetAssetBucketName(c.User.ID)
312 bucket, err := c.Store.UpsertBucket(bucketName)
313 if err != nil {
314 return err
315 }
316
317 totalFileSize, err := c.Store.GetBucketQuota(bucket)
318 if err != nil {
319 return err
320 }
321
322 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
323 if err != nil {
324 return err
325 }
326
327 headers := []string{"Used (GB)", "Quota (GB)", "Used (%)", "Projects (#)"}
328 data := []string{
329 fmt.Sprintf("%.4f", utils.BytesToGB(int(totalFileSize))),
330 fmt.Sprintf("%.4f", utils.BytesToGB(int(storageMax))),
331 fmt.Sprintf("%.4f", (float32(totalFileSize)/float32(storageMax))*100),
332 fmt.Sprintf("%d", len(projects)),
333 }
334
335 t := table.New().
336 Width(c.Width).
337 Border(lipgloss.RoundedBorder()).
338 Headers(headers...).
339 Rows(data)
340 c.output(t.String())
341
342 return c.statsSites()
343}
344
345func (c *Cmd) ls() error {
346 projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
347 if err != nil {
348 return err
349 }
350
351 if len(projects) == 0 {
352 c.output("no projects found")
353 }
354
355 t := projectTable(c.Styles, projects, c.Width)
356 c.output(t.String())
357
358 return nil
359}
360
361func (c *Cmd) unlink(projectName string) error {
362 c.Log.Info("user running `unlink` command", "user", c.User.Name, "project", projectName)
363 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
364 if err != nil {
365 return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
366 }
367
368 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, project.Name, c.Write)
369 if err != nil {
370 return err
371 }
372 c.output(fmt.Sprintf("(%s) unlinked", project.Name))
373
374 return nil
375}
376
377func (c *Cmd) link(projectName, linkTo string) error {
378 c.Log.Info("user running `link` command", "user", c.User.Name, "project", projectName, "link", linkTo)
379
380 projectDir := linkTo
381 _, err := c.Dbpool.FindProjectByName(c.User.ID, linkTo)
382 if err != nil {
383 e := fmt.Errorf("(%s) project doesn't exist", linkTo)
384 return e
385 }
386
387 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
388 projectID := ""
389 if err == nil {
390 projectID = project.ID
391 c.Log.Info("user already has project, updating", "user", c.User.Name, "project", projectName)
392 err = c.Dbpool.LinkToProject(c.User.ID, project.ID, projectDir, c.Write)
393 if err != nil {
394 return err
395 }
396 } else {
397 c.Log.Info("user has no project record, creating", "user", c.User.Name, "project", projectName)
398 if !c.Write {
399 out := fmt.Sprintf("(%s) cannot create a new project without `--write` permission, aborting", projectName)
400 c.output(out)
401 return nil
402 }
403 id, err := c.Dbpool.InsertProject(c.User.ID, projectName, projectName)
404 if err != nil {
405 return err
406 }
407 projectID = id
408 }
409
410 c.Log.Info("user linking", "user", c.User.Name, "project", projectName, "projectDir", projectDir)
411 err = c.Dbpool.LinkToProject(c.User.ID, projectID, projectDir, c.Write)
412 if err != nil {
413 return err
414 }
415
416 out := fmt.Sprintf("(%s) might have orphaned assets, removing", projectName)
417 c.output(out)
418
419 err = c.RmProjectAssets(projectName)
420 if err != nil {
421 return err
422 }
423
424 out = fmt.Sprintf("(%s) now points to (%s)", projectName, linkTo)
425 c.output(out)
426 return nil
427}
428
429func (c *Cmd) depends(projectName string) error {
430 projects, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
431 if err != nil {
432 return err
433 }
434
435 if len(projects) == 0 {
436 out := fmt.Sprintf("no projects linked to (%s)", projectName)
437 c.output(out)
438 return nil
439 }
440
441 t := projectTable(c.Styles, projects, c.Width)
442 c.output(t.String())
443
444 return nil
445}
446
447// delete all the projects and associated assets matching prefix
448// but keep the latest N records.
449func (c *Cmd) prune(prefix string, keepNumLatest int) error {
450 c.Log.Info("user running `clean` command", "user", c.User.Name, "prefix", prefix)
451 c.output(fmt.Sprintf("searching for projects that match prefix (%s) and are not linked to other projects", prefix))
452
453 if prefix == "" || prefix == "*" {
454 e := fmt.Errorf("must provide valid prefix")
455 return e
456 }
457
458 projects, err := c.Dbpool.FindProjectsByPrefix(c.User.ID, prefix)
459 if err != nil {
460 return err
461 }
462
463 if len(projects) == 0 {
464 c.output(fmt.Sprintf("no projects found matching prefix (%s)", prefix))
465 return nil
466 }
467
468 rmProjects := []*db.Project{}
469 for _, project := range projects {
470 links, err := c.Dbpool.FindProjectLinks(c.User.ID, project.Name)
471 if err != nil {
472 return err
473 }
474
475 if len(links) == 0 {
476 rmProjects = append(rmProjects, project)
477 } else {
478 out := fmt.Sprintf("project (%s) has (%d) projects linked to it, cannot prune", project.Name, len(links))
479 c.output(out)
480 }
481 }
482
483 goodbye := rmProjects
484 if keepNumLatest > 0 {
485 pmax := len(rmProjects) - (keepNumLatest)
486 if pmax <= 0 {
487 out := fmt.Sprintf(
488 "no projects available to prune (retention policy: %d, total: %d)",
489 keepNumLatest,
490 len(rmProjects),
491 )
492 c.output(out)
493 return nil
494 }
495 goodbye = rmProjects[:pmax]
496 }
497
498 for _, project := range goodbye {
499 out := fmt.Sprintf("project (%s) is available to be pruned", project.Name)
500 c.output(out)
501 err = c.RmProjectAssets(project.Name)
502 if err != nil {
503 return err
504 }
505
506 out = fmt.Sprintf("(%s) removing", project.Name)
507 c.output(out)
508
509 if c.Write {
510 c.Log.Info("removing project", "project", project.Name)
511 err = c.Dbpool.RemoveProject(project.ID)
512 if err != nil {
513 return err
514 }
515 }
516 }
517
518 c.output("\nsummary")
519 c.output("=======")
520 for _, project := range goodbye {
521 c.output(fmt.Sprintf("project (%s) removed", project.Name))
522 }
523
524 return nil
525}
526
527func (c *Cmd) rm(projectName string) error {
528 c.Log.Info("user running `rm` command", "user", c.User.Name, "project", projectName)
529 project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
530 if err == nil {
531 c.Log.Info("found project, checking dependencies", "project", projectName, "projectID", project.ID)
532
533 links, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
534 if err != nil {
535 return err
536 }
537
538 if len(links) > 0 {
539 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))
540 return e
541 }
542
543 out := fmt.Sprintf("(%s) removing", project.Name)
544 c.output(out)
545 if c.Write {
546 c.Log.Info("removing project", "project", project.Name)
547 err = c.Dbpool.RemoveProject(project.ID)
548 if err != nil {
549 return err
550 }
551 }
552 } else {
553 msg := fmt.Sprintf("(%s) project record not found for user (%s)", projectName, c.User.Name)
554 c.output(msg)
555 }
556
557 err = c.RmProjectAssets(projectName)
558 return err
559}
560
561func (c *Cmd) acl(projectName, aclType string, acls []string) error {
562 c.Log.Info(
563 "user running `acl` command",
564 "user", c.User.Name,
565 "project", projectName,
566 "actType", aclType,
567 "acls", acls,
568 )
569 c.output(fmt.Sprintf("setting acl for %s to %s (%s)", projectName, aclType, strings.Join(acls, ",")))
570 acl := db.ProjectAcl{
571 Type: aclType,
572 Data: acls,
573 }
574 if c.Write {
575 return c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl)
576 }
577 return nil
578}