repos / pico

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

pico / pkg / apps / pgs
Eric Bower  ·  2025-07-04

cli.go

  1package pgs
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"log/slog"
  8	"path/filepath"
  9	"strings"
 10	"text/tabwriter"
 11	"time"
 12
 13	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 14	"github.com/picosh/pico/pkg/db"
 15	sst "github.com/picosh/pico/pkg/pobj/storage"
 16	"github.com/picosh/pico/pkg/shared"
 17	"github.com/picosh/utils"
 18)
 19
 20func NewTabWriter(out io.Writer) *tabwriter.Writer {
 21	return tabwriter.NewWriter(out, 0, 0, 2, ' ', tabwriter.TabIndent)
 22}
 23
 24func projectTable(sesh io.Writer, projects []*db.Project) {
 25	writer := NewTabWriter(sesh)
 26	_, _ = fmt.Fprintln(writer, "Name\tLast Updated\tLinks To\tACL Type\tACL\tBlocked")
 27
 28	for _, project := range projects {
 29		links := ""
 30		if project.ProjectDir != project.Name {
 31			links = project.ProjectDir
 32		}
 33		_, _ = fmt.Fprintf(
 34			writer,
 35			"%s\t%s\t%s\t%s\t%s\t%s\r\n",
 36			project.Name,
 37			project.UpdatedAt.Format("2006-01-02 15:04:05"),
 38			links,
 39			project.Acl.Type,
 40			strings.Join(project.Acl.Data, " "),
 41			project.Blocked,
 42		)
 43	}
 44	_ = writer.Flush()
 45}
 46
 47type Cmd struct {
 48	User    *db.User
 49	Session utils.CmdSession
 50	Log     *slog.Logger
 51	Store   sst.ObjectStorage
 52	Dbpool  pgsdb.PgsDB
 53	Write   bool
 54	Width   int
 55	Height  int
 56	Cfg     *PgsConfig
 57}
 58
 59func (c *Cmd) output(out string) {
 60	_, _ = c.Session.Write([]byte(out + "\r\n"))
 61}
 62
 63func (c *Cmd) error(err error) {
 64	_, _ = fmt.Fprint(c.Session.Stderr(), err, "\r\n")
 65	_ = c.Session.Exit(1)
 66	_ = c.Session.Close()
 67}
 68
 69func (c *Cmd) bail(err error) {
 70	if err == nil {
 71		return
 72	}
 73	c.Log.Error(err.Error())
 74	c.error(err)
 75}
 76
 77func (c *Cmd) notice() {
 78	if !c.Write {
 79		c.output("\nNOTICE: changes not commited, use `--write` to save operation")
 80	}
 81}
 82
 83func (c *Cmd) RmProjectAssets(projectName string) error {
 84	bucketName := shared.GetAssetBucketName(c.User.ID)
 85	bucket, err := c.Store.GetBucket(bucketName)
 86	if err != nil {
 87		return err
 88	}
 89	c.output(fmt.Sprintf("removing project assets (%s)", projectName))
 90
 91	fileList, err := c.Store.ListObjects(bucket, projectName+"/", true)
 92	if err != nil {
 93		return err
 94	}
 95
 96	if len(fileList) == 0 {
 97		c.output(fmt.Sprintf("no assets found for project (%s)", projectName))
 98		return nil
 99	}
100	c.output(fmt.Sprintf("found (%d) assets for project (%s), removing", len(fileList), projectName))
101
102	for _, file := range fileList {
103		if file.IsDir() {
104			continue
105		}
106		intent := fmt.Sprintf("deleted (%s)", file.Name())
107		c.Log.Info(
108			"attempting to delete file",
109			"user", c.User.Name,
110			"bucket", bucket.Name,
111			"filename", file.Name(),
112		)
113		if c.Write {
114			err = c.Store.DeleteObject(
115				bucket,
116				filepath.Join(projectName, file.Name()),
117			)
118			if err == nil {
119				c.output(intent)
120			} else {
121				return err
122			}
123		} else {
124			c.output(intent)
125		}
126	}
127	return nil
128}
129
130func (c *Cmd) help() {
131	helpStr := "Commands: [help, stats, ls, fzf, rm, link, unlink, prune, retain, depends, acl, cache]\r\n"
132	helpStr += "NOTICE:" + " *must* append with `--write` for the changes to persist.\r\n"
133	c.output(helpStr)
134	projectName := "projA"
135
136	data := [][]string{
137		{
138			"help",
139			"prints this screen",
140		},
141		{
142			"stats",
143			"usage statistics",
144		},
145		{
146			"ls",
147			"lists projects",
148		},
149		{
150			fmt.Sprintf("fzf %s", projectName),
151			fmt.Sprintf("lists urls of all assets in %s", projectName),
152		},
153		{
154			fmt.Sprintf("rm %s", projectName),
155			fmt.Sprintf("delete %s", projectName),
156		},
157		{
158			fmt.Sprintf("link %s --to projB", projectName),
159			fmt.Sprintf("symbolic link `%s` to `projB`", projectName),
160		},
161		{
162			fmt.Sprintf("unlink %s", projectName),
163			fmt.Sprintf("removes symbolic link for `%s`", projectName),
164		},
165		{
166			fmt.Sprintf("prune %s", projectName),
167			fmt.Sprintf("removes projects that match prefix `%s`", projectName),
168		},
169		{
170			fmt.Sprintf("retain %s", projectName),
171			"alias to `prune` but keeps last N projects",
172		},
173		{
174			fmt.Sprintf("depends %s", projectName),
175			fmt.Sprintf("lists all projects linked to `%s`", projectName),
176		},
177		{
178			fmt.Sprintf("acl %s", projectName),
179			fmt.Sprintf("access control for `%s`", projectName),
180		},
181		{
182			fmt.Sprintf("cache %s", projectName),
183			fmt.Sprintf("clear http cache for `%s`", projectName),
184		},
185	}
186
187	writer := NewTabWriter(c.Session)
188	_, _ = fmt.Fprintln(writer, "Cmd\tDescription")
189	for _, dat := range data {
190		_, _ = fmt.Fprintf(writer, "%s\t%s\r\n", dat[0], dat[1])
191	}
192	_ = writer.Flush()
193}
194
195func (c *Cmd) stats(cfgMaxSize uint64) error {
196	ff, err := c.Dbpool.FindFeature(c.User.ID, "plus")
197	if err != nil {
198		ff = db.NewFeatureFlag(c.User.ID, "plus", cfgMaxSize, 0, 0)
199	}
200	// this is jank
201	ff.Data.StorageMax = ff.FindStorageMax(cfgMaxSize)
202	storageMax := ff.Data.StorageMax
203
204	bucketName := shared.GetAssetBucketName(c.User.ID)
205	bucket, err := c.Store.GetBucket(bucketName)
206	if err != nil {
207		return err
208	}
209
210	totalFileSize, err := c.Store.GetBucketQuota(bucket)
211	if err != nil {
212		return err
213	}
214
215	projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
216	if err != nil {
217		return err
218	}
219
220	writer := NewTabWriter(c.Session)
221	_, _ = fmt.Fprintln(writer, "Used (GB)\tQuota (GB)\tUsed (%)\tProjects (#)")
222	_, _ = fmt.Fprintf(
223		writer,
224		"%.4f\t%.4f\t%.4f\t%d\r\n",
225		utils.BytesToGB(int(totalFileSize)),
226		utils.BytesToGB(int(storageMax)),
227		(float32(totalFileSize)/float32(storageMax))*100,
228		len(projects),
229	)
230	return writer.Flush()
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
511	c.output(fmt.Sprintf("clearing http cache for %s", projectName))
512
513	if c.Write {
514		surrogate := getSurrogateKey(c.User.Name, projectName)
515		return purgeCache(c.Cfg, c.Cfg.Pubsub, surrogate)
516	}
517	return nil
518}
519
520func (c *Cmd) cacheAll() error {
521	isAdmin := false
522	ff, _ := c.Dbpool.FindFeature(c.User.ID, "admin")
523	if ff != nil {
524		if ff.ExpiresAt.Before(time.Now()) {
525			isAdmin = true
526		}
527	}
528
529	if !isAdmin {
530		return fmt.Errorf("must be admin to use this command")
531	}
532
533	c.Log.Info(
534		"admin running `cache-all` command",
535		"user", c.User.Name,
536	)
537	c.output("clearing http cache for all sites")
538	if c.Write {
539		return purgeAllCache(c.Cfg, c.Cfg.Pubsub)
540	}
541	return nil
542}