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 · 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}