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