repos / pico

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

commit
eb3b3bb
parent
bccde8d
author
Eric Bower
date
2026-01-25 09:55:59 -0500 EST
refactor: fix issues with imports and create shared/router pkg
55 files changed,  +479, -482
M cmd/pgs/cdn/main.go
+3, -5
 1@@ -13,14 +13,12 @@ import (
 2 	"github.com/darkweak/souin/pkg/middleware"
 3 	"github.com/hashicorp/golang-lru/v2/expirable"
 4 	"github.com/picosh/pico/pkg/apps/pgs"
 5-	"github.com/picosh/pico/pkg/cache"
 6 	"github.com/picosh/pico/pkg/shared"
 7-	"github.com/picosh/utils"
 8 	"github.com/prometheus/client_golang/prometheus/promhttp"
 9 )
10 
11 func main() {
12-	withPipe := strings.ToLower(utils.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
13+	withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
14 	logger := shared.CreateLogger("pgs-cdn", withPipe)
15 	ctx := context.Background()
16 	drain := pgs.CreateSubCacheDrain(ctx, logger)
17@@ -32,8 +30,8 @@ func main() {
18 	httpCache := pgs.SetupCache(cfg)
19 	router := &pgs.WebRouter{
20 		Cfg:            cfg,
21-		RedirectsCache: expirable.NewLRU[string, []*pgs.RedirectRule](2048, nil, cache.CacheTimeout),
22-		HeadersCache:   expirable.NewLRU[string, []*pgs.HeaderRule](2048, nil, cache.CacheTimeout),
23+		RedirectsCache: expirable.NewLRU[string, []*pgs.RedirectRule](2048, nil, shared.CacheTimeout),
24+		HeadersCache:   expirable.NewLRU[string, []*pgs.HeaderRule](2048, nil, shared.CacheTimeout),
25 	}
26 	cacher := &cachedHttp{
27 		handler: httpCache,
M cmd/pgs/standalone/main.go
+2, -3
 1@@ -8,12 +8,11 @@ import (
 2 	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 3 	"github.com/picosh/pico/pkg/shared"
 4 	"github.com/picosh/pico/pkg/shared/storage"
 5-	"github.com/picosh/utils"
 6 	"golang.org/x/crypto/ssh"
 7 )
 8 
 9 func main() {
10-	dbURL := utils.GetEnv("DATABASE_URL", "./data/pgs.sqlite3")
11+	dbURL := shared.GetEnv("DATABASE_URL", "./data/pgs.sqlite3")
12 	logger := shared.CreateLogger("pgs-standalone", false)
13 	dbpool, err := pgsdb.NewSqliteDB(dbURL, logger)
14 	if err != nil {
15@@ -45,7 +44,7 @@ func main() {
16 			logger.Error("parse pubkey", "err", err)
17 			return
18 		}
19-		pubkey := utils.KeyForKeyText(key)
20+		pubkey := shared.KeyForKeyText(key)
21 		logger.Info("init cli", "userName", userName, "pubkey", pubkey)
22 
23 		err = dbpool.RegisterAdmin(userName, pubkey, comment)
M cmd/scripts/analytics/analytics.go
+2, -2
 1@@ -6,7 +6,7 @@ import (
 2 
 3 	"github.com/picosh/pico/pkg/db"
 4 	"github.com/picosh/pico/pkg/db/postgres"
 5-	"github.com/picosh/utils"
 6+	"github.com/picosh/pico/pkg/shared"
 7 )
 8 
 9 func main() {
10@@ -19,7 +19,7 @@ func main() {
11 
12 	stats, err := dbpool.VisitSummary(
13 		&db.SummaryOpts{
14-			Origin: utils.StartOfMonth(),
15+			Origin: shared.StartOfMonth(),
16 			Host:   host,
17 		},
18 	)
M cmd/scripts/clean-analytics/clean.go
+3, -3
 1@@ -6,7 +6,7 @@ import (
 2 	"os"
 3 
 4 	"github.com/picosh/pico/pkg/db/postgres"
 5-	"github.com/picosh/pico/pkg/shared"
 6+	"github.com/picosh/pico/pkg/shared/router"
 7 )
 8 
 9 func main() {
10@@ -47,7 +47,7 @@ func main() {
11 
12 			update := false
13 
14-			host, err := shared.CleanHost(origHost)
15+			host, err := router.CleanHost(origHost)
16 			if err != nil {
17 				fmt.Println(err)
18 			}
19@@ -60,7 +60,7 @@ func main() {
20 				)
21 			}
22 
23-			ref, err := shared.CleanReferer(origRef)
24+			ref, err := router.CleanReferer(origRef)
25 			if err != nil {
26 				fmt.Println(err)
27 			}
M cmd/scripts/shasum/shasum.go
+1, -2
 1@@ -6,7 +6,6 @@ import (
 2 
 3 	"github.com/picosh/pico/pkg/db/postgres"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 func main() {
 9@@ -25,7 +24,7 @@ func main() {
10 	empty := 0
11 	diff := 0
12 	for _, post := range posts {
13-		nextShasum := utils.Shasum([]byte(post.Text))
14+		nextShasum := shared.Shasum([]byte(post.Text))
15 		if post.Shasum == "" {
16 			empty += 1
17 		} else if post.Shasum != nextShasum {
M pkg/apps/auth/api.go
+28, -28
  1@@ -19,7 +19,7 @@ import (
  2 	"github.com/picosh/pico/pkg/db"
  3 	"github.com/picosh/pico/pkg/db/postgres"
  4 	"github.com/picosh/pico/pkg/shared"
  5-	"github.com/picosh/utils"
  6+	"github.com/picosh/pico/pkg/shared/router"
  7 	"github.com/picosh/utils/pipe"
  8 	"github.com/picosh/utils/pipe/metrics"
  9 	"github.com/prometheus/client_golang/prometheus/promhttp"
 10@@ -48,7 +48,7 @@ func generateURL(cfg *shared.ConfigSite, path string, space string) string {
 11 	return fmt.Sprintf("%s/%s%s", cfg.Domain, path, query)
 12 }
 13 
 14-func wellKnownHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 15+func wellKnownHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
 16 	return func(w http.ResponseWriter, r *http.Request) {
 17 		space := r.PathValue("space")
 18 		if space == "" {
 19@@ -80,7 +80,7 @@ type oauth2Introspection struct {
 20 	Username string `json:"username"`
 21 }
 22 
 23-func introspectHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 24+func introspectHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
 25 	return func(w http.ResponseWriter, r *http.Request) {
 26 		token := r.FormValue("token")
 27 		apiConfig.Cfg.Logger.Info("introspect token", "token", token)
 28@@ -114,7 +114,7 @@ func introspectHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 29 	}
 30 }
 31 
 32-func authorizeHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 33+func authorizeHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
 34 	return func(w http.ResponseWriter, r *http.Request) {
 35 		responseType := r.URL.Query().Get("response_type")
 36 		clientID := r.URL.Query().Get("client_id")
 37@@ -158,7 +158,7 @@ func authorizeHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 38 	}
 39 }
 40 
 41-func redirectHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 42+func redirectHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
 43 	return func(w http.ResponseWriter, r *http.Request) {
 44 		token := r.FormValue("token")
 45 		redirectURI := r.FormValue("redirect_uri")
 46@@ -194,7 +194,7 @@ type oauth2Token struct {
 47 	AccessToken string `json:"access_token"`
 48 }
 49 
 50-func tokenHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 51+func tokenHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
 52 	return func(w http.ResponseWriter, r *http.Request) {
 53 		token := r.FormValue("code")
 54 		redirectURI := r.FormValue("redirect_uri")
 55@@ -233,7 +233,7 @@ type sishData struct {
 56 	RemoteAddress string `json:"remote_addr"`
 57 }
 58 
 59-func keyHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 60+func keyHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
 61 	return func(w http.ResponseWriter, r *http.Request) {
 62 		var data sishData
 63 
 64@@ -292,7 +292,7 @@ func keyHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 65 			log.Error("cannot insert access log", "err", err)
 66 		}
 67 
 68-		if !apiConfig.HasPrivilegedAccess(shared.GetApiToken(r)) {
 69+		if !apiConfig.HasPrivilegedAccess(router.GetApiToken(r)) {
 70 			w.WriteHeader(http.StatusOK)
 71 			return
 72 		}
 73@@ -307,9 +307,9 @@ func keyHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 74 	}
 75 }
 76 
 77-func userHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 78+func userHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
 79 	return func(w http.ResponseWriter, r *http.Request) {
 80-		if !apiConfig.HasPrivilegedAccess(shared.GetApiToken(r)) {
 81+		if !apiConfig.HasPrivilegedAccess(router.GetApiToken(r)) {
 82 			w.WriteHeader(http.StatusForbidden)
 83 			return
 84 		}
 85@@ -354,7 +354,7 @@ func userHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 86 	}
 87 }
 88 
 89-func rssHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 90+func rssHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
 91 	return func(w http.ResponseWriter, r *http.Request) {
 92 		apiToken := r.PathValue("token")
 93 		user, err := apiConfig.Dbpool.FindUserByToken(apiToken)
 94@@ -387,7 +387,7 @@ func rssHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 95 	}
 96 }
 97 
 98-func pubkeysHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
 99+func pubkeysHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
100 	return func(w http.ResponseWriter, r *http.Request) {
101 		userName := r.PathValue("user")
102 		user, err := apiConfig.Dbpool.FindUserByName(userName)
103@@ -456,7 +456,7 @@ type OrderEvent struct {
104 
105 // Status code must be 200 or else lemonsqueezy will keep retrying
106 // https://docs.lemonsqueezy.com/help/webhooks
107-func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
108+func paymentWebhookHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
109 	return func(w http.ResponseWriter, r *http.Request) {
110 		dbpool := apiConfig.Dbpool
111 		logger := apiConfig.Cfg.Logger
112@@ -482,7 +482,7 @@ func paymentWebhookHandler(apiConfig *shared.ApiConfig) http.HandlerFunc {
113 			return
114 		}
115 
116-		hash := shared.HmacString(apiConfig.Cfg.SecretWebhook, string(payload))
117+		hash := router.HmacString(apiConfig.Cfg.SecretWebhook, string(payload))
118 		sig := r.Header.Get("X-Signature")
119 		if !hmac.Equal([]byte(hash), []byte(sig)) {
120 			logger.Error("invalid signature X-Signature")
121@@ -678,14 +678,14 @@ func deserializeCaddyAccessLog(dbpool db.DB, access *AccessLog) (*db.AnalyticsVi
122 	} else if strings.HasSuffix(host, "prose.sh") {
123 		subdomain = strings.TrimSuffix(host, ".prose.sh")
124 	} else {
125-		subdomain = shared.GetCustomDomain(host, space)
126+		subdomain = router.GetCustomDomain(host, space)
127 	}
128 
129 	subdomain = strings.TrimSuffix(subdomain, ".nue")
130 	subdomain = strings.TrimSuffix(subdomain, ".ash")
131 
132 	// get user and namespace details from subdomain
133-	props, err := shared.GetProjectFromSubdomain(subdomain)
134+	props, err := router.GetProjectFromSubdomain(subdomain)
135 	if err != nil {
136 		return nil, fmt.Errorf("could not get project from subdomain %s: %w", subdomain, err)
137 	}
138@@ -772,7 +772,7 @@ func metricDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger, secr
139 			}
140 
141 			logger.Info("received visit", "visit", visit)
142-			err = shared.AnalyticsVisitFromVisit(visit, dbpool, secret)
143+			err = router.AnalyticsVisitFromVisit(visit, dbpool, secret)
144 			if err != nil {
145 				logger.Info("could not record analytics visit", "err", err)
146 				continue
147@@ -834,7 +834,7 @@ func tunsEventLogDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger
148 	}
149 }
150 
151-func authMux(apiConfig *shared.ApiConfig) *http.ServeMux {
152+func authMux(apiConfig *router.ApiConfig) *http.ServeMux {
153 	serverRoot, err := fs.Sub(embedFS, "public")
154 	if err != nil {
155 		panic(err)
156@@ -866,24 +866,24 @@ func authMux(apiConfig *shared.ApiConfig) *http.ServeMux {
157 	mux.HandleFunc("GET /_metrics", promhttp.Handler().ServeHTTP)
158 
159 	if apiConfig.Cfg.Debug {
160-		shared.CreatePProfRoutesMux(mux)
161+		router.CreatePProfRoutesMux(mux)
162 	}
163 
164 	return mux
165 }
166 
167 func StartApiServer() {
168-	debug := utils.GetEnv("AUTH_DEBUG", "0")
169-	withPipe := strings.ToLower(utils.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
170+	debug := shared.GetEnv("AUTH_DEBUG", "0")
171+	withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
172 
173 	cfg := &shared.ConfigSite{
174-		DbURL:         utils.GetEnv("DATABASE_URL", ""),
175+		DbURL:         shared.GetEnv("DATABASE_URL", ""),
176 		Debug:         debug == "1",
177-		Issuer:        utils.GetEnv("AUTH_ISSUER", "pico.sh"),
178-		Domain:        utils.GetEnv("AUTH_DOMAIN", "http://0.0.0.0:3000"),
179-		Port:          utils.GetEnv("AUTH_WEB_PORT", "3000"),
180-		Secret:        utils.GetEnv("PICO_SECRET", ""),
181-		SecretWebhook: utils.GetEnv("PICO_SECRET_WEBHOOK", ""),
182+		Issuer:        shared.GetEnv("AUTH_ISSUER", "pico.sh"),
183+		Domain:        shared.GetEnv("AUTH_DOMAIN", "http://0.0.0.0:3000"),
184+		Port:          shared.GetEnv("AUTH_WEB_PORT", "3000"),
185+		Secret:        shared.GetEnv("PICO_SECRET", ""),
186+		SecretWebhook: shared.GetEnv("PICO_SECRET_WEBHOOK", ""),
187 	}
188 
189 	if cfg.SecretWebhook == "" {
190@@ -911,7 +911,7 @@ func StartApiServer() {
191 	// gather connect/disconnect logs from tuns
192 	go tunsEventLogDrainSub(ctx, db, logger, cfg.Secret)
193 
194-	apiConfig := &shared.ApiConfig{
195+	apiConfig := &router.ApiConfig{
196 		Cfg:    cfg,
197 		Dbpool: db,
198 	}
M pkg/apps/auth/api_test.go
+4, -3
 1@@ -15,6 +15,7 @@ import (
 2 	"github.com/picosh/pico/pkg/db"
 3 	"github.com/picosh/pico/pkg/db/stub"
 4 	"github.com/picosh/pico/pkg/shared"
 5+	"github.com/picosh/pico/pkg/shared/router"
 6 )
 7 
 8 var testUserID = "user-1"
 9@@ -41,7 +42,7 @@ func TestPaymentWebhook(t *testing.T) {
10 	}
11 	jso, err := json.Marshal(event)
12 	bail(err)
13-	hash := shared.HmacString(apiConfig.Cfg.SecretWebhook, string(jso))
14+	hash := router.HmacString(apiConfig.Cfg.SecretWebhook, string(jso))
15 	body := bytes.NewReader(jso)
16 
17 	request := httptest.NewRequest("POST", mkpath("/webhook"), body)
18@@ -275,7 +276,7 @@ func mkpath(path string) string {
19 	return fmt.Sprintf("https://auth.pico.test%s", path)
20 }
21 
22-func setupTest() *shared.ApiConfig {
23+func setupTest() *router.ApiConfig {
24 	logger := shared.CreateLogger("auth-test", false)
25 	cfg := &shared.ConfigSite{
26 		Issuer:        "auth.pico.test",
27@@ -286,7 +287,7 @@ func setupTest() *shared.ApiConfig {
28 	}
29 	cfg.Logger = logger
30 	db := NewAuthDb(cfg.Logger)
31-	apiConfig := &shared.ApiConfig{
32+	apiConfig := &router.ApiConfig{
33 		Cfg:    cfg,
34 		Dbpool: db,
35 	}
M pkg/apps/feeds/api.go
+25, -24
 1@@ -8,13 +8,14 @@ import (
 2 
 3 	"github.com/picosh/pico/pkg/db/postgres"
 4 	"github.com/picosh/pico/pkg/shared"
 5+	"github.com/picosh/pico/pkg/shared/router"
 6 	"github.com/prometheus/client_golang/prometheus/promhttp"
 7 )
 8 
 9 func keepAliveHandler(w http.ResponseWriter, r *http.Request) {
10-	dbpool := shared.GetDB(r)
11-	logger := shared.GetLogger(r)
12-	postID, _ := url.PathUnescape(shared.GetField(r, 0))
13+	dbpool := router.GetDB(r)
14+	logger := router.GetLogger(r)
15+	postID, _ := url.PathUnescape(router.GetField(r, 0))
16 
17 	post, err := dbpool.FindPost(postID)
18 	if err != nil {
19@@ -60,9 +61,9 @@ func keepAliveHandler(w http.ResponseWriter, r *http.Request) {
20 }
21 
22 func unsubHandler(w http.ResponseWriter, r *http.Request) {
23-	dbpool := shared.GetDB(r)
24-	logger := shared.GetLogger(r)
25-	postID, _ := url.PathUnescape(shared.GetField(r, 0))
26+	dbpool := router.GetDB(r)
27+	logger := router.GetLogger(r)
28+	postID, _ := url.PathUnescape(router.GetField(r, 0))
29 
30 	post, err := dbpool.FindPost(postID)
31 	if err != nil {
32@@ -96,12 +97,12 @@ func unsubHandler(w http.ResponseWriter, r *http.Request) {
33 	}
34 }
35 
36-func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
37-	routes := []shared.Route{
38-		shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
39-		shared.NewRoute("GET", "/keep-alive/(.+)", keepAliveHandler),
40-		shared.NewRoute("GET", "/unsub/(.+)", unsubHandler),
41-		shared.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
42+func createMainRoutes(staticRoutes []router.Route) []router.Route {
43+	routes := []router.Route{
44+		router.NewRoute("GET", "/", router.CreatePageHandler("html/marketing.page.tmpl")),
45+		router.NewRoute("GET", "/keep-alive/(.+)", keepAliveHandler),
46+		router.NewRoute("GET", "/unsub/(.+)", unsubHandler),
47+		router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
48 	}
49 
50 	routes = append(
51@@ -112,15 +113,15 @@ func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
52 	return routes
53 }
54 
55-func createStaticRoutes() []shared.Route {
56-	return []shared.Route{
57-		shared.NewRoute("GET", "/main.css", shared.ServeFile("main.css", "text/css")),
58-		shared.NewRoute("GET", "/card.png", shared.ServeFile("card.png", "image/png")),
59-		shared.NewRoute("GET", "/favicon-16x16.png", shared.ServeFile("favicon-16x16.png", "image/png")),
60-		shared.NewRoute("GET", "/favicon-32x32.png", shared.ServeFile("favicon-32x32.png", "image/png")),
61-		shared.NewRoute("GET", "/apple-touch-icon.png", shared.ServeFile("apple-touch-icon.png", "image/png")),
62-		shared.NewRoute("GET", "/favicon.ico", shared.ServeFile("favicon.ico", "image/x-icon")),
63-		shared.NewRoute("GET", "/robots.txt", shared.ServeFile("robots.txt", "text/plain")),
64+func createStaticRoutes() []router.Route {
65+	return []router.Route{
66+		router.NewRoute("GET", "/main.css", router.ServeFile("main.css", "text/css")),
67+		router.NewRoute("GET", "/card.png", router.ServeFile("card.png", "image/png")),
68+		router.NewRoute("GET", "/favicon-16x16.png", router.ServeFile("favicon-16x16.png", "image/png")),
69+		router.NewRoute("GET", "/favicon-32x32.png", router.ServeFile("favicon-32x32.png", "image/png")),
70+		router.NewRoute("GET", "/apple-touch-icon.png", router.ServeFile("apple-touch-icon.png", "image/png")),
71+		router.NewRoute("GET", "/favicon.ico", router.ServeFile("favicon.ico", "image/x-icon")),
72+		router.NewRoute("GET", "/robots.txt", router.ServeFile("robots.txt", "text/plain")),
73 	}
74 }
75 
76@@ -139,16 +140,16 @@ func StartApiServer() {
77 	staticRoutes := createStaticRoutes()
78 
79 	if cfg.Debug {
80-		staticRoutes = shared.CreatePProfRoutes(staticRoutes)
81+		staticRoutes = router.CreatePProfRoutes(staticRoutes)
82 	}
83 
84 	mainRoutes := createMainRoutes(staticRoutes)
85 
86-	apiConfig := &shared.ApiConfig{
87+	apiConfig := &router.ApiConfig{
88 		Cfg:    cfg,
89 		Dbpool: db,
90 	}
91-	handler := shared.CreateServe(mainRoutes, []shared.Route{}, apiConfig)
92+	handler := router.CreateServe(mainRoutes, []router.Route{}, apiConfig)
93 	router := http.HandlerFunc(handler)
94 
95 	portStr := fmt.Sprintf(":%s", cfg.Port)
M pkg/apps/feeds/config.go
+7, -8
 1@@ -4,17 +4,16 @@ import (
 2 	"strings"
 3 
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 func NewConfigSite(service string) *shared.ConfigSite {
 9-	debug := utils.GetEnv("FEEDS_DEBUG", "0")
10-	domain := utils.GetEnv("FEEDS_DOMAIN", "feeds.pico.sh")
11-	port := utils.GetEnv("FEEDS_WEB_PORT", "3000")
12-	protocol := utils.GetEnv("FEEDS_PROTOCOL", "https")
13-	dbURL := utils.GetEnv("DATABASE_URL", "")
14-	sendgridKey := utils.GetEnv("SENDGRID_API_KEY", "")
15-	withPipe := strings.ToLower(utils.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
16+	debug := shared.GetEnv("FEEDS_DEBUG", "0")
17+	domain := shared.GetEnv("FEEDS_DOMAIN", "feeds.pico.sh")
18+	port := shared.GetEnv("FEEDS_WEB_PORT", "3000")
19+	protocol := shared.GetEnv("FEEDS_PROTOCOL", "https")
20+	dbURL := shared.GetEnv("DATABASE_URL", "")
21+	sendgridKey := shared.GetEnv("SENDGRID_API_KEY", "")
22+	withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
23 
24 	return &shared.ConfigSite{
25 		Debug:       debug == "1",
M pkg/apps/feeds/cron.go
+1, -2
 1@@ -21,7 +21,6 @@ import (
 2 	"github.com/mmcdole/gofeed"
 3 	"github.com/picosh/pico/pkg/db"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 var ErrNoRecentArticles = errors.New("no recent articles")
 9@@ -564,7 +563,7 @@ func (f *Fetcher) FetchAll(logger *slog.Logger, urls []string, inlineContent boo
10 	}
11 
12 	// cap body size to prevent abuse
13-	if len(html)+len(text) > 5*utils.MB {
14+	if len(html)+len(text) > 5*shared.MB {
15 		feeds.Options.InlineContent = false
16 		feeds.SizeWarning = true
17 		html, err = f.PrintHtml(feeds)
M pkg/apps/feeds/scp_hooks.go
+2, -3
 1@@ -11,7 +11,6 @@ import (
 2 	"github.com/picosh/pico/pkg/filehandlers"
 3 	"github.com/picosh/pico/pkg/pssh"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 type FeedHooks struct {
 9@@ -20,7 +19,7 @@ type FeedHooks struct {
10 }
11 
12 func (p *FeedHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) (bool, error) {
13-	if !utils.IsTextFile(string(data.Text)) {
14+	if !shared.IsTextFile(string(data.Text)) {
15 		err := fmt.Errorf(
16 			"WARNING: (%s) invalid file must be plain text (utf-8), skipping",
17 			data.Filename,
18@@ -28,7 +27,7 @@ func (p *FeedHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandler
19 		return false, err
20 	}
21 
22-	if !utils.IsExtAllowed(data.Filename, p.Cfg.AllowedExt) {
23+	if !shared.IsExtAllowed(data.Filename, p.Cfg.AllowedExt) {
24 		extStr := strings.Join(p.Cfg.AllowedExt, ",")
25 		err := fmt.Errorf(
26 			"WARNING: (%s) invalid file, format must be (%s), skipping",
M pkg/apps/feeds/ssh.go
+3, -4
 1@@ -16,15 +16,14 @@ import (
 2 	"github.com/picosh/pico/pkg/send/protocols/scp"
 3 	"github.com/picosh/pico/pkg/send/protocols/sftp"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 func StartSshServer() {
 9 	appName := "feeds-ssh"
10 
11-	host := utils.GetEnv("FEEDS_HOST", "0.0.0.0")
12-	port := utils.GetEnv("FEEDS_SSH_PORT", "2222")
13-	promPort := utils.GetEnv("FEEDS_PROM_PORT", "9222")
14+	host := shared.GetEnv("FEEDS_HOST", "0.0.0.0")
15+	port := shared.GetEnv("FEEDS_SSH_PORT", "2222")
16+	promPort := shared.GetEnv("FEEDS_PROM_PORT", "9222")
17 	cfg := NewConfigSite(appName)
18 	logger := cfg.Logger
19 
M pkg/apps/pastes/api.go
+54, -54
  1@@ -11,7 +11,7 @@ import (
  2 	"github.com/picosh/pico/pkg/db"
  3 	"github.com/picosh/pico/pkg/db/postgres"
  4 	"github.com/picosh/pico/pkg/shared"
  5-	"github.com/picosh/utils"
  6+	"github.com/picosh/pico/pkg/shared/router"
  7 	"github.com/prometheus/client_golang/prometheus/promhttp"
  8 )
  9 
 10@@ -73,11 +73,11 @@ type HeaderTxt struct {
 11 }
 12 
 13 func blogHandler(w http.ResponseWriter, r *http.Request) {
 14-	username := shared.GetUsernameFromRequest(r)
 15-	dbpool := shared.GetDB(r)
 16-	blogger := shared.GetLogger(r)
 17+	username := router.GetUsernameFromRequest(r)
 18+	dbpool := router.GetDB(r)
 19+	blogger := router.GetLogger(r)
 20 	logger := blogger.With("user", username)
 21-	cfg := shared.GetCfg(r)
 22+	cfg := router.GetCfg(r)
 23 
 24 	user, err := dbpool.FindUserByName(username)
 25 	if err != nil {
 26@@ -96,7 +96,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 27 
 28 	posts := pager.Data
 29 
 30-	ts, err := shared.RenderTemplate(cfg, []string{
 31+	ts, err := router.RenderTemplate(cfg, []string{
 32 		cfg.StaticPath("html/blog.page.tmpl"),
 33 	})
 34 
 35@@ -120,7 +120,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 36 			Title:          post.Filename,
 37 			PublishAt:      post.PublishAt.Format(time.DateOnly),
 38 			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
 39-			UpdatedTimeAgo: utils.TimeAgo(post.UpdatedAt),
 40+			UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt),
 41 			UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
 42 		}
 43 		postCollection = append(postCollection, p)
 44@@ -156,19 +156,19 @@ func GetBlogName(username string) string {
 45 }
 46 
 47 func postHandler(w http.ResponseWriter, r *http.Request) {
 48-	username := shared.GetUsernameFromRequest(r)
 49-	subdomain := shared.GetSubdomain(r)
 50-	cfg := shared.GetCfg(r)
 51+	username := router.GetUsernameFromRequest(r)
 52+	subdomain := router.GetSubdomain(r)
 53+	cfg := router.GetCfg(r)
 54 
 55 	var slug string
 56 	if !cfg.IsSubdomains() || subdomain == "" {
 57-		slug, _ = url.PathUnescape(shared.GetField(r, 1))
 58+		slug, _ = url.PathUnescape(router.GetField(r, 1))
 59 	} else {
 60-		slug, _ = url.PathUnescape(shared.GetField(r, 0))
 61+		slug, _ = url.PathUnescape(router.GetField(r, 0))
 62 	}
 63 
 64-	dbpool := shared.GetDB(r)
 65-	blogger := shared.GetLogger(r)
 66+	dbpool := router.GetDB(r)
 67+	blogger := router.GetLogger(r)
 68 	logger := blogger.With("slug", slug, "user", username)
 69 
 70 	user, err := dbpool.FindUserByName(username)
 71@@ -190,7 +190,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 72 		unlisted := false
 73 		parsedText := ""
 74 		// we dont want to syntax highlight huge files
 75-		if post.FileSize > 1*utils.MB {
 76+		if post.FileSize > 1*shared.MB {
 77 			logger.Warn("paste too large to parse and apply syntax highlighting")
 78 			parsedText = post.Text
 79 		} else {
 80@@ -242,7 +242,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 81 		}
 82 	}
 83 
 84-	ts, err := shared.RenderTemplate(cfg, []string{
 85+	ts, err := router.RenderTemplate(cfg, []string{
 86 		cfg.StaticPath("html/post.page.tmpl"),
 87 	})
 88 
 89@@ -259,19 +259,19 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
 90 }
 91 
 92 func postHandlerRaw(w http.ResponseWriter, r *http.Request) {
 93-	username := shared.GetUsernameFromRequest(r)
 94-	subdomain := shared.GetSubdomain(r)
 95-	cfg := shared.GetCfg(r)
 96+	username := router.GetUsernameFromRequest(r)
 97+	subdomain := router.GetSubdomain(r)
 98+	cfg := router.GetCfg(r)
 99 
100 	var slug string
101 	if !cfg.IsSubdomains() || subdomain == "" {
102-		slug, _ = url.PathUnescape(shared.GetField(r, 1))
103+		slug, _ = url.PathUnescape(router.GetField(r, 1))
104 	} else {
105-		slug, _ = url.PathUnescape(shared.GetField(r, 0))
106+		slug, _ = url.PathUnescape(router.GetField(r, 0))
107 	}
108 
109-	dbpool := shared.GetDB(r)
110-	blogger := shared.GetLogger(r)
111+	dbpool := router.GetDB(r)
112+	blogger := router.GetLogger(r)
113 	logger := blogger.With("user", username, "slug", slug)
114 
115 	user, err := dbpool.FindUserByName(username)
116@@ -300,8 +300,8 @@ func postHandlerRaw(w http.ResponseWriter, r *http.Request) {
117 
118 func serveFile(file string, contentType string) http.HandlerFunc {
119 	return func(w http.ResponseWriter, r *http.Request) {
120-		logger := shared.GetLogger(r)
121-		cfg := shared.GetCfg(r)
122+		logger := router.GetLogger(r)
123+		cfg := router.GetCfg(r)
124 
125 		contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
126 		if err != nil {
127@@ -318,25 +318,25 @@ func serveFile(file string, contentType string) http.HandlerFunc {
128 	}
129 }
130 
131-func createStaticRoutes() []shared.Route {
132-	return []shared.Route{
133-		shared.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
134-		shared.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
135-		shared.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
136-		shared.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
137-		shared.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
138-		shared.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
139-		shared.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
140-		shared.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
141-		shared.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
142+func createStaticRoutes() []router.Route {
143+	return []router.Route{
144+		router.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
145+		router.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
146+		router.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
147+		router.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
148+		router.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
149+		router.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
150+		router.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
151+		router.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
152+		router.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
153 	}
154 }
155 
156-func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
157-	routes := []shared.Route{
158-		shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
159-		shared.NewRoute("GET", "/check", shared.CheckHandler),
160-		shared.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
161+func createMainRoutes(staticRoutes []router.Route) []router.Route {
162+	routes := []router.Route{
163+		router.NewRoute("GET", "/", router.CreatePageHandler("html/marketing.page.tmpl")),
164+		router.NewRoute("GET", "/check", router.CheckHandler),
165+		router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
166 	}
167 
168 	routes = append(
169@@ -346,18 +346,18 @@ func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
170 
171 	routes = append(
172 		routes,
173-		shared.NewRoute("GET", "/([^/]+)", blogHandler),
174-		shared.NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
175-		shared.NewRoute("GET", "/([^/]+)/([^/]+)/raw", postHandlerRaw),
176-		shared.NewRoute("GET", "/raw/([^/]+)/([^/]+)", postHandlerRaw),
177+		router.NewRoute("GET", "/([^/]+)", blogHandler),
178+		router.NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
179+		router.NewRoute("GET", "/([^/]+)/([^/]+)/raw", postHandlerRaw),
180+		router.NewRoute("GET", "/raw/([^/]+)/([^/]+)", postHandlerRaw),
181 	)
182 
183 	return routes
184 }
185 
186-func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
187-	routes := []shared.Route{
188-		shared.NewRoute("GET", "/", blogHandler),
189+func createSubdomainRoutes(staticRoutes []router.Route) []router.Route {
190+	routes := []router.Route{
191+		router.NewRoute("GET", "/", blogHandler),
192 	}
193 
194 	routes = append(
195@@ -367,9 +367,9 @@ func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
196 
197 	routes = append(
198 		routes,
199-		shared.NewRoute("GET", "/([^/]+)", postHandler),
200-		shared.NewRoute("GET", "/([^/]+)/raw", postHandlerRaw),
201-		shared.NewRoute("GET", "/raw/([^/]+)", postHandlerRaw),
202+		router.NewRoute("GET", "/([^/]+)", postHandler),
203+		router.NewRoute("GET", "/([^/]+)/raw", postHandlerRaw),
204+		router.NewRoute("GET", "/raw/([^/]+)", postHandlerRaw),
205 	)
206 
207 	return routes
208@@ -388,17 +388,17 @@ func StartApiServer() {
209 	staticRoutes := createStaticRoutes()
210 
211 	if cfg.Debug {
212-		staticRoutes = shared.CreatePProfRoutes(staticRoutes)
213+		staticRoutes = router.CreatePProfRoutes(staticRoutes)
214 	}
215 
216 	mainRoutes := createMainRoutes(staticRoutes)
217 	subdomainRoutes := createSubdomainRoutes(staticRoutes)
218 
219-	apiConfig := &shared.ApiConfig{
220+	apiConfig := &router.ApiConfig{
221 		Cfg:    cfg,
222 		Dbpool: db,
223 	}
224-	handler := shared.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
225+	handler := router.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
226 	router := http.HandlerFunc(handler)
227 
228 	portStr := fmt.Sprintf(":%s", cfg.Port)
M pkg/apps/pastes/config.go
+7, -8
 1@@ -4,16 +4,15 @@ import (
 2 	"strings"
 3 
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 func NewConfigSite(service string) *shared.ConfigSite {
 9-	debug := utils.GetEnv("PASTES_DEBUG", "0")
10-	domain := utils.GetEnv("PASTES_DOMAIN", "pastes.sh")
11-	port := utils.GetEnv("PASTES_WEB_PORT", "3000")
12-	dbURL := utils.GetEnv("DATABASE_URL", "")
13-	protocol := utils.GetEnv("PASTES_PROTOCOL", "https")
14-	withPipe := strings.ToLower(utils.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
15+	debug := shared.GetEnv("PASTES_DEBUG", "0")
16+	domain := shared.GetEnv("PASTES_DOMAIN", "pastes.sh")
17+	port := shared.GetEnv("PASTES_WEB_PORT", "3000")
18+	dbURL := shared.GetEnv("DATABASE_URL", "")
19+	protocol := shared.GetEnv("PASTES_PROTOCOL", "https")
20+	withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
21 
22 	return &shared.ConfigSite{
23 		Debug:        debug == "1",
24@@ -23,6 +22,6 @@ func NewConfigSite(service string) *shared.ConfigSite {
25 		DbURL:        dbURL,
26 		Space:        "pastes",
27 		Logger:       shared.CreateLogger(service, withPipe),
28-		MaxAssetSize: int64(3 * utils.MB),
29+		MaxAssetSize: int64(3 * shared.MB),
30 	}
31 }
M pkg/apps/pastes/scp_hooks.go
+2, -3
 1@@ -11,7 +11,6 @@ import (
 2 	"github.com/picosh/pico/pkg/filehandlers"
 3 	"github.com/picosh/pico/pkg/pssh"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 var DEFAULT_EXPIRES_AT = 90
 9@@ -22,7 +21,7 @@ type FileHooks struct {
10 }
11 
12 func (p *FileHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) (bool, error) {
13-	if !utils.IsTextFile(string(data.Text)) {
14+	if !shared.IsTextFile(string(data.Text)) {
15 		err := fmt.Errorf(
16 			"ERROR: (%s) invalid file must be plain text (utf-8), skipping",
17 			data.Filename,
18@@ -43,7 +42,7 @@ func (p *FileHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandler
19 }
20 
21 func (p *FileHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
22-	data.Title = utils.ToUpper(data.Slug)
23+	data.Title = shared.ToUpper(data.Slug)
24 	// we want the slug to be the filename for pastes
25 	data.Slug = data.Filename
26 
M pkg/apps/pastes/ssh.go
+3, -4
 1@@ -17,15 +17,14 @@ import (
 2 	"github.com/picosh/pico/pkg/send/protocols/scp"
 3 	"github.com/picosh/pico/pkg/send/protocols/sftp"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 func StartSshServer() {
 9 	appName := "pastes-ssh"
10 
11-	host := utils.GetEnv("PASTES_HOST", "0.0.0.0")
12-	port := utils.GetEnv("PASTES_SSH_PORT", "2222")
13-	promPort := utils.GetEnv("PASTES_PROM_PORT", "9222")
14+	host := shared.GetEnv("PASTES_HOST", "0.0.0.0")
15+	port := shared.GetEnv("PASTES_SSH_PORT", "2222")
16+	promPort := shared.GetEnv("PASTES_PROM_PORT", "9222")
17 	cfg := NewConfigSite(appName)
18 	logger := cfg.Logger
19 
M pkg/apps/pgs/cli.go
+3, -4
 1@@ -14,7 +14,6 @@ import (
 2 	"github.com/picosh/pico/pkg/db"
 3 	sst "github.com/picosh/pico/pkg/pobj/storage"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 func NewTabWriter(out io.Writer) *tabwriter.Writer {
 9@@ -46,7 +45,7 @@ func projectTable(sesh io.Writer, projects []*db.Project) {
10 
11 type Cmd struct {
12 	User    *db.User
13-	Session utils.CmdSession
14+	Session shared.CmdSession
15 	Log     *slog.Logger
16 	Store   sst.ObjectStorage
17 	Dbpool  pgsdb.PgsDB
18@@ -222,8 +221,8 @@ func (c *Cmd) stats(cfgMaxSize uint64) error {
19 	_, _ = fmt.Fprintf(
20 		writer,
21 		"%.4f\t%.4f\t%.4f\t%d\r\n",
22-		utils.BytesToGB(int(totalFileSize)),
23-		utils.BytesToGB(int(storageMax)),
24+		shared.BytesToGB(int(totalFileSize)),
25+		shared.BytesToGB(int(storageMax)),
26 		(float32(totalFileSize)/float32(storageMax))*100,
27 		len(projects),
28 	)
M pkg/apps/pgs/config.go
+11, -11
 1@@ -7,8 +7,8 @@ import (
 2 	"time"
 3 
 4 	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 5+	"github.com/picosh/pico/pkg/shared"
 6 	"github.com/picosh/pico/pkg/shared/storage"
 7-	"github.com/picosh/utils"
 8 )
 9 
10 type PgsConfig struct {
11@@ -61,26 +61,26 @@ func (c *PgsConfig) StaticPath(fname string) string {
12 	return filepath.Join("pkg", "apps", "pgs", fname)
13 }
14 
15-var maxSize = uint64(25 * utils.MB)
16-var maxAssetSize = int64(10 * utils.MB)
17+var maxSize = uint64(25 * shared.MB)
18+var maxAssetSize = int64(10 * shared.MB)
19 
20 // Needs to be small for caching files like _headers and _redirects.
21-var maxSpecialFileSize = int64(5 * utils.KB)
22+var maxSpecialFileSize = int64(5 * shared.KB)
23 
24 func NewPgsConfig(logger *slog.Logger, dbpool pgsdb.PgsDB, st storage.StorageServe, pubsub PicoPubsub) *PgsConfig {
25-	domain := utils.GetEnv("PGS_DOMAIN", "pgs.sh")
26-	port := utils.GetEnv("PGS_WEB_PORT", "3000")
27-	protocol := utils.GetEnv("PGS_PROTOCOL", "https")
28-	cacheTTL, err := time.ParseDuration(utils.GetEnv("PGS_CACHE_TTL", ""))
29+	domain := shared.GetEnv("PGS_DOMAIN", "pgs.sh")
30+	port := shared.GetEnv("PGS_WEB_PORT", "3000")
31+	protocol := shared.GetEnv("PGS_PROTOCOL", "https")
32+	cacheTTL, err := time.ParseDuration(shared.GetEnv("PGS_CACHE_TTL", ""))
33 	if err != nil {
34 		cacheTTL = 600 * time.Second
35 	}
36-	cacheControl := utils.GetEnv(
37+	cacheControl := shared.GetEnv(
38 		"PGS_CACHE_CONTROL",
39 		fmt.Sprintf("max-age=%d", int(cacheTTL.Seconds())))
40 
41-	sshHost := utils.GetEnv("PGS_SSH_HOST", "0.0.0.0")
42-	sshPort := utils.GetEnv("PGS_SSH_PORT", "2222")
43+	sshHost := shared.GetEnv("PGS_SSH_HOST", "0.0.0.0")
44+	sshPort := shared.GetEnv("PGS_SSH_PORT", "2222")
45 
46 	cfg := PgsConfig{
47 		CacheControl:       cacheControl,
M pkg/apps/pgs/db/memory.go
+4, -4
 1@@ -7,7 +7,7 @@ import (
 2 
 3 	"github.com/google/uuid"
 4 	"github.com/picosh/pico/pkg/db"
 5-	"github.com/picosh/utils"
 6+	"github.com/picosh/pico/pkg/shared"
 7 )
 8 
 9 type MemoryDB struct {
10@@ -37,9 +37,9 @@ func (me *MemoryDB) SetupTestData() {
11 	feature := db.NewFeatureFlag(
12 		user.ID,
13 		"plus",
14-		uint64(25*utils.MB),
15-		int64(10*utils.MB),
16-		int64(5*utils.KB),
17+		uint64(25*shared.MB),
18+		int64(10*shared.MB),
19+		int64(5*shared.KB),
20 	)
21 	expiresAt := time.Now().Add(time.Hour * 24)
22 	feature.ExpiresAt = &expiresAt
M pkg/apps/pgs/db/postgres.go
+2, -2
 1@@ -8,7 +8,7 @@ import (
 2 	"github.com/jmoiron/sqlx"
 3 	_ "github.com/lib/pq"
 4 	"github.com/picosh/pico/pkg/db"
 5-	"github.com/picosh/utils"
 6+	"github.com/picosh/pico/pkg/shared"
 7 )
 8 
 9 type PgsPsqlDB struct {
10@@ -107,7 +107,7 @@ func (me *PgsPsqlDB) InsertAccessLog(log *db.AccessLog) error {
11 }
12 
13 func (me *PgsPsqlDB) InsertProject(userID, name, projectDir string) (string, error) {
14-	if !utils.IsValidSubdomain(name) {
15+	if !shared.IsValidSubdomain(name) {
16 		return "", fmt.Errorf("'%s' is not a valid project name, must match /^[a-z0-9-]+$/", name)
17 	}
18 
M pkg/apps/pgs/ssh.go
+3, -4
 1@@ -15,13 +15,12 @@ import (
 2 	"github.com/picosh/pico/pkg/send/protocols/sftp"
 3 	"github.com/picosh/pico/pkg/shared"
 4 	"github.com/picosh/pico/pkg/tunkit"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 func StartSshServer(cfg *PgsConfig, killCh chan error) {
 9-	host := utils.GetEnv("PGS_HOST", "0.0.0.0")
10-	port := utils.GetEnv("PGS_SSH_PORT", "2222")
11-	promPort := utils.GetEnv("PGS_PROM_PORT", "9222")
12+	host := shared.GetEnv("PGS_HOST", "0.0.0.0")
13+	port := shared.GetEnv("PGS_SSH_PORT", "2222")
14+	promPort := shared.GetEnv("PGS_PROM_PORT", "9222")
15 	logger := cfg.Logger
16 
17 	ctx, cancel := context.WithCancel(context.Background())
M pkg/apps/pgs/ssh_test.go
+3, -3
 1@@ -16,8 +16,8 @@ import (
 2 
 3 	pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
 4 	"github.com/picosh/pico/pkg/db"
 5+	"github.com/picosh/pico/pkg/shared"
 6 	"github.com/picosh/pico/pkg/shared/storage"
 7-	"github.com/picosh/utils"
 8 	"github.com/pkg/sftp"
 9 	"github.com/prometheus/client_golang/prometheus"
10 	"golang.org/x/crypto/ssh"
11@@ -55,7 +55,7 @@ func TestSshServerSftp(t *testing.T) {
12 	dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
13 		ID:     "nice-pubkey",
14 		UserID: dbpool.Users[0].ID,
15-		Key:    utils.KeyForKeyText(user.signer.PublicKey()),
16+		Key:    shared.KeyForKeyText(user.signer.PublicKey()),
17 	})
18 
19 	client, err := user.NewClient()
20@@ -139,7 +139,7 @@ func TestSshServerRsync(t *testing.T) {
21 	time.Sleep(time.Millisecond * 100)
22 
23 	user := GenerateUser()
24-	key := utils.KeyForKeyText(user.signer.PublicKey())
25+	key := shared.KeyForKeyText(user.signer.PublicKey())
26 	// add user's pubkey to the default test account
27 	dbpool.Pubkeys = append(dbpool.Pubkeys, &db.PublicKey{
28 		ID:     "nice-pubkey",
M pkg/apps/pgs/tunnel.go
+10, -10
 1@@ -8,7 +8,7 @@ import (
 2 
 3 	"github.com/picosh/pico/pkg/db"
 4 	"github.com/picosh/pico/pkg/pssh"
 5-	"github.com/picosh/pico/pkg/shared"
 6+	"github.com/picosh/pico/pkg/shared/router"
 7 	"golang.org/x/crypto/ssh"
 8 )
 9 
10@@ -30,7 +30,7 @@ func tunnelPerm(proj *db.Project) bool {
11 
12 func (web *TunnelWebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
13 	ctx := r.Context()
14-	ctx = context.WithValue(ctx, shared.CtxSubdomainKey{}, web.subdomain)
15+	ctx = context.WithValue(ctx, router.CtxSubdomainKey{}, web.subdomain)
16 	web.UserRouter.ServeHTTP(w, r.WithContext(ctx))
17 }
18 
19@@ -57,17 +57,17 @@ func CreateHttpHandler(cfg *PgsConfig) CtxHttpBridge {
20 		pubkey := ctx.Permissions().Extensions["pubkey"]
21 		if pubkey == "" {
22 			log.Error("pubkey not found in extensions", "subdomain", subdomain)
23-			return http.HandlerFunc(shared.UnauthorizedHandler)
24+			return http.HandlerFunc(router.UnauthorizedHandler)
25 		}
26 
27 		log = log.With(
28 			"pubkey", pubkey,
29 		)
30 
31-		props, err := shared.GetProjectFromSubdomain(subdomain)
32+		props, err := router.GetProjectFromSubdomain(subdomain)
33 		if err != nil {
34 			log.Error("could not get project from subdomain", "err", err.Error())
35-			return http.HandlerFunc(shared.UnauthorizedHandler)
36+			return http.HandlerFunc(router.UnauthorizedHandler)
37 		}
38 
39 		owner, err := cfg.DB.FindUserByName(props.Username)
40@@ -77,7 +77,7 @@ func CreateHttpHandler(cfg *PgsConfig) CtxHttpBridge {
41 				"name", props.Username,
42 				"err", err.Error(),
43 			)
44-			return http.HandlerFunc(shared.UnauthorizedHandler)
45+			return http.HandlerFunc(router.UnauthorizedHandler)
46 		}
47 		log = log.With(
48 			"owner", owner.Name,
49@@ -86,7 +86,7 @@ func CreateHttpHandler(cfg *PgsConfig) CtxHttpBridge {
50 		project, err := cfg.DB.FindProjectByName(owner.ID, props.ProjectName)
51 		if err != nil {
52 			log.Error("could not get project by name", "project", props.ProjectName, "err", err.Error())
53-			return http.HandlerFunc(shared.UnauthorizedHandler)
54+			return http.HandlerFunc(router.UnauthorizedHandler)
55 		}
56 
57 		requester, _ := cfg.DB.FindUserByPubkey(pubkey)
58@@ -108,7 +108,7 @@ func CreateHttpHandler(cfg *PgsConfig) CtxHttpBridge {
59 
60 			if !isAdmin {
61 				log.Error("impersonation attempt failed")
62-				return http.HandlerFunc(shared.UnauthorizedHandler)
63+				return http.HandlerFunc(router.UnauthorizedHandler)
64 			}
65 			requester, _ = cfg.DB.FindUserByName(asUser)
66 		}
67@@ -117,11 +117,11 @@ func CreateHttpHandler(cfg *PgsConfig) CtxHttpBridge {
68 		publicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey))
69 		if err != nil {
70 			log.Error("could not parse public key", "pubkey", pubkey, "err", err)
71-			return http.HandlerFunc(shared.UnauthorizedHandler)
72+			return http.HandlerFunc(router.UnauthorizedHandler)
73 		}
74 		if !HasProjectAccess(project, owner, requester, publicKey) {
75 			log.Error("no access")
76-			return http.HandlerFunc(shared.UnauthorizedHandler)
77+			return http.HandlerFunc(router.UnauthorizedHandler)
78 		}
79 
80 		log.Info("user has access to site")
M pkg/apps/pgs/uploader.go
+7, -8
 1@@ -22,7 +22,6 @@ import (
 2 	"github.com/picosh/pico/pkg/pssh"
 3 	sendutils "github.com/picosh/pico/pkg/send/utils"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 	ignore "github.com/sabhiram/go-gitignore"
 7 )
 8 
 9@@ -405,7 +404,7 @@ func (h *UploadAssetHandler) Write(s *pssh.SSHServerConnSession, entry *sendutil
10 
11 	fsize, err := h.writeAsset(
12 		s,
13-		utils.NewMaxBytesReader(data.Reader, int64(sizeRemaining)),
14+		shared.NewMaxBytesReader(data.Reader, int64(sizeRemaining)),
15 		data,
16 	)
17 	if err != nil {
18@@ -413,10 +412,10 @@ func (h *UploadAssetHandler) Write(s *pssh.SSHServerConnSession, entry *sendutil
19 		cerr := fmt.Errorf(
20 			"%s: storage size %.2fmb, storage max %.2fmb, file max %.2fmb, special file max %.4fmb",
21 			err,
22-			utils.BytesToMB(int(curStorageSize)),
23-			utils.BytesToMB(int(storageMax)),
24-			utils.BytesToMB(int(fileMax)),
25-			utils.BytesToMB(int(specialFileMax)),
26+			shared.BytesToMB(int(curStorageSize)),
27+			shared.BytesToMB(int(storageMax)),
28+			shared.BytesToMB(int(fileMax)),
29+			shared.BytesToMB(int(specialFileMax)),
30 		)
31 		return "", cerr
32 	}
33@@ -434,8 +433,8 @@ func (h *UploadAssetHandler) Write(s *pssh.SSHServerConnSession, entry *sendutil
34 	str := fmt.Sprintf(
35 		"%s (space: %.2f/%.2fGB, %.2f%%)",
36 		url,
37-		utils.BytesToGB(int(nextStorageSize)),
38-		utils.BytesToGB(maxSize),
39+		shared.BytesToGB(int(nextStorageSize)),
40+		shared.BytesToGB(maxSize),
41 		(float32(nextStorageSize)/float32(maxSize))*100,
42 	)
43 
M pkg/apps/pgs/web.go
+13, -13
 1@@ -22,10 +22,10 @@ import (
 2 	"github.com/darkweak/storages/core"
 3 	"github.com/gorilla/feeds"
 4 	"github.com/hashicorp/golang-lru/v2/expirable"
 5-	"github.com/picosh/pico/pkg/cache"
 6 	"github.com/picosh/pico/pkg/db"
 7 	sst "github.com/picosh/pico/pkg/pobj/storage"
 8 	"github.com/picosh/pico/pkg/shared"
 9+	"github.com/picosh/pico/pkg/shared/router"
10 	"github.com/picosh/pico/pkg/shared/storage"
11 	"github.com/prometheus/client_golang/prometheus/promhttp"
12 	"google.golang.org/protobuf/proto"
13@@ -115,8 +115,8 @@ func NewWebRouter(cfg *PgsConfig) *WebRouter {
14 func newWebRouter(cfg *PgsConfig) *WebRouter {
15 	router := &WebRouter{
16 		Cfg:            cfg,
17-		RedirectsCache: expirable.NewLRU[string, []*RedirectRule](2048, nil, cache.CacheTimeout),
18-		HeadersCache:   expirable.NewLRU[string, []*HeaderRule](2048, nil, cache.CacheTimeout),
19+		RedirectsCache: expirable.NewLRU[string, []*RedirectRule](2048, nil, shared.CacheTimeout),
20+		HeadersCache:   expirable.NewLRU[string, []*HeaderRule](2048, nil, shared.CacheTimeout),
21 	}
22 	router.initRouters()
23 	return router
24@@ -242,8 +242,8 @@ func (web *WebRouter) checkHandler(w http.ResponseWriter, r *http.Request) {
25 	appDomain := strings.Split(cfg.Domain, ":")[0]
26 
27 	if !strings.Contains(hostDomain, appDomain) {
28-		subdomain := shared.GetCustomDomain(hostDomain, cfg.TxtPrefix)
29-		props, err := shared.GetProjectFromSubdomain(subdomain)
30+		subdomain := router.GetCustomDomain(hostDomain, cfg.TxtPrefix)
31+		props, err := router.GetProjectFromSubdomain(subdomain)
32 		if err != nil {
33 			logger.Error(
34 				"could not get project from subdomain",
35@@ -448,7 +448,7 @@ func (web *WebRouter) ImageRequest(perm func(proj *db.Project) bool) http.Handle
36 }
37 
38 func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, hasPerm HasPerm, w http.ResponseWriter, r *http.Request) {
39-	subdomain := shared.GetSubdomain(r)
40+	subdomain := router.GetSubdomain(r)
41 
42 	logger := web.Cfg.Logger.With(
43 		"subdomain", subdomain,
44@@ -457,7 +457,7 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
45 		"host", r.Host,
46 	)
47 
48-	props, err := shared.GetProjectFromSubdomain(subdomain)
49+	props, err := router.GetProjectFromSubdomain(subdomain)
50 	if err != nil {
51 		logger.Info(
52 			"could not determine project from subdomain",
53@@ -542,23 +542,23 @@ func (web *WebRouter) ServeAsset(fname string, opts *storage.ImgProcessOpts, has
54 }
55 
56 func (web *WebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
57-	subdomain := shared.GetSubdomainFromRequest(r, web.Cfg.Domain, web.Cfg.TxtPrefix)
58+	subdomain := router.GetSubdomainFromRequest(r, web.Cfg.Domain, web.Cfg.TxtPrefix)
59 	if web.RootRouter == nil || web.UserRouter == nil {
60 		web.Cfg.Logger.Error("routers not initialized")
61 		http.Error(w, "routers not initialized", http.StatusInternalServerError)
62 		return
63 	}
64 
65-	var router *http.ServeMux
66+	var mux *http.ServeMux
67 	if subdomain == "" {
68-		router = web.RootRouter
69+		mux = web.RootRouter
70 	} else {
71-		router = web.UserRouter
72+		mux = web.UserRouter
73 	}
74 
75 	ctx := r.Context()
76-	ctx = context.WithValue(ctx, shared.CtxSubdomainKey{}, subdomain)
77-	router.ServeHTTP(w, r.WithContext(ctx))
78+	ctx = context.WithValue(ctx, router.CtxSubdomainKey{}, subdomain)
79+	mux.ServeHTTP(w, r.WithContext(ctx))
80 }
81 
82 type CompatLogger struct {
M pkg/apps/pico/cli.go
+7, -8
 1@@ -12,7 +12,6 @@ import (
 2 	"github.com/picosh/pico/pkg/db"
 3 	"github.com/picosh/pico/pkg/pssh"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 
 7 	pipeLogger "github.com/picosh/utils/pipe/log"
 8 )
 9@@ -22,7 +21,7 @@ func getUser(s *pssh.SSHServerConnSession, dbpool db.DB) (*db.User, error) {
10 		return nil, fmt.Errorf("key not found")
11 	}
12 
13-	key := utils.KeyForKeyText(s.PublicKey())
14+	key := shared.KeyForKeyText(s.PublicKey())
15 
16 	user, err := dbpool.FindUserByKey(s.User(), key)
17 	if err != nil {
18@@ -39,7 +38,7 @@ func getUser(s *pssh.SSHServerConnSession, dbpool db.DB) (*db.User, error) {
19 type Cmd struct {
20 	User       *db.User
21 	SshSession *pssh.SSHServerConnSession
22-	Session    utils.CmdSession
23+	Session    shared.CmdSession
24 	Log        *slog.Logger
25 	Dbpool     db.DB
26 	Write      bool
27@@ -77,9 +76,9 @@ func (c *Cmd) user() {
28 }
29 
30 func (c *Cmd) notFound(host, interval string) error {
31-	origin := utils.StartOfYear()
32+	origin := shared.StartOfYear()
33 	if interval == "month" {
34-		origin = utils.StartOfMonth()
35+		origin = shared.StartOfMonth()
36 	}
37 	c.output(fmt.Sprintf("starting from: %s\n", origin.Format(time.RFC3339)))
38 	urls, err := c.Dbpool.VisitUrlNotFound(&db.SummaryOpts{
39@@ -155,10 +154,10 @@ func (c *Cmd) logs(ctx context.Context) error {
40 			continue
41 		}
42 
43-		user := utils.AnyToStr(parsedData, "user")
44-		userId := utils.AnyToStr(parsedData, "userId")
45+		user := shared.AnyToStr(parsedData, "user")
46+		userId := shared.AnyToStr(parsedData, "userId")
47 
48-		hidden := utils.AnyToBool(parsedData, "hidden")
49+		hidden := shared.AnyToBool(parsedData, "hidden")
50 
51 		if !hidden && (user == c.User.Name || userId == c.User.ID) {
52 			select {
M pkg/apps/pico/config.go
+3, -4
 1@@ -4,13 +4,12 @@ import (
 2 	"strings"
 3 
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 func NewConfigSite(service string) *shared.ConfigSite {
 9-	dbURL := utils.GetEnv("DATABASE_URL", "")
10-	tuns := utils.GetEnv("TUNS_CONSOLE_SECRET", "")
11-	withPipe := strings.ToLower(utils.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
12+	dbURL := shared.GetEnv("DATABASE_URL", "")
13+	tuns := shared.GetEnv("TUNS_CONSOLE_SECRET", "")
14+	withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
15 
16 	return &shared.ConfigSite{
17 		DbURL:      dbURL,
M pkg/apps/pico/file_handler.go
+2, -3
 1@@ -15,7 +15,6 @@ import (
 2 	"github.com/picosh/pico/pkg/pssh"
 3 	sendutils "github.com/picosh/pico/pkg/send/utils"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 	"golang.org/x/crypto/ssh"
 7 )
 8 
 9@@ -242,7 +241,7 @@ func (h *UploadHandler) ProcessAuthorizedKeys(text []byte, logger *slog.Logger,
10 	diff := authorizedKeysDiff(s.PublicKey(), curKeys, nextKeys)
11 
12 	for _, pk := range diff.Add {
13-		key := utils.KeyForKeyText(pk.Pk)
14+		key := shared.KeyForKeyText(pk.Pk)
15 
16 		_, _ = fmt.Fprintf(s.Stderr(), "adding pubkey (%s)\n", key)
17 		logger.Info("adding pubkey", "pubkey", key)
18@@ -255,7 +254,7 @@ func (h *UploadHandler) ProcessAuthorizedKeys(text []byte, logger *slog.Logger,
19 	}
20 
21 	for _, pk := range diff.Update {
22-		key := utils.KeyForKeyText(pk.Pk)
23+		key := shared.KeyForKeyText(pk.Pk)
24 
25 		_, _ = fmt.Fprintf(s.Stderr(), "updating pubkey with comment: %s (%s)\n", pk.Comment, key)
26 		logger.Info(
M pkg/apps/pico/ssh.go
+4, -5
 1@@ -19,7 +19,6 @@ import (
 2 	"github.com/picosh/pico/pkg/send/protocols/sftp"
 3 	"github.com/picosh/pico/pkg/shared"
 4 	"github.com/picosh/pico/pkg/tui"
 5-	"github.com/picosh/utils"
 6 	"golang.org/x/crypto/ssh"
 7 )
 8 
 9@@ -41,9 +40,9 @@ func createTui(shrd *tui.SharedModel) pssh.SSHServerMiddleware {
10 func StartSshServer() {
11 	appName := "pico-ssh"
12 
13-	host := utils.GetEnv("PICO_HOST", "0.0.0.0")
14-	port := utils.GetEnv("PICO_SSH_PORT", "2222")
15-	promPort := utils.GetEnv("PICO_PROM_PORT", "9222")
16+	host := shared.GetEnv("PICO_HOST", "0.0.0.0")
17+	port := shared.GetEnv("PICO_SSH_PORT", "2222")
18+	promPort := shared.GetEnv("PICO_PROM_PORT", "9222")
19 	cfg := NewConfigSite(appName)
20 	logger := cfg.Logger
21 
22@@ -84,7 +83,7 @@ func StartSshServer() {
23 			if perms == nil {
24 				perms = &ssh.Permissions{
25 					Extensions: map[string]string{
26-						"pubkey": utils.KeyForKeyText(key),
27+						"pubkey": shared.KeyForKeyText(key),
28 					},
29 				}
30 			}
M pkg/apps/pipe/api.go
+37, -36
  1@@ -20,6 +20,7 @@ import (
  2 	"github.com/picosh/pico/pkg/db"
  3 	"github.com/picosh/pico/pkg/db/postgres"
  4 	"github.com/picosh/pico/pkg/shared"
  5+	"github.com/picosh/pico/pkg/shared/router"
  6 	"github.com/picosh/utils/pipe"
  7 	"github.com/prometheus/client_golang/prometheus/promhttp"
  8 )
  9@@ -36,8 +37,8 @@ var (
 10 
 11 func serveFile(file string, contentType string) http.HandlerFunc {
 12 	return func(w http.ResponseWriter, r *http.Request) {
 13-		logger := shared.GetLogger(r)
 14-		cfg := shared.GetCfg(r)
 15+		logger := router.GetLogger(r)
 16+		cfg := router.GetCfg(r)
 17 
 18 		contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
 19 		if err != nil {
 20@@ -54,18 +55,18 @@ func serveFile(file string, contentType string) http.HandlerFunc {
 21 	}
 22 }
 23 
 24-func createStaticRoutes() []shared.Route {
 25-	return []shared.Route{
 26-		shared.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
 27-		shared.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
 28-		shared.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
 29-		shared.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
 30-		shared.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
 31-		shared.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
 32-		shared.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
 33-		shared.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
 34-		shared.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
 35-		shared.NewRoute("GET", "/anim.js", serveFile("anim.js", "text/javascript")),
 36+func createStaticRoutes() []router.Route {
 37+	return []router.Route{
 38+		router.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
 39+		router.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
 40+		router.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
 41+		router.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
 42+		router.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
 43+		router.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
 44+		router.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
 45+		router.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
 46+		router.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
 47+		router.NewRoute("GET", "/anim.js", serveFile("anim.js", "text/javascript")),
 48 	}
 49 }
 50 
 51@@ -86,10 +87,10 @@ var _ io.Writer = writeFlusher{}
 52 
 53 func handleSub(pubsub bool) http.HandlerFunc {
 54 	return func(w http.ResponseWriter, r *http.Request) {
 55-		logger := shared.GetLogger(r)
 56+		logger := router.GetLogger(r)
 57 
 58 		clientInfo := shared.NewPicoPipeClient()
 59-		topic, _ := url.PathUnescape(shared.GetField(r, 0))
 60+		topic, _ := url.PathUnescape(router.GetField(r, 0))
 61 
 62 		topic = cleanRegex.ReplaceAllString(topic, "")
 63 
 64@@ -139,10 +140,10 @@ func handleSub(pubsub bool) http.HandlerFunc {
 65 
 66 func handlePub(pubsub bool) http.HandlerFunc {
 67 	return func(w http.ResponseWriter, r *http.Request) {
 68-		logger := shared.GetLogger(r)
 69+		logger := router.GetLogger(r)
 70 
 71 		clientInfo := shared.NewPicoPipeClient()
 72-		topic, _ := url.PathUnescape(shared.GetField(r, 0))
 73+		topic, _ := url.PathUnescape(router.GetField(r, 0))
 74 
 75 		topic = cleanRegex.ReplaceAllString(topic, "")
 76 
 77@@ -283,7 +284,7 @@ func handlePub(pubsub bool) http.HandlerFunc {
 78 
 79 func handlePipe() http.HandlerFunc {
 80 	return func(w http.ResponseWriter, r *http.Request) {
 81-		logger := shared.GetLogger(r)
 82+		logger := router.GetLogger(r)
 83 
 84 		c, err := upgrader.Upgrade(w, r, nil)
 85 		if err != nil {
 86@@ -296,7 +297,7 @@ func handlePipe() http.HandlerFunc {
 87 		}()
 88 
 89 		clientInfo := shared.NewPicoPipeClient()
 90-		topic, _ := url.PathUnescape(shared.GetField(r, 0))
 91+		topic, _ := url.PathUnescape(router.GetField(r, 0))
 92 
 93 		topic = cleanRegex.ReplaceAllString(topic, "")
 94 
 95@@ -435,7 +436,7 @@ func handlePipe() http.HandlerFunc {
 96 
 97 func rssHandler(cfg *shared.ConfigSite, dbpool db.DB) http.HandlerFunc {
 98 	return func(w http.ResponseWriter, r *http.Request) {
 99-		apiToken, _ := url.PathUnescape(shared.GetField(r, 0))
100+		apiToken, _ := url.PathUnescape(router.GetField(r, 0))
101 		user, err := dbpool.FindUserByToken(apiToken)
102 		if err != nil {
103 			cfg.Logger.Error(
104@@ -470,20 +471,20 @@ func rssHandler(cfg *shared.ConfigSite, dbpool db.DB) http.HandlerFunc {
105 	}
106 }
107 
108-func createMainRoutes(staticRoutes []shared.Route, cfg *shared.ConfigSite, dbpool db.DB) []shared.Route {
109-	routes := []shared.Route{
110-		shared.NewRoute("GET", "/", shared.CreatePageHandler("html/marketing.page.tmpl")),
111-		shared.NewRoute("GET", "/check", shared.CheckHandler),
112-		shared.NewRoute("GET", "/rss/(.+)", rssHandler(cfg, dbpool)),
113-		shared.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
114+func createMainRoutes(staticRoutes []router.Route, cfg *shared.ConfigSite, dbpool db.DB) []router.Route {
115+	routes := []router.Route{
116+		router.NewRoute("GET", "/", router.CreatePageHandler("html/marketing.page.tmpl")),
117+		router.NewRoute("GET", "/check", router.CheckHandler),
118+		router.NewRoute("GET", "/rss/(.+)", rssHandler(cfg, dbpool)),
119+		router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
120 	}
121 
122-	pipeRoutes := []shared.Route{
123-		shared.NewRoute("GET", "/topic/(.+)", handleSub(false)),
124-		shared.NewRoute("POST", "/topic/(.+)", handlePub(false)),
125-		shared.NewRoute("GET", "/pubsub/(.+)", handleSub(true)),
126-		shared.NewRoute("POST", "/pubsub/(.+)", handlePub(true)),
127-		shared.NewRoute("GET", "/pipe/(.+)", handlePipe()),
128+	pipeRoutes := []router.Route{
129+		router.NewRoute("GET", "/topic/(.+)", handleSub(false)),
130+		router.NewRoute("POST", "/topic/(.+)", handlePub(false)),
131+		router.NewRoute("GET", "/pubsub/(.+)", handleSub(true)),
132+		router.NewRoute("POST", "/pubsub/(.+)", handlePub(true)),
133+		router.NewRoute("GET", "/pipe/(.+)", handlePipe()),
134 	}
135 
136 	for _, route := range pipeRoutes {
137@@ -510,7 +511,7 @@ func StartApiServer() {
138 	staticRoutes := createStaticRoutes()
139 
140 	if cfg.Debug {
141-		staticRoutes = shared.CreatePProfRoutes(staticRoutes)
142+		staticRoutes = router.CreatePProfRoutes(staticRoutes)
143 	}
144 
145 	mainRoutes := createMainRoutes(staticRoutes, cfg, db)
146@@ -541,11 +542,11 @@ func StartApiServer() {
147 		}
148 	}()
149 
150-	apiConfig := &shared.ApiConfig{
151+	apiConfig := &router.ApiConfig{
152 		Cfg:    cfg,
153 		Dbpool: db,
154 	}
155-	handler := shared.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
156+	handler := router.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
157 	router := http.HandlerFunc(handler)
158 
159 	portStr := fmt.Sprintf(":%s", cfg.Port)
M pkg/apps/pipe/config.go
+5, -6
 1@@ -4,15 +4,14 @@ import (
 2 	"strings"
 3 
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 func NewConfigSite(service string) *shared.ConfigSite {
 9-	domain := utils.GetEnv("PIPE_DOMAIN", "pipe.pico.sh")
10-	port := utils.GetEnv("PIPE_WEB_PORT", "3000")
11-	dbURL := utils.GetEnv("DATABASE_URL", "")
12-	protocol := utils.GetEnv("PIPE_PROTOCOL", "https")
13-	withPipe := strings.ToLower(utils.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
14+	domain := shared.GetEnv("PIPE_DOMAIN", "pipe.pico.sh")
15+	port := shared.GetEnv("PIPE_WEB_PORT", "3000")
16+	dbURL := shared.GetEnv("DATABASE_URL", "")
17+	protocol := shared.GetEnv("PIPE_PROTOCOL", "https")
18+	withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
19 
20 	return &shared.ConfigSite{
21 		Domain:   domain,
M pkg/apps/pipe/ssh.go
+5, -6
 1@@ -11,17 +11,16 @@ import (
 2 	"github.com/picosh/pico/pkg/pssh"
 3 	"github.com/picosh/pico/pkg/shared"
 4 	psub "github.com/picosh/pubsub"
 5-	"github.com/picosh/utils"
 6 	"golang.org/x/crypto/ssh"
 7 )
 8 
 9 func StartSshServer() {
10 	appName := "pipe-ssh"
11 
12-	host := utils.GetEnv("PIPE_HOST", "0.0.0.0")
13-	port := utils.GetEnv("PIPE_SSH_PORT", "2222")
14-	portOverride := utils.GetEnv("PIPE_SSH_PORT_OVERRIDE", port)
15-	promPort := utils.GetEnv("PIPE_PROM_PORT", "9222")
16+	host := shared.GetEnv("PIPE_HOST", "0.0.0.0")
17+	port := shared.GetEnv("PIPE_SSH_PORT", "2222")
18+	portOverride := shared.GetEnv("PIPE_SSH_PORT_OVERRIDE", port)
19+	promPort := shared.GetEnv("PIPE_PROM_PORT", "9222")
20 	cfg := NewConfigSite(appName)
21 	logger := cfg.Logger
22 
23@@ -62,7 +61,7 @@ func StartSshServer() {
24 			if perms == nil {
25 				perms = &ssh.Permissions{
26 					Extensions: map[string]string{
27-						"pubkey": utils.KeyForKeyText(key),
28+						"pubkey": shared.KeyForKeyText(key),
29 					},
30 				}
31 			}
M pkg/apps/pipe/ssh_test.go
+2, -3
 1@@ -18,7 +18,6 @@ import (
 2 	"github.com/picosh/pico/pkg/pssh"
 3 	"github.com/picosh/pico/pkg/shared"
 4 	psub "github.com/picosh/pubsub"
 5-	"github.com/picosh/utils"
 6 	"github.com/prometheus/client_golang/prometheus"
 7 	"golang.org/x/crypto/ssh"
 8 )
 9@@ -225,7 +224,7 @@ func NewTestSSHServer(t *testing.T) *TestSSHServer {
10 			if perms == nil {
11 				perms = &ssh.Permissions{
12 					Extensions: map[string]string{
13-						"pubkey": utils.KeyForKeyText(key),
14+						"pubkey": shared.KeyForKeyText(key),
15 					},
16 				}
17 			}
18@@ -294,7 +293,7 @@ func GenerateUser(username string) UserSSH {
19 }
20 
21 func (u UserSSH) PublicKey() string {
22-	return utils.KeyForKeyText(u.signer.PublicKey())
23+	return shared.KeyForKeyText(u.signer.PublicKey())
24 }
25 
26 func (u UserSSH) NewClient() (*ssh.Client, error) {
M pkg/apps/prose/api.go
+97, -97
  1@@ -19,8 +19,8 @@ import (
  2 	"github.com/picosh/pico/pkg/db"
  3 	"github.com/picosh/pico/pkg/db/postgres"
  4 	"github.com/picosh/pico/pkg/shared"
  5+	"github.com/picosh/pico/pkg/shared/router"
  6 	"github.com/picosh/pico/pkg/shared/storage"
  7-	"github.com/picosh/utils"
  8 	"github.com/prometheus/client_golang/prometheus/promhttp"
  9 )
 10 
 11@@ -124,10 +124,10 @@ func GetBlogName(username string) string {
 12 }
 13 
 14 func blogStyleHandler(w http.ResponseWriter, r *http.Request) {
 15-	username := shared.GetUsernameFromRequest(r)
 16-	dbpool := shared.GetDB(r)
 17-	logger := shared.GetLogger(r)
 18-	cfg := shared.GetCfg(r)
 19+	username := router.GetUsernameFromRequest(r)
 20+	dbpool := router.GetDB(r)
 21+	logger := router.GetLogger(r)
 22+	cfg := router.GetCfg(r)
 23 
 24 	user, err := dbpool.FindUserByName(username)
 25 	if err != nil {
 26@@ -154,10 +154,10 @@ func blogStyleHandler(w http.ResponseWriter, r *http.Request) {
 27 }
 28 
 29 func blogHandler(w http.ResponseWriter, r *http.Request) {
 30-	username := shared.GetUsernameFromRequest(r)
 31-	dbpool := shared.GetDB(r)
 32-	logger := shared.GetLogger(r)
 33-	cfg := shared.GetCfg(r)
 34+	username := router.GetUsernameFromRequest(r)
 35+	dbpool := router.GetDB(r)
 36+	logger := router.GetLogger(r)
 37+	cfg := router.GetCfg(r)
 38 
 39 	user, err := dbpool.FindUserByName(username)
 40 	if err != nil {
 41@@ -184,7 +184,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 42 		return
 43 	}
 44 
 45-	ts, err := shared.RenderTemplate(cfg, []string{
 46+	ts, err := router.RenderTemplate(cfg, []string{
 47 		cfg.StaticPath("html/blog-default.partial.tmpl"),
 48 		cfg.StaticPath("html/blog-aside.partial.tmpl"),
 49 		cfg.StaticPath("html/blog.page.tmpl"),
 50@@ -257,10 +257,10 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 51 		p := PostItemData{
 52 			URL:            template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
 53 			BlogURL:        template.URL(cfg.FullBlogURL(curl, post.Username)),
 54-			Title:          utils.FilenameToTitle(post.Filename, post.Title),
 55+			Title:          shared.FilenameToTitle(post.Filename, post.Title),
 56 			PublishAt:      post.PublishAt.Format(time.DateOnly),
 57 			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
 58-			UpdatedTimeAgo: utils.TimeAgo(post.UpdatedAt),
 59+			UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt),
 60 			UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
 61 		}
 62 		postCollection = append(postCollection, p)
 63@@ -289,20 +289,20 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
 64 }
 65 
 66 func postRawHandler(w http.ResponseWriter, r *http.Request) {
 67-	username := shared.GetUsernameFromRequest(r)
 68-	subdomain := shared.GetSubdomain(r)
 69-	cfg := shared.GetCfg(r)
 70+	username := router.GetUsernameFromRequest(r)
 71+	subdomain := router.GetSubdomain(r)
 72+	cfg := router.GetCfg(r)
 73 
 74 	var slug string
 75 	if !cfg.IsSubdomains() || subdomain == "" {
 76-		slug, _ = url.PathUnescape(shared.GetField(r, 1))
 77+		slug, _ = url.PathUnescape(router.GetField(r, 1))
 78 	} else {
 79-		slug, _ = url.PathUnescape(shared.GetField(r, 0))
 80+		slug, _ = url.PathUnescape(router.GetField(r, 0))
 81 	}
 82 	slug = strings.TrimSuffix(slug, "/")
 83 
 84-	dbpool := shared.GetDB(r)
 85-	logger := shared.GetLogger(r)
 86+	dbpool := router.GetDB(r)
 87+	logger := router.GetLogger(r)
 88 	logger = logger.With("slug", slug)
 89 
 90 	user, err := dbpool.FindUserByName(username)
 91@@ -330,10 +330,10 @@ func postRawHandler(w http.ResponseWriter, r *http.Request) {
 92 }
 93 
 94 func robotsHandler(w http.ResponseWriter, r *http.Request) {
 95-	username := shared.GetUsernameFromRequest(r)
 96-	cfg := shared.GetCfg(r)
 97-	dbpool := shared.GetDB(r)
 98-	logger := shared.GetLogger(r)
 99+	username := router.GetUsernameFromRequest(r)
100+	cfg := router.GetCfg(r)
101+	dbpool := router.GetDB(r)
102+	logger := router.GetLogger(r)
103 	user, err := dbpool.FindUserByName(username)
104 	if err != nil {
105 		logger.Info("blog not found", "user", username)
106@@ -356,20 +356,20 @@ func robotsHandler(w http.ResponseWriter, r *http.Request) {
107 }
108 
109 func postHandler(w http.ResponseWriter, r *http.Request) {
110-	username := shared.GetUsernameFromRequest(r)
111-	subdomain := shared.GetSubdomain(r)
112-	cfg := shared.GetCfg(r)
113+	username := router.GetUsernameFromRequest(r)
114+	subdomain := router.GetSubdomain(r)
115+	cfg := router.GetCfg(r)
116 
117 	var slug string
118 	if !cfg.IsSubdomains() || subdomain == "" {
119-		slug, _ = url.PathUnescape(shared.GetField(r, 1))
120+		slug, _ = url.PathUnescape(router.GetField(r, 1))
121 	} else {
122-		slug, _ = url.PathUnescape(shared.GetField(r, 0))
123+		slug, _ = url.PathUnescape(router.GetField(r, 0))
124 	}
125 	slug = strings.TrimSuffix(slug, "/")
126 
127-	dbpool := shared.GetDB(r)
128-	logger := shared.GetLogger(r)
129+	dbpool := router.GetDB(r)
130+	logger := router.GetLogger(r)
131 
132 	user, err := dbpool.FindUserByName(username)
133 	if err != nil {
134@@ -473,7 +473,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
135 			URL:          template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
136 			BlogURL:      template.URL(cfg.FullBlogURL(curl, username)),
137 			Description:  post.Description,
138-			Title:        utils.FilenameToTitle(post.Filename, post.Title),
139+			Title:        shared.FilenameToTitle(post.Filename, post.Title),
140 			Slug:         post.Slug,
141 			PublishAt:    post.PublishAt.Format(time.DateOnly),
142 			PublishAtISO: post.PublishAt.Format(time.RFC3339),
143@@ -544,7 +544,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
144 		w.WriteHeader(http.StatusNotFound)
145 	}
146 
147-	ts, err := shared.RenderTemplate(cfg, []string{
148+	ts, err := router.RenderTemplate(cfg, []string{
149 		cfg.StaticPath("html/list.partial.tmpl"),
150 		cfg.StaticPath("html/post.page.tmpl"),
151 	})
152@@ -563,9 +563,9 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
153 }
154 
155 func readHandler(w http.ResponseWriter, r *http.Request) {
156-	dbpool := shared.GetDB(r)
157-	logger := shared.GetLogger(r)
158-	cfg := shared.GetCfg(r)
159+	dbpool := router.GetDB(r)
160+	logger := router.GetLogger(r)
161+	cfg := router.GetCfg(r)
162 
163 	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
164 	tag := r.URL.Query().Get("tag")
165@@ -583,7 +583,7 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
166 		return
167 	}
168 
169-	ts, err := shared.RenderTemplate(cfg, []string{
170+	ts, err := router.RenderTemplate(cfg, []string{
171 		cfg.StaticPath("html/read.page.tmpl"),
172 	})
173 
174@@ -625,12 +625,12 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
175 		item := PostItemData{
176 			URL:            template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
177 			BlogURL:        template.URL(cfg.FullBlogURL(curl, post.Username)),
178-			Title:          utils.FilenameToTitle(post.Filename, post.Title),
179+			Title:          shared.FilenameToTitle(post.Filename, post.Title),
180 			Description:    post.Description,
181 			Username:       post.Username,
182 			PublishAt:      post.PublishAt.Format(time.DateOnly),
183 			PublishAtISO:   post.PublishAt.Format(time.RFC3339),
184-			UpdatedTimeAgo: utils.TimeAgo(post.UpdatedAt),
185+			UpdatedTimeAgo: shared.TimeAgo(post.UpdatedAt),
186 			UpdatedAtISO:   post.UpdatedAt.Format(time.RFC3339),
187 		}
188 		data.Posts = append(data.Posts, item)
189@@ -644,10 +644,10 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
190 }
191 
192 func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
193-	username := shared.GetUsernameFromRequest(r)
194-	dbpool := shared.GetDB(r)
195-	logger := shared.GetLogger(r)
196-	cfg := shared.GetCfg(r)
197+	username := router.GetUsernameFromRequest(r)
198+	dbpool := router.GetDB(r)
199+	logger := router.GetLogger(r)
200+	cfg := router.GetCfg(r)
201 
202 	user, err := dbpool.FindUserByName(username)
203 	if err != nil {
204@@ -675,7 +675,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
205 		return
206 	}
207 
208-	ts, err := template.New("rss.page.tmpl").Funcs(shared.FuncMap).ParseFiles(
209+	ts, err := template.New("rss.page.tmpl").Funcs(router.FuncMap).ParseFiles(
210 		cfg.StaticPath("html/list.partial.tmpl"),
211 		cfg.StaticPath("html/rss.page.tmpl"),
212 	)
213@@ -775,7 +775,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
214 
215 		item := &feeds.Item{
216 			Id:          feedId,
217-			Title:       utils.FilenameToTitle(post.Filename, post.Title),
218+			Title:       shared.FilenameToTitle(post.Filename, post.Title),
219 			Link:        &feeds.Link{Href: realUrl},
220 			Content:     content,
221 			Updated:     *post.PublishAt,
222@@ -805,9 +805,9 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
223 }
224 
225 func rssHandler(w http.ResponseWriter, r *http.Request) {
226-	dbpool := shared.GetDB(r)
227-	logger := shared.GetLogger(r)
228-	cfg := shared.GetCfg(r)
229+	dbpool := router.GetDB(r)
230+	logger := router.GetLogger(r)
231+	cfg := router.GetCfg(r)
232 
233 	pager, err := dbpool.FindPostsByFeed(&db.Pager{Num: 25, Page: 0}, cfg.Space)
234 	if err != nil {
235@@ -816,7 +816,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
236 		return
237 	}
238 
239-	ts, err := template.New("rss.page.tmpl").Funcs(shared.FuncMap).ParseFiles(
240+	ts, err := template.New("rss.page.tmpl").Funcs(router.FuncMap).ParseFiles(
241 		cfg.StaticPath("html/list.partial.tmpl"),
242 		cfg.StaticPath("html/rss.page.tmpl"),
243 	)
244@@ -907,8 +907,8 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
245 
246 func serveFile(file string, contentType string) http.HandlerFunc {
247 	return func(w http.ResponseWriter, r *http.Request) {
248-		logger := shared.GetLogger(r)
249-		cfg := shared.GetCfg(r)
250+		logger := router.GetLogger(r)
251+		cfg := router.GetCfg(r)
252 
253 		contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
254 		if err != nil {
255@@ -925,29 +925,29 @@ func serveFile(file string, contentType string) http.HandlerFunc {
256 	}
257 }
258 
259-func createStaticRoutes() []shared.Route {
260-	return []shared.Route{
261-		shared.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
262-		shared.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
263-		shared.NewRoute("GET", "/smol-v2.css", serveFile("smol-v2.css", "text/css")),
264-		shared.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
265-		shared.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
266-		shared.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
267-		shared.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
268-		shared.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
269-		shared.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
270-		shared.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
271+func createStaticRoutes() []router.Route {
272+	return []router.Route{
273+		router.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
274+		router.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
275+		router.NewRoute("GET", "/smol-v2.css", serveFile("smol-v2.css", "text/css")),
276+		router.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
277+		router.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
278+		router.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
279+		router.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
280+		router.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
281+		router.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
282+		router.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
283 	}
284 }
285 
286-func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
287-	routes := []shared.Route{
288-		shared.NewRoute("GET", "/", readHandler),
289-		shared.NewRoute("GET", "/read", readHandler),
290-		shared.NewRoute("GET", "/check", shared.CheckHandler),
291-		shared.NewRoute("GET", "/rss", rssHandler),
292-		shared.NewRoute("GET", "/rss.atom", rssHandler),
293-		shared.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
294+func createMainRoutes(staticRoutes []router.Route) []router.Route {
295+	routes := []router.Route{
296+		router.NewRoute("GET", "/", readHandler),
297+		router.NewRoute("GET", "/read", readHandler),
298+		router.NewRoute("GET", "/check", router.CheckHandler),
299+		router.NewRoute("GET", "/rss", rssHandler),
300+		router.NewRoute("GET", "/rss.atom", rssHandler),
301+		router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
302 	}
303 
304 	routes = append(
305@@ -959,10 +959,10 @@ func createMainRoutes(staticRoutes []shared.Route) []shared.Route {
306 }
307 
308 func imgRequest(w http.ResponseWriter, r *http.Request) {
309-	logger := shared.GetLogger(r)
310-	st := shared.GetStorage(r)
311-	dbpool := shared.GetDB(r)
312-	username := shared.GetUsernameFromRequest(r)
313+	logger := router.GetLogger(r)
314+	st := router.GetStorage(r)
315+	dbpool := router.GetDB(r)
316+	username := router.GetUsernameFromRequest(r)
317 	user, err := dbpool.FindUserByName(username)
318 	if err != nil {
319 		logger.Error("could not find user", "username", username)
320@@ -971,8 +971,8 @@ func imgRequest(w http.ResponseWriter, r *http.Request) {
321 	}
322 	logger = shared.LoggerWithUser(logger, user)
323 
324-	rawname := shared.GetField(r, 0)
325-	imgOpts := shared.GetField(r, 1)
326+	rawname := router.GetField(r, 0)
327+	imgOpts := router.GetField(r, 1)
328 	// we place all prose images inside a "prose" folder
329 	fname := filepath.Join("/prose", rawname)
330 
331@@ -1037,17 +1037,17 @@ func imgRequest(w http.ResponseWriter, r *http.Request) {
332 	}
333 }
334 
335-func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
336-	routes := []shared.Route{
337-		shared.NewRoute("GET", "/", blogHandler),
338-		shared.NewRoute("GET", "/_styles.css", blogStyleHandler),
339-		shared.NewRoute("GET", "/robots.txt", robotsHandler),
340-		shared.NewRoute("GET", "/rss", rssBlogHandler),
341-		shared.NewRoute("GET", "/rss.xml", rssBlogHandler),
342-		shared.NewRoute("GET", "/atom.xml", rssBlogHandler),
343-		shared.NewRoute("GET", "/feed.xml", rssBlogHandler),
344-		shared.NewRoute("GET", "/atom", rssBlogHandler),
345-		shared.NewRoute("GET", "/blog/index.xml", rssBlogHandler),
346+func createSubdomainRoutes(staticRoutes []router.Route) []router.Route {
347+	routes := []router.Route{
348+		router.NewRoute("GET", "/", blogHandler),
349+		router.NewRoute("GET", "/_styles.css", blogStyleHandler),
350+		router.NewRoute("GET", "/robots.txt", robotsHandler),
351+		router.NewRoute("GET", "/rss", rssBlogHandler),
352+		router.NewRoute("GET", "/rss.xml", rssBlogHandler),
353+		router.NewRoute("GET", "/atom.xml", rssBlogHandler),
354+		router.NewRoute("GET", "/feed.xml", rssBlogHandler),
355+		router.NewRoute("GET", "/atom", rssBlogHandler),
356+		router.NewRoute("GET", "/blog/index.xml", rssBlogHandler),
357 	}
358 
359 	routes = append(
360@@ -1057,13 +1057,13 @@ func createSubdomainRoutes(staticRoutes []shared.Route) []shared.Route {
361 
362 	routes = append(
363 		routes,
364-		shared.NewRoute("GET", "/raw/(.+)", postRawHandler),
365-		shared.NewRoute("GET", "/(.+).md", postRawHandler),
366-		shared.NewRoute("GET", "/(.+).lxt", postRawHandler),
367-		shared.NewRoute("GET", `/(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))/(.+)`, imgRequest),
368-		shared.NewRoute("GET", `/(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))$`, imgRequest),
369-		shared.NewRoute("GET", "/(.+).html", postHandler),
370-		shared.NewRoute("GET", "/(.+)", postHandler),
371+		router.NewRoute("GET", "/raw/(.+)", postRawHandler),
372+		router.NewRoute("GET", "/(.+).md", postRawHandler),
373+		router.NewRoute("GET", "/(.+).lxt", postRawHandler),
374+		router.NewRoute("GET", `/(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))/(.+)`, imgRequest),
375+		router.NewRoute("GET", `/(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))$`, imgRequest),
376+		router.NewRoute("GET", "/(.+).html", postHandler),
377+		router.NewRoute("GET", "/(.+)", postHandler),
378 	)
379 
380 	return routes
381@@ -1087,18 +1087,18 @@ func StartApiServer() {
382 	staticRoutes := createStaticRoutes()
383 
384 	if cfg.Debug {
385-		staticRoutes = shared.CreatePProfRoutes(staticRoutes)
386+		staticRoutes = router.CreatePProfRoutes(staticRoutes)
387 	}
388 
389 	mainRoutes := createMainRoutes(staticRoutes)
390 	subdomainRoutes := createSubdomainRoutes(staticRoutes)
391 
392-	apiConfig := &shared.ApiConfig{
393+	apiConfig := &router.ApiConfig{
394 		Cfg:     cfg,
395 		Dbpool:  dbpool,
396 		Storage: st,
397 	}
398-	handler := shared.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
399+	handler := router.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
400 	router := http.HandlerFunc(handler)
401 
402 	portStr := fmt.Sprintf(":%s", cfg.Port)
M pkg/apps/prose/config.go
+9, -10
 1@@ -4,20 +4,19 @@ import (
 2 	"strings"
 3 
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8-var MAX_FILE_SIZE = 3 * utils.MB
 9+var MAX_FILE_SIZE = 3 * shared.MB
10 
11 func NewConfigSite(service string) *shared.ConfigSite {
12-	debug := utils.GetEnv("PROSE_DEBUG", "0")
13-	domain := utils.GetEnv("PROSE_DOMAIN", "prose.sh")
14-	port := utils.GetEnv("PROSE_WEB_PORT", "3000")
15-	protocol := utils.GetEnv("PROSE_PROTOCOL", "https")
16-	dbURL := utils.GetEnv("DATABASE_URL", "")
17-	maxSize := uint64(25 * utils.MB)
18-	maxImgSize := int64(10 * utils.MB)
19-	withPipe := strings.ToLower(utils.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
20+	debug := shared.GetEnv("PROSE_DEBUG", "0")
21+	domain := shared.GetEnv("PROSE_DOMAIN", "prose.sh")
22+	port := shared.GetEnv("PROSE_WEB_PORT", "3000")
23+	protocol := shared.GetEnv("PROSE_PROTOCOL", "https")
24+	dbURL := shared.GetEnv("DATABASE_URL", "")
25+	maxSize := uint64(25 * shared.MB)
26+	maxImgSize := int64(10 * shared.MB)
27+	withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
28 
29 	return &shared.ConfigSite{
30 		Debug:    debug == "1",
M pkg/apps/prose/scp_hooks.go
+4, -5
 1@@ -11,7 +11,6 @@ import (
 2 	"github.com/picosh/pico/pkg/filehandlers"
 3 	"github.com/picosh/pico/pkg/pssh"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 	pipeUtil "github.com/picosh/utils/pipe"
 7 )
 8 
 9@@ -22,7 +21,7 @@ type MarkdownHooks struct {
10 }
11 
12 func (p *MarkdownHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) (bool, error) {
13-	if !utils.IsTextFile(data.Text) {
14+	if !shared.IsTextFile(data.Text) {
15 		err := fmt.Errorf(
16 			"ERROR: (%s) invalid file must be plain text (utf-8), skipping",
17 			data.Filename,
18@@ -42,7 +41,7 @@ func (p *MarkdownHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehan
19 		return true, nil
20 	}
21 
22-	if !utils.IsExtAllowed(data.Filename, p.Cfg.AllowedExt) {
23+	if !shared.IsExtAllowed(data.Filename, p.Cfg.AllowedExt) {
24 		extStr := strings.Join(p.Cfg.AllowedExt, ",")
25 		err := fmt.Errorf(
26 			"ERROR: (%s) invalid file, format must be (%s), skipping",
27@@ -67,7 +66,7 @@ func (p *MarkdownHooks) metaLxt(data *filehandlers.PostMetaData) error {
28 	parsedText := shared.ListParseText(data.Text)
29 
30 	if parsedText.Title == "" {
31-		data.Title = utils.ToUpper(data.Slug)
32+		data.Title = shared.ToUpper(data.Slug)
33 	} else {
34 		data.Title = parsedText.Title
35 	}
36@@ -91,7 +90,7 @@ func (p *MarkdownHooks) metaMd(data *filehandlers.PostMetaData) error {
37 	}
38 
39 	if parsedText.Title == "" {
40-		data.Title = utils.ToUpper(data.Slug)
41+		data.Title = shared.ToUpper(data.Slug)
42 	} else {
43 		data.Title = parsedText.Title
44 	}
M pkg/apps/prose/ssh.go
+3, -4
 1@@ -19,15 +19,14 @@ import (
 2 	"github.com/picosh/pico/pkg/send/protocols/sftp"
 3 	"github.com/picosh/pico/pkg/shared"
 4 	"github.com/picosh/pico/pkg/shared/storage"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 func StartSshServer() {
 9 	appName := "prose-ssh"
10 
11-	host := utils.GetEnv("PROSE_HOST", "0.0.0.0")
12-	port := utils.GetEnv("PROSE_SSH_PORT", "2222")
13-	promPort := utils.GetEnv("PROSE_PROM_PORT", "9222")
14+	host := shared.GetEnv("PROSE_HOST", "0.0.0.0")
15+	port := shared.GetEnv("PROSE_SSH_PORT", "2222")
16+	promPort := shared.GetEnv("PROSE_PROM_PORT", "9222")
17 	cfg := NewConfigSite(appName)
18 	logger := cfg.Logger
19 
D pkg/cache/cache.go
+0, -21
 1@@ -1,21 +0,0 @@
 2-package cache
 3-
 4-import (
 5-	"log/slog"
 6-	"time"
 7-
 8-	"github.com/picosh/utils"
 9-)
10-
11-var CacheTimeout time.Duration
12-
13-func init() {
14-	cacheDuration := utils.GetEnv("STORAGE_MINIO_CACHE_DURATION", "1m")
15-	duration, err := time.ParseDuration(cacheDuration)
16-	if err != nil {
17-		slog.Error("Invalid STORAGE_MINIO_CACHE_DURATION value, using default 1m", "error", err)
18-		duration = 1 * time.Minute
19-	}
20-
21-	CacheTimeout = duration
22-}
M pkg/db/postgres/storage.go
+2, -2
 1@@ -14,7 +14,7 @@ import (
 2 	"github.com/jmoiron/sqlx"
 3 	_ "github.com/lib/pq"
 4 	"github.com/picosh/pico/pkg/db"
 5-	"github.com/picosh/utils"
 6+	"github.com/picosh/pico/pkg/shared"
 7 )
 8 
 9 var PAGER_SIZE = 15
10@@ -1232,7 +1232,7 @@ func (me *PsqlDB) FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error) {
11 }
12 
13 func (me *PsqlDB) InsertProject(userID, name, projectDir string) (string, error) {
14-	if !utils.IsValidSubdomain(name) {
15+	if !shared.IsValidSubdomain(name) {
16 		return "", fmt.Errorf("'%s' is not a valid project name, must match /^[a-z0-9-]+$/", name)
17 	}
18 
M pkg/filehandlers/imgs/handler.go
+3, -4
 1@@ -19,7 +19,6 @@ import (
 2 	sendutils "github.com/picosh/pico/pkg/send/utils"
 3 	"github.com/picosh/pico/pkg/shared"
 4 	"github.com/picosh/pico/pkg/shared/storage"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 var Space = "imgs"
 9@@ -214,8 +213,8 @@ func (h *UploadImgHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.
10 	str := fmt.Sprintf(
11 		"%s (space: %.2f/%.2fGB, %.2f%%)",
12 		url,
13-		utils.BytesToGB(metadata.TotalFileSize+fileSize),
14-		utils.BytesToGB(maxSize),
15+		shared.BytesToGB(metadata.TotalFileSize+fileSize),
16+		shared.BytesToGB(maxSize),
17 		(float32(totalFileSize)/float32(maxSize))*100,
18 	)
19 	return str, nil
20@@ -262,7 +261,7 @@ func (h *UploadImgHandler) validateImg(data *PostMetaData) (bool, error) {
21 		return false, fmt.Errorf("ERROR: user (%s) has exceeded (%d bytes) max (%d bytes)", data.User.Name, data.TotalFileSize, storageMax)
22 	}
23 
24-	if !utils.IsExtAllowed(data.Filename, h.Cfg.AllowedExt) {
25+	if !shared.IsExtAllowed(data.Filename, h.Cfg.AllowedExt) {
26 		extStr := strings.Join(h.Cfg.AllowedExt, ",")
27 		err := fmt.Errorf(
28 			"ERROR: (%s) invalid file, format must be (%s), skipping",
M pkg/filehandlers/post_handler.go
+2, -3
 1@@ -14,7 +14,6 @@ import (
 2 	"github.com/picosh/pico/pkg/pssh"
 3 	sendutils "github.com/picosh/pico/pkg/send/utils"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 type PostMetaData struct {
 9@@ -116,9 +115,9 @@ func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.
10 	}
11 
12 	now := time.Now()
13-	slug := utils.SanitizeFileExt(filename)
14+	slug := shared.SanitizeFileExt(filename)
15 	fileSize := binary.Size(origText)
16-	shasum := utils.Shasum(origText)
17+	shasum := shared.Shasum(origText)
18 
19 	nextPost := db.Post{
20 		Filename:  filename,
M pkg/pobj/storage/fs.go
+4, -2
 1@@ -17,9 +17,11 @@ import (
 2 	"github.com/google/renameio/v2"
 3 	"github.com/picosh/pico/pkg/send/utils"
 4 	"github.com/picosh/pico/pkg/shared/mime"
 5-	putils "github.com/picosh/utils"
 6 )
 7 
 8+var KB = 1000
 9+var MB = KB * 1000
10+
11 // https://stackoverflow.com/a/32482941
12 func dirSize(path string) (int64, error) {
13 	var size int64
14@@ -121,7 +123,7 @@ func (s *StorageFS) GetObject(bucket Bucket, fpath string) (utils.ReadAndReaderA
15 
16 	etag := ""
17 	// only generate etag if file is less than 10MB
18-	if info.Size() <= int64(10*putils.MB) {
19+	if info.Size() <= int64(10*MB) {
20 		// calculate etag
21 		h := md5.New()
22 		if _, err := io.Copy(h, dat); err != nil {
A pkg/shared/cache.go
+5, -0
1@@ -0,0 +1,5 @@
2+package shared
3+
4+import "time"
5+
6+var CacheTimeout = 2 * time.Minute
A pkg/shared/cmd_session.go
+37, -0
 1@@ -0,0 +1,37 @@
 2+package shared
 3+
 4+import (
 5+	"fmt"
 6+	"io"
 7+	"log/slog"
 8+	"os"
 9+)
10+
11+type CmdSessionLogger struct {
12+	Log *slog.Logger
13+}
14+
15+func (c *CmdSessionLogger) Write(out []byte) (int, error) {
16+	c.Log.Info(string(out))
17+	return 0, nil
18+}
19+
20+func (c *CmdSessionLogger) Exit(code int) error {
21+	os.Exit(code)
22+	return fmt.Errorf("panic %d", code)
23+}
24+
25+func (c *CmdSessionLogger) Close() error {
26+	return fmt.Errorf("closing")
27+}
28+
29+func (c *CmdSessionLogger) Stderr() io.ReadWriter {
30+	return nil
31+}
32+
33+type CmdSession interface {
34+	Write([]byte) (int, error)
35+	Exit(code int) error
36+	Close() error
37+	Stderr() io.ReadWriter
38+}
M pkg/shared/pubsub.go
+6, -7
 1@@ -1,17 +1,16 @@
 2 package shared
 3 
 4 import (
 5-	"github.com/picosh/utils"
 6 	"github.com/picosh/utils/pipe"
 7 )
 8 
 9 func NewPicoPipeClient() *pipe.SSHClientInfo {
10 	return &pipe.SSHClientInfo{
11-		RemoteHost:          utils.GetEnv("PICO_PIPE_ENDPOINT", "pipe.pico.sh:22"),
12-		KeyLocation:         utils.GetEnv("PICO_PIPE_KEY", "ssh_data/term_info_ed25519"),
13-		CertificateLocation: utils.GetEnv("PICO_PIPE_KEY_CERT", ""),
14-		KeyPassphrase:       utils.GetEnv("PICO_PIPE_PASSPHRASE", ""),
15-		RemoteHostname:      utils.GetEnv("PICO_PIPE_REMOTE_HOST", "pipe.pico.sh"),
16-		RemoteUser:          utils.GetEnv("PICO_PIPE_USER", "pico"),
17+		RemoteHost:          GetEnv("PICO_PIPE_ENDPOINT", "pipe.pico.sh:22"),
18+		KeyLocation:         GetEnv("PICO_PIPE_KEY", "ssh_data/term_info_ed25519"),
19+		CertificateLocation: GetEnv("PICO_PIPE_KEY_CERT", ""),
20+		KeyPassphrase:       GetEnv("PICO_PIPE_PASSPHRASE", ""),
21+		RemoteHostname:      GetEnv("PICO_PIPE_REMOTE_HOST", "pipe.pico.sh"),
22+		RemoteUser:          GetEnv("PICO_PIPE_USER", "pico"),
23 	}
24 }
R pkg/shared/analytics.go => pkg/shared/router/analytics.go
+3, -2
 1@@ -1,4 +1,4 @@
 2-package shared
 3+package router
 4 
 5 import (
 6 	"context"
 7@@ -16,6 +16,7 @@ import (
 8 	"time"
 9 
10 	"github.com/picosh/pico/pkg/db"
11+	"github.com/picosh/pico/pkg/shared"
12 	"github.com/picosh/utils/pipe/metrics"
13 	"github.com/simplesurance/go-ip-anonymizer/ipanonymizer"
14 	"github.com/x-way/crawlerdetect"
15@@ -233,7 +234,7 @@ func AnalyticsCollect(ch chan *db.AnalyticsVisits, dbpool db.DB, logger *slog.Lo
16 	drain := metrics.RegisterReconnectMetricRecorder(
17 		context.Background(),
18 		logger,
19-		NewPicoPipeClient(),
20+		shared.NewPicoPipeClient(),
21 		100,
22 		10*time.Millisecond,
23 	)
R pkg/shared/api.go => pkg/shared/router/api.go
+5, -5
 1@@ -1,4 +1,4 @@
 2-package shared
 3+package router
 4 
 5 import (
 6 	"encoding/json"
 7@@ -9,7 +9,7 @@ import (
 8 	"strings"
 9 
10 	"github.com/picosh/pico/pkg/db"
11-	"github.com/picosh/utils"
12+	"github.com/picosh/pico/pkg/shared"
13 	"golang.org/x/crypto/ssh"
14 )
15 
16@@ -61,7 +61,7 @@ type UserApi struct {
17 func NewUserApi(user *db.User, pubkey ssh.PublicKey) *UserApi {
18 	return &UserApi{
19 		User:        user,
20-		Fingerprint: utils.KeyForSha256(pubkey),
21+		Fingerprint: shared.KeyForSha256(pubkey),
22 	}
23 }
24 
25@@ -136,7 +136,7 @@ var FuncMap = template.FuncMap{
26 	"intRange": intRange,
27 }
28 
29-func RenderTemplate(cfg *ConfigSite, templates []string) (*template.Template, error) {
30+func RenderTemplate(cfg *shared.ConfigSite, templates []string) (*template.Template, error) {
31 	files := make([]string, len(templates))
32 	copy(files, templates)
33 	files = append(
34@@ -165,7 +165,7 @@ func CreatePageHandler(fname string) http.HandlerFunc {
35 			return
36 		}
37 
38-		data := PageData{
39+		data := shared.PageData{
40 			Site: *cfg.GetSiteData(),
41 		}
42 		err = ts.Execute(w, data)
R pkg/shared/router.go => pkg/shared/router/router.go
+7, -7
 1@@ -1,4 +1,4 @@
 2-package shared
 3+package router
 4 
 5 import (
 6 	"context"
 7@@ -11,9 +11,9 @@ import (
 8 	"strings"
 9 
10 	"github.com/hashicorp/golang-lru/v2/expirable"
11-	"github.com/picosh/pico/pkg/cache"
12 	"github.com/picosh/pico/pkg/db"
13 	"github.com/picosh/pico/pkg/pssh"
14+	"github.com/picosh/pico/pkg/shared"
15 	"github.com/picosh/pico/pkg/shared/storage"
16 )
17 
18@@ -71,7 +71,7 @@ func CreatePProfRoutesMux(mux *http.ServeMux) {
19 }
20 
21 type ApiConfig struct {
22-	Cfg     *ConfigSite
23+	Cfg     *shared.ConfigSite
24 	Dbpool  db.DB
25 	Storage storage.StorageServe
26 }
27@@ -147,7 +147,7 @@ func GetSubdomainFromRequest(r *http.Request, domain, space string) string {
28 	return ""
29 }
30 
31-func findRouteConfig(r *http.Request, routes []Route, subdomainRoutes []Route, cfg *ConfigSite) ([]Route, string) {
32+func findRouteConfig(r *http.Request, routes []Route, subdomainRoutes []Route, cfg *shared.ConfigSite) ([]Route, string) {
33 	if len(subdomainRoutes) == 0 {
34 		return routes, ""
35 	}
36@@ -185,8 +185,8 @@ func GetSshCtx(r *http.Request) (*pssh.SSHServerConnSession, error) {
37 	return payload, nil
38 }
39 
40-func GetCfg(r *http.Request) *ConfigSite {
41-	return r.Context().Value(ctxCfg{}).(*ConfigSite)
42+func GetCfg(r *http.Request) *shared.ConfigSite {
43+	return r.Context().Value(ctxCfg{}).(*shared.ConfigSite)
44 }
45 
46 func GetLogger(r *http.Request) *slog.Logger {
47@@ -213,7 +213,7 @@ func GetSubdomain(r *http.Request) string {
48 	return r.Context().Value(CtxSubdomainKey{}).(string)
49 }
50 
51-var txtCache = expirable.NewLRU[string, string](2048, nil, cache.CacheTimeout)
52+var txtCache = expirable.NewLRU[string, string](2048, nil, shared.CacheTimeout)
53 
54 func GetCustomDomain(host string, space string) string {
55 	txt := fmt.Sprintf("_%s.%s", space, host)
M pkg/shared/ssh.go
+2, -3
 1@@ -7,7 +7,6 @@ import (
 2 	"time"
 3 
 4 	"github.com/picosh/pico/pkg/db"
 5-	"github.com/picosh/utils"
 6 	"golang.org/x/crypto/ssh"
 7 )
 8 
 9@@ -41,7 +40,7 @@ type AuthedPubkey struct {
10 }
11 
12 func PubkeyCertVerify(key ssh.PublicKey, srcPrincipal string) (*AuthedPubkey, error) {
13-	origPubkey := utils.KeyForKeyText(key)
14+	origPubkey := KeyForKeyText(key)
15 	authed := &AuthedPubkey{
16 		OrigPubkey: origPubkey,
17 		Pubkey:     origPubkey,
18@@ -74,7 +73,7 @@ func PubkeyCertVerify(key ssh.PublicKey, srcPrincipal string) (*AuthedPubkey, er
19 			return nil, fmt.Errorf("ssh-cert has expired")
20 		}
21 
22-		authed.Pubkey = utils.KeyForKeyText(cert.SignatureKey)
23+		authed.Pubkey = KeyForKeyText(cert.SignatureKey)
24 		authed.Identity = cert.KeyId
25 		return authed, nil
26 	}
M pkg/shared/storage/base.go
+3, -3
 1@@ -4,18 +4,18 @@ import (
 2 	"fmt"
 3 	"log/slog"
 4 
 5-	"github.com/picosh/utils"
 6+	"github.com/picosh/pico/pkg/shared"
 7 )
 8 
 9 func GetStorageTypeFromEnv() string {
10-	return utils.GetEnv("STORAGE_ADAPTER", "fs")
11+	return shared.GetEnv("STORAGE_ADAPTER", "fs")
12 }
13 
14 func NewStorage(logger *slog.Logger, adapter string) (StorageServe, error) {
15 	logger.Info("storage adapter", "adapter", adapter)
16 	switch adapter {
17 	case "fs":
18-		fsPath := utils.GetEnv("FS_STORAGE_DIR", "/tmp/pico_storage")
19+		fsPath := shared.GetEnv("FS_STORAGE_DIR", "/tmp/pico_storage")
20 		logger.Info("using filesystem storage", "path", fsPath)
21 		return NewStorageFS(logger, fsPath)
22 	case "memory":
M pkg/tui/analytics.go
+4, -4
 1@@ -11,7 +11,7 @@ import (
 2 	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
 3 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
 4 	"github.com/picosh/pico/pkg/db"
 5-	"github.com/picosh/utils"
 6+	"github.com/picosh/pico/pkg/shared"
 7 )
 8 
 9 type SitesLoaded struct{}
10@@ -355,7 +355,7 @@ func (m *AnalyticsPage) visits(ctx vxfw.DrawContext, intervals []*db.VisitInterv
11 func (m *AnalyticsPage) fetchSites() {
12 	siteList, err := m.shared.Dbpool.FindVisitSiteList(&db.SummaryOpts{
13 		UserID: m.shared.User.ID,
14-		Origin: utils.StartOfMonth(),
15+		Origin: shared.StartOfMonth(),
16 	})
17 	if err != nil {
18 		m.loadingSites = false
19@@ -376,9 +376,9 @@ func (m *AnalyticsPage) fetchSiteStats(site string, interval string) {
20 	}
21 
22 	if interval == "day" {
23-		opts.Origin = utils.StartOfMonth()
24+		opts.Origin = shared.StartOfMonth()
25 	} else {
26-		opts.Origin = utils.StartOfYear()
27+		opts.Origin = shared.StartOfYear()
28 	}
29 
30 	summary, err := m.shared.Dbpool.VisitSummary(opts)
M pkg/tui/logs.go
+10, -11
 1@@ -14,7 +14,6 @@ import (
 2 	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
 3 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 	pipeLogger "github.com/picosh/utils/pipe/log"
 7 )
 8 
 9@@ -215,10 +214,10 @@ func (m *LogsPage) connectToLogs() error {
10 			continue
11 		}
12 
13-		user := utils.AnyToStr(parsedData, "user")
14-		userId := utils.AnyToStr(parsedData, "userId")
15+		user := shared.AnyToStr(parsedData, "user")
16+		userId := shared.AnyToStr(parsedData, "userId")
17 
18-		hidden := utils.AnyToBool(parsedData, "hidden")
19+		hidden := shared.AnyToBool(parsedData, "hidden")
20 
21 		if !hidden && (user == m.shared.User.Name || userId == m.shared.User.ID) {
22 			m.shared.App.PostEvent(LogLineLoaded{parsedData})
23@@ -250,13 +249,13 @@ type LogLine struct {
24 }
25 
26 func NewLogLine(data map[string]any) *LogLine {
27-	rawtime := utils.AnyToStr(data, "time")
28-	service := utils.AnyToStr(data, "service")
29-	level := utils.AnyToStr(data, "level")
30-	msg := utils.AnyToStr(data, "msg")
31-	errMsg := utils.AnyToStr(data, "err")
32-	status := utils.AnyToFloat(data, "status")
33-	url := utils.AnyToStr(data, "url")
34+	rawtime := shared.AnyToStr(data, "time")
35+	service := shared.AnyToStr(data, "service")
36+	level := shared.AnyToStr(data, "level")
37+	msg := shared.AnyToStr(data, "msg")
38+	errMsg := shared.AnyToStr(data, "err")
39+	status := shared.AnyToFloat(data, "status")
40+	url := shared.AnyToStr(data, "url")
41 	date, err := time.Parse(time.RFC3339Nano, rawtime)
42 	dateStr := rawtime
43 	if err == nil {
M pkg/tui/pubkeys.go
+2, -2
 1@@ -11,7 +11,7 @@ import (
 2 	"git.sr.ht/~rockorager/vaxis/vxfw/richtext"
 3 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
 4 	"github.com/picosh/pico/pkg/db"
 5-	"github.com/picosh/utils"
 6+	"github.com/picosh/pico/pkg/shared"
 7 	"golang.org/x/crypto/ssh"
 8 )
 9 
10@@ -253,7 +253,7 @@ func (m *AddKeyPage) addPubkey(pubkey string) error {
11 		return err
12 	}
13 
14-	key := utils.KeyForKeyText(pk)
15+	key := shared.KeyForKeyText(pk)
16 
17 	return m.shared.Dbpool.InsertPublicKey(
18 		m.shared.User.ID, key, comment,
M pkg/tui/signup.go
+2, -2
 1@@ -10,7 +10,7 @@ import (
 2 	"git.sr.ht/~rockorager/vaxis/vxfw/text"
 3 	"github.com/picosh/pico/pkg/db"
 4 	"github.com/picosh/pico/pkg/pssh"
 5-	"github.com/picosh/utils"
 6+	"github.com/picosh/pico/pkg/shared"
 7 	"golang.org/x/crypto/ssh"
 8 )
 9 
10@@ -44,7 +44,7 @@ func (m *SignupPage) createAccount(name string) (*db.User, error) {
11 	if name == "" {
12 		return nil, fmt.Errorf("name cannot be empty")
13 	}
14-	key := utils.KeyForKeyText(m.shared.Session.PublicKey())
15+	key := shared.KeyForKeyText(m.shared.Session.PublicKey())
16 	return m.shared.Dbpool.RegisterUser(name, key, "")
17 }
18 
M pkg/tui/ui.go
+1, -2
 1@@ -14,7 +14,6 @@ import (
 2 	"github.com/picosh/pico/pkg/db"
 3 	"github.com/picosh/pico/pkg/pssh"
 4 	"github.com/picosh/pico/pkg/shared"
 5-	"github.com/picosh/utils"
 6 )
 7 
 8 const (
 9@@ -289,7 +288,7 @@ func FindUser(shrd *SharedModel) (*db.User, error) {
10 		return nil, fmt.Errorf("unable to find public key")
11 	}
12 
13-	key := utils.KeyForKeyText(shrd.Session.PublicKey())
14+	key := shared.KeyForKeyText(shrd.Session.PublicKey())
15 
16 	user, err := shrd.Dbpool.FindUserByKey(usr, key)
17 	if err != nil {