repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / apps / pgs
Eric Bower  ·  2025-06-26

cli.go

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