- 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
db/db.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)
+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) {
+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) {
+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
+0,
-1
1@@ -1,4 +1,3 @@
2-version: "3.8"
3 services:
4 auth-caddy:
5 image: ghcr.io/picosh/pico/caddy:latest
+0,
-1
1@@ -1,4 +1,3 @@
2-version: "3.8"
3 services:
4 postgres:
5 env_file:
+0,
-1
1@@ -1,4 +1,3 @@
2-version: "3.8"
3 services:
4 postgres:
5 image: postgres:14
+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 {
+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 }
+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{
+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{
+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+}
+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
+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"
+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
+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"
+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"
+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,
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,
+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)
+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 ""
+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)
+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 {
+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 }
+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,
+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 {
+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:
+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 }
+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-}
+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