repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

pico / pgs
Eric Bower · 19 Aug 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/shared/storage"
 17	"github.com/picosh/pico/tui/common"
 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]\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			fmt.Sprintf("stats %s", projectName),
 72			fmt.Sprintf("site analytics for `%s`", projectName),
 73		},
 74		{
 75			"ls",
 76			"lists projects",
 77		},
 78		{
 79			fmt.Sprintf("rm %s", projectName),
 80			fmt.Sprintf("delete %s", projectName),
 81		},
 82		{
 83			fmt.Sprintf("link %s --to projB", projectName),
 84			fmt.Sprintf("symbolic link `%s` to `projB`", projectName),
 85		},
 86		{
 87			fmt.Sprintf("unlink %s", projectName),
 88			fmt.Sprintf("removes symbolic link for `%s`", projectName),
 89		},
 90		{
 91			fmt.Sprintf("prune %s", projectName),
 92			fmt.Sprintf("removes projects that match prefix `%s`", projectName),
 93		},
 94		{
 95			fmt.Sprintf("retain %s", projectName),
 96			"alias to `prune` but keeps last N projects",
 97		},
 98		{
 99			fmt.Sprintf("depends %s", projectName),
100			fmt.Sprintf("lists all projects linked to `%s`", projectName),
101		},
102		{
103			fmt.Sprintf("acl %s", projectName),
104			fmt.Sprintf("access control 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 shared.CmdSession
121	Log     *slog.Logger
122	Store   storage.StorageServe
123	Dbpool  db.DB
124	Write   bool
125	Styles  common.Styles
126	Width   int
127	Height  int
128}
129
130func (c *Cmd) output(out string) {
131	_, _ = c.Session.Write([]byte(out + "\r\n"))
132}
133
134func (c *Cmd) error(err error) {
135	_, _ = fmt.Fprint(c.Session.Stderr(), err, "\r\n")
136	_ = c.Session.Exit(1)
137	_ = c.Session.Close()
138}
139
140func (c *Cmd) bail(err error) {
141	if err == nil {
142		return
143	}
144	c.Log.Error(err.Error())
145	c.error(err)
146}
147
148func (c *Cmd) notice() {
149	if !c.Write {
150		c.output("\nNOTICE: changes not commited, use `--write` to save operation")
151	}
152}
153
154func (c *Cmd) RmProjectAssets(projectName string) error {
155	bucketName := shared.GetAssetBucketName(c.User.ID)
156	bucket, err := c.Store.GetBucket(bucketName)
157	if err != nil {
158		return err
159	}
160	c.output(fmt.Sprintf("removing project assets (%s)", projectName))
161
162	fileList, err := c.Store.ListObjects(bucket, projectName+"/", true)
163	if err != nil {
164		return err
165	}
166
167	if len(fileList) == 0 {
168		c.output(fmt.Sprintf("no assets found for project (%s)", projectName))
169		return nil
170	}
171	c.output(fmt.Sprintf("found (%d) assets for project (%s), removing", len(fileList), projectName))
172
173	for _, file := range fileList {
174		intent := fmt.Sprintf("deleted (%s)", file.Name())
175		c.Log.Info(
176			"attempting to delete file",
177			"user", c.User.Name,
178			"bucket", bucket.Name,
179			"filename", file.Name(),
180		)
181		if c.Write {
182			err = c.Store.DeleteObject(
183				bucket,
184				filepath.Join(projectName, file.Name()),
185			)
186			if err == nil {
187				c.output(intent)
188			} else {
189				return err
190			}
191		} else {
192			c.output(intent)
193		}
194	}
195	return nil
196}
197
198func (c *Cmd) help() {
199	c.output(getHelpText(c.Styles, c.User.Name, c.Width))
200}
201
202func (c *Cmd) statsByProject(projectName string) error {
203	project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
204	if err != nil {
205		return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
206	}
207
208	opts := &db.SummaryOpts{
209		FkID:     project.ID,
210		By:       "project_id",
211		Interval: "day",
212		Origin:   shared.StartOfMonth(),
213	}
214
215	summary, err := c.Dbpool.VisitSummary(opts)
216	if err != nil {
217		return err
218	}
219
220	c.output("Top URLs")
221	topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls)
222	c.output(topUrlsTbl.Width(c.Width).String())
223
224	c.output("Top Referers")
225	topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
226	c.output(topRefsTbl.Width(c.Width).String())
227
228	uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals)
229	c.output("Unique Visitors this Month")
230	c.output(uniqueTbl.Width(c.Width).String())
231
232	return nil
233}
234
235func (c *Cmd) statsSites() error {
236	opts := &db.SummaryOpts{
237		FkID:     c.User.ID,
238		By:       "user_id",
239		Interval: "day",
240		Origin:   shared.StartOfMonth(),
241	}
242
243	summary, err := c.Dbpool.VisitSummary(opts)
244	if err != nil {
245		return err
246	}
247
248	projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
249	if err != nil {
250		return err
251	}
252
253	c.output("\nVisitor Analytics this Month\n")
254
255	c.output("Top URLs")
256	topUrlsTbl := common.VisitUrlsWithProjectTbl(projects, summary.TopUrls)
257
258	c.output(topUrlsTbl.Width(c.Width).String())
259
260	c.output("Top Referers")
261	topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
262	c.output(topRefsTbl.Width(c.Width).String())
263
264	mapper := map[string]*db.VisitInterval{}
265	result := []*db.VisitUrl{}
266	// combine visitors by project_id
267	for _, interval := range summary.Intervals {
268		if interval.ProjectID == "" {
269			continue
270		}
271		if _, ok := mapper[interval.ProjectID]; !ok {
272			mapper[interval.ProjectID] = interval
273		}
274		mapper[interval.ProjectID].Visitors += interval.Visitors
275	}
276
277	for _, val := range mapper {
278		projectName := ""
279		for _, project := range projects {
280			if project.ID == val.ProjectID {
281				projectName = project.Name
282			}
283		}
284		result = append(result, &db.VisitUrl{
285			Url:   projectName,
286			Count: val.Visitors,
287		})
288	}
289
290	slices.SortFunc(result, func(a, b *db.VisitUrl) int {
291		return cmp.Compare(b.Count, a.Count)
292	})
293
294	uniqueTbl := common.VisitUrlsTbl(result)
295	c.output("Unique Visitors by Site")
296	c.output(uniqueTbl.Width(c.Width).String())
297
298	return nil
299}
300
301func (c *Cmd) stats(cfgMaxSize uint64) error {
302	ff, err := c.Dbpool.FindFeatureForUser(c.User.ID, "plus")
303	if err != nil {
304		ff = db.NewFeatureFlag(c.User.ID, "plus", cfgMaxSize, 0)
305	}
306	// this is jank
307	ff.Data.StorageMax = ff.FindStorageMax(cfgMaxSize)
308	storageMax := ff.Data.StorageMax
309
310	bucketName := shared.GetAssetBucketName(c.User.ID)
311	bucket, err := c.Store.UpsertBucket(bucketName)
312	if err != nil {
313		return err
314	}
315
316	totalFileSize, err := c.Store.GetBucketQuota(bucket)
317	if err != nil {
318		return err
319	}
320
321	projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
322	if err != nil {
323		return err
324	}
325
326	headers := []string{"Used (GB)", "Quota (GB)", "Used (%)", "Projects (#)"}
327	data := []string{
328		fmt.Sprintf("%.4f", shared.BytesToGB(int(totalFileSize))),
329		fmt.Sprintf("%.4f", shared.BytesToGB(int(storageMax))),
330		fmt.Sprintf("%.4f", (float32(totalFileSize)/float32(storageMax))*100),
331		fmt.Sprintf("%d", len(projects)),
332	}
333
334	t := table.New().
335		Width(c.Width).
336		Border(lipgloss.RoundedBorder()).
337		Headers(headers...).
338		Rows(data)
339	c.output(t.String())
340
341	return c.statsSites()
342}
343
344func (c *Cmd) ls() error {
345	projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
346	if err != nil {
347		return err
348	}
349
350	if len(projects) == 0 {
351		c.output("no projects found")
352	}
353
354	t := projectTable(c.Styles, projects, c.Width)
355	c.output(t.String())
356
357	return nil
358}
359
360func (c *Cmd) unlink(projectName string) error {
361	c.Log.Info("user running `unlink` command", "user", c.User.Name, "project", projectName)
362	project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
363	if err != nil {
364		return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
365	}
366
367	err = c.Dbpool.LinkToProject(c.User.ID, project.ID, project.Name, c.Write)
368	if err != nil {
369		return err
370	}
371	c.output(fmt.Sprintf("(%s) unlinked", project.Name))
372
373	return nil
374}
375
376func (c *Cmd) link(projectName, linkTo string) error {
377	c.Log.Info("user running `link` command", "user", c.User.Name, "project", projectName, "link", linkTo)
378
379	projectDir := linkTo
380	_, err := c.Dbpool.FindProjectByName(c.User.ID, linkTo)
381	if err != nil {
382		e := fmt.Errorf("(%s) project doesn't exist", linkTo)
383		return e
384	}
385
386	project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
387	projectID := ""
388	if err == nil {
389		projectID = project.ID
390		c.Log.Info("user already has project, updating", "user", c.User.Name, "project", projectName)
391		err = c.Dbpool.LinkToProject(c.User.ID, project.ID, projectDir, c.Write)
392		if err != nil {
393			return err
394		}
395	} else {
396		c.Log.Info("user has no project record, creating", "user", c.User.Name, "project", projectName)
397		if !c.Write {
398			out := fmt.Sprintf("(%s) cannot create a new project without `--write` permission, aborting", projectName)
399			c.output(out)
400			return nil
401		}
402		id, err := c.Dbpool.InsertProject(c.User.ID, projectName, projectName)
403		if err != nil {
404			return err
405		}
406		projectID = id
407	}
408
409	c.Log.Info("user linking", "user", c.User.Name, "project", projectName, "projectDir", projectDir)
410	err = c.Dbpool.LinkToProject(c.User.ID, projectID, projectDir, c.Write)
411	if err != nil {
412		return err
413	}
414
415	out := fmt.Sprintf("(%s) might have orphaned assets, removing", projectName)
416	c.output(out)
417
418	err = c.RmProjectAssets(projectName)
419	if err != nil {
420		return err
421	}
422
423	out = fmt.Sprintf("(%s) now points to (%s)", projectName, linkTo)
424	c.output(out)
425	return nil
426}
427
428func (c *Cmd) depends(projectName string) error {
429	projects, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
430	if err != nil {
431		return err
432	}
433
434	if len(projects) == 0 {
435		out := fmt.Sprintf("no projects linked to (%s)", projectName)
436		c.output(out)
437		return nil
438	}
439
440	t := projectTable(c.Styles, projects, c.Width)
441	c.output(t.String())
442
443	return nil
444}
445
446// delete all the projects and associated assets matching prefix
447// but keep the latest N records.
448func (c *Cmd) prune(prefix string, keepNumLatest int) error {
449	c.Log.Info("user running `clean` command", "user", c.User.Name, "prefix", prefix)
450	c.output(fmt.Sprintf("searching for projects that match prefix (%s) and are not linked to other projects", prefix))
451
452	if prefix == "" || prefix == "*" {
453		e := fmt.Errorf("must provide valid prefix")
454		return e
455	}
456
457	projects, err := c.Dbpool.FindProjectsByPrefix(c.User.ID, prefix)
458	if err != nil {
459		return err
460	}
461
462	if len(projects) == 0 {
463		c.output(fmt.Sprintf("no projects found matching prefix (%s)", prefix))
464		return nil
465	}
466
467	rmProjects := []*db.Project{}
468	for _, project := range projects {
469		links, err := c.Dbpool.FindProjectLinks(c.User.ID, project.Name)
470		if err != nil {
471			return err
472		}
473
474		if len(links) == 0 {
475			rmProjects = append(rmProjects, project)
476		} else {
477			out := fmt.Sprintf("project (%s) has (%d) projects linked to it, cannot prune", project.Name, len(links))
478			c.output(out)
479		}
480	}
481
482	goodbye := rmProjects
483	if keepNumLatest > 0 {
484		pmax := len(rmProjects) - (keepNumLatest)
485		if pmax <= 0 {
486			out := fmt.Sprintf(
487				"no projects available to prune (retention policy: %d, total: %d)",
488				keepNumLatest,
489				len(rmProjects),
490			)
491			c.output(out)
492			return nil
493		}
494		goodbye = rmProjects[:pmax]
495	}
496
497	for _, project := range goodbye {
498		out := fmt.Sprintf("project (%s) is available to be pruned", project.Name)
499		c.output(out)
500		err = c.RmProjectAssets(project.Name)
501		if err != nil {
502			return err
503		}
504
505		out = fmt.Sprintf("(%s) removing", project.Name)
506		c.output(out)
507
508		if c.Write {
509			c.Log.Info("removing project", "project", project.Name)
510			err = c.Dbpool.RemoveProject(project.ID)
511			if err != nil {
512				return err
513			}
514		}
515	}
516
517	c.output("\nsummary")
518	c.output("=======")
519	for _, project := range goodbye {
520		c.output(fmt.Sprintf("project (%s) removed", project.Name))
521	}
522
523	return nil
524}
525
526func (c *Cmd) rm(projectName string) error {
527	c.Log.Info("user running `rm` command", "user", c.User.Name, "project", projectName)
528	project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
529	if err == nil {
530		c.Log.Info("found project, checking dependencies", "project", projectName, "projectID", project.ID)
531
532		links, err := c.Dbpool.FindProjectLinks(c.User.ID, projectName)
533		if err != nil {
534			return err
535		}
536
537		if len(links) > 0 {
538			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))
539			return e
540		}
541
542		out := fmt.Sprintf("(%s) removing", project.Name)
543		c.output(out)
544		if c.Write {
545			c.Log.Info("removing project", "project", project.Name)
546			err = c.Dbpool.RemoveProject(project.ID)
547			if err != nil {
548				return err
549			}
550		}
551	} else {
552		msg := fmt.Sprintf("(%s) project record not found for user (%s)", projectName, c.User.Name)
553		c.output(msg)
554	}
555
556	err = c.RmProjectAssets(projectName)
557	return err
558}
559
560func (c *Cmd) acl(projectName, aclType string, acls []string) error {
561	c.Log.Info(
562		"user running `acl` command",
563		"user", c.User.Name,
564		"project", projectName,
565		"actType", aclType,
566		"acls", acls,
567	)
568	c.output(fmt.Sprintf("setting acl for %s to %s (%s)", projectName, aclType, strings.Join(acls, ",")))
569	acl := db.ProjectAcl{
570		Type: aclType,
571		Data: acls,
572	}
573	if c.Write {
574		return c.Dbpool.UpdateProjectAcl(c.User.ID, projectName, acl)
575	}
576	return nil
577}