repos / pico

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

pico / pkg / apps / pgs
Eric Bower  ·  2025-03-14

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