repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
999ff1e
parent
c4168ba
author
Eric Bower
date
2024-11-29 16:32:06 +0000 UTC
feat(tui): analytics
31 files changed,  +555, -1050
M cmd/scripts/analytics/analytics.go
+3, -13
 1@@ -15,16 +15,12 @@ func main() {
 2 	dbpool := postgres.NewDB(DbURL, logger)
 3 
 4 	args := os.Args
 5-	fkID := args[1]
 6+	host := args[1]
 7 
 8 	stats, err := dbpool.VisitSummary(
 9 		&db.SummaryOpts{
10-			FkID: fkID,
11-			// By:   "post_id",
12-			By:       "user_id",
13-			Interval: "day",
14-			Origin:   utils.StartOfMonth(),
15-			// Where:    "AND (post_id IS NOT NULL OR (post_id IS NULL AND project_id IS NULL))",
16+			Origin: utils.StartOfMonth(),
17+			Host:   host,
18 		},
19 	)
20 	if err != nil {
21@@ -36,8 +32,6 @@ func main() {
22 			"interval",
23 			"interval", s.Interval,
24 			"visitors", s.Visitors,
25-			"postID", s.PostID,
26-			"projectID", s.ProjectID,
27 		)
28 	}
29 
30@@ -46,8 +40,6 @@ func main() {
31 			"url",
32 			"url", url.Url,
33 			"count", url.Count,
34-			"postID", url.PostID,
35-			"projectID", url.ProjectID,
36 		)
37 	}
38 
39@@ -56,8 +48,6 @@ func main() {
40 			"referer",
41 			"url", url.Url,
42 			"count", url.Count,
43-			"postID", url.PostID,
44-			"projectID", url.ProjectID,
45 		)
46 	}
47 }
M db/db.go
+20, -24
 1@@ -145,12 +145,28 @@ type Analytics struct {
 2 	UsersWithPost  int
 3 }
 4 
 5+type VisitInterval struct {
 6+	Interval *time.Time `json:"interval"`
 7+	Visitors int        `json:"visitors"`
 8+}
 9+
10+type VisitUrl struct {
11+	Url   string `json:"url"`
12+	Count int    `json:"count"`
13+}
14+
15 type SummaryOpts struct {
16-	FkID     string
17-	By       string
18 	Interval string
19 	Origin   time.Time
20-	Where    string
21+	Host     string
22+	Path     string
23+	UserID   string
24+}
25+
26+type SummaryVisits struct {
27+	Intervals   []*VisitInterval `json:"intervals"`
28+	TopUrls     []*VisitUrl      `json:"top_urls"`
29+	TopReferers []*VisitUrl      `json:"top_referers"`
30 }
31 
32 type PostAnalytics struct {
33@@ -175,26 +191,6 @@ type AnalyticsVisits struct {
34 	ContentType string `json:"content_type"`
35 }
36 
37-type VisitInterval struct {
38-	PostID    string     `json:"post_id"`
39-	ProjectID string     `json:"project_id"`
40-	Interval  *time.Time `json:"interval"`
41-	Visitors  int        `json:"visitors"`
42-}
43-
44-type VisitUrl struct {
45-	PostID    string `json:"post_id"`
46-	ProjectID string `json:"project_id"`
47-	Url       string `json:"url"`
48-	Count     int    `json:"count"`
49-}
50-
51-type SummaryVisits struct {
52-	Intervals   []*VisitInterval `json:"intervals"`
53-	TopUrls     []*VisitUrl      `json:"top_urls"`
54-	TopReferers []*VisitUrl      `json:"top_referers"`
55-}
56-
57 type Pager struct {
58 	Num  int
59 	Page int
60@@ -382,7 +378,7 @@ type DB interface {
61 
62 	InsertVisit(view *AnalyticsVisits) error
63 	VisitSummary(opts *SummaryOpts) (*SummaryVisits, error)
64-	FindVisitSiteList(userID string) ([]string, error)
65+	FindVisitSiteList(opts *SummaryOpts) ([]*VisitUrl, error)
66 
67 	AddPicoPlusUser(username string, paymentType, txId string) error
68 	FindFeatureForUser(userID string, feature string) (*FeatureFlag, error)
M db/postgres/storage.go
+58, -83
  1@@ -1002,16 +1002,31 @@ func (me *PsqlDB) InsertVisit(visit *db.AnalyticsVisits) error {
  2 	return err
  3 }
  4 
  5-func (me *PsqlDB) visitUniqueBlog(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
  6+func visitFilterBy(opts *db.SummaryOpts) (string, string) {
  7+	where := ""
  8+	val := ""
  9+	if opts.Host != "" {
 10+		where = "host"
 11+		val = opts.Host
 12+	} else if opts.Path != "" {
 13+		where = "path"
 14+		val = opts.Path
 15+	}
 16+
 17+	return where, val
 18+}
 19+
 20+func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
 21+	where, with := visitFilterBy(opts)
 22 	uniqueVisitors := fmt.Sprintf(`SELECT
 23 		date_trunc('%s', created_at) as interval_start,
 24         count(DISTINCT ip_address) as unique_visitors
 25 	FROM analytics_visits
 26-	WHERE %s=$1 AND created_at >= $2 AND status <> 404 %s
 27-	GROUP BY interval_start`, opts.Interval, opts.By, opts.Where)
 28+	WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND status <> 404
 29+	GROUP BY interval_start`, opts.Interval, where)
 30 
 31 	intervals := []*db.VisitInterval{}
 32-	rs, err := me.Db.Query(uniqueVisitors, opts.FkID, opts.Origin)
 33+	rs, err := me.Db.Query(uniqueVisitors, opts.Origin, with, opts.UserID)
 34 	if err != nil {
 35 		return nil, err
 36 	}
 37@@ -1034,37 +1049,32 @@ func (me *PsqlDB) visitUniqueBlog(opts *db.SummaryOpts) ([]*db.VisitInterval, er
 38 	return intervals, nil
 39 }
 40 
 41-func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
 42-	uniqueVisitors := fmt.Sprintf(`SELECT
 43-		post_id,
 44-		project_id,
 45-		date_trunc('%s', created_at) as interval_start,
 46-        count(DISTINCT ip_address) as unique_visitors
 47+func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
 48+	where, with := visitFilterBy(opts)
 49+	topUrls := fmt.Sprintf(`SELECT
 50+		referer,
 51+		count(DISTINCT ip_address) as referer_count
 52 	FROM analytics_visits
 53-	WHERE %s=$1 AND created_at >= $2 AND status <> 404 %s
 54-	GROUP BY post_id, project_id, interval_start`, opts.Interval, opts.By, opts.Where)
 55+	WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND referer <> '' AND status <> 404
 56+	GROUP BY referer
 57+	ORDER BY referer_count DESC
 58+	LIMIT 10`, where)
 59 
 60-	intervals := []*db.VisitInterval{}
 61-	rs, err := me.Db.Query(uniqueVisitors, opts.FkID, opts.Origin)
 62+	intervals := []*db.VisitUrl{}
 63+	rs, err := me.Db.Query(topUrls, opts.Origin, with, opts.UserID)
 64 	if err != nil {
 65 		return nil, err
 66 	}
 67 
 68 	for rs.Next() {
 69-		interval := &db.VisitInterval{}
 70-		var postID sql.NullString
 71-		var projectID sql.NullString
 72+		interval := &db.VisitUrl{}
 73 		err := rs.Scan(
 74-			&postID,
 75-			&projectID,
 76-			&interval.Interval,
 77-			&interval.Visitors,
 78+			&interval.Url,
 79+			&interval.Count,
 80 		)
 81 		if err != nil {
 82 			return nil, err
 83 		}
 84-		interval.PostID = postID.String
 85-		interval.ProjectID = projectID.String
 86 
 87 		intervals = append(intervals, interval)
 88 	}
 89@@ -1074,18 +1084,19 @@ func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error)
 90 	return intervals, nil
 91 }
 92 
 93-func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
 94+func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
 95+	where, with := visitFilterBy(opts)
 96 	topUrls := fmt.Sprintf(`SELECT
 97-		referer,
 98-		count(DISTINCT ip_address) as referer_count
 99+		path,
100+		count(DISTINCT ip_address) as path_count
101 	FROM analytics_visits
102-	WHERE %s=$1 AND created_at >= $2 AND referer <> '' AND status <> 404 %s
103-	GROUP BY referer
104-	ORDER BY referer_count DESC
105-	LIMIT 10`, opts.By, opts.Where)
106+	WHERE created_at >= $1 AND %s = $2 AND user_id = $3 AND path <> '' AND status <> 404
107+	GROUP BY path
108+	ORDER BY path_count DESC
109+	LIMIT 10`, where)
110 
111 	intervals := []*db.VisitUrl{}
112-	rs, err := me.Db.Query(topUrls, opts.FkID, opts.Origin)
113+	rs, err := me.Db.Query(topUrls, opts.Origin, with, opts.UserID)
114 	if err != nil {
115 		return nil, err
116 	}
117@@ -1108,39 +1119,31 @@ func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
118 	return intervals, nil
119 }
120 
121-func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
122-	topUrls := fmt.Sprintf(`SELECT
123-		path,
124-		count(DISTINCT ip_address) as path_count,
125-		post_id,
126-		project_id
127+func (me *PsqlDB) visitHost(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
128+	topUrls := `SELECT
129+		host,
130+		count(DISTINCT ip_address) as host_count
131 	FROM analytics_visits
132-	WHERE %s=$1 AND created_at >= $2 AND path <> '' AND status <> 404 %s
133-	GROUP BY path, post_id, project_id
134-	ORDER BY path_count DESC
135-	LIMIT 10`, opts.By, opts.Where)
136+	WHERE created_at >= $1 AND user_id = $2 AND path <> '' AND status <> 404
137+	GROUP BY host
138+	ORDER BY host_count DESC
139+	LIMIT 10`
140 
141 	intervals := []*db.VisitUrl{}
142-	rs, err := me.Db.Query(topUrls, opts.FkID, opts.Origin)
143+	rs, err := me.Db.Query(topUrls, opts.Origin, opts.UserID)
144 	if err != nil {
145 		return nil, err
146 	}
147 
148 	for rs.Next() {
149 		interval := &db.VisitUrl{}
150-		var postID sql.NullString
151-		var projectID sql.NullString
152 		err := rs.Scan(
153 			&interval.Url,
154 			&interval.Count,
155-			&postID,
156-			&projectID,
157 		)
158 		if err != nil {
159 			return nil, err
160 		}
161-		interval.PostID = postID.String
162-		interval.ProjectID = projectID.String
163 
164 		intervals = append(intervals, interval)
165 	}
166@@ -1151,28 +1154,21 @@ func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
167 }
168 
169 func (me *PsqlDB) VisitSummary(opts *db.SummaryOpts) (*db.SummaryVisits, error) {
170-	var visitors []*db.VisitInterval
171-	var err error
172-	if opts.Where == "" {
173-		visitors, err = me.visitUnique(opts)
174-		if err != nil {
175-			return nil, err
176-		}
177-	} else {
178-		visitors, err = me.visitUniqueBlog(opts)
179-		if err != nil {
180-			return nil, err
181-		}
182+	visitors, err := me.visitUnique(opts)
183+	if err != nil {
184+		return nil, err
185 	}
186 
187 	urls, err := me.visitUrl(opts)
188 	if err != nil {
189 		return nil, err
190 	}
191+
192 	refs, err := me.visitReferer(opts)
193 	if err != nil {
194 		return nil, err
195 	}
196+
197 	return &db.SummaryVisits{
198 		Intervals:   visitors,
199 		TopUrls:     urls,
200@@ -1180,29 +1176,8 @@ func (me *PsqlDB) VisitSummary(opts *db.SummaryOpts) (*db.SummaryVisits, error)
201 	}, nil
202 }
203 
204-func (me *PsqlDB) FindVisitSiteList(userID string) ([]string, error) {
205-	siteList := []string{}
206-
207-	rs, err := me.Db.Query("SELECT DISTINCT(host) FROM analytics_visits WHERE user_id=$1", userID)
208-	if err != nil {
209-		return nil, err
210-	}
211-
212-	for rs.Next() {
213-		var host string
214-		err := rs.Scan(
215-			&host,
216-		)
217-		if err != nil {
218-			return nil, err
219-		}
220-		siteList = append(siteList, host)
221-	}
222-	if rs.Err() != nil {
223-		return nil, rs.Err()
224-	}
225-
226-	return siteList, nil
227+func (me *PsqlDB) FindVisitSiteList(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
228+	return me.visitHost(opts)
229 }
230 
231 func (me *PsqlDB) FindUsers() ([]*db.User, error) {
M db/stub/stub.go
+2, -2
 1@@ -157,8 +157,8 @@ func (me *StubDB) VisitSummary(opts *db.SummaryOpts) (*db.SummaryVisits, error)
 2 	return &db.SummaryVisits{}, notImpl
 3 }
 4 
 5-func (me *StubDB) FindVisitSiteList(userID string) ([]string, error) {
 6-	return []string{}, notImpl
 7+func (me *StubDB) FindVisitSiteList(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
 8+	return []*db.VisitUrl{}, notImpl
 9 }
10 
11 func (me *StubDB) FindUsers() ([]*db.User, error) {
M docker-compose.override.yml
+1, -1
 1@@ -1,10 +1,10 @@
 2-version: "3.8"
 3 services:
 4   postgres:
 5     env_file:
 6       - .env.example
 7     ports:
 8       - "5432:5432"
 9+    command: -c log_statement=all -c log_destination=stderr
10   minio:
11     env_file:
12       - .env.example
M docker-compose.prod-irc.yml
+0, -1
1@@ -1,4 +1,3 @@
2-version: "3.8"
3 services:
4   auth-caddy:
5     image: ghcr.io/picosh/pico/caddy:latest
M docker-compose.prod.yml
+0, -1
1@@ -1,4 +1,3 @@
2-version: "3.8"
3 services:
4   postgres:
5     env_file:
M docker-compose.yml
+0, -1
1@@ -1,4 +1,3 @@
2-version: "3.8"
3 services:
4   postgres:
5     image: postgres:14
M pgs/cli.go
+11, -103
  1@@ -1,12 +1,10 @@
  2 package pgs
  3 
  4 import (
  5-	"cmp"
  6 	"errors"
  7 	"fmt"
  8 	"log/slog"
  9 	"path/filepath"
 10-	"slices"
 11 	"strings"
 12 
 13 	"github.com/charmbracelet/lipgloss"
 14@@ -68,10 +66,6 @@ func getHelpText(styles common.Styles, userName string, width int) string {
 15 			"stats",
 16 			"usage statistics",
 17 		},
 18-		{
 19-			fmt.Sprintf("stats %s", projectName),
 20-			fmt.Sprintf("site analytics for `%s`", projectName),
 21-		},
 22 		{
 23 			"ls",
 24 			"lists projects",
 25@@ -200,102 +194,13 @@ func (c *Cmd) help() {
 26 	c.output(getHelpText(c.Styles, c.User.Name, c.Width))
 27 }
 28 
 29-func (c *Cmd) statsByProject(projectName string) error {
 30-	project, err := c.Dbpool.FindProjectByName(c.User.ID, projectName)
 31-	if err != nil {
 32-		return errors.Join(err, fmt.Errorf("project (%s) does not exit", projectName))
 33-	}
 34-
 35-	opts := &db.SummaryOpts{
 36-		FkID:     project.ID,
 37-		By:       "project_id",
 38-		Interval: "day",
 39-		Origin:   utils.StartOfMonth(),
 40-	}
 41-
 42-	summary, err := c.Dbpool.VisitSummary(opts)
 43-	if err != nil {
 44-		return err
 45-	}
 46-
 47-	c.output("Top URLs")
 48-	topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls)
 49-	c.output(topUrlsTbl.Width(c.Width).String())
 50-
 51-	c.output("Top Referers")
 52-	topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
 53-	c.output(topRefsTbl.Width(c.Width).String())
 54-
 55-	uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals)
 56-	c.output("Unique Visitors this Month")
 57-	c.output(uniqueTbl.Width(c.Width).String())
 58-
 59-	return nil
 60-}
 61-
 62-func (c *Cmd) statsSites() error {
 63-	opts := &db.SummaryOpts{
 64-		FkID:     c.User.ID,
 65-		By:       "user_id",
 66-		Interval: "day",
 67-		Origin:   utils.StartOfMonth(),
 68-	}
 69-
 70-	summary, err := c.Dbpool.VisitSummary(opts)
 71-	if err != nil {
 72-		return err
 73-	}
 74-
 75-	projects, err := c.Dbpool.FindProjectsByUser(c.User.ID)
 76-	if err != nil {
 77-		return err
 78-	}
 79-
 80-	c.output("\nVisitor Analytics this Month\n")
 81-
 82-	c.output("Top URLs")
 83-	topUrlsTbl := common.VisitUrlsWithProjectTbl(projects, summary.TopUrls)
 84-
 85-	c.output(topUrlsTbl.Width(c.Width).String())
 86-
 87-	c.output("Top Referers")
 88-	topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
 89-	c.output(topRefsTbl.Width(c.Width).String())
 90-
 91-	mapper := map[string]*db.VisitInterval{}
 92-	result := []*db.VisitUrl{}
 93-	// combine visitors by project_id
 94-	for _, interval := range summary.Intervals {
 95-		if interval.ProjectID == "" {
 96-			continue
 97-		}
 98-		if _, ok := mapper[interval.ProjectID]; !ok {
 99-			mapper[interval.ProjectID] = interval
100-		}
101-		mapper[interval.ProjectID].Visitors += interval.Visitors
102-	}
103-
104-	for _, val := range mapper {
105-		projectName := ""
106-		for _, project := range projects {
107-			if project.ID == val.ProjectID {
108-				projectName = project.Name
109-			}
110-		}
111-		result = append(result, &db.VisitUrl{
112-			Url:   projectName,
113-			Count: val.Visitors,
114-		})
115-	}
116-
117-	slices.SortFunc(result, func(a, b *db.VisitUrl) int {
118-		return cmp.Compare(b.Count, a.Count)
119-	})
120-
121-	uniqueTbl := common.VisitUrlsTbl(result)
122-	c.output("Unique Visitors by Site")
123-	c.output(uniqueTbl.Width(c.Width).String())
124-
125+func (c *Cmd) statsByProject(_ string) error {
126+	msg := fmt.Sprintf(
127+		"%s\n\nRun %s to access pico's analytics TUI",
128+		c.Styles.Logo.Render("DEPRECATED"),
129+		c.Styles.Code.Render("ssh pico.sh"),
130+	)
131+	c.output(c.Styles.RoundedBorder.Render(msg))
132 	return nil
133 }
134 
135@@ -339,7 +244,10 @@ func (c *Cmd) stats(cfgMaxSize uint64) error {
136 		Rows(data)
137 	c.output(t.String())
138 
139-	return c.statsSites()
140+	c.output("Site usage analytics:")
141+	_ = c.statsByProject("")
142+
143+	return nil
144 }
145 
146 func (c *Cmd) ls() error {
M pgs/tunnel.go
+0, -7
 1@@ -127,13 +127,6 @@ func createHttpHandler(apiConfig *shared.ApiConfig) CtxHttpBridge {
 2 		router.HandleFunc("GET /{fname}/{options}...", tunnelRouter.ImageRequest)
 3 		router.HandleFunc("GET /{fname}", tunnelRouter.AssetRequest)
 4 		router.HandleFunc("GET /{$}", tunnelRouter.AssetRequest)
 5-
 6-		/* subdomainRoutes := createSubdomainRoutes(allowPerm)
 7-		routes = append(routes, subdomainRoutes...)
 8-		finctx := apiConfig.CreateCtx(context.Background(), subdomain)
 9-		finctx = context.WithValue(finctx, shared.CtxSshKey{}, ctx)
10-		httpHandler := shared.CreateServeBasic(routes, finctx)
11-		httpRouter := http.HandlerFunc(httpHandler) */
12 		return router
13 	}
14 }
M pgs/wish.go
+2, -0
 1@@ -9,6 +9,7 @@ import (
 2 	"github.com/charmbracelet/ssh"
 3 	"github.com/charmbracelet/wish"
 4 	bm "github.com/charmbracelet/wish/bubbletea"
 5+	"github.com/muesli/termenv"
 6 	"github.com/picosh/pico/db"
 7 	"github.com/picosh/pico/tui/common"
 8 	sendutils "github.com/picosh/send/utils"
 9@@ -92,6 +93,7 @@ func WishMiddleware(handler *UploadAssetHandler) wish.Middleware {
10 			}
11 
12 			renderer := bm.MakeRenderer(sesh)
13+			renderer.SetColorProfile(termenv.TrueColor)
14 			styles := common.DefaultStyles(renderer)
15 
16 			opts := Cmd{
M prose/cli.go
+10, -57
 1@@ -1,7 +1,6 @@
 2 package prose
 3 
 4 import (
 5-	"errors"
 6 	"fmt"
 7 	"log/slog"
 8 	"strings"
 9@@ -9,6 +8,7 @@ import (
10 	"github.com/charmbracelet/ssh"
11 	"github.com/charmbracelet/wish"
12 	bm "github.com/charmbracelet/wish/bubbletea"
13+	"github.com/muesli/termenv"
14 	"github.com/picosh/pico/db"
15 	"github.com/picosh/pico/tui/common"
16 	"github.com/picosh/utils"
17@@ -52,66 +52,18 @@ func (c *Cmd) help() {
18 	c.output(helpStr)
19 }
20 
21-func (c *Cmd) statsByPost(postSlug string) error {
22-	post, err := c.Dbpool.FindPostWithSlug(postSlug, c.User.ID, "prose")
23-	if err != nil {
24-		return errors.Join(err, fmt.Errorf("post (%s) does not exit", postSlug))
25-	}
26-
27-	opts := &db.SummaryOpts{
28-		FkID:     post.ID,
29-		By:       "post_id",
30-		Interval: "day",
31-		Origin:   utils.StartOfMonth(),
32-	}
33-
34-	summary, err := c.Dbpool.VisitSummary(opts)
35-	if err != nil {
36-		return err
37-	}
38-
39-	c.output("Top URLs")
40-	topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls)
41-	c.output(topUrlsTbl.Width(c.Width).String())
42-
43-	c.output("Top Referers")
44-	topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
45-	c.output(topRefsTbl.Width(c.Width).String())
46-
47-	uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals)
48-	c.output("Unique Visitors this Month")
49-	c.output(uniqueTbl.Width(c.Width).String())
50-
51+func (c *Cmd) statsByPost(_ string) error {
52+	msg := fmt.Sprintf(
53+		"%s\n\nRun %s to access pico's analytics TUI",
54+		c.Styles.Logo.Render("DEPRECATED"),
55+		c.Styles.Code.Render("ssh pico.sh"),
56+	)
57+	c.output(c.Styles.RoundedBorder.Render(msg))
58 	return nil
59 }
60 
61 func (c *Cmd) stats() error {
62-	opts := &db.SummaryOpts{
63-		FkID:     c.User.ID,
64-		By:       "user_id",
65-		Interval: "day",
66-		Origin:   utils.StartOfMonth(),
67-		Where:    "AND (post_id IS NOT NULL OR (post_id IS NULL AND project_id IS NULL))",
68-	}
69-
70-	summary, err := c.Dbpool.VisitSummary(opts)
71-	if err != nil {
72-		return err
73-	}
74-
75-	c.output("Top URLs")
76-	topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls)
77-	c.output(topUrlsTbl.Width(c.Width).String())
78-
79-	c.output("Top Referers")
80-	topRefsTbl := common.VisitUrlsTbl(summary.TopReferers)
81-	c.output(topRefsTbl.Width(c.Width).String())
82-
83-	uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals)
84-	c.output("Unique Visitors this Month")
85-	c.output(uniqueTbl.Width(c.Width).String())
86-
87-	return nil
88+	return c.statsByPost("")
89 }
90 
91 type CliHandler struct {
92@@ -147,6 +99,7 @@ func WishMiddleware(handler *CliHandler) wish.Middleware {
93 			}
94 
95 			renderer := bm.MakeRenderer(sesh)
96+			renderer.SetColorProfile(termenv.TrueColor)
97 			styles := common.DefaultStyles(renderer)
98 
99 			opts := Cmd{
A tui/analytics/analytics.go
+385, -0
  1@@ -0,0 +1,385 @@
  2+package analytics
  3+
  4+import (
  5+	"context"
  6+	"fmt"
  7+	"strings"
  8+
  9+	input "github.com/charmbracelet/bubbles/textinput"
 10+	"github.com/charmbracelet/bubbles/viewport"
 11+	tea "github.com/charmbracelet/bubbletea"
 12+	"github.com/charmbracelet/glamour"
 13+	"github.com/charmbracelet/lipgloss"
 14+	"github.com/picosh/pico/db"
 15+	"github.com/picosh/pico/tui/common"
 16+	"github.com/picosh/pico/tui/pages"
 17+	"github.com/picosh/utils"
 18+)
 19+
 20+type state int
 21+
 22+const (
 23+	stateLoading state = iota
 24+	stateReady
 25+)
 26+
 27+type errMsg error
 28+
 29+type SiteStatsLoaded struct {
 30+	Summary *db.SummaryVisits
 31+}
 32+
 33+type SiteListLoaded struct {
 34+	Sites []*db.VisitUrl
 35+}
 36+
 37+type PathStatsLoaded struct {
 38+	Summary *db.SummaryVisits
 39+}
 40+
 41+type HasAnalyticsFeature struct {
 42+	Has bool
 43+}
 44+
 45+type Model struct {
 46+	shared           *common.SharedModel
 47+	state            state
 48+	logData          []map[string]any
 49+	viewport         viewport.Model
 50+	input            input.Model
 51+	sub              chan map[string]any
 52+	ctx              context.Context
 53+	done             context.CancelFunc
 54+	errMsg           error
 55+	statsBySite      *db.SummaryVisits
 56+	statsByPath      *db.SummaryVisits
 57+	siteList         []*db.VisitUrl
 58+	repl             string
 59+	analyticsEnabled bool
 60+}
 61+
 62+func headerHeight(shrd *common.SharedModel) int {
 63+	return shrd.HeaderHeight
 64+}
 65+
 66+func headerWidth(w int) int {
 67+	return w - 2
 68+}
 69+
 70+var helpMsg = `This view shows site usage analytics for prose, pages, and tuns.
 71+
 72+[Read our docs about site usage analytics](https://pico.sh/analytics)
 73+
 74+Shortcuts:
 75+
 76+- esc: leave page
 77+- tab: toggle between viewport and input box
 78+- ctrl+u: scroll viewport up a page
 79+- ctrl+d: scroll viewport down a page
 80+- j,k: scroll viewport
 81+
 82+Commands: [help, stats, site {domain}, post {slug}]
 83+
 84+**View usage stats for all sites for this month:**
 85+
 86+> stats
 87+
 88+**View usage stats for your site by month this year:**
 89+
 90+> site pico.sh
 91+
 92+**View usage stats for your site by day this month:**
 93+
 94+> site pico.sh day
 95+
 96+**View usage stats for your blog post by month this year:**
 97+
 98+> post my-post
 99+
100+**View usage stats for blog posts by day this month:**
101+
102+> post my-post day
103+
104+`
105+
106+func NewModel(shrd *common.SharedModel) Model {
107+	im := input.New()
108+	im.Cursor.Style = shrd.Styles.Cursor
109+	im.Placeholder = "type 'help' to learn how to use the repl"
110+	im.PlaceholderStyle = shrd.Styles.InputPlaceholder
111+	im.Prompt = shrd.Styles.FocusedPrompt.String()
112+	im.CharLimit = 50
113+	im.Focus()
114+
115+	hh := headerHeight(shrd)
116+	ww := headerWidth(shrd.Width)
117+	inputHeight := lipgloss.Height(im.View())
118+	viewport := viewport.New(ww, shrd.Height-hh-inputHeight)
119+	viewport.YPosition = hh
120+
121+	ctx, cancel := context.WithCancel(shrd.Session.Context())
122+
123+	return Model{
124+		shared:   shrd,
125+		state:    stateLoading,
126+		viewport: viewport,
127+		logData:  []map[string]any{},
128+		input:    im,
129+		sub:      make(chan map[string]any),
130+		ctx:      ctx,
131+		done:     cancel,
132+	}
133+}
134+
135+func (m Model) Init() tea.Cmd {
136+	return m.hasAnalyticsFeature()
137+}
138+
139+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
140+	var cmds []tea.Cmd
141+	var cmd tea.Cmd
142+	updateViewport := true
143+	switch msg := msg.(type) {
144+	case tea.WindowSizeMsg:
145+		m.viewport.Width = headerWidth(msg.Width)
146+		inputHeight := lipgloss.Height(m.input.View())
147+		hh := headerHeight(m.shared)
148+		m.viewport.Height = msg.Height - hh - inputHeight
149+		m.viewport.SetContent(m.renderViewport())
150+
151+	case errMsg:
152+		m.errMsg = msg
153+
154+	case pages.NavigateMsg:
155+		// cancel activity logger
156+		m.done()
157+		// reset model
158+		next := NewModel(m.shared)
159+		return next, nil
160+
161+	case tea.KeyMsg:
162+		switch msg.String() {
163+		case "q", "esc":
164+			return m, pages.Navigate(pages.MenuPage)
165+		// when typing in input, ignore viewport updates
166+		case " ", "k", "j":
167+			if m.input.Focused() {
168+				updateViewport = false
169+			}
170+		case "tab":
171+			if m.input.Focused() {
172+				m.input.Blur()
173+			} else {
174+				cmds = append(cmds, m.input.Focus())
175+			}
176+		case "enter":
177+			replCmd := m.input.Value()
178+			m.repl = replCmd
179+			if replCmd == "stats" {
180+				m.state = stateLoading
181+				cmds = append(cmds, m.fetchSiteList())
182+			} else if strings.HasPrefix(replCmd, "site") {
183+				name, by := splitReplCmd(replCmd)
184+				m.state = stateLoading
185+				cmds = append(cmds, m.fetchSiteStats(name, by))
186+			} else if strings.HasPrefix(replCmd, "post") {
187+				slug, by := splitReplCmd(replCmd)
188+				m.state = stateLoading
189+				cmds = append(cmds, m.fetchPostStats(slug, by))
190+			}
191+
192+			m.viewport.SetContent(m.renderViewport())
193+			m.input.SetValue("")
194+		}
195+
196+	case SiteStatsLoaded:
197+		m.state = stateReady
198+		m.statsBySite = msg.Summary
199+		m.viewport.SetContent(m.renderViewport())
200+
201+	case PathStatsLoaded:
202+		m.state = stateReady
203+		m.statsByPath = msg.Summary
204+		m.viewport.SetContent(m.renderViewport())
205+
206+	case SiteListLoaded:
207+		m.state = stateReady
208+		m.siteList = msg.Sites
209+		m.viewport.SetContent(m.renderViewport())
210+
211+	case HasAnalyticsFeature:
212+		m.state = stateReady
213+		m.analyticsEnabled = msg.Has
214+		m.viewport.SetContent(m.renderViewport())
215+	}
216+
217+	m.input, cmd = m.input.Update(msg)
218+	cmds = append(cmds, cmd)
219+	if updateViewport {
220+		m.viewport, cmd = m.viewport.Update(msg)
221+		cmds = append(cmds, cmd)
222+	}
223+	return m, tea.Batch(cmds...)
224+}
225+
226+func (m Model) View() string {
227+	if m.errMsg != nil {
228+		return m.shared.Styles.Error.Render(m.errMsg.Error())
229+	}
230+	return m.viewport.View() + "\n" + m.input.View()
231+}
232+
233+func (m Model) renderViewport() string {
234+	if m.state == stateLoading {
235+		return "Loading ..."
236+	}
237+
238+	if m.shared.PlusFeatureFlag == nil || !m.shared.PlusFeatureFlag.IsValid() {
239+		return m.renderMd(`**Analytics is only available for pico+ users.**
240+
241+[Read our docs about site usage analytics](https://pico.sh/analytics)`)
242+	}
243+
244+	if !m.analyticsEnabled {
245+		return m.renderMd(`**Analytics must be enabled in the Settings page.**
246+
247+[Read our docs about site usage analytics](https://pico.sh/analytics)`)
248+	}
249+
250+	cmd := m.repl
251+	if cmd == "help" {
252+		return m.renderMd(helpMsg)
253+	} else if cmd == "stats" {
254+		return m.renderSiteList()
255+	} else if strings.HasPrefix(cmd, "site") {
256+		return m.renderSiteStats(m.statsBySite)
257+	} else if strings.HasPrefix(cmd, "post") {
258+		return m.renderSiteStats(m.statsByPath)
259+	}
260+
261+	return m.renderMd(helpMsg)
262+}
263+
264+func (m Model) renderMd(md string) string {
265+	r, _ := glamour.NewTermRenderer(
266+		// detect background color and pick either the default dark or light theme
267+		glamour.WithAutoStyle(),
268+		glamour.WithWordWrap(m.viewport.Width),
269+	)
270+	out, _ := r.Render(md)
271+	return out
272+}
273+
274+func (m Model) renderSiteStats(summary *db.SummaryVisits) string {
275+	name, by := splitReplCmd(m.repl)
276+	str := m.shared.Styles.Label.SetString(fmt.Sprintf("%s by %s\n", name, by)).String()
277+
278+	if !strings.HasPrefix(m.repl, "post") {
279+		str += "Top URLs\n"
280+		topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls, m.shared.Styles.Renderer, m.viewport.Width)
281+		str += topUrlsTbl.String()
282+	}
283+
284+	str += "\nTop Referers\n"
285+	topRefsTbl := common.VisitUrlsTbl(summary.TopReferers, m.shared.Styles.Renderer, m.viewport.Width)
286+	str += topRefsTbl.String()
287+
288+	if by == "day" {
289+		str += "\nUnique Visitors by Day this Month\n"
290+	} else {
291+		str += "\nUnique Visitors by Month this Year\n"
292+	}
293+	uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals, m.shared.Styles.Renderer, m.viewport.Width)
294+	str += uniqueTbl.String()
295+	return str
296+}
297+
298+func (m Model) fetchSiteStats(site string, interval string) tea.Cmd {
299+	return func() tea.Msg {
300+		opts := &db.SummaryOpts{
301+			Host: site,
302+
303+			UserID:   m.shared.User.ID,
304+			Interval: interval,
305+		}
306+
307+		if interval == "day" {
308+			opts.Origin = utils.StartOfMonth()
309+		} else {
310+			opts.Origin = utils.StartOfYear()
311+		}
312+
313+		summary, err := m.shared.Dbpool.VisitSummary(opts)
314+		if err != nil {
315+			return errMsg(err)
316+		}
317+
318+		return SiteStatsLoaded{summary}
319+	}
320+}
321+
322+func (m Model) fetchPostStats(raw string, interval string) tea.Cmd {
323+	return func() tea.Msg {
324+		slug := raw
325+		if !strings.HasPrefix(slug, "/") {
326+			slug = "/" + raw
327+		}
328+
329+		opts := &db.SummaryOpts{
330+			Path: slug,
331+
332+			UserID:   m.shared.User.ID,
333+			Interval: interval,
334+		}
335+
336+		if interval == "day" {
337+			opts.Origin = utils.StartOfMonth()
338+		} else {
339+			opts.Origin = utils.StartOfYear()
340+		}
341+
342+		summary, err := m.shared.Dbpool.VisitSummary(opts)
343+		if err != nil {
344+			return errMsg(err)
345+		}
346+
347+		return PathStatsLoaded{summary}
348+	}
349+}
350+
351+func (m Model) renderSiteList() string {
352+	tbl := common.VisitUrlsTbl(m.siteList, m.shared.Styles.Renderer, m.viewport.Width)
353+	str := "Sites: Unique Visitors this Month\n"
354+	str += tbl.String()
355+	return str
356+}
357+
358+func (m Model) fetchSiteList() tea.Cmd {
359+	return func() tea.Msg {
360+		siteList, err := m.shared.Dbpool.FindVisitSiteList(&db.SummaryOpts{
361+			UserID: m.shared.User.ID,
362+			Origin: utils.StartOfMonth(),
363+		})
364+		if err != nil {
365+			return errMsg(err)
366+		}
367+		return SiteListLoaded{siteList}
368+	}
369+}
370+
371+func (m Model) hasAnalyticsFeature() tea.Cmd {
372+	return func() tea.Msg {
373+		feature := m.shared.Dbpool.HasFeatureForUser(m.shared.User.ID, "analytics")
374+		return HasAnalyticsFeature{feature}
375+	}
376+}
377+
378+func splitReplCmd(replCmd string) (string, string) {
379+	replRaw := strings.SplitN(replCmd, " ", 3)
380+	name := strings.TrimSpace(replRaw[1])
381+	by := "month"
382+	if len(replRaw) > 2 {
383+		by = replRaw[2]
384+	}
385+	return name, by
386+}
M tui/common/tbl.go
+20, -31
 1@@ -9,62 +9,51 @@ import (
 2 	"github.com/picosh/pico/db"
 3 )
 4 
 5-func UniqueVisitorsTbl(intervals []*db.VisitInterval) *table.Table {
 6+func UniqueVisitorsTbl(intervals []*db.VisitInterval, renderer *lipgloss.Renderer, maxWidth int) *table.Table {
 7 	headers := []string{"Date", "Unique Visitors"}
 8 	data := [][]string{}
 9+	sum := 0
10 	for _, d := range intervals {
11 		data = append(data, []string{
12-			d.Interval.Format(time.RFC3339Nano),
13+			d.Interval.Format(time.DateOnly),
14 			fmt.Sprintf("%d", d.Visitors),
15 		})
16+		sum += d.Visitors
17 	}
18 
19+	data = append(data, []string{
20+		"Total",
21+		fmt.Sprintf("%d", sum),
22+	})
23+
24 	t := table.New().
25-		Border(lipgloss.RoundedBorder()).
26+		BorderStyle(renderer.NewStyle().BorderForeground(Indigo)).
27+		Width(maxWidth).
28 		Headers(headers...).
29 		Rows(data...)
30 	return t
31 }
32 
33-func VisitUrlsTbl(urls []*db.VisitUrl) *table.Table {
34+func VisitUrlsTbl(urls []*db.VisitUrl, renderer *lipgloss.Renderer, maxWidth int) *table.Table {
35 	headers := []string{"URL", "Count"}
36 	data := [][]string{}
37+	sum := 0
38 	for _, d := range urls {
39 		data = append(data, []string{
40 			d.Url,
41 			fmt.Sprintf("%d", d.Count),
42 		})
43+		sum += d.Count
44 	}
45 
46-	t := table.New().
47-		Border(lipgloss.RoundedBorder()).
48-		Headers(headers...).
49-		Rows(data...)
50-	return t
51-}
52-
53-func VisitUrlsWithProjectTbl(projects []*db.Project, urls []*db.VisitUrl) *table.Table {
54-	headers := []string{"Project", "URL", "Count"}
55-	data := [][]string{}
56-	for _, d := range urls {
57-		if d.ProjectID == "" {
58-			continue
59-		}
60-		projectName := ""
61-		for _, project := range projects {
62-			if project.ID == d.ProjectID {
63-				projectName = project.Name
64-			}
65-		}
66-		data = append(data, []string{
67-			projectName,
68-			d.Url,
69-			fmt.Sprintf("%d", d.Count),
70-		})
71-	}
72+	data = append(data, []string{
73+		"Total",
74+		fmt.Sprintf("%d", sum),
75+	})
76 
77 	t := table.New().
78-		Border(lipgloss.RoundedBorder()).
79+		BorderStyle(renderer.NewStyle().BorderForeground(Indigo)).
80+		Width(maxWidth).
81 		Headers(headers...).
82 		Rows(data...)
83 	return t
M tui/createaccount/create.go
+2, -2
 1@@ -45,7 +45,7 @@ var helpMsg = fmt.Sprintf("Names can only contain plain letters and numbers and
 2 
 3 // Model holds the state of the username UI.
 4 type Model struct {
 5-	shared common.SharedModel
 6+	shared *common.SharedModel
 7 
 8 	state   state
 9 	newName string
10@@ -55,7 +55,7 @@ type Model struct {
11 }
12 
13 // NewModel returns a new username model in its initial state.
14-func NewModel(shared common.SharedModel) Model {
15+func NewModel(shared *common.SharedModel) Model {
16 	im := input.New()
17 	im.Cursor.Style = shared.Styles.Cursor
18 	im.Placeholder = "enter username"
M tui/createkey/create.go
+2, -2
 1@@ -40,7 +40,7 @@ type errMsg struct {
 2 func (e errMsg) Error() string { return e.err.Error() }
 3 
 4 type Model struct {
 5-	shared common.SharedModel
 6+	shared *common.SharedModel
 7 
 8 	state  state
 9 	newKey string
10@@ -82,7 +82,7 @@ func (m *Model) indexBackward() {
11 }
12 
13 // NewModel returns a new username model in its initial state.
14-func NewModel(shared common.SharedModel) Model {
15+func NewModel(shared *common.SharedModel) Model {
16 	im := input.New()
17 	im.PlaceholderStyle = shared.Styles.InputPlaceholder
18 	im.Cursor.Style = shared.Styles.Cursor
M tui/createtoken/create.go
+2, -2
 1@@ -37,7 +37,7 @@ type errMsg struct {
 2 func (e errMsg) Error() string { return e.err.Error() }
 3 
 4 type Model struct {
 5-	shared common.SharedModel
 6+	shared *common.SharedModel
 7 
 8 	state     state
 9 	tokenName string
10@@ -48,7 +48,7 @@ type Model struct {
11 }
12 
13 // NewModel returns a new username model in its initial state.
14-func NewModel(shared common.SharedModel) Model {
15+func NewModel(shared *common.SharedModel) Model {
16 	im := input.New()
17 	im.Cursor.Style = shared.Styles.Cursor
18 	im.Placeholder = "A name used for your reference"
M tui/logs/logs.go
+3, -3
 1@@ -29,7 +29,7 @@ type logLineLoadedMsg map[string]any
 2 type errMsg error
 3 
 4 type Model struct {
 5-	shared   common.SharedModel
 6+	shared   *common.SharedModel
 7 	state    state
 8 	logData  []map[string]any
 9 	viewport viewport.Model
10@@ -40,7 +40,7 @@ type Model struct {
11 	errMsg   error
12 }
13 
14-func headerHeight(shrd common.SharedModel) int {
15+func headerHeight(shrd *common.SharedModel) int {
16 	return shrd.HeaderHeight
17 }
18 
19@@ -50,7 +50,7 @@ func headerWidth(w int) int {
20 
21 var defMsg = "This view shows all logs generated by our services tagged with your user.  This view will show errors triggered by your pages sites, blogs, tuns, etc.  Logs will show up here in realtime as they are generated.  There is no log buffer."
22 
23-func NewModel(shrd common.SharedModel) Model {
24+func NewModel(shrd *common.SharedModel) Model {
25 	im := input.New()
26 	im.Cursor.Style = shrd.Styles.Cursor
27 	im.Placeholder = "filter logs"
M tui/mdw.go
+1, -1
1@@ -23,7 +23,7 @@ func CmsMiddleware(cfg *shared.ConfigSite) bm.Handler {
2 		renderer := bm.MakeRenderer(sesh)
3 		styles := common.DefaultStyles(renderer)
4 
5-		shrd := common.SharedModel{
6+		shrd := &common.SharedModel{
7 			Session: sesh,
8 			Cfg:     cfg,
9 			Dbpool:  dbpool,
M tui/menu/menu.go
+4, -2
 1@@ -20,6 +20,7 @@ const (
 2 	PlusChoice
 3 	SettingsChoice
 4 	LogsChoice
 5+	AnalyticsChoice
 6 	ChatChoice
 7 	ExitChoice
 8 	UnsetChoice // set when no choice has been made
 9@@ -33,18 +34,19 @@ var menuChoices = map[menuChoice]string{
10 	PlusChoice:          "Pico+",
11 	SettingsChoice:      "Settings",
12 	LogsChoice:          "Logs",
13+	AnalyticsChoice:     "Analytics",
14 	ChatChoice:          "Chat",
15 	ExitChoice:          "Exit",
16 }
17 
18 type Model struct {
19-	shared     common.SharedModel
20+	shared     *common.SharedModel
21 	err        error
22 	menuIndex  int
23 	menuChoice menuChoice
24 }
25 
26-func NewModel(shared common.SharedModel) Model {
27+func NewModel(shared *common.SharedModel) Model {
28 	return Model{
29 		shared:     shared,
30 		menuChoice: UnsetChoice,
M tui/notifications/notifications.go
+3, -3
 1@@ -53,7 +53,7 @@ Create a feeds file (e.g. pico.txt):`, url)
 2 
 3 // Model holds the state of the username UI.
 4 type Model struct {
 5-	shared common.SharedModel
 6+	shared *common.SharedModel
 7 
 8 	Done bool // true when it's time to exit this view
 9 	Quit bool // true when the user wants to quit the whole program
10@@ -61,7 +61,7 @@ type Model struct {
11 	viewport viewport.Model
12 }
13 
14-func headerHeight(shrd common.SharedModel) int {
15+func headerHeight(shrd *common.SharedModel) int {
16 	return shrd.HeaderHeight
17 }
18 
19@@ -69,7 +69,7 @@ func headerWidth(w int) int {
20 	return w - 2
21 }
22 
23-func NewModel(shared common.SharedModel) Model {
24+func NewModel(shared *common.SharedModel) Model {
25 	hh := headerHeight(shared)
26 	ww := headerWidth(shared.Width)
27 	viewport := viewport.New(ww, shared.Height-hh)
M tui/pages/pages.go
+3, -0
 1@@ -15,6 +15,7 @@ const (
 2 	PlusPage
 3 	SettingsPage
 4 	LogsPage
 5+	AnalyticsPage
 6 )
 7 
 8 type NavigateMsg struct{ Page }
 9@@ -47,6 +48,8 @@ func ToTitle(page Page) string {
10 		return "settings"
11 	case LogsPage:
12 		return "logs"
13+	case AnalyticsPage:
14+		return "analytics"
15 	}
16 
17 	return ""
M tui/plus/plus.go
+4, -4
 1@@ -40,7 +40,7 @@ method.
 2 
 3 Send cash (USD) or check to:
 4 - pico.sh LLC
 5-- 206 E Huron St STE 103
 6+- 206 E Huron St
 7 - Ann Arbor MI 48104
 8 
 9 ## Notes
10@@ -64,11 +64,11 @@ we plan on continuing to include more services as we build them.`, url)
11 
12 // Model holds the state of the username UI.
13 type Model struct {
14-	shared   common.SharedModel
15+	shared   *common.SharedModel
16 	viewport viewport.Model
17 }
18 
19-func headerHeight(shrd common.SharedModel) int {
20+func headerHeight(shrd *common.SharedModel) int {
21 	return shrd.HeaderHeight
22 }
23 
24@@ -77,7 +77,7 @@ func headerWidth(w int) int {
25 }
26 
27 // NewModel returns a new username model in its initial state.
28-func NewModel(shared common.SharedModel) Model {
29+func NewModel(shared *common.SharedModel) Model {
30 	hh := headerHeight(shared)
31 	ww := headerWidth(shared.Width)
32 	viewport := viewport.New(ww, shared.Height-hh)
M tui/pubkeys/keys.go
+3, -3
 1@@ -36,7 +36,7 @@ type (
 2 
 3 // Model is the Tea state model for this user interface.
 4 type Model struct {
 5-	shared common.SharedModel
 6+	shared *common.SharedModel
 7 
 8 	state          state
 9 	err            error
10@@ -48,7 +48,7 @@ type Model struct {
11 }
12 
13 // NewModel creates a new model with defaults.
14-func NewModel(shared common.SharedModel) Model {
15+func NewModel(shared *common.SharedModel) Model {
16 	p := pager.New()
17 	p.PerPage = keysPerPage
18 	p.Type = pager.Dots
19@@ -302,7 +302,7 @@ func (m *Model) promptView(prompt string) string {
20 }
21 
22 // FetchKeys loads the current set of keys via the charm client.
23-func FetchKeys(shrd common.SharedModel) tea.Cmd {
24+func FetchKeys(shrd *common.SharedModel) tea.Cmd {
25 	return func() tea.Msg {
26 		ak, err := shrd.Dbpool.FindKeysForUser(shrd.User)
27 		if err != nil {
M tui/senpai.go
+2, -2
 1@@ -9,7 +9,7 @@ import (
 2 )
 3 
 4 type SenpaiCmd struct {
 5-	shared common.SharedModel
 6+	shared *common.SharedModel
 7 }
 8 
 9 func (m *SenpaiCmd) Run() error {
10@@ -30,7 +30,7 @@ func (m *SenpaiCmd) SetStdin(io.Reader)  {}
11 func (m *SenpaiCmd) SetStdout(io.Writer) {}
12 func (m *SenpaiCmd) SetStderr(io.Writer) {}
13 
14-func LoadChat(shrd common.SharedModel) tea.Cmd {
15+func LoadChat(shrd *common.SharedModel) tea.Cmd {
16 	sp := &SenpaiCmd{
17 		shared: shrd,
18 	}
M tui/settings/settings.go
+2, -2
 1@@ -32,13 +32,13 @@ const (
 2 type featuresLoadedMsg []*db.FeatureFlag
 3 
 4 type Model struct {
 5-	shared   common.SharedModel
 6+	shared   *common.SharedModel
 7 	features []*db.FeatureFlag
 8 	state    state
 9 	focus    focus
10 }
11 
12-func NewModel(shrd common.SharedModel) Model {
13+func NewModel(shrd *common.SharedModel) Model {
14 	return Model{
15 		shared: shrd,
16 		state:  stateLoading,
M tui/tokens/tokens.go
+3, -3
 1@@ -39,7 +39,7 @@ type (
 2 
 3 // Model is the Tea state model for this user interface.
 4 type Model struct {
 5-	shared common.SharedModel
 6+	shared *common.SharedModel
 7 
 8 	state          state
 9 	err            error
10@@ -69,7 +69,7 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
11 }
12 
13 // NewModel creates a new model with defaults.
14-func NewModel(shared common.SharedModel) Model {
15+func NewModel(shared *common.SharedModel) Model {
16 	p := pager.New()
17 	p.PerPage = keysPerPage
18 	p.Type = pager.Dots
19@@ -265,7 +265,7 @@ func (m *Model) promptView(prompt string) string {
20 		m.shared.Styles.Delete.Render("(y/N)")
21 }
22 
23-func FetchTokens(shrd common.SharedModel) tea.Cmd {
24+func FetchTokens(shrd *common.SharedModel) tea.Cmd {
25 	return func() tea.Msg {
26 		ak, err := shrd.Dbpool.FindTokensForUser(shrd.User.ID)
27 		if err != nil {
M tui/ui.go
+7, -3
 1@@ -8,6 +8,7 @@ import (
 2 	"github.com/charmbracelet/wish"
 3 	"github.com/muesli/reflow/wordwrap"
 4 	"github.com/muesli/reflow/wrap"
 5+	"github.com/picosh/pico/tui/analytics"
 6 	"github.com/picosh/pico/tui/common"
 7 	"github.com/picosh/pico/tui/createaccount"
 8 	"github.com/picosh/pico/tui/createkey"
 9@@ -31,18 +32,18 @@ const (
10 
11 // Just a generic tea.Model to demo terminal information of ssh.
12 type UI struct {
13-	shared common.SharedModel
14+	shared *common.SharedModel
15 
16 	state      state
17 	activePage pages.Page
18 	pages      []tea.Model
19 }
20 
21-func NewUI(shared common.SharedModel) *UI {
22+func NewUI(shared *common.SharedModel) *UI {
23 	m := &UI{
24 		shared: shared,
25 		state:  initState,
26-		pages:  make([]tea.Model, 10),
27+		pages:  make([]tea.Model, 11),
28 	}
29 	return m
30 }
31@@ -78,6 +79,7 @@ func (m *UI) Init() tea.Cmd {
32 	m.pages[pages.PlusPage] = plus.NewModel(m.shared)
33 	m.pages[pages.SettingsPage] = settings.NewModel(m.shared)
34 	m.pages[pages.LogsPage] = logs.NewModel(m.shared)
35+	m.pages[pages.AnalyticsPage] = analytics.NewModel(m.shared)
36 	if m.shared.User == nil {
37 		m.activePage = pages.CreateAccountPage
38 	} else {
39@@ -141,6 +143,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
40 			m.activePage = pages.SettingsPage
41 		case menu.LogsChoice:
42 			m.activePage = pages.LogsPage
43+		case menu.AnalyticsChoice:
44+			m.activePage = pages.AnalyticsPage
45 		case menu.ChatChoice:
46 			return m, LoadChat(m.shared)
47 		case menu.ExitChoice:
M tui/util.go
+2, -2
 1@@ -9,7 +9,7 @@ import (
 2 	"github.com/picosh/utils"
 3 )
 4 
 5-func findUser(shrd common.SharedModel) (*db.User, error) {
 6+func findUser(shrd *common.SharedModel) (*db.User, error) {
 7 	logger := shrd.Cfg.Logger
 8 	var user *db.User
 9 	usr := shrd.Session.User()
10@@ -34,7 +34,7 @@ func findUser(shrd common.SharedModel) (*db.User, error) {
11 	return user, nil
12 }
13 
14-func findPlusFeatureFlag(shrd common.SharedModel) (*db.FeatureFlag, error) {
15+func findPlusFeatureFlag(shrd *common.SharedModel) (*db.FeatureFlag, error) {
16 	if shrd.User == nil {
17 		return nil, nil
18 	}
D ui/api.go
+0, -691
  1@@ -1,691 +0,0 @@
  2-package ui
  3-
  4-import (
  5-	"encoding/json"
  6-	"fmt"
  7-	"io"
  8-	"net/http"
  9-	"slices"
 10-	"strings"
 11-	"time"
 12-
 13-	"github.com/charmbracelet/ssh"
 14-	"github.com/picosh/pico/db"
 15-	"github.com/picosh/pico/shared"
 16-	"github.com/picosh/utils"
 17-)
 18-
 19-type registerPayload struct {
 20-	Name string `json:"name"`
 21-}
 22-
 23-func ensureUser(w http.ResponseWriter, user *db.User) bool {
 24-	if user == nil {
 25-		shared.JSONError(w, "User not found", http.StatusNotFound)
 26-		return false
 27-	}
 28-	return true
 29-}
 30-
 31-func registerUser(apiConfig *shared.ApiConfig, ctx ssh.Context, pubkey ssh.PublicKey) http.HandlerFunc {
 32-	logger := apiConfig.Cfg.Logger
 33-	return func(w http.ResponseWriter, r *http.Request) {
 34-		w.Header().Set("Content-Type", "application/json")
 35-		dbpool := shared.GetDB(r)
 36-		var payload registerPayload
 37-		body, _ := io.ReadAll(r.Body)
 38-		_ = json.Unmarshal(body, &payload)
 39-
 40-		pubkeyStr := utils.KeyForKeyText(pubkey)
 41-
 42-		user, err := dbpool.RegisterUser(payload.Name, pubkeyStr, "")
 43-		if err != nil {
 44-			errMsg := fmt.Sprintf("error registering user: %s", err.Error())
 45-			logger.Error("error registering user", "err", err.Error())
 46-			shared.JSONError(w, errMsg, http.StatusUnprocessableEntity)
 47-			return
 48-		}
 49-
 50-		picoApi := shared.NewUserApi(user, pubkey)
 51-		shared.SetUser(ctx, user)
 52-		err = json.NewEncoder(w).Encode(picoApi)
 53-		if err != nil {
 54-			logger.Error("json encoding error", "err", err.Error())
 55-		}
 56-	}
 57-}
 58-
 59-type featuresPayload struct {
 60-	Features []*db.FeatureFlag `json:"features"`
 61-}
 62-
 63-func getFeatures(apiConfig *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
 64-	logger := apiConfig.Cfg.Logger
 65-	return func(w http.ResponseWriter, r *http.Request) {
 66-		w.Header().Set("Content-Type", "application/json")
 67-		user, _ := shared.GetUser(ctx)
 68-		if !ensureUser(w, user) {
 69-			return
 70-		}
 71-
 72-		dbpool := shared.GetDB(r)
 73-		features, err := dbpool.FindFeaturesForUser(user.ID)
 74-		if err != nil {
 75-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
 76-			return
 77-		}
 78-
 79-		if features == nil {
 80-			features = []*db.FeatureFlag{}
 81-		}
 82-		err = json.NewEncoder(w).Encode(&featuresPayload{Features: features})
 83-		if err != nil {
 84-			logger.Error(err.Error())
 85-		}
 86-	}
 87-}
 88-
 89-type tokenSecretPayload struct {
 90-	Secret string `json:"secret"`
 91-}
 92-
 93-func findOrCreateRssToken(apiConfig *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
 94-	logger := apiConfig.Cfg.Logger
 95-	return func(w http.ResponseWriter, r *http.Request) {
 96-		w.Header().Set("Content-Type", "application/json")
 97-		user, _ := shared.GetUser(ctx)
 98-		if !ensureUser(w, user) {
 99-			return
100-		}
101-
102-		dbpool := shared.GetDB(r)
103-		var err error
104-		rssToken, _ := dbpool.FindTokenByName(user.ID, "pico-rss")
105-		if rssToken == "" {
106-			rssToken, err = dbpool.InsertToken(user.ID, "pico-rss")
107-			if err != nil {
108-				shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
109-				return
110-			}
111-		}
112-
113-		err = json.NewEncoder(w).Encode(&tokenSecretPayload{Secret: rssToken})
114-		if err != nil {
115-			logger.Error(err.Error())
116-		}
117-	}
118-}
119-
120-type pubkeysPayload struct {
121-	Pubkeys []*db.PublicKey `json:"pubkeys"`
122-}
123-
124-func toFingerprint(pubkey string) (string, error) {
125-	kk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey))
126-	if err != nil {
127-		return "", err
128-	}
129-	return utils.KeyForSha256(kk), nil
130-}
131-
132-func getPublicKeys(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
133-	logger := httpCtx.Cfg.Logger
134-	return func(w http.ResponseWriter, r *http.Request) {
135-		w.Header().Set("Content-Type", "application/json")
136-		user, _ := shared.GetUser(ctx)
137-		if !ensureUser(w, user) {
138-			return
139-		}
140-
141-		dbpool := shared.GetDB(r)
142-		pubkeys, err := dbpool.FindKeysForUser(user)
143-		if err != nil {
144-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
145-			return
146-		}
147-
148-		for _, pk := range pubkeys {
149-			fingerprint, err := toFingerprint(pk.Key)
150-			if err != nil {
151-				logger.Error("could not parse public key", "err", err.Error())
152-				continue
153-			}
154-			pk.Key = fingerprint
155-		}
156-
157-		err = json.NewEncoder(w).Encode(&pubkeysPayload{Pubkeys: pubkeys})
158-		if err != nil {
159-			logger.Error("json encode", "err", err.Error())
160-		}
161-	}
162-}
163-
164-type createPubkeyPayload struct {
165-	Pubkey string `json:"pubkey"`
166-	Name   string `json:"name"`
167-}
168-
169-func createPubkey(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
170-	logger := httpCtx.Cfg.Logger
171-	return func(w http.ResponseWriter, r *http.Request) {
172-		w.Header().Set("Content-Type", "application/json")
173-		user, _ := shared.GetUser(ctx)
174-		if !ensureUser(w, user) {
175-			return
176-		}
177-
178-		dbpool := shared.GetDB(r)
179-		var payload createPubkeyPayload
180-		body, _ := io.ReadAll(r.Body)
181-		_ = json.Unmarshal(body, &payload)
182-		err := dbpool.InsertPublicKey(user.ID, payload.Pubkey, payload.Name, nil)
183-		if err != nil {
184-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
185-			return
186-		}
187-
188-		pubkey, err := dbpool.FindPublicKeyForKey(payload.Pubkey)
189-		if err != nil {
190-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
191-			return
192-		}
193-
194-		fingerprint, err := toFingerprint(pubkey.Key)
195-		if err != nil {
196-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
197-			return
198-		}
199-		pubkey.Key = fingerprint
200-		err = json.NewEncoder(w).Encode(pubkey)
201-		if err != nil {
202-			logger.Error("json encode", "err", err.Error())
203-		}
204-	}
205-}
206-
207-func deletePubkey(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
208-	logger := httpCtx.Cfg.Logger
209-	return func(w http.ResponseWriter, r *http.Request) {
210-		w.Header().Set("Content-Type", "application/json")
211-		user, _ := shared.GetUser(ctx)
212-		if !ensureUser(w, user) {
213-			return
214-		}
215-		dbpool := shared.GetDB(r)
216-		pubkeyID := shared.GetField(r, 0)
217-
218-		ownedKeys, err := dbpool.FindKeysForUser(user)
219-		if err != nil {
220-			logger.Error("could not query for pubkeys", "err", err.Error())
221-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
222-			return
223-		}
224-
225-		found := false
226-		for _, key := range ownedKeys {
227-			if key.ID == pubkeyID {
228-				found = true
229-				break
230-			}
231-		}
232-
233-		if !found {
234-			logger.Error("user trying to delete key they do not own")
235-			shared.JSONError(w, "user trying to delete key they do not own", http.StatusUnauthorized)
236-			return
237-		}
238-
239-		err = dbpool.RemoveKeys([]string{pubkeyID})
240-		if err != nil {
241-			logger.Error("could not remove pubkey", "err", err.Error())
242-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
243-			return
244-		}
245-		w.WriteHeader(http.StatusNoContent)
246-	}
247-}
248-
249-func patchPubkey(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
250-	logger := httpCtx.Cfg.Logger
251-	return func(w http.ResponseWriter, r *http.Request) {
252-		w.Header().Set("Content-Type", "application/json")
253-		user, _ := shared.GetUser(ctx)
254-		if !ensureUser(w, user) {
255-			return
256-		}
257-
258-		dbpool := shared.GetDB(r)
259-		pubkeyID := shared.GetField(r, 0)
260-
261-		var payload createPubkeyPayload
262-		body, _ := io.ReadAll(r.Body)
263-		_ = json.Unmarshal(body, &payload)
264-
265-		auth, err := dbpool.FindPublicKey(pubkeyID)
266-		if err != nil {
267-			logger.Error("could not find user with pubkey provided", "err", err.Error())
268-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
269-			return
270-		}
271-
272-		if auth.UserID != user.ID {
273-			logger.Error("user trying to update pubkey they do not own")
274-			shared.JSONError(w, "user trying to update pubkey they do not own", http.StatusUnauthorized)
275-			return
276-		}
277-
278-		pubkey, err := dbpool.UpdatePublicKey(pubkeyID, payload.Name)
279-		if err != nil {
280-			logger.Error("could not update pubkey", "err", err.Error())
281-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
282-			return
283-		}
284-
285-		fingerprint, err := toFingerprint(pubkey.Key)
286-		if err != nil {
287-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
288-			return
289-		}
290-		pubkey.Key = fingerprint
291-
292-		err = json.NewEncoder(w).Encode(pubkey)
293-		if err != nil {
294-			logger.Error("json encode", "err", err.Error())
295-		}
296-	}
297-}
298-
299-type tokensPayload struct {
300-	Tokens []*db.Token `json:"tokens"`
301-}
302-
303-func getTokens(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
304-	logger := httpCtx.Cfg.Logger
305-	return func(w http.ResponseWriter, r *http.Request) {
306-		w.Header().Set("Content-Type", "application/json")
307-		user, _ := shared.GetUser(ctx)
308-		if !ensureUser(w, user) {
309-			return
310-		}
311-
312-		dbpool := shared.GetDB(r)
313-		tokens, err := dbpool.FindTokensForUser(user.ID)
314-		if err != nil {
315-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
316-			return
317-		}
318-
319-		if tokens == nil {
320-			tokens = []*db.Token{}
321-		}
322-
323-		err = json.NewEncoder(w).Encode(&tokensPayload{Tokens: tokens})
324-		if err != nil {
325-			logger.Error(err.Error())
326-		}
327-	}
328-}
329-
330-type createTokenPayload struct {
331-	Name string `json:"name"`
332-}
333-
334-type createTokenResponsePayload struct {
335-	Secret string    `json:"secret"`
336-	Token  *db.Token `json:"token"`
337-}
338-
339-func createToken(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
340-	logger := httpCtx.Cfg.Logger
341-	return func(w http.ResponseWriter, r *http.Request) {
342-		w.Header().Set("Content-Type", "application/json")
343-		user, _ := shared.GetUser(ctx)
344-		if !ensureUser(w, user) {
345-			return
346-		}
347-
348-		dbpool := shared.GetDB(r)
349-		var payload createTokenPayload
350-		body, _ := io.ReadAll(r.Body)
351-		_ = json.Unmarshal(body, &payload)
352-		secret, err := dbpool.InsertToken(user.ID, payload.Name)
353-		if err != nil {
354-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
355-			return
356-		}
357-
358-		// TODO: find token by name
359-		tokens, err := dbpool.FindTokensForUser(user.ID)
360-		if err != nil {
361-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
362-		}
363-
364-		var token *db.Token
365-		for _, tok := range tokens {
366-			if tok.Name == payload.Name {
367-				token = tok
368-				break
369-			}
370-		}
371-
372-		err = json.NewEncoder(w).Encode(&createTokenResponsePayload{Secret: secret, Token: token})
373-		if err != nil {
374-			logger.Error("json encode", "err", err.Error())
375-		}
376-	}
377-}
378-
379-func deleteToken(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
380-	logger := httpCtx.Cfg.Logger
381-	return func(w http.ResponseWriter, r *http.Request) {
382-		w.Header().Set("Content-Type", "application/json")
383-		user, _ := shared.GetUser(ctx)
384-		if !ensureUser(w, user) {
385-			return
386-		}
387-		dbpool := shared.GetDB(r)
388-		tokenID := shared.GetField(r, 0)
389-
390-		toks, err := dbpool.FindTokensForUser(user.ID)
391-		if err != nil {
392-			logger.Error("could not query for user tokens", "err", err.Error())
393-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
394-			return
395-		}
396-
397-		found := false
398-		for _, tok := range toks {
399-			if tok.ID == tokenID {
400-				found = true
401-				break
402-			}
403-		}
404-
405-		if !found {
406-			logger.Error("user trying to delete token they do not own")
407-			shared.JSONError(w, "user trying to delete token they do not own", http.StatusUnauthorized)
408-			return
409-		}
410-
411-		err = dbpool.RemoveToken(tokenID)
412-		if err != nil {
413-			logger.Error("could not remove token", "err", err.Error())
414-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
415-			return
416-		}
417-		w.WriteHeader(http.StatusNoContent)
418-	}
419-}
420-
421-type projectsPayload struct {
422-	Projects []*db.Project `json:"projects"`
423-}
424-
425-func getProjects(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
426-	logger := httpCtx.Cfg.Logger
427-	return func(w http.ResponseWriter, r *http.Request) {
428-		w.Header().Set("Content-Type", "application/json")
429-		user, _ := shared.GetUser(ctx)
430-		if !ensureUser(w, user) {
431-			return
432-		}
433-
434-		dbpool := shared.GetDB(r)
435-		projects, err := dbpool.FindProjectsByUser(user.ID)
436-		if err != nil {
437-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
438-			return
439-		}
440-
441-		if projects == nil {
442-			projects = []*db.Project{}
443-		}
444-
445-		err = json.NewEncoder(w).Encode(&projectsPayload{Projects: projects})
446-		if err != nil {
447-			logger.Error(err.Error())
448-		}
449-	}
450-}
451-
452-type postsPayload struct {
453-	Posts []*db.Post `json:"posts"`
454-}
455-
456-func getPosts(httpCtx *shared.ApiConfig, ctx ssh.Context, space string) http.HandlerFunc {
457-	logger := httpCtx.Cfg.Logger
458-	return func(w http.ResponseWriter, r *http.Request) {
459-		w.Header().Set("Content-Type", "application/json")
460-		user, _ := shared.GetUser(ctx)
461-		if !ensureUser(w, user) {
462-			return
463-		}
464-
465-		dbpool := shared.GetDB(r)
466-		posts, err := dbpool.FindAllPostsForUser(user.ID, space)
467-		if err != nil {
468-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
469-			return
470-		}
471-
472-		if posts == nil {
473-			posts = []*db.Post{}
474-		}
475-
476-		err = json.NewEncoder(w).Encode(&postsPayload{Posts: posts})
477-		if err != nil {
478-			logger.Error(err.Error())
479-		}
480-	}
481-}
482-
483-type objectsPayload struct {
484-	Objects []*ProjectObject `json:"objects"`
485-}
486-
487-type ProjectObject struct {
488-	ID      string    `json:"id"`
489-	Name    string    `json:"name"`
490-	Size    int64     `json:"size"`
491-	ModTime time.Time `json:"mod_time"`
492-}
493-
494-type createFeaturePayload struct {
495-	Name string `json:"name"`
496-}
497-
498-var featureAllowList = []string{
499-	"analytics",
500-}
501-
502-func createFeature(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
503-	logger := httpCtx.Cfg.Logger
504-	return func(w http.ResponseWriter, r *http.Request) {
505-		w.Header().Set("Content-Type", "application/json")
506-		user, _ := shared.GetUser(ctx)
507-		if !ensureUser(w, user) {
508-			return
509-		}
510-
511-		dbpool := shared.GetDB(r)
512-		var payload createFeaturePayload
513-		body, _ := io.ReadAll(r.Body)
514-		_ = json.Unmarshal(body, &payload)
515-
516-		// only allow the user to add certain features to their account
517-		if !slices.Contains(featureAllowList, payload.Name) {
518-			err := fmt.Errorf(
519-				"(%s) is not in feature allowlist (%s)",
520-				payload.Name,
521-				strings.Join(featureAllowList, ", "),
522-			)
523-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
524-			return
525-		}
526-
527-		now := time.Now()
528-		expiresAt := now.AddDate(100, 0, 0)
529-		feature, err := dbpool.InsertFeature(user.ID, payload.Name, expiresAt)
530-		if err != nil {
531-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
532-			return
533-		}
534-
535-		err = json.NewEncoder(w).Encode(feature)
536-		if err != nil {
537-			logger.Error("json encode", "err", err.Error())
538-		}
539-	}
540-}
541-
542-func deleteFeature(httpCtx *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
543-	logger := httpCtx.Cfg.Logger
544-	return func(w http.ResponseWriter, r *http.Request) {
545-		w.Header().Set("Content-Type", "application/json")
546-		user, _ := shared.GetUser(ctx)
547-		if !ensureUser(w, user) {
548-			return
549-		}
550-		dbpool := shared.GetDB(r)
551-		featureName := shared.GetField(r, 0)
552-
553-		if !slices.Contains(featureAllowList, featureName) {
554-			err := fmt.Errorf(
555-				"(%s) is not in feature allowlist (%s)",
556-				featureName,
557-				strings.Join(featureAllowList, ", "),
558-			)
559-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
560-			return
561-		}
562-
563-		err := dbpool.RemoveFeature(user.ID, featureName)
564-		if err != nil {
565-			logger.Error("could not remove features", "err", err.Error())
566-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
567-			return
568-		}
569-		w.WriteHeader(http.StatusNoContent)
570-	}
571-}
572-
573-func getProjectObjects(apiConfig *shared.ApiConfig, ctx ssh.Context) http.HandlerFunc {
574-	logger := apiConfig.Cfg.Logger
575-	storage := apiConfig.Storage
576-	return func(w http.ResponseWriter, r *http.Request) {
577-		w.Header().Set("Content-Type", "application/json")
578-		user, _ := shared.GetUser(ctx)
579-		if !ensureUser(w, user) {
580-			return
581-		}
582-
583-		projectName := shared.GetField(r, 0) + "/"
584-		bucketName := shared.GetAssetBucketName(user.ID)
585-		bucket, err := storage.GetBucket(bucketName)
586-		if err != nil {
587-			logger.Info("bucket not found", "err", err.Error())
588-			shared.JSONError(w, err.Error(), http.StatusNotFound)
589-			return
590-		}
591-		objects, err := storage.ListObjects(bucket, projectName, true)
592-		if err != nil {
593-			logger.Info("cannot fetch objects", "err", err.Error())
594-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
595-			return
596-		}
597-
598-		pobjs := []*ProjectObject{}
599-		for _, obj := range objects {
600-			pobjs = append(pobjs, &ProjectObject{
601-				ID:      fmt.Sprintf("%s%s", projectName, obj.Name()),
602-				Name:    obj.Name(),
603-				Size:    obj.Size(),
604-				ModTime: obj.ModTime(),
605-			})
606-		}
607-
608-		err = json.NewEncoder(w).Encode(&objectsPayload{Objects: pobjs})
609-		if err != nil {
610-			logger.Error(err.Error())
611-		}
612-	}
613-}
614-
615-func getAnalytics(apiConfig *shared.ApiConfig, ctx ssh.Context, sumtype, bytype, where string) http.HandlerFunc {
616-	logger := apiConfig.Cfg.Logger
617-	dbpool := apiConfig.Dbpool
618-	return func(w http.ResponseWriter, r *http.Request) {
619-		w.Header().Set("Content-Type", "application/json")
620-		user, _ := shared.GetUser(ctx)
621-		if !ensureUser(w, user) {
622-			return
623-		}
624-
625-		fkID := user.ID
626-		by := "user_id"
627-		if bytype == "project" {
628-			fkID = shared.GetField(r, 0)
629-			by = "project_id"
630-		} else if bytype == "post" {
631-			fkID = shared.GetField(r, 0)
632-			by = "post_id"
633-		}
634-
635-		year := &db.SummaryOpts{FkID: fkID, By: by, Interval: "month", Origin: utils.StartOfYear(), Where: where}
636-		month := &db.SummaryOpts{FkID: fkID, By: by, Interval: "day", Origin: utils.StartOfMonth(), Where: where}
637-
638-		opts := year
639-		if sumtype == "month" {
640-			opts = month
641-		}
642-
643-		summary, err := dbpool.VisitSummary(opts)
644-		if err != nil {
645-			logger.Info("cannot fetch analytics", "err", err.Error())
646-			shared.JSONError(w, err.Error(), http.StatusUnprocessableEntity)
647-			return
648-		}
649-
650-		err = json.NewEncoder(w).Encode(&summary)
651-		if err != nil {
652-			logger.Error(err.Error())
653-		}
654-	}
655-}
656-
657-func CreateRoutes(apiConfig *shared.ApiConfig, ctx ssh.Context) []shared.Route {
658-	logger := apiConfig.Cfg.Logger
659-	pubkey, err := shared.GetPublicKey(ctx)
660-	if err != nil {
661-		logger.Error("could not get pubkey from ctx", "err", err.Error())
662-		return []shared.Route{}
663-	}
664-
665-	return []shared.Route{
666-		shared.NewCorsRoute("POST", "/api/users", registerUser(apiConfig, ctx, pubkey)),
667-		shared.NewCorsRoute("GET", "/api/features", getFeatures(apiConfig, ctx)),
668-		shared.NewCorsRoute("PUT", "/api/rss-token", findOrCreateRssToken(apiConfig, ctx)),
669-		shared.NewCorsRoute("GET", "/api/pubkeys", getPublicKeys(apiConfig, ctx)),
670-		shared.NewCorsRoute("POST", "/api/pubkeys", createPubkey(apiConfig, ctx)),
671-		shared.NewCorsRoute("DELETE", "/api/pubkeys/(.+)", deletePubkey(apiConfig, ctx)),
672-		shared.NewCorsRoute("POST", "/api/features", createFeature(apiConfig, ctx)),
673-		shared.NewCorsRoute("DELETE", "/api/features/(.+)", deleteFeature(apiConfig, ctx)),
674-		shared.NewCorsRoute("PATCH", "/api/pubkeys/(.+)", patchPubkey(apiConfig, ctx)),
675-		shared.NewCorsRoute("GET", "/api/tokens", getTokens(apiConfig, ctx)),
676-		shared.NewCorsRoute("POST", "/api/tokens", createToken(apiConfig, ctx)),
677-		shared.NewCorsRoute("DELETE", "/api/tokens/(.+)", deleteToken(apiConfig, ctx)),
678-		shared.NewCorsRoute("GET", "/api/projects/(.+)/analytics", getAnalytics(apiConfig, ctx, "month", "project", "")),
679-		shared.NewCorsRoute("GET", "/api/projects/(.+)/analytics/year", getAnalytics(apiConfig, ctx, "year", "project", "")),
680-		shared.NewCorsRoute("GET", "/api/projects/(.+)", getProjectObjects(apiConfig, ctx)),
681-		shared.NewCorsRoute("GET", "/api/projects", getProjects(apiConfig, ctx)),
682-		shared.NewCorsRoute("GET", "/api/posts/analytics/year", getAnalytics(apiConfig, ctx, "year", "user", "AND (post_id IS NOT NULL OR (post_id IS NULL AND project_id IS NULL))")),
683-		shared.NewCorsRoute("GET", "/api/posts/analytics", getAnalytics(apiConfig, ctx, "month", "user", "AND (post_id IS NOT NULL OR (post_id IS NULL AND project_id IS NULL))")),
684-		shared.NewCorsRoute("GET", "/api/posts/(.+)/analytics", getAnalytics(apiConfig, ctx, "month", "post", "")),
685-		shared.NewCorsRoute("GET", "/api/posts/(.+)/analytics/year", getAnalytics(apiConfig, ctx, "year", "post", "")),
686-		shared.NewCorsRoute("GET", "/api/posts/prose", getPosts(apiConfig, ctx, "prose")),
687-		shared.NewCorsRoute("GET", "/api/posts/pastes", getPosts(apiConfig, ctx, "pastes")),
688-		shared.NewCorsRoute("GET", "/api/posts/feeds", getPosts(apiConfig, ctx, "feeds")),
689-		shared.NewCorsRoute("GET", "/api/analytics/year", getAnalytics(apiConfig, ctx, "year", "user", "")),
690-		shared.NewCorsRoute("GET", "/api/analytics", getAnalytics(apiConfig, ctx, "month", "user", "")),
691-	}
692-}
M wish/mdw.go
+0, -1
1@@ -16,7 +16,6 @@ func SessionMessage(sesh ssh.Session, msg string) {
2 func DeprecatedNotice() wish.Middleware {
3 	return func(next ssh.Handler) ssh.Handler {
4 		return func(sesh ssh.Session) {
5-
6 			renderer := bm.MakeRenderer(sesh)
7 			styles := common.DefaultStyles(renderer)
8