Commit 874bfe4

Antonio Mika  ·  2026-10-11 13:56:13 -0400 EDT
parents bd15825, e1a3a25
Merge pull request #227 from picosh/am/decouple-apps

Decouple apps for more standalone dep graphs
115 files changed,  +6788, -5566
+2, -2
......@@ -9,8 +9,8 @@ import (
99 )
1010
1111 func main() {
12- cfg := feeds.NewConfigSite("feeds-fetch")
13- dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
12+ cfg := feeds.NewConfig("feeds-fetch")
13+ dbh := feeds.NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
1414 defer func() {
1515 _ = dbh.Close()
1616 }()
+1, -1
......@@ -26,7 +26,7 @@ func main() {
2626 defer func() {
2727 _ = pubsub.Close()
2828 }()
29- cfg := pgs.NewPgsConfig(logger, nil, nil, drain)
29+ cfg := pgs.NewConfig(logger, nil, nil, drain)
3030 proxy := newProxyServe(cfg.Logger)
3131 httpCache, cache := pgs.NewPgsHttpCache(cfg, proxy)
3232 cacher := &cachedHttp{
+1, -1
......@@ -29,7 +29,7 @@ func main() {
2929 defer func() {
3030 _ = pubsub.Close()
3131 }()
32- cfg := pgs.NewPgsConfig(logger, dbpool, st, pubsub)
32+ cfg := pgs.NewConfig(logger, dbpool, st, pubsub)
3333 killCh := make(chan error)
3434 pgs.StartSshServer(cfg, killCh)
3535 }
+1, -1
......@@ -27,7 +27,7 @@ func main() {
2727 defer func() {
2828 _ = pubsub.Close()
2929 }()
30- cfg := pgs.NewPgsConfig(logger, dbpool, st, pubsub)
30+ cfg := pgs.NewConfig(logger, dbpool, st, pubsub)
3131 killCh := make(chan error)
3232
3333 // first time user experience flow
+1, -1
......@@ -29,6 +29,6 @@ func main() {
2929 defer func() {
3030 _ = pubsub.Close()
3131 }()
32- cfg := pgs.NewPgsConfig(logger, dbpool, st, pubsub)
32+ cfg := pgs.NewConfig(logger, dbpool, st, pubsub)
3333 pgs.StartApiServer(cfg)
3434 }
+1, -4
......@@ -59,10 +59,7 @@ func updateDates(tx *sql.Tx, postID string, date *time.Time) error {
5959 func main() {
6060 logger := slog.Default()
6161
62- picoCfg := shared.NewConfigSite()
63- picoCfg.Logger = logger
64- picoCfg.DbURL = os.Getenv("DATABASE_URL")
65- picoDb := postgres.NewDB(picoCfg.DbURL, picoCfg.Logger)
62+ picoDb := postgres.NewDB(os.Getenv("DATABASE_URL"), logger)
6663
6764 logger.Info("fetching all posts")
6865 posts, err := findPosts(picoDb.Db)
+1, -5
......@@ -6,7 +6,6 @@ import (
66 "os"
77
88 "github.com/picosh/pico/pkg/db/postgres"
9- "github.com/picosh/pico/pkg/shared"
109 )
1110
1211 func bail(err error) {
......@@ -18,10 +17,7 @@ func bail(err error) {
1817 func main() {
1918 logger := slog.Default()
2019
21- picoCfg := shared.NewConfigSite()
22- picoCfg.Logger = logger
23- picoCfg.DbURL = os.Getenv("DATABASE_URL")
24- picoDb := postgres.NewDB(picoCfg.DbURL, picoCfg.Logger)
20+ picoDb := postgres.NewDB(os.Getenv("DATABASE_URL"), logger)
2521
2622 posts, err := picoDb.FindPosts()
2723 bail(err)
+3, -13
......@@ -10,7 +10,6 @@ import (
1010 "github.com/jmoiron/sqlx"
1111 "github.com/picosh/pico/pkg/db"
1212 "github.com/picosh/pico/pkg/db/postgres"
13- "github.com/picosh/pico/pkg/shared"
1413 )
1514
1615 func findPosts(dbpool *sqlx.DB) ([]*db.Post, error) {
......@@ -102,20 +101,11 @@ type ConflictData struct {
102101 func main() {
103102 logger := slog.Default()
104103
105- listsCfg := shared.NewConfigSite()
106- listsCfg.Logger = logger
107- listsCfg.DbURL = os.Getenv("LISTS_DB_URL")
108- listsDb := postgres.NewDB(listsCfg.DbURL, listsCfg.Logger)
104+ listsDb := postgres.NewDB(os.Getenv("LISTS_DB_URL"), logger)
109105
110- proseCfg := shared.NewConfigSite()
111- proseCfg.DbURL = os.Getenv("PROSE_DB_URL")
112- proseCfg.Logger = logger
113- proseDb := postgres.NewDB(proseCfg.DbURL, proseCfg.Logger)
106+ proseDb := postgres.NewDB(os.Getenv("PROSE_DB_URL"), logger)
114107
115- picoCfg := shared.NewConfigSite()
116- picoCfg.Logger = logger
117- picoCfg.DbURL = os.Getenv("PICO_DB_URL")
118- picoDb := postgres.NewDB(picoCfg.DbURL, picoCfg.Logger)
108+ picoDb := postgres.NewDB(os.Getenv("PICO_DB_URL"), logger)
119109
120110 ctx := context.Background()
121111 tx, err := picoDb.Db.BeginTx(ctx, nil)
+1, -4
......@@ -10,10 +10,7 @@ import (
1010
1111 func main() {
1212 logger := slog.Default()
13- picoCfg := shared.NewConfigSite()
14- picoCfg.Logger = logger
15- picoCfg.DbURL = os.Getenv("DATABASE_URL")
16- picoDb := postgres.NewDB(picoCfg.DbURL, picoCfg.Logger)
13+ picoDb := postgres.NewDB(os.Getenv("DATABASE_URL"), logger)
1714
1815 logger.Info("fetching all posts")
1916 posts, err := picoDb.FindPosts()
+1, -4
......@@ -51,10 +51,7 @@ func findPosts(dbpool *sqlx.DB) ([]*db.Post, error) {
5151 func main() {
5252 logger := slog.Default()
5353
54- picoCfg := shared.NewConfigSite()
55- picoCfg.Logger = logger
56- picoCfg.DbURL = os.Getenv("DATABASE_URL")
57- picoDb := postgres.NewDB(picoCfg.DbURL, picoCfg.Logger)
54+ picoDb := postgres.NewDB(os.Getenv("DATABASE_URL"), logger)
5855
5956 logger.Info("fetching all posts")
6057 posts, err := findPosts(picoDb.Db)
+32, -28
......@@ -39,7 +39,7 @@ type oauth2Server struct {
3939 ResponseTypesSupported []string `json:"response_types_supported"`
4040 }
4141
42-func generateURL(cfg *shared.ConfigSite, path string, space string) string {
42+func generateURL(cfg *Config, path string, space string) string {
4343 query := ""
4444
4545 if space != "" {
......@@ -49,7 +49,24 @@ func generateURL(cfg *shared.ConfigSite, path string, space string) string {
4949 return fmt.Sprintf("%s/%s%s", cfg.Domain, path, query)
5050 }
5151
52-func wellKnownHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
52+type ApiConfig struct {
53+ Cfg *Config
54+ Dbpool db.DB
55+}
56+
57+func (hc *ApiConfig) HasPrivilegedAccess(apiToken string) bool {
58+ user, err := hc.Dbpool.FindUserByToken(apiToken)
59+ if err != nil {
60+ return false
61+ }
62+ return db.HasFeature(hc.Dbpool, user.ID, "auth")
63+}
64+
65+func (hc *ApiConfig) HasPlusOrSpace(user *db.User, space string) bool {
66+ return db.HasPlusOrSpace(hc.Dbpool, user.ID, space)
67+}
68+
69+func wellKnownHandler(apiConfig *ApiConfig) http.HandlerFunc {
5370 return func(w http.ResponseWriter, r *http.Request) {
5471 space := r.PathValue("space")
5572 if space == "" {
......@@ -81,7 +98,7 @@ type oauth2Introspection struct {
8198 Username string `json:"username"`
8299 }
83100
84-func introspectHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
101+func introspectHandler(apiConfig *ApiConfig) http.HandlerFunc {
85102 return func(w http.ResponseWriter, r *http.Request) {
86103 token := r.FormValue("token")
87104 apiConfig.Cfg.Logger.Info("introspect token", "token", token)
......@@ -115,7 +132,7 @@ func introspectHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
115132 }
116133 }
117134
118-func authorizeHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
135+func authorizeHandler(apiConfig *ApiConfig) http.HandlerFunc {
119136 return func(w http.ResponseWriter, r *http.Request) {
120137 responseType := r.URL.Query().Get("response_type")
121138 clientID := r.URL.Query().Get("client_id")
......@@ -159,7 +176,7 @@ func authorizeHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
159176 }
160177 }
161178
162-func redirectHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
179+func redirectHandler(apiConfig *ApiConfig) http.HandlerFunc {
163180 return func(w http.ResponseWriter, r *http.Request) {
164181 token := r.FormValue("token")
165182 redirectURI := r.FormValue("redirect_uri")
......@@ -195,7 +212,7 @@ type oauth2Token struct {
195212 AccessToken string `json:"access_token"`
196213 }
197214
198-func tokenHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
215+func tokenHandler(apiConfig *ApiConfig) http.HandlerFunc {
199216 return func(w http.ResponseWriter, r *http.Request) {
200217 token := r.FormValue("code")
201218 redirectURI := r.FormValue("redirect_uri")
......@@ -234,7 +251,7 @@ type sishData struct {
234251 RemoteAddress string `json:"remote_addr"`
235252 }
236253
237-func keyHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
254+func keyHandler(apiConfig *ApiConfig) http.HandlerFunc {
238255 return func(w http.ResponseWriter, r *http.Request) {
239256 var data sishData
240257
......@@ -308,7 +325,7 @@ func keyHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
308325 }
309326 }
310327
311-func userHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
328+func userHandler(apiConfig *ApiConfig) http.HandlerFunc {
312329 return func(w http.ResponseWriter, r *http.Request) {
313330 if !apiConfig.HasPrivilegedAccess(router.GetApiToken(r)) {
314331 w.WriteHeader(http.StatusForbidden)
......@@ -355,7 +372,7 @@ func userHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
355372 }
356373 }
357374
358-func rssHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
375+func rssHandler(apiConfig *ApiConfig) http.HandlerFunc {
359376 return func(w http.ResponseWriter, r *http.Request) {
360377 apiToken := r.PathValue("token")
361378 user, err := apiConfig.Dbpool.FindUserByToken(apiToken)
......@@ -388,7 +405,7 @@ func rssHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
388405 }
389406 }
390407
391-func pubkeysHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
408+func pubkeysHandler(apiConfig *ApiConfig) http.HandlerFunc {
392409 return func(w http.ResponseWriter, r *http.Request) {
393410 userName := r.PathValue("user")
394411 user, err := apiConfig.Dbpool.FindUserByName(userName)
......@@ -457,7 +474,7 @@ type OrderEvent struct {
457474
458475 // Status code must be 200 or else lemonsqueezy will keep retrying
459476 // https://docs.lemonsqueezy.com/help/webhooks
460-func paymentWebhookHandler(apiConfig *router.ApiConfig) http.HandlerFunc {
477+func paymentWebhookHandler(apiConfig *ApiConfig) http.HandlerFunc {
461478 return func(w http.ResponseWriter, r *http.Request) {
462479 dbpool := apiConfig.Dbpool
463480 logger := apiConfig.Cfg.Logger
......@@ -863,7 +880,7 @@ func tunsEventLogDrainSub(ctx context.Context, dbpool db.DB, logger *slog.Logger
863880 }
864881 }
865882
866-func authMux(apiConfig *router.ApiConfig) *http.ServeMux {
883+func authMux(apiConfig *ApiConfig) *http.ServeMux {
867884 serverRoot, err := fs.Sub(embedFS, "public")
868885 if err != nil {
869886 panic(err)
......@@ -902,18 +919,7 @@ func authMux(apiConfig *router.ApiConfig) *http.ServeMux {
902919 }
903920
904921 func StartApiServer() {
905- debug := shared.GetEnv("AUTH_DEBUG", "0")
906- withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
907-
908- cfg := &shared.ConfigSite{
909- DbURL: shared.GetEnv("DATABASE_URL", ""),
910- Debug: debug == "1",
911- Issuer: shared.GetEnv("AUTH_ISSUER", "pico.sh"),
912- Domain: shared.GetEnv("AUTH_DOMAIN", "http://0.0.0.0:3000"),
913- Port: shared.GetEnv("AUTH_WEB_PORT", "3000"),
914- Secret: shared.GetEnv("PICO_SECRET", ""),
915- SecretWebhook: shared.GetEnv("PICO_SECRET_WEBHOOK", ""),
916- }
922+ cfg := NewConfig()
917923
918924 if cfg.SecretWebhook == "" {
919925 panic("must provide PICO_SECRET_WEBHOOK environment variable")
......@@ -923,9 +929,7 @@ func StartApiServer() {
923929 panic("must provide PICO_SECRET environment variable")
924930 }
925931
926- logger := shared.CreateLogger("auth-web", withPipe)
927-
928- cfg.Logger = logger
932+ logger := cfg.Logger
929933
930934 db := postgres.NewDB(cfg.DbURL, logger)
931935 defer func() {
......@@ -942,7 +946,7 @@ func StartApiServer() {
942946 // monthly analytics aggregation (checks daily, runs if previous month is missing)
943947 go analyticsAggregationCron(ctx, db, logger)
944948
945- apiConfig := &router.ApiConfig{
949+ apiConfig := &ApiConfig{
946950 Cfg: cfg,
947951 Dbpool: db,
948952 }
+17, -11
......@@ -2,6 +2,7 @@ package auth
22
33 import (
44 "bytes"
5+ "database/sql"
56 "encoding/json"
67 "errors"
78 "fmt"
......@@ -237,18 +238,19 @@ func (a *AuthDb) FindUserByToken(token string) (*db.User, error) {
237238 return &db.User{ID: testUserID, Name: testUsername}, nil
238239 }
239240
240-func (a *AuthDb) HasFeatureByUser(userID string, feature string) bool {
241- return true
242-}
243-
244241 func (a *AuthDb) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
245242 return []*db.PublicKey{{ID: "1", UserID: user.ID, Name: "my-key", Key: "nice-pubkey", CreatedAt: &time.Time{}}}, nil
246243 }
247244
245+// FindFeature gives pico+ a fixed expiry for the feed snapshots, and every
246+// other feature an active flag.
248247 func (a *AuthDb) FindFeature(userID string, feature string) (*db.FeatureFlag, error) {
249248 now := time.Date(2021, 8, 15, 14, 30, 45, 100, time.UTC)
250- oneDayWarning := now.AddDate(0, 0, 1)
251- return &db.FeatureFlag{ID: "2", UserID: userID, Name: "plus", ExpiresAt: &oneDayWarning, CreatedAt: &now}, nil
249+ expiresAt := now.AddDate(0, 0, 1)
250+ if feature != "plus" {
251+ expiresAt = time.Now().AddDate(0, 0, 1)
252+ }
253+ return &db.FeatureFlag{ID: "2", UserID: userID, Name: feature, ExpiresAt: &expiresAt, CreatedAt: &now}, nil
252254 }
253255
254256 func (a *AuthDb) InsertPost(post *db.Post) (*db.Post, error) {
......@@ -277,9 +279,9 @@ func mkpath(path string) string {
277279 return fmt.Sprintf("https://auth.pico.test%s", path)
278280 }
279281
280-func setupTest() *router.ApiConfig {
282+func setupTest() *ApiConfig {
281283 logger := shared.CreateLogger("auth-test", false)
282- cfg := &shared.ConfigSite{
284+ cfg := &Config{
283285 Issuer: "auth.pico.test",
284286 Domain: "http://0.0.0.0:3000",
285287 Port: "3000",
......@@ -288,7 +290,7 @@ func setupTest() *router.ApiConfig {
288290 }
289291 cfg.Logger = logger
290292 db := NewAuthDb(cfg.Logger)
291- apiConfig := &router.ApiConfig{
293+ apiConfig := &ApiConfig{
292294 Cfg: cfg,
293295 Dbpool: db,
294296 }
......@@ -323,8 +325,12 @@ type stubDBWithFeatures struct {
323325 features map[string]bool
324326 }
325327
326-func (s *stubDBWithFeatures) HasFeatureByUser(userID string, feature string) bool {
327- return s.features[feature]
328+func (s *stubDBWithFeatures) FindFeature(userID string, feature string) (*db.FeatureFlag, error) {
329+ if !s.features[feature] {
330+ return nil, sql.ErrNoRows
331+ }
332+ expiresAt := time.Now().AddDate(0, 0, 1)
333+ return &db.FeatureFlag{UserID: userID, Name: feature, ExpiresAt: &expiresAt}, nil
328334 }
329335
330336 func newStubDBWithFeatures(features ...string) *stubDBWithFeatures {
+35, -0
......@@ -0,0 +1,35 @@
1+package auth
2+
3+import (
4+ "log/slog"
5+ "strings"
6+
7+ "github.com/picosh/pico/pkg/shared"
8+)
9+
10+type Config struct {
11+ Debug bool
12+ DbURL string
13+ Issuer string
14+ Domain string
15+ Port string
16+ Secret string
17+ SecretWebhook string
18+ Logger *slog.Logger
19+}
20+
21+func NewConfig() *Config {
22+ debug := shared.GetEnv("AUTH_DEBUG", "0")
23+ withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
24+
25+ return &Config{
26+ DbURL: shared.GetEnv("DATABASE_URL", ""),
27+ Debug: debug == "1",
28+ Issuer: shared.GetEnv("AUTH_ISSUER", "pico.sh"),
29+ Domain: shared.GetEnv("AUTH_DOMAIN", "http://0.0.0.0:3000"),
30+ Port: shared.GetEnv("AUTH_WEB_PORT", "3000"),
31+ Secret: shared.GetEnv("PICO_SECRET", ""),
32+ SecretWebhook: shared.GetEnv("PICO_SECRET_WEBHOOK", ""),
33+ Logger: shared.CreateLogger("auth-web", withPipe),
34+ }
35+}
+32, -48
......@@ -12,10 +12,10 @@ import (
1212 "github.com/prometheus/client_golang/prometheus/promhttp"
1313 )
1414
15-func keepAliveHandler(w http.ResponseWriter, r *http.Request) {
16- dbpool := router.GetDB(r)
17- logger := router.GetLogger(r)
18- postID, _ := url.PathUnescape(router.GetField(r, 0))
15+func (web *webRouter) keepAliveHandler(w http.ResponseWriter, r *http.Request) {
16+ dbpool := web.db
17+ logger := web.cfg.Logger
18+ postID, _ := url.PathUnescape(r.PathValue("id"))
1919
2020 post, err := dbpool.FindPost(postID)
2121 if err != nil {
......@@ -60,10 +60,10 @@ func keepAliveHandler(w http.ResponseWriter, r *http.Request) {
6060 }
6161 }
6262
63-func unsubHandler(w http.ResponseWriter, r *http.Request) {
64- dbpool := router.GetDB(r)
65- logger := router.GetLogger(r)
66- postID, _ := url.PathUnescape(router.GetField(r, 0))
63+func (web *webRouter) unsubHandler(w http.ResponseWriter, r *http.Request) {
64+ dbpool := web.db
65+ logger := web.cfg.Logger
66+ postID, _ := url.PathUnescape(r.PathValue("id"))
6767
6868 post, err := dbpool.FindPost(postID)
6969 if err != nil {
......@@ -97,37 +97,34 @@ func unsubHandler(w http.ResponseWriter, r *http.Request) {
9797 }
9898 }
9999
100-func createMainRoutes(staticRoutes []router.Route) []router.Route {
101- routes := []router.Route{
102- router.NewRoute("GET", "/", router.CreatePageHandler("html/marketing.page.tmpl")),
103- router.NewRoute("GET", "/keep-alive/(.+)", keepAliveHandler),
104- router.NewRoute("GET", "/unsub/(.+)", unsubHandler),
105- router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
106- }
107-
108- routes = append(
109- routes,
110- staticRoutes...,
111- )
112-
113- return routes
100+type webRouter struct {
101+ cfg *Config
102+ db DB
114103 }
115104
116-func createStaticRoutes() []router.Route {
117- return []router.Route{
118- router.NewRoute("GET", "/main.css", router.ServeFile("main.css", "text/css")),
119- router.NewRoute("GET", "/card.png", router.ServeFile("card.png", "image/png")),
120- router.NewRoute("GET", "/favicon-16x16.png", router.ServeFile("favicon-16x16.png", "image/png")),
121- router.NewRoute("GET", "/favicon-32x32.png", router.ServeFile("favicon-32x32.png", "image/png")),
122- router.NewRoute("GET", "/apple-touch-icon.png", router.ServeFile("apple-touch-icon.png", "image/png")),
123- router.NewRoute("GET", "/favicon.ico", router.ServeFile("favicon.ico", "image/x-icon")),
124- router.NewRoute("GET", "/robots.txt", router.ServeFile("robots.txt", "text/plain")),
105+func (web *webRouter) mux() *http.ServeMux {
106+ cfg := web.cfg
107+ files := cfg.Static()
108+ mux := http.NewServeMux()
109+ mux.HandleFunc("GET /{$}", files.Page("html/marketing.page.tmpl"))
110+ mux.HandleFunc("GET /keep-alive/{id...}", web.keepAliveHandler)
111+ mux.HandleFunc("GET /unsub/{id...}", web.unsubHandler)
112+ mux.Handle("GET /_metrics", promhttp.Handler())
113+ mux.HandleFunc("GET /main.css", files.File("main.css", "text/css"))
114+ mux.HandleFunc("GET /card.png", files.File("card.png", "image/png"))
115+ mux.HandleFunc("GET /favicon-16x16.png", files.File("favicon-16x16.png", "image/png"))
116+ mux.HandleFunc("GET /apple-touch-icon.png", files.File("apple-touch-icon.png", "image/png"))
117+ mux.HandleFunc("GET /favicon.ico", files.File("favicon.ico", "image/x-icon"))
118+ mux.HandleFunc("GET /robots.txt", files.File("robots.txt", "text/plain"))
119+ if cfg.Debug {
120+ router.CreatePProfRoutesMux(mux)
125121 }
122+ return mux
126123 }
127124
128125 func StartApiServer() {
129- cfg := NewConfigSite("feeds-web")
130- db := postgres.NewDB(cfg.DbURL, cfg.Logger)
126+ cfg := NewConfig("feeds-web")
127+ db := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
131128 defer func() {
132129 _ = db.Close()
133130 }()
......@@ -137,20 +134,7 @@ func StartApiServer() {
137134 fetcher := NewFetcher(db, cfg)
138135 go fetcher.Loop()
139136
140- staticRoutes := createStaticRoutes()
141-
142- if cfg.Debug {
143- staticRoutes = router.CreatePProfRoutes(staticRoutes)
144- }
145-
146- mainRoutes := createMainRoutes(staticRoutes)
147-
148- apiConfig := &router.ApiConfig{
149- Cfg: cfg,
150- Dbpool: db,
151- }
152- handler := router.CreateServe(mainRoutes, []router.Route{}, apiConfig)
153- router := http.HandlerFunc(handler)
137+ web := &webRouter{cfg: cfg, db: db}
154138
155139 portStr := fmt.Sprintf(":%s", cfg.Port)
156140 logger.Info(
......@@ -159,5 +143,5 @@ func StartApiServer() {
159143 "domain", cfg.Domain,
160144 )
161145
162- logger.Error(http.ListenAndServe(portStr, router).Error())
146+ logger.Error(http.ListenAndServe(portStr, web.mux()).Error())
163147 }
+36, -0
......@@ -0,0 +1,36 @@
1+package feeds
2+
3+import (
4+ "log/slog"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/picosh/pico/pkg/db"
10+ "github.com/picosh/pico/pkg/db/stub"
11+)
12+
13+func TestRoutes(t *testing.T) {
14+ t.Chdir("../../..")
15+ cfg := NewConfig("feeds-test")
16+ cfg.Logger = slog.New(slog.DiscardHandler)
17+ web := &webRouter{cfg: cfg, db: stub.NewPostsDB(cfg.Logger, &db.User{ID: "user-1"})}
18+ mux := web.mux()
19+
20+ for _, tt := range []struct {
21+ url string
22+ status int
23+ }{
24+ {url: "/", status: http.StatusOK},
25+ {url: "/main.css", status: http.StatusOK},
26+ {url: "/keep-alive/missing", status: http.StatusNotFound},
27+ {url: "/unsub/missing", status: http.StatusNotFound},
28+ {url: "/missing", status: http.StatusNotFound},
29+ } {
30+ res := httptest.NewRecorder()
31+ mux.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "https://"+cfg.Domain+tt.url, nil))
32+ if res.Code != tt.status {
33+ t.Errorf("%s = %d, want %d", tt.url, res.Code, tt.status)
34+ }
35+ }
36+}
+1, -1
......@@ -11,7 +11,7 @@ import (
1111 "github.com/picosh/pico/pkg/shared"
1212 )
1313
14-func Middleware(dbpool db.DB, cfg *shared.ConfigSite) pssh.SSHServerMiddleware {
14+func Middleware(dbpool DB, cfg *Config) pssh.SSHServerMiddleware {
1515 return func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
1616 return func(sesh *pssh.SSHServerConnSession) error {
1717 args := sesh.Command()
+36, -3
......@@ -1,12 +1,31 @@
11 package feeds
22
33 import (
4+ "html/template"
5+ "log/slog"
6+ "path/filepath"
47 "strings"
58
69 "github.com/picosh/pico/pkg/shared"
10+ "github.com/picosh/pico/pkg/shared/router"
711 )
812
9-func NewConfigSite(service string) *shared.ConfigSite {
13+// staticDir holds feeds' templates and public files.
14+const staticDir = "pkg/apps/feeds"
15+
16+type Config struct {
17+ Debug bool
18+ SendgridKey string
19+ Domain string
20+ Port string
21+ Protocol string
22+ DbURL string
23+ Space string
24+ AllowedExt []string
25+ Logger *slog.Logger
26+}
27+
28+func NewConfig(service string) *Config {
1029 debug := shared.GetEnv("FEEDS_DEBUG", "0")
1130 domain := shared.GetEnv("FEEDS_DOMAIN", "feeds.pico.sh")
1231 port := shared.GetEnv("FEEDS_WEB_PORT", "3000")
......@@ -15,7 +34,7 @@ func NewConfigSite(service string) *shared.ConfigSite {
1534 sendgridKey := shared.GetEnv("SENDGRID_API_KEY", "")
1635 withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
1736
18- return &shared.ConfigSite{
37+ return &Config{
1938 Debug: debug == "1",
2039 SendgridKey: sendgridKey,
2140 Domain: domain,
......@@ -24,7 +43,21 @@ func NewConfigSite(service string) *shared.ConfigSite {
2443 DbURL: dbURL,
2544 Space: "feeds",
2645 AllowedExt: []string{".txt", ".lxt"},
27- HiddenPosts: []string{},
2846 Logger: shared.CreateLogger(service, withPipe),
2947 }
3048 }
49+
50+func (c *Config) StaticPath(fname string) string {
51+ return filepath.Join(staticDir, fname)
52+}
53+
54+func (c *Config) Static() router.Static {
55+ return router.Static{
56+ Dir: staticDir,
57+ Logger: c.Logger,
58+ Site: shared.SitePageData{
59+ Domain: template.URL(c.Domain),
60+ HomeURL: template.URL("//" + c.Domain),
61+ },
62+ }
63+}
+3, -3
......@@ -128,14 +128,14 @@ func isValidItem(logger *slog.Logger, item *gofeed.Item, feedItems []*db.FeedIte
128128 }
129129
130130 type Fetcher struct {
131- cfg *shared.ConfigSite
132- db db.DB
131+ cfg *Config
132+ db DB
133133 auth sasl.Client
134134 gron *gronx.Gronx
135135 host string
136136 }
137137
138-func NewFetcher(dbpool db.DB, cfg *shared.ConfigSite) *Fetcher {
138+func NewFetcher(dbpool DB, cfg *Config) *Fetcher {
139139 host := os.Getenv("PICO_SMTP_HOST")
140140 smtPass := os.Getenv("PICO_SMTP_PASS")
141141 emailLogin := os.Getenv("PICO_SMTP_USER")
+102, -0
......@@ -0,0 +1,102 @@
1+package feeds
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+
7+ "github.com/picosh/pico/pkg/db"
8+ "github.com/picosh/pico/pkg/db/postgres"
9+)
10+
11+// DB is the storage feeds uses.
12+type DB interface {
13+ db.AuthDB
14+ db.PostDB
15+ FindUsersWithPost(space string) ([]*db.User, error)
16+ InsertFeedItems(postID string, items []*db.FeedItem) error
17+ FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error)
18+}
19+
20+// PsqlDB is feeds's postgres storage: the queries apps share, and the ones
21+// only feeds makes.
22+type PsqlDB struct {
23+ *postgres.PsqlDB
24+}
25+
26+var _ DB = (*PsqlDB)(nil)
27+
28+func NewDB(base *postgres.PsqlDB) *PsqlDB {
29+ return &PsqlDB{PsqlDB: base}
30+}
31+
32+func (me *PsqlDB) FindUsersWithPost(space string) ([]*db.User, error) {
33+ var users []*db.User
34+ rs, err := me.Db.Queryx(
35+ `SELECT u.id, u.name, u.created_at
36+ FROM app_users u
37+ INNER JOIN posts ON u.id=posts.user_id
38+ WHERE cur_space='feeds'
39+ GROUP BY u.id, u.name, u.created_at
40+ ORDER BY name ASC`,
41+ )
42+ if err != nil {
43+ return users, err
44+ }
45+ defer func() { _ = rs.Close() }()
46+ for rs.Next() {
47+ var name sql.NullString
48+ user := &db.User{}
49+ err := rs.Scan(
50+ &user.ID,
51+ &name,
52+ &user.CreatedAt,
53+ )
54+ if err != nil {
55+ return users, err
56+ }
57+ user.Name = name.String
58+
59+ users = append(users, user)
60+ }
61+ if rs.Err() != nil {
62+ return users, rs.Err()
63+ }
64+ return users, nil
65+}
66+
67+func (me *PsqlDB) InsertFeedItems(postID string, items []*db.FeedItem) error {
68+ tx, err := me.Db.Beginx()
69+ if err != nil {
70+ return err
71+ }
72+ defer func() {
73+ _ = tx.Rollback()
74+ }()
75+
76+ for _, item := range items {
77+ _, err := tx.Exec(
78+ `INSERT INTO feed_items (post_id, guid, data) VALUES ($1, $2, $3) RETURNING id;`,
79+ item.PostID,
80+ item.GUID,
81+ item.Data,
82+ )
83+ if err != nil {
84+ return fmt.Errorf(
85+ "post id:%s, link:%s, guid:%s, err:%w",
86+ item.PostID, item.Data.Link, item.GUID, err,
87+ )
88+ }
89+ }
90+
91+ err = tx.Commit()
92+ return err
93+}
94+
95+func (me *PsqlDB) FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error) {
96+ var items []*db.FeedItem
97+ err := me.Db.Select(&items, `SELECT * FROM feed_items WHERE post_id=$1`, postID)
98+ if err != nil {
99+ return nil, err
100+ }
101+ return items, nil
102+}
+119, -0
......@@ -0,0 +1,119 @@
1+package feeds
2+
3+import (
4+ "log/slog"
5+ "testing"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db"
9+ "github.com/picosh/pico/pkg/db/postgres"
10+ "github.com/picosh/pico/pkg/db/postgres/pgtest"
11+)
12+
13+var testDB *PsqlDB
14+
15+func TestMain(m *testing.M) {
16+ pgtest.Main(m)
17+}
18+
19+func cleanupTestData(t *testing.T) {
20+ t.Helper()
21+ if testDB == nil {
22+ testDB = NewDB(postgres.NewDB(pgtest.URL(t), slog.New(slog.DiscardHandler)))
23+ }
24+ pgtest.Clean(t, testDB.Db)
25+}
26+
27+func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
28+ t.Helper()
29+ return pgtest.InsertPost(t, testDB, post)
30+}
31+
32+func TestFindUsersWithPost(t *testing.T) {
33+ cleanupTestData(t)
34+
35+ user, _ := testDB.RegisterUser("feedspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedspostowner", "comment", "")
36+
37+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
38+
39+ users, err := testDB.FindUsersWithPost("feeds")
40+ if err != nil {
41+ t.Fatalf("FindUsersWithPost failed: %v", err)
42+ }
43+ if len(users) != 1 {
44+ t.Errorf("expected 1 user, got %d", len(users))
45+ }
46+}
47+
48+func TestInsertFeedItems(t *testing.T) {
49+ cleanupTestData(t)
50+
51+ user, _ := testDB.RegisterUser("feeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feeditemsowner", "comment", "")
52+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
53+
54+ items := []*db.FeedItem{
55+ {PostID: post.ID, GUID: "guid-1", Data: db.FeedItemData{Title: "Item 1", Link: "http://example.com/1"}},
56+ {PostID: post.ID, GUID: "guid-2", Data: db.FeedItemData{Title: "Item 2", Link: "http://example.com/2"}},
57+ }
58+
59+ err := testDB.InsertFeedItems(post.ID, items)
60+ if err != nil {
61+ t.Fatalf("InsertFeedItems failed: %v", err)
62+ }
63+}
64+
65+func TestFindFeedItemsByPostID(t *testing.T) {
66+ cleanupTestData(t)
67+
68+ user, _ := testDB.RegisterUser("findfeeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeeditemsowner", "comment", "")
69+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "findfeed.txt", Slug: "findfeed", Title: "Find Feed", Space: "feeds"})
70+
71+ items := []*db.FeedItem{
72+ {PostID: post.ID, GUID: "find-guid-1", Data: db.FeedItemData{Title: "Find Item 1"}},
73+ }
74+ _ = testDB.InsertFeedItems(post.ID, items)
75+
76+ found, err := testDB.FindFeedItemsByPostID(post.ID)
77+ if err != nil {
78+ t.Fatalf("FindFeedItemsByPostID failed: %v", err)
79+ }
80+ if len(found) != 1 {
81+ t.Errorf("expected 1 feed item, got %d", len(found))
82+ }
83+}
84+
85+// ============ Projects Tests ============
86+
87+func TestFeedItemData_JSONBRoundtrip(t *testing.T) {
88+ cleanupTestData(t)
89+
90+ user, _ := testDB.RegisterUser("feedjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedjsonbowner", "comment", "")
91+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedjsonb.txt", Slug: "feedjsonb", Title: "Feed JSONB", Space: "feeds"})
92+
93+ now := time.Now().Truncate(time.Second)
94+ items := []*db.FeedItem{
95+ {
96+ PostID: post.ID,
97+ GUID: "jsonb-guid",
98+ Data: db.FeedItemData{
99+ Title: "JSONB Item",
100+ Description: "Description",
101+ Content: "Content",
102+ Link: "http://example.com",
103+ PublishedAt: &now,
104+ },
105+ },
106+ }
107+ _ = testDB.InsertFeedItems(post.ID, items)
108+
109+ found, err := testDB.FindFeedItemsByPostID(post.ID)
110+ if err != nil {
111+ t.Fatalf("FindFeedItemsByPostID failed: %v", err)
112+ }
113+ if len(found) != 1 {
114+ t.Fatalf("expected 1 item, got %d", len(found))
115+ }
116+ if found[0].Data.Title != "JSONB Item" {
117+ t.Errorf("expected title 'JSONB Item', got '%s'", found[0].Data.Title)
118+ }
119+}
+0, -85
......@@ -1,85 +0,0 @@
1-package feeds
2-
3-import (
4- "errors"
5- "fmt"
6- "net/url"
7- "strings"
8-
9- "github.com/adhocore/gronx"
10- "github.com/picosh/pico/pkg/db"
11- "github.com/picosh/pico/pkg/filehandlers"
12- "github.com/picosh/pico/pkg/pssh"
13- "github.com/picosh/pico/pkg/shared"
14-)
15-
16-type FeedHooks struct {
17- Cfg *shared.ConfigSite
18- Db db.DB
19-}
20-
21-func (p *FeedHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) (bool, error) {
22- if !shared.IsTextFile(string(data.Text)) {
23- err := fmt.Errorf(
24- "WARNING: (%s) invalid file must be plain text (utf-8), skipping",
25- data.Filename,
26- )
27- return false, err
28- }
29-
30- if !shared.IsExtAllowed(data.Filename, p.Cfg.AllowedExt) {
31- extStr := strings.Join(p.Cfg.AllowedExt, ",")
32- err := fmt.Errorf(
33- "WARNING: (%s) invalid file, format must be (%s), skipping",
34- data.Filename,
35- extStr,
36- )
37- return false, err
38- }
39-
40- // SFTP validates a file when it is opened, before its contents
41- // arrive, so there is nothing to parse yet.
42- if data.Text == "" {
43- return true, nil
44- }
45-
46- parsed := shared.ListParseText(string(data.Text))
47- if parsed.Email == "" {
48- return false, fmt.Errorf("ERROR: no email variable detected for %s, check the format of your file, skipping", data.Filename)
49- }
50-
51- if parsed.DigestInterval != "" {
52- return false, fmt.Errorf("ERROR: `digest_interval` is deprecated; use `cron`: https://pico.sh/feeds#cron")
53- }
54-
55- if parsed.Cron != "" {
56- if !gronx.IsValid(parsed.Cron) {
57- return false, fmt.Errorf("ERROR: `cron` is invalid, reference: https://github.com/adhocore/gronx?tab=readme-ov-file#cron-expression")
58- }
59- }
60-
61- var allErr error
62- for _, txt := range parsed.Items {
63- u := ""
64- if txt.IsText {
65- u = txt.Value
66- } else if txt.IsURL {
67- u = string(txt.URL)
68- }
69-
70- _, err := url.Parse(u)
71- if err != nil {
72- allErr = errors.Join(allErr, fmt.Errorf("%s: %w", u, err))
73- continue
74- }
75- }
76- if allErr != nil {
77- return false, fmt.Errorf("ERROR: some urls provided were invalid check the format of your file, skipping: %w", allErr)
78- }
79-
80- return true, nil
81-}
82-
83-func (p *FeedHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
84- return nil
85-}
+3, -12
......@@ -7,7 +7,6 @@ import (
77 "syscall"
88
99 "github.com/picosh/pico/pkg/db/postgres"
10- "github.com/picosh/pico/pkg/filehandlers"
1110 "github.com/picosh/pico/pkg/pssh"
1211 "github.com/picosh/pico/pkg/send/auth"
1312 "github.com/picosh/pico/pkg/send/list"
......@@ -24,26 +23,18 @@ func StartSshServer() {
2423 host := shared.GetEnv("FEEDS_HOST", "0.0.0.0")
2524 port := shared.GetEnv("FEEDS_SSH_PORT", "2222")
2625 promPort := shared.GetEnv("FEEDS_PROM_PORT", "9222")
27- cfg := NewConfigSite(appName)
26+ cfg := NewConfig(appName)
2827 logger := cfg.Logger
2928
3029 ctx, cancel := context.WithCancel(context.Background())
3130 defer cancel()
3231
33- dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
32+ dbh := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
3433 defer func() {
3534 _ = dbh.Close()
3635 }()
3736
38- hooks := &FeedHooks{
39- Cfg: cfg,
40- Db: dbh,
41- }
42-
43- fileMap := map[string]filehandlers.ReadWriteHandler{
44- "fallback": filehandlers.NewScpPostHandler(dbh, cfg, hooks),
45- }
46- handler := filehandlers.NewFileHandlerRouter(cfg, dbh, fileMap)
37+ handler := NewUploadHandler(cfg, dbh)
4738
4839 sshAuth := shared.NewSshAuthHandler(dbh, logger, "feeds")
4940
+171, -0
......@@ -0,0 +1,171 @@
1+package feeds
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "log/slog"
7+ "net/url"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+
12+ "github.com/adhocore/gronx"
13+ "github.com/picosh/pico/pkg/db"
14+ "github.com/picosh/pico/pkg/postfs"
15+ "github.com/picosh/pico/pkg/pssh"
16+ sendutils "github.com/picosh/pico/pkg/send/utils"
17+ "github.com/picosh/pico/pkg/shared"
18+)
19+
20+// UploadHandler stores every file as a feed.
21+type UploadHandler struct {
22+ Cfg *Config
23+ DB DB
24+}
25+
26+var (
27+ _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
28+ _ sendutils.WriteValidator = (*UploadHandler)(nil)
29+ _ sendutils.FileRenamer = (*UploadHandler)(nil)
30+)
31+
32+func NewUploadHandler(cfg *Config, dbpool DB) *UploadHandler {
33+ return &UploadHandler{Cfg: cfg, DB: dbpool}
34+}
35+
36+func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
37+ return pssh.GetLogger(s)
38+}
39+
40+func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error {
41+ user, err := postfs.SessionUser(s)
42+ if err != nil {
43+ return err
44+ }
45+ h.GetLogger(s).Info("attempting to upload files", "user", user.Name, "space", h.Cfg.Space)
46+ return nil
47+}
48+
49+func (h *UploadHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
50+ return postfs.ListPosts(s, fpath, isDir, recursive, []string{h.Cfg.Space}, h.DB)
51+}
52+
53+func (h *UploadHandler) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
54+ return postfs.ReadPost(s, h.DB, h.Cfg.Space, entry)
55+}
56+
57+func (h *UploadHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
58+ return postfs.DeletePost(s, h.DB, h.Cfg.Space, entry)
59+}
60+
61+// ValidateWrite checks a feed's name before its contents arrive.
62+func (h *UploadHandler) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
63+ if _, err := postfs.SessionUser(s); err != nil {
64+ return err
65+ }
66+ if entry.Mode.IsDir() {
67+ return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
68+ }
69+ return h.validate(postfs.NewPost(filepath.Base(entry.Filepath), nil))
70+}
71+
72+func (h *UploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
73+ user, err := postfs.SessionUser(s)
74+ if err != nil {
75+ return "", err
76+ }
77+ logger := h.GetLogger(s).With("filename", filepath.Base(entry.Filepath))
78+
79+ post, err := postfs.PostFromEntry(entry, user, h.Cfg.Space)
80+ if err != nil {
81+ return "", err
82+ }
83+ if err := h.validate(post); err != nil {
84+ logger.Error("file failed validation", "err", err.Error())
85+ return "", err
86+ }
87+
88+ cur, err := postfs.LookupPost(h.DB, user.ID, h.Cfg.Space, post.Filename)
89+ if err != nil {
90+ return "", err
91+ }
92+ // The feed's digest state lives in its data.
93+ if cur != nil {
94+ post.Data, post.PublishAt = cur.Data, cur.PublishAt
95+ }
96+
97+ _, changed, err := postfs.SavePost(h.DB, cur, post)
98+ if err != nil {
99+ logger.Error("feed could not be saved", "err", err.Error())
100+ return "", err
101+ }
102+ logger.Info("saved feed", "new", cur == nil, "changed", changed)
103+ return shared.NewCreateURL(h.Cfg.Protocol, h.Cfg.Domain).FullPostURL(user.Name, post.Slug), nil
104+}
105+
106+// Rename moves a feed to a new filename in place, keeping its digest state.
107+func (h *UploadHandler) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
108+ return postfs.RenamePost(s, h.DB, h.Cfg.Space, from, to, func(cur, next *db.Post) ([]string, error) {
109+ return nil, h.validate(next)
110+ })
111+}
112+
113+func (h *UploadHandler) validate(post *db.Post) error {
114+ if !shared.IsTextFile(post.Text) {
115+ return fmt.Errorf(
116+ "WARNING: (%s) invalid file must be plain text (utf-8), skipping",
117+ post.Filename,
118+ )
119+ }
120+
121+ if !shared.IsExtAllowed(post.Filename, h.Cfg.AllowedExt) {
122+ extStr := strings.Join(h.Cfg.AllowedExt, ",")
123+ return fmt.Errorf(
124+ "WARNING: (%s) invalid file, format must be (%s), skipping",
125+ post.Filename,
126+ extStr,
127+ )
128+ }
129+
130+ // SFTP validates a file when it is opened, before its contents
131+ // arrive, so there is nothing to parse yet.
132+ if post.Text == "" {
133+ return nil
134+ }
135+
136+ parsed := shared.ListParseText(post.Text)
137+ if parsed.Email == "" {
138+ return fmt.Errorf("ERROR: no email variable detected for %s, check the format of your file, skipping", post.Filename)
139+ }
140+
141+ if parsed.DigestInterval != "" {
142+ return fmt.Errorf("ERROR: `digest_interval` is deprecated; use `cron`: https://pico.sh/feeds#cron")
143+ }
144+
145+ if parsed.Cron != "" {
146+ if !gronx.IsValid(parsed.Cron) {
147+ return fmt.Errorf("ERROR: `cron` is invalid, reference: https://github.com/adhocore/gronx?tab=readme-ov-file#cron-expression")
148+ }
149+ }
150+
151+ var allErr error
152+ for _, txt := range parsed.Items {
153+ u := ""
154+ if txt.IsText {
155+ u = txt.Value
156+ } else if txt.IsURL {
157+ u = string(txt.URL)
158+ }
159+
160+ _, err := url.Parse(u)
161+ if err != nil {
162+ allErr = errors.Join(allErr, fmt.Errorf("%s: %w", u, err))
163+ continue
164+ }
165+ }
166+ if allErr != nil {
167+ return fmt.Errorf("ERROR: some urls provided were invalid check the format of your file, skipping: %w", allErr)
168+ }
169+
170+ return nil
171+}
+63, -0
......@@ -0,0 +1,63 @@
1+package feeds
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/picosh/pico/pkg/db"
11+ "github.com/picosh/pico/pkg/db/stub"
12+ "github.com/picosh/pico/pkg/pssh"
13+ sendutils "github.com/picosh/pico/pkg/send/utils"
14+ "golang.org/x/crypto/ssh"
15+)
16+
17+func TestWriteKeepsDigestState(t *testing.T) {
18+ logger := slog.New(slog.DiscardHandler)
19+ session := &pssh.SSHServerConnSession{
20+ SSHServerConn: &pssh.SSHServerConn{
21+ Conn: &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
22+ Logger: logger,
23+ },
24+ Ctx: context.Background(),
25+ }
26+ user := &db.User{ID: "user-1", Name: "tester"}
27+ pssh.SetUser(session, user)
28+ posts := stub.NewPostsDB(logger, user)
29+ cfg := NewConfig("feeds-test")
30+ h := NewUploadHandler(cfg, posts)
31+ write := func(text string, mtime int64) {
32+ t.Helper()
33+ _, err := h.Write(session, &sendutils.FileEntry{Filepath: "/news.txt", Reader: strings.NewReader(text), Mtime: mtime})
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ }
38+
39+ write("=: email me@pico.test\n=> https://pico.sh/rss.xml\n", 100)
40+ post, err := posts.FindPostWithFilename("news.txt", user.ID, cfg.Space)
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ digest := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
45+ post.Data = db.PostData{LastDigest: &digest, Attempts: 2}
46+ if _, err := posts.UpdatePost(post); err != nil {
47+ t.Fatal(err)
48+ }
49+
50+ write("=: email me@pico.test\n=> https://blog.pico.sh/rss.xml\n", 200)
51+ got, err := posts.FindPostWithFilename("news.txt", user.ID, cfg.Space)
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ if !strings.Contains(got.Text, "blog.pico.sh") || got.Data.LastDigest == nil || !got.Data.LastDigest.Equal(digest) || got.Data.Attempts != 2 {
56+ t.Errorf("feed = %q with data %+v, want the new text and the old digest state", got.Text, got.Data)
57+ }
58+
59+ _, err = h.Write(session, &sendutils.FileEntry{Filepath: "/news.txt", Reader: strings.NewReader("=> https://pico.sh/rss.xml\n")})
60+ if err == nil || !strings.Contains(err.Error(), "no email") {
61+ t.Errorf("feed without an email: %v", err)
62+ }
63+}
+86, -123
......@@ -5,7 +5,6 @@ import (
55 "html/template"
66 "net/http"
77 "net/url"
8- "os"
98 "time"
109
1110 "github.com/picosh/pico/pkg/db"
......@@ -72,12 +71,12 @@ type HeaderTxt struct {
7271 HasLinks bool
7372 }
7473
75-func blogHandler(w http.ResponseWriter, r *http.Request) {
74+func (web *webRouter) blogHandler(w http.ResponseWriter, r *http.Request) {
7675 username := router.GetUsernameFromRequest(r)
77- dbpool := router.GetDB(r)
78- blogger := router.GetLogger(r)
76+ dbpool := web.db
77+ blogger := web.cfg.Logger
7978 logger := blogger.With("user", username)
80- cfg := router.GetCfg(r)
79+ cfg := web.cfg
8180
8281 user, err := dbpool.FindUserByName(username)
8382 if err != nil {
......@@ -96,7 +95,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
9695
9796 posts := pager.Data
9897
99- ts, err := router.RenderTemplate(cfg, []string{
98+ ts, err := router.RenderTemplate(staticDir, []string{
10099 cfg.StaticPath("html/blog.page.tmpl"),
101100 })
102101
......@@ -111,12 +110,12 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
111110 Bio: "",
112111 }
113112
114- curl := shared.CreateURLFromRequest(cfg, r)
113+ curl := shared.CreateURLFromRequest(cfg.Protocol, cfg.Domain, r)
115114 postCollection := make([]PostItemData, 0, len(posts))
116115 for _, post := range posts {
117116 p := PostItemData{
118- URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
119- BlogURL: template.URL(cfg.FullBlogURL(curl, post.Username)),
117+ URL: template.URL(curl.FullPostURL(post.Username, post.Slug)),
118+ BlogURL: template.URL(curl.FullBlogURL(post.Username)),
120119 Title: post.Filename,
121120 PublishAt: post.PublishAt.Format(time.DateOnly),
122121 PublishAtISO: post.PublishAt.Format(time.RFC3339),
......@@ -127,10 +126,10 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
127126 }
128127
129128 data := BlogPageData{
130- Site: *cfg.GetSiteData(),
129+ Site: cfg.SiteData(),
131130 PageTitle: headerTxt.Title,
132- URL: template.URL(cfg.FullBlogURL(curl, username)),
133- RSSURL: template.URL(cfg.RssBlogURL(curl, username, "")),
131+ URL: template.URL(curl.FullBlogURL(username)),
132+ RSSURL: template.URL(curl.RssBlogURL(username, "")),
134133 Header: headerTxt,
135134 Username: username,
136135 Posts: postCollection,
......@@ -155,20 +154,13 @@ func GetBlogName(username string) string {
155154 return fmt.Sprintf("%s's pastes", username)
156155 }
157156
158-func postHandler(w http.ResponseWriter, r *http.Request) {
157+func (web *webRouter) postHandler(w http.ResponseWriter, r *http.Request) {
159158 username := router.GetUsernameFromRequest(r)
160- subdomain := router.GetSubdomain(r)
161- cfg := router.GetCfg(r)
159+ cfg := web.cfg
160+ slug, _ := url.PathUnescape(r.PathValue("slug"))
162161
163- var slug string
164- if !cfg.IsSubdomains() || subdomain == "" {
165- slug, _ = url.PathUnescape(router.GetField(r, 1))
166- } else {
167- slug, _ = url.PathUnescape(router.GetField(r, 0))
168- }
169-
170- dbpool := router.GetDB(r)
171- blogger := router.GetLogger(r)
162+ dbpool := web.db
163+ blogger := web.cfg.Logger
172164 logger := blogger.With("slug", slug, "user", username)
173165
174166 user, err := dbpool.FindUserByName(username)
......@@ -209,7 +201,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
209201 }
210202
211203 data = PostPageData{
212- Site: *cfg.GetSiteData(),
204+ Site: cfg.SiteData(),
213205 PageTitle: post.Filename,
214206 URL: template.URL(cfg.PostURL(post.Username, post.Slug)),
215207 RawURL: template.URL(cfg.RawPostURL(post.Username, post.Slug)),
......@@ -228,7 +220,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
228220 } else {
229221 logger.Info("paste not found")
230222 data = PostPageData{
231- Site: *cfg.GetSiteData(),
223+ Site: cfg.SiteData(),
232224 PageTitle: "Paste not found",
233225 Description: "Paste not found",
234226 Title: "Paste not found",
......@@ -243,7 +235,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
243235 }
244236 }
245237
246- ts, err := router.RenderTemplate(cfg, []string{
238+ ts, err := router.RenderTemplate(staticDir, []string{
247239 cfg.StaticPath("html/post.page.tmpl"),
248240 })
249241
......@@ -259,20 +251,13 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
259251 }
260252 }
261253
262-func postHandlerRaw(w http.ResponseWriter, r *http.Request) {
254+func (web *webRouter) postHandlerRaw(w http.ResponseWriter, r *http.Request) {
263255 username := router.GetUsernameFromRequest(r)
264- subdomain := router.GetSubdomain(r)
265- cfg := router.GetCfg(r)
266-
267- var slug string
268- if !cfg.IsSubdomains() || subdomain == "" {
269- slug, _ = url.PathUnescape(router.GetField(r, 1))
270- } else {
271- slug, _ = url.PathUnescape(router.GetField(r, 0))
272- }
256+ cfg := web.cfg
257+ slug, _ := url.PathUnescape(r.PathValue("slug"))
273258
274- dbpool := router.GetDB(r)
275- blogger := router.GetLogger(r)
259+ dbpool := web.db
260+ blogger := web.cfg.Logger
276261 logger := blogger.With("user", username, "slug", slug)
277262
278263 user, err := dbpool.FindUserByName(username)
......@@ -299,86 +284,77 @@ func postHandlerRaw(w http.ResponseWriter, r *http.Request) {
299284 }
300285 }
301286
302-func serveFile(file string, contentType string) http.HandlerFunc {
303- return func(w http.ResponseWriter, r *http.Request) {
304- logger := router.GetLogger(r)
305- cfg := router.GetCfg(r)
306-
307- contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
308- if err != nil {
309- logger.Error("could not read file", "err", err)
310- http.Error(w, "file not found", 404)
311- }
312- w.Header().Add("Content-Type", contentType)
313-
314- _, err = w.Write(contents)
315- if err != nil {
316- logger.Error("could not write contents", "err", err)
317- http.Error(w, "server error", 500)
318- }
319- }
287+type webRouter struct {
288+ cfg *Config
289+ db DB
320290 }
321291
322-func createStaticRoutes() []router.Route {
323- return []router.Route{
324- router.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
325- router.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
326- router.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
327- router.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
328- router.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
329- router.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
330- router.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
331- router.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
332- router.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
292+func (web *webRouter) static(mux *http.ServeMux) {
293+ cfg := web.cfg
294+ files := cfg.Static()
295+ mux.HandleFunc("GET /main.css", files.File("main.css", "text/css"))
296+ mux.HandleFunc("GET /smol.css", files.File("smol.css", "text/css"))
297+ mux.HandleFunc("GET /syntax.css", files.File("syntax.css", "text/css"))
298+ mux.HandleFunc("GET /card.png", files.File("card.png", "image/png"))
299+ mux.HandleFunc("GET /favicon-16x16.png", files.File("favicon-16x16.png", "image/png"))
300+ mux.HandleFunc("GET /apple-touch-icon.png", files.File("apple-touch-icon.png", "image/png"))
301+ mux.HandleFunc("GET /favicon.ico", files.File("favicon.ico", "image/x-icon"))
302+ mux.HandleFunc("GET /robots.txt", files.File("robots.txt", "text/plain"))
303+ if cfg.Debug {
304+ router.CreatePProfRoutesMux(mux)
333305 }
334306 }
335307
336-func createMainRoutes(staticRoutes []router.Route) []router.Route {
337- routes := []router.Route{
338- router.NewRoute("GET", "/", router.CreatePageHandler("html/marketing.page.tmpl")),
339- router.NewRoute("GET", "/check", router.CheckHandler),
340- router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
341- }
342-
343- routes = append(
344- routes,
345- staticRoutes...,
346- )
347-
348- routes = append(
349- routes,
350- router.NewRoute("GET", "/([^/]+)", blogHandler),
351- router.NewRoute("GET", "/([^/]+)/([^/]+)", postHandler),
352- router.NewRoute("GET", "/([^/]+)/([^/]+)/raw", postHandlerRaw),
353- router.NewRoute("GET", "/raw/([^/]+)/([^/]+)", postHandlerRaw),
354- )
308+// mainMux serves the marketing site and users' pastes below /{user}.
309+func (web *webRouter) mainMux() *http.ServeMux {
310+ mux := http.NewServeMux()
311+ mux.HandleFunc("GET /{$}", web.cfg.Static().Page("html/marketing.page.tmpl"))
312+ mux.HandleFunc("GET /check", router.CheckHandler(web.cfg.Domain, web.cfg.Space, web.db))
313+ mux.Handle("GET /_metrics", promhttp.Handler())
314+ web.static(mux)
315+ mux.HandleFunc("GET /{user}", web.blogHandler)
316+ mux.HandleFunc("GET /{user}/{slug}", web.postHandler)
317+ mux.HandleFunc("GET /{first}/{second}/{third}", web.rawRoute)
318+ return mux
319+}
355320
356- return routes
321+// sitesMux serves a user's pastes on their subdomain.
322+func (web *webRouter) sitesMux() *http.ServeMux {
323+ mux := http.NewServeMux()
324+ mux.HandleFunc("GET /{$}", web.blogHandler)
325+ web.static(mux)
326+ mux.HandleFunc("GET /{slug}", web.postHandler)
327+ mux.HandleFunc("GET /{first}/{second}", web.rawRoute)
328+ return mux
357329 }
358330
359-func createSubdomainRoutes(staticRoutes []router.Route) []router.Route {
360- routes := []router.Route{
361- router.NewRoute("GET", "/", blogHandler),
331+// rawRoute serves a raw paste at .../{slug}/raw or raw/.../{slug}, with the
332+// user first on the main domain. The two overlap as patterns, so a path
333+// that fits both, like /raw/raw, is the first kind.
334+func (web *webRouter) rawRoute(w http.ResponseWriter, r *http.Request) {
335+ segs := []string{r.PathValue("first"), r.PathValue("second")}
336+ if third := r.PathValue("third"); third != "" {
337+ segs = append(segs, third)
362338 }
363-
364- routes = append(
365- routes,
366- staticRoutes...,
367- )
368-
369- routes = append(
370- routes,
371- router.NewRoute("GET", "/([^/]+)", postHandler),
372- router.NewRoute("GET", "/([^/]+)/raw", postHandlerRaw),
373- router.NewRoute("GET", "/raw/([^/]+)", postHandlerRaw),
374- )
375-
376- return routes
339+ switch {
340+ case segs[len(segs)-1] == "raw":
341+ segs = segs[:len(segs)-1]
342+ case segs[0] == "raw":
343+ segs = segs[1:]
344+ default:
345+ http.NotFound(w, r)
346+ return
347+ }
348+ if len(segs) == 2 {
349+ r.SetPathValue("user", segs[0])
350+ }
351+ r.SetPathValue("slug", segs[len(segs)-1])
352+ web.postHandlerRaw(w, r)
377353 }
378354
379355 func StartApiServer() {
380- cfg := NewConfigSite("pastes-web")
381- db := postgres.NewDB(cfg.DbURL, cfg.Logger)
356+ cfg := NewConfig("pastes-web")
357+ db := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
382358 defer func() {
383359 _ = db.Close()
384360 }()
......@@ -386,21 +362,8 @@ func StartApiServer() {
386362
387363 go CronDeleteExpiredPosts(cfg, db)
388364
389- staticRoutes := createStaticRoutes()
390-
391- if cfg.Debug {
392- staticRoutes = router.CreatePProfRoutes(staticRoutes)
393- }
394-
395- mainRoutes := createMainRoutes(staticRoutes)
396- subdomainRoutes := createSubdomainRoutes(staticRoutes)
397-
398- apiConfig := &router.ApiConfig{
399- Cfg: cfg,
400- Dbpool: db,
401- }
402- handler := router.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
403- router := http.HandlerFunc(handler)
365+ web := &webRouter{cfg: cfg, db: db}
366+ handler := router.NewHostRouter(cfg.Domain, cfg.Space, web.mainMux(), web.sitesMux())
404367
405368 portStr := fmt.Sprintf(":%s", cfg.Port)
406369 logger.Info(
......@@ -409,5 +372,5 @@ func StartApiServer() {
409372 "domain", cfg.Domain,
410373 )
411374
412- logger.Error(http.ListenAndServe(portStr, router).Error())
375+ logger.Error(http.ListenAndServe(portStr, handler).Error())
413376 }
+58, -9
......@@ -1,7 +1,6 @@
11 package pastes
22
33 import (
4- "context"
54 "html/template"
65 "log/slog"
76 "net/http"
......@@ -19,20 +18,24 @@ import (
1918 type pasteDB struct {
2019 *stub.StubDB
2120 post *db.Post
21+ // user and slug are the last ones asked for.
22+ user, slug string
2223 }
2324
24-func (m *pasteDB) FindUserByName(string) (*db.User, error) {
25- return &db.User{ID: "user-1", Name: "tester"}, nil
25+func (m *pasteDB) FindUserByName(name string) (*db.User, error) {
26+ m.user = name
27+ return &db.User{ID: "user-1", Name: name}, nil
2628 }
2729
28-func (m *pasteDB) FindPostWithSlug(string, string, string) (*db.Post, error) {
30+func (m *pasteDB) FindPostWithSlug(slug string, _ string, _ string) (*db.Post, error) {
31+ m.slug = slug
2932 return m.post, nil
3033 }
3134
3235 func TestLargePasteEscapesHTML(t *testing.T) {
3336 t.Chdir("../../..")
3437 logger := slog.New(slog.DiscardHandler)
35- cfg := &shared.ConfigSite{
38+ cfg := &Config{
3639 Domain: "pastes.test", Protocol: "https", Space: "pastes", Logger: logger,
3740 }
3841 now := time.Now()
......@@ -44,11 +47,11 @@ func TestLargePasteEscapesHTML(t *testing.T) {
4447 Text: text, FileSize: len(text), PublishAt: &now, UpdatedAt: &now,
4548 },
4649 }
47- api := &router.ApiConfig{Cfg: cfg, Dbpool: dbpool}
48- handler := router.CreateServeBasic(createSubdomainRoutes(nil), api.CreateCtx(context.Background(), "tester"))
50+ web := &webRouter{cfg: cfg, db: dbpool}
51+ handler := router.NewHostRouter(cfg.Domain, cfg.Space, web.mainMux(), web.sitesMux())
4952
5053 page := httptest.NewRecorder()
51- handler(page, httptest.NewRequest(http.MethodGet, "https://tester.pastes.test/test.html", nil))
54+ handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "https://tester.pastes.test/test.html", nil))
5255 if page.Code != http.StatusOK {
5356 t.Fatalf("page status = %d", page.Code)
5457 }
......@@ -61,8 +64,54 @@ func TestLargePasteEscapesHTML(t *testing.T) {
6164 }
6265
6366 raw := httptest.NewRecorder()
64- handler(raw, httptest.NewRequest(http.MethodGet, "https://tester.pastes.test/test.html/raw", nil))
67+ handler.ServeHTTP(raw, httptest.NewRequest(http.MethodGet, "https://tester.pastes.test/test.html/raw", nil))
6568 if raw.Code != http.StatusOK || raw.Body.String() != text {
6669 t.Fatal("raw response does not preserve the uploaded text")
6770 }
6871 }
72+
73+func TestRoutes(t *testing.T) {
74+ t.Chdir("../../..")
75+ logger := slog.New(slog.DiscardHandler)
76+ cfg := &Config{
77+ Domain: "pastes.test", Protocol: "https", Space: "pastes", Logger: logger,
78+ }
79+ now := time.Now()
80+ dbpool := &pasteDB{
81+ StubDB: stub.NewStubDB(logger),
82+ post: &db.Post{
83+ Filename: "a.txt", Slug: "a.txt", Username: "tester",
84+ Text: "paste", FileSize: 5, PublishAt: &now, UpdatedAt: &now,
85+ },
86+ }
87+ web := &webRouter{cfg: cfg, db: dbpool}
88+ handler := router.NewHostRouter(cfg.Domain, cfg.Space, web.mainMux(), web.sitesMux())
89+
90+ for _, tt := range []struct {
91+ url string
92+ status int
93+ raw bool
94+ user string
95+ slug string
96+ }{
97+ {url: "https://pastes.test/tester/a.txt", status: http.StatusOK, user: "tester", slug: "a.txt"},
98+ {url: "https://pastes.test/tester/a.txt/raw", status: http.StatusOK, raw: true, user: "tester", slug: "a.txt"},
99+ {url: "https://pastes.test/raw/tester/a.txt", status: http.StatusOK, raw: true, user: "tester", slug: "a.txt"},
100+ // Both raw forms fit, and the trailing one wins.
101+ {url: "https://pastes.test/raw/tester/raw", status: http.StatusOK, raw: true, user: "raw", slug: "tester"},
102+ {url: "https://pastes.test/tester/a.txt/other", status: http.StatusNotFound},
103+ {url: "https://tester.pastes.test/a.txt", status: http.StatusOK, user: "tester", slug: "a.txt"},
104+ {url: "https://tester.pastes.test/a.txt/raw", status: http.StatusOK, raw: true, user: "tester", slug: "a.txt"},
105+ {url: "https://tester.pastes.test/raw/a.txt", status: http.StatusOK, raw: true, user: "tester", slug: "a.txt"},
106+ {url: "https://tester.pastes.test/a.txt/other", status: http.StatusNotFound},
107+ } {
108+ dbpool.user, dbpool.slug = "", ""
109+ res := httptest.NewRecorder()
110+ handler.ServeHTTP(res, httptest.NewRequest(http.MethodGet, tt.url, nil))
111+ raw := res.Body.String() == "paste"
112+ if res.Code != tt.status || dbpool.user != tt.user || dbpool.slug != tt.slug || (res.Code == http.StatusOK && raw != tt.raw) {
113+ t.Errorf("%s = %d raw %v for %q/%q, want %d raw %v for %q/%q",
114+ tt.url, res.Code, raw, dbpool.user, dbpool.slug, tt.status, tt.raw, tt.user, tt.slug)
115+ }
116+ }
117+}
+49, -2
......@@ -1,12 +1,32 @@
11 package pastes
22
33 import (
4+ "fmt"
5+ "html/template"
6+ "log/slog"
7+ "net/url"
8+ "path/filepath"
49 "strings"
510
611 "github.com/picosh/pico/pkg/shared"
12+ "github.com/picosh/pico/pkg/shared/router"
713 )
814
9-func NewConfigSite(service string) *shared.ConfigSite {
15+// staticDir holds pastes' templates and public files.
16+const staticDir = "pkg/apps/pastes"
17+
18+type Config struct {
19+ Debug bool
20+ Domain string
21+ Port string
22+ Protocol string
23+ DbURL string
24+ Space string
25+ MaxAssetSize int64
26+ Logger *slog.Logger
27+}
28+
29+func NewConfig(service string) *Config {
1030 debug := shared.GetEnv("PASTES_DEBUG", "0")
1131 domain := shared.GetEnv("PASTES_DOMAIN", "pastes.sh")
1232 port := shared.GetEnv("PASTES_WEB_PORT", "3000")
......@@ -14,7 +34,7 @@ func NewConfigSite(service string) *shared.ConfigSite {
1434 protocol := shared.GetEnv("PASTES_PROTOCOL", "https")
1535 withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
1636
17- return &shared.ConfigSite{
37+ return &Config{
1838 Debug: debug == "1",
1939 Domain: domain,
2040 Port: port,
......@@ -25,3 +45,30 @@ func NewConfigSite(service string) *shared.ConfigSite {
2545 MaxAssetSize: int64(3 * shared.MB),
2646 }
2747 }
48+
49+func (c *Config) StaticPath(fname string) string {
50+ return filepath.Join(staticDir, fname)
51+}
52+
53+func (c *Config) SiteData() shared.SitePageData {
54+ return shared.SitePageData{
55+ Domain: template.URL(c.Domain),
56+ HomeURL: template.URL("//" + c.Domain),
57+ }
58+}
59+
60+func (c *Config) Static() router.Static {
61+ return router.Static{Dir: staticDir, Logger: c.Logger, Site: c.SiteData()}
62+}
63+
64+func (c *Config) BlogURL(username string) string {
65+ return fmt.Sprintf("%s://%s.%s", c.Protocol, username, c.Domain)
66+}
67+
68+func (c *Config) PostURL(username, slug string) string {
69+ return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, url.PathEscape(slug))
70+}
71+
72+func (c *Config) RawPostURL(username, slug string) string {
73+ return fmt.Sprintf("%s://%s.%s/raw/%s", c.Protocol, username, c.Domain, url.PathEscape(slug))
74+}
+2, -5
......@@ -2,12 +2,9 @@ package pastes
22
33 import (
44 "time"
5-
6- "github.com/picosh/pico/pkg/db"
7- "github.com/picosh/pico/pkg/shared"
85 )
96
10-func deleteExpiredPosts(cfg *shared.ConfigSite, dbpool db.DB) error {
7+func deleteExpiredPosts(cfg *Config, dbpool DB) error {
118 cfg.Logger.Info("checking for expired posts")
129 posts, err := dbpool.FindExpiredPosts(cfg.Space)
1310 if err != nil {
......@@ -28,7 +25,7 @@ func deleteExpiredPosts(cfg *shared.ConfigSite, dbpool db.DB) error {
2825 return nil
2926 }
3027
31-func CronDeleteExpiredPosts(cfg *shared.ConfigSite, dbpool db.DB) {
28+func CronDeleteExpiredPosts(cfg *Config, dbpool DB) {
3229 for {
3330 err := deleteExpiredPosts(cfg, dbpool)
3431 if err != nil {
+44, -0
......@@ -0,0 +1,44 @@
1+package pastes
2+
3+import (
4+ "fmt"
5+
6+ "github.com/picosh/pico/pkg/db"
7+ "github.com/picosh/pico/pkg/db/postgres"
8+)
9+
10+// DB is the storage pastes uses.
11+type DB interface {
12+ db.AuthDB
13+ db.PostDB
14+ FindExpiredPosts(space string) ([]*db.Post, error)
15+}
16+
17+// PsqlDB is pastes's postgres storage: the queries apps share, and the ones
18+// only pastes makes.
19+type PsqlDB struct {
20+ *postgres.PsqlDB
21+}
22+
23+var _ DB = (*PsqlDB)(nil)
24+
25+func NewDB(base *postgres.PsqlDB) *PsqlDB {
26+ return &PsqlDB{PsqlDB: base}
27+}
28+
29+func (me *PsqlDB) FindExpiredPosts(space string) ([]*db.Post, error) {
30+ var posts []*db.Post
31+ query := fmt.Sprintf(`
32+ SELECT %s
33+ FROM posts
34+ LEFT JOIN app_users ON app_users.id = posts.user_id
35+ WHERE
36+ cur_space = $1 AND
37+ expires_at <= now();
38+ `, postgres.SelectPost)
39+ err := me.Db.Select(&posts, query, space)
40+ if err != nil {
41+ return nil, err
42+ }
43+ return posts, nil
44+}
+54, -0
......@@ -0,0 +1,54 @@
1+package pastes
2+
3+import (
4+ "log/slog"
5+ "testing"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db"
9+ "github.com/picosh/pico/pkg/db/postgres"
10+ "github.com/picosh/pico/pkg/db/postgres/pgtest"
11+)
12+
13+var testDB *PsqlDB
14+
15+func TestMain(m *testing.M) {
16+ pgtest.Main(m)
17+}
18+
19+func cleanupTestData(t *testing.T) {
20+ t.Helper()
21+ if testDB == nil {
22+ testDB = NewDB(postgres.NewDB(pgtest.URL(t), slog.New(slog.DiscardHandler)))
23+ }
24+ pgtest.Clean(t, testDB.Db)
25+}
26+
27+func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
28+ t.Helper()
29+ return pgtest.InsertPost(t, testDB, post)
30+}
31+
32+func TestFindExpiredPosts(t *testing.T) {
33+ cleanupTestData(t)
34+
35+ user, _ := testDB.RegisterUser("expiredpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredpostowner", "comment", "")
36+
37+ expired := time.Now().Add(-24 * time.Hour)
38+ _ = mustInsertPost(t, &db.Post{
39+ UserID: user.ID,
40+ Filename: "expired.txt",
41+ Slug: "expired",
42+ Title: "Expired",
43+ Space: "pastes",
44+ ExpiresAt: &expired,
45+ })
46+
47+ posts, err := testDB.FindExpiredPosts("pastes")
48+ if err != nil {
49+ t.Fatalf("FindExpiredPosts failed: %v", err)
50+ }
51+ if len(posts) != 1 {
52+ t.Errorf("expected 1 expired post, got %d", len(posts))
53+ }
54+}
+0, -116
......@@ -1,116 +0,0 @@
1-package pastes
2-
3-import (
4- "fmt"
5- "strconv"
6- "strings"
7- "time"
8-
9- "github.com/araddon/dateparse"
10- "github.com/picosh/pico/pkg/db"
11- "github.com/picosh/pico/pkg/filehandlers"
12- "github.com/picosh/pico/pkg/pssh"
13- "github.com/picosh/pico/pkg/shared"
14-)
15-
16-var DEFAULT_EXPIRES_AT = 90
17-
18-type FileHooks struct {
19- Cfg *shared.ConfigSite
20- Db db.DB
21-}
22-
23-func (p *FileHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) (bool, error) {
24- if !shared.IsTextFile(string(data.Text)) {
25- err := fmt.Errorf(
26- "ERROR: (%s) invalid file must be plain text (utf-8), skipping",
27- data.Filename,
28- )
29- return false, err
30- }
31-
32- maxFileSize := int(p.Cfg.MaxAssetSize)
33- if data.FileSize > maxFileSize {
34- return false, fmt.Errorf(
35- "ERROR: file (%s) has exceeded maximum file size (%d bytes)",
36- data.Filename,
37- maxFileSize,
38- )
39- }
40-
41- return true, nil
42-}
43-
44-func (p *FileHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
45- data.Title = shared.ToUpper(data.Slug)
46- // we want the slug to be the filename for pastes
47- data.Slug = data.Filename
48-
49- // mark posts for deletion a X days after creation
50- expiresAt := time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)
51- data.ExpiresAt = &expiresAt
52- var hidden bool
53- // A paste written without arguments, as over sftp, keeps its settings.
54- if cur := data.Cur; cur != nil {
55- hidden = cur.Hidden
56- if cur.ExpiresAt == nil || cur.ExpiresAt.After(time.Now()) {
57- data.ExpiresAt = cur.ExpiresAt
58- }
59- }
60-
61- var expiresFound bool
62- var expires *time.Time
63-
64- cmd := s.Command()
65-
66- for _, arg := range cmd {
67- if strings.Contains(arg, "=") {
68- splitArg := strings.Split(arg, "=")
69- if len(splitArg) != 2 {
70- continue
71- }
72-
73- switch splitArg[0] {
74- case "hidden":
75- val, err := strconv.ParseBool(splitArg[1])
76- if err != nil {
77- continue
78- }
79-
80- hidden = val
81- case "expires":
82- val, err := strconv.ParseBool(splitArg[1])
83- if err == nil {
84- expiresFound = true
85- expires = nil
86- if val {
87- expires = &expiresAt
88- }
89- continue
90- }
91-
92- duration, err := time.ParseDuration(splitArg[1])
93- if err == nil {
94- expiresFound = true
95- expireTime := time.Now().Add(duration)
96- expires = &expireTime
97- continue
98- }
99-
100- expireTime, err := dateparse.ParseStrict(splitArg[1])
101- if err == nil {
102- expiresFound = true
103- expires = &expireTime
104- }
105- }
106- }
107- }
108-
109- data.Hidden = hidden
110-
111- if expiresFound {
112- data.ExpiresAt = expires
113- }
114-
115- return nil
116-}
+0, -54
......@@ -1,54 +0,0 @@
1-package pastes
2-
3-import (
4- "context"
5- "testing"
6- "time"
7-
8- "github.com/picosh/pico/pkg/db"
9- "github.com/picosh/pico/pkg/filehandlers"
10- "github.com/picosh/pico/pkg/pssh"
11-)
12-
13-func TestFileMetaKeepsSettings(t *testing.T) {
14- soon := time.Now().Add(time.Hour)
15- past := time.Now().Add(-time.Hour)
16- // Pastes expire after calendar days, which a daylight saving change
17- // makes an hour longer or shorter.
18- defaultExpiry := time.Until(time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)).Round(time.Minute)
19- tests := []struct {
20- name string
21- cmd []string
22- cur *db.Post
23- hidden bool
24- // expires is how long until the paste expires, or 0 for never.
25- expires time.Duration
26- }{
27- {name: "new", expires: defaultExpiry},
28- {name: "new with arguments", cmd: []string{"hidden=true", "expires=2h"}, hidden: true, expires: 2 * time.Hour},
29- {name: "existing", cur: &db.Post{Hidden: true, ExpiresAt: &soon}, hidden: true, expires: time.Hour},
30- {name: "existing without expiry", cur: &db.Post{Hidden: true}, hidden: true},
31- {name: "existing past its expiry", cur: &db.Post{Hidden: true, ExpiresAt: &past}, hidden: true, expires: defaultExpiry},
32- {name: "existing with arguments", cmd: []string{"hidden=false", "expires=true"}, cur: &db.Post{Hidden: true}, expires: defaultExpiry},
33- {name: "existing made permanent", cmd: []string{"expires=false"}, cur: &db.Post{ExpiresAt: &soon}},
34- }
35- for _, tt := range tests {
36- t.Run(tt.name, func(t *testing.T) {
37- s := &pssh.SSHServerConnSession{Ctx: context.Background()}
38- s.SetValue("command", tt.cmd)
39- data := &filehandlers.PostMetaData{Post: &db.Post{Filename: "a.txt", Slug: "a"}, Cur: tt.cur}
40- if err := (&FileHooks{}).FileMeta(s, data); err != nil {
41- t.Fatal(err)
42- }
43- if data.Hidden != tt.hidden {
44- t.Errorf("hidden = %v, want %v", data.Hidden, tt.hidden)
45- }
46- switch {
47- case tt.expires == 0 && data.ExpiresAt != nil:
48- t.Errorf("expires at %v, want never", data.ExpiresAt)
49- case tt.expires != 0 && (data.ExpiresAt == nil || time.Until(*data.ExpiresAt).Round(time.Minute) != tt.expires):
50- t.Errorf("expires at %v, want in %v", data.ExpiresAt, tt.expires)
51- }
52- })
53- }
54-}
+3, -3
......@@ -24,17 +24,17 @@ func StartSshServer() {
2424 host := shared.GetEnv("PASTES_HOST", "0.0.0.0")
2525 port := shared.GetEnv("PASTES_SSH_PORT", "2222")
2626 promPort := shared.GetEnv("PASTES_PROM_PORT", "9222")
27- cfg := NewConfigSite(appName)
27+ cfg := NewConfig(appName)
2828 logger := cfg.Logger
2929
3030 ctx, cancel := context.WithCancel(context.Background())
3131 defer cancel()
3232
33- dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
33+ dbh := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
3434 defer func() {
3535 _ = dbh.Close()
3636 }()
37- handler := NewFileHandler(cfg, dbh)
37+ handler := NewUploadHandler(cfg, dbh)
3838 sshAuth := shared.NewSshAuthHandler(dbh, logger, "pastes")
3939
4040 // Create a new SSH server
+183, -47
......@@ -3,57 +3,36 @@ package pastes
33 import (
44 "fmt"
55 "log/slog"
6+ "os"
7+ "path/filepath"
8+ "strconv"
9+ "strings"
10+ "time"
611
12+ "github.com/araddon/dateparse"
713 "github.com/picosh/pico/pkg/db"
8- "github.com/picosh/pico/pkg/filehandlers"
14+ "github.com/picosh/pico/pkg/postfs"
915 "github.com/picosh/pico/pkg/pssh"
1016 sendutils "github.com/picosh/pico/pkg/send/utils"
1117 "github.com/picosh/pico/pkg/shared"
1218 )
1319
14-func checkAccess(dbpool db.DB, userID string) error {
15- ffPlus, plusErr := dbpool.FindFeature(userID, "plus")
16- if plusErr == nil && ffPlus.IsValid() {
17- return nil
18- }
19-
20- ffPastes, pastesErr := dbpool.FindFeature(userID, "pastes")
21- if pastesErr == nil {
22- if ffPastes.IsValid() {
23- return nil
24- }
25- return fmt.Errorf("ERROR: your pastes access has expired: https://blog.pico.sh/ann-038-pico-invite-system")
26- }
27-
28- if plusErr == nil {
29- return fmt.Errorf("ERROR: your pico+ has expired: https://blog.pico.sh/ann-038-pico-invite-system")
30- }
31- return fmt.Errorf("ERROR: uploading to pastes requires an invitation or pico+: https://blog.pico.sh/ann-038-pico-invite-system")
32-}
20+var DEFAULT_EXPIRES_AT = 90
3321
22+// UploadHandler stores every file as a paste.
3423 type UploadHandler struct {
35- *filehandlers.FileHandlerRouter
36- Cfg *shared.ConfigSite
37- DB db.DB
24+ Cfg *Config
25+ DB DB
3826 }
3927
40-var _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
41-
42-func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]filehandlers.ReadWriteHandler) *UploadHandler {
43- router := filehandlers.NewFileHandlerRouter(cfg, dbpool, fileMap)
44- return &UploadHandler{
45- FileHandlerRouter: router,
46- Cfg: cfg,
47- DB: dbpool,
48- }
49-}
28+var (
29+ _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
30+ _ sendutils.WriteValidator = (*UploadHandler)(nil)
31+ _ sendutils.FileRenamer = (*UploadHandler)(nil)
32+)
5033
51-// NewFileHandler stores every file as a paste.
52-func NewFileHandler(cfg *shared.ConfigSite, dbpool db.DB) *UploadHandler {
53- hooks := &FileHooks{Cfg: cfg, Db: dbpool}
54- return NewUploadHandler(cfg, dbpool, map[string]filehandlers.ReadWriteHandler{
55- "fallback": filehandlers.NewScpPostHandler(dbpool, cfg, hooks),
56- })
34+func NewUploadHandler(cfg *Config, dbpool DB) *UploadHandler {
35+ return &UploadHandler{Cfg: cfg, DB: dbpool}
5736 }
5837
5938 func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
......@@ -68,18 +47,175 @@ func (h *UploadHandler) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
6847 }
6948
7049 func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error {
71- logger := h.GetLogger(s)
72- user := pssh.GetUser(s)
73-
74- if user == nil {
75- err := fmt.Errorf("could not get user from ctx")
76- logger.Error("error getting user from ctx", "err", err)
50+ user, err := postfs.SessionUser(s)
51+ if err != nil {
52+ return err
53+ }
54+ if _, err := db.FindAccess(h.DB, user.ID, "pastes"); err != nil {
7755 return err
7856 }
57+ h.GetLogger(s).Info("attempting to upload files", "user", user.Name, "space", h.Cfg.Space)
58+ return nil
59+}
60+
61+func (h *UploadHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
62+ return postfs.ListPosts(s, fpath, isDir, recursive, []string{h.Cfg.Space}, h.DB)
63+}
64+
65+func (h *UploadHandler) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
66+ return postfs.ReadPost(s, h.DB, h.Cfg.Space, entry)
67+}
68+
69+func (h *UploadHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
70+ return postfs.DeletePost(s, h.DB, h.Cfg.Space, entry)
71+}
7972
80- if err := checkAccess(h.DB, user.ID); err != nil {
73+// ValidateWrite checks a paste's name before its contents arrive.
74+func (h *UploadHandler) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
75+ if _, err := postfs.SessionUser(s); err != nil {
8176 return err
8277 }
78+ if entry.Mode.IsDir() {
79+ return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
80+ }
81+ return h.validate(postfs.NewPost(filepath.Base(entry.Filepath), nil))
82+}
83+
84+func (h *UploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
85+ user, err := postfs.SessionUser(s)
86+ if err != nil {
87+ return "", err
88+ }
89+ logger := h.GetLogger(s).With("filename", filepath.Base(entry.Filepath))
90+
91+ post, err := postfs.PostFromEntry(entry, user, h.Cfg.Space)
92+ if err != nil {
93+ return "", err
94+ }
95+ if err := h.validate(post); err != nil {
96+ logger.Error("file failed validation", "err", err.Error())
97+ return "", err
98+ }
99+
100+ cur, err := postfs.LookupPost(h.DB, user.ID, h.Cfg.Space, post.Filename)
101+ if err != nil {
102+ return "", err
103+ }
104+ if cur != nil {
105+ post.Data, post.PublishAt = cur.Data, cur.PublishAt
106+ }
107+ setMeta(s, cur, post)
108+
109+ _, changed, err := postfs.SavePost(h.DB, cur, post)
110+ if err != nil {
111+ logger.Error("paste could not be saved", "err", err.Error())
112+ return "", err
113+ }
114+ logger.Info("saved paste", "new", cur == nil, "changed", changed)
115+ return shared.NewCreateURL(h.Cfg.Protocol, h.Cfg.Domain).FullPostURL(user.Name, post.Slug), nil
116+}
117+
118+// Rename moves a paste to a new filename in place, keeping its settings.
119+func (h *UploadHandler) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
120+ return postfs.RenamePost(s, h.DB, h.Cfg.Space, from, to, func(cur, next *db.Post) ([]string, error) {
121+ if err := h.validate(next); err != nil {
122+ return nil, err
123+ }
124+ setMeta(s, cur, next)
125+ return nil, nil
126+ })
127+}
128+
129+func (h *UploadHandler) validate(post *db.Post) error {
130+ if !shared.IsTextFile(post.Text) {
131+ return fmt.Errorf(
132+ "ERROR: (%s) invalid file must be plain text (utf-8), skipping",
133+ post.Filename,
134+ )
135+ }
136+
137+ maxFileSize := int(h.Cfg.MaxAssetSize)
138+ if post.FileSize > maxFileSize {
139+ return fmt.Errorf(
140+ "ERROR: file (%s) has exceeded maximum file size (%d bytes)",
141+ post.Filename,
142+ maxFileSize,
143+ )
144+ }
145+
146+ return nil
147+}
148+
149+// setMeta applies the paste's settings: those of cur, the paste it
150+// replaces, unless the upload command sets them.
151+func setMeta(s *pssh.SSHServerConnSession, cur, post *db.Post) {
152+ post.Title = shared.ToUpper(post.Slug)
153+ // we want the slug to be the filename for pastes
154+ post.Slug = post.Filename
155+
156+ // mark posts for deletion a X days after creation
157+ expiresAt := time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)
158+ post.ExpiresAt = &expiresAt
159+ var hidden bool
160+ // A paste written without arguments, as over sftp, keeps its settings.
161+ if cur != nil {
162+ hidden = cur.Hidden
163+ if cur.ExpiresAt == nil || cur.ExpiresAt.After(time.Now()) {
164+ post.ExpiresAt = cur.ExpiresAt
165+ }
166+ }
167+
168+ var expiresFound bool
169+ var expires *time.Time
170+
171+ cmd := s.Command()
172+
173+ for _, arg := range cmd {
174+ if strings.Contains(arg, "=") {
175+ splitArg := strings.Split(arg, "=")
176+ if len(splitArg) != 2 {
177+ continue
178+ }
179+
180+ switch splitArg[0] {
181+ case "hidden":
182+ val, err := strconv.ParseBool(splitArg[1])
183+ if err != nil {
184+ continue
185+ }
83186
84- return h.FileHandlerRouter.Validate(s)
187+ hidden = val
188+ case "expires":
189+ val, err := strconv.ParseBool(splitArg[1])
190+ if err == nil {
191+ expiresFound = true
192+ expires = nil
193+ if val {
194+ expires = &expiresAt
195+ }
196+ continue
197+ }
198+
199+ duration, err := time.ParseDuration(splitArg[1])
200+ if err == nil {
201+ expiresFound = true
202+ expireTime := time.Now().Add(duration)
203+ expires = &expireTime
204+ continue
205+ }
206+
207+ expireTime, err := dateparse.ParseStrict(splitArg[1])
208+ if err == nil {
209+ expiresFound = true
210+ expires = &expireTime
211+ }
212+ }
213+ }
214+ }
215+
216+ post.Hidden = hidden
217+
218+ if expiresFound {
219+ post.ExpiresAt = expires
220+ }
85221 }
+88, -3
......@@ -2,16 +2,19 @@ package pastes
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
7+ "io"
68 "log/slog"
79 "strings"
810 "testing"
11+ "testing/iotest"
912 "time"
1013
1114 "github.com/picosh/pico/pkg/db"
1215 "github.com/picosh/pico/pkg/db/stub"
13- "github.com/picosh/pico/pkg/filehandlers"
1416 "github.com/picosh/pico/pkg/pssh"
17+ sendutils "github.com/picosh/pico/pkg/send/utils"
1518 "golang.org/x/crypto/ssh"
1619 )
1720
......@@ -39,7 +42,7 @@ func (m *mockFeatureDB) setFeature(userID, name string, expiresAt time.Time) {
3942 }
4043
4144 func TestUploadHandlerValidate(t *testing.T) {
42- cfg := NewConfigSite("pastes-test")
45+ cfg := NewConfig("pastes-test")
4346 userID := "user-1"
4447 future := time.Now().Add(24 * time.Hour)
4548 past := time.Now().Add(-24 * time.Hour)
......@@ -113,7 +116,7 @@ func TestUploadHandlerValidate(t *testing.T) {
113116 t.Run(tt.name, func(t *testing.T) {
114117 mockDB := newMockFeatureDB()
115118 tt.setup(mockDB)
116- handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{})
119+ handler := NewUploadHandler(cfg, mockDB)
117120
118121 err := handler.Validate(createSession(tt.user))
119122 if tt.wantErr == "" {
......@@ -128,3 +131,85 @@ func TestUploadHandlerValidate(t *testing.T) {
128131 })
129132 }
130133 }
134+
135+func TestSetMetaKeepsSettings(t *testing.T) {
136+ soon := time.Now().Add(time.Hour)
137+ past := time.Now().Add(-time.Hour)
138+ // Pastes expire after calendar days, which a daylight saving change
139+ // makes an hour longer or shorter.
140+ defaultExpiry := time.Until(time.Now().AddDate(0, 0, DEFAULT_EXPIRES_AT)).Round(time.Minute)
141+ tests := []struct {
142+ name string
143+ cmd []string
144+ cur *db.Post
145+ hidden bool
146+ // expires is how long until the paste expires, or 0 for never.
147+ expires time.Duration
148+ }{
149+ {name: "new", expires: defaultExpiry},
150+ {name: "new with arguments", cmd: []string{"hidden=true", "expires=2h"}, hidden: true, expires: 2 * time.Hour},
151+ {name: "existing", cur: &db.Post{Hidden: true, ExpiresAt: &soon}, hidden: true, expires: time.Hour},
152+ {name: "existing without expiry", cur: &db.Post{Hidden: true}, hidden: true},
153+ {name: "existing past its expiry", cur: &db.Post{Hidden: true, ExpiresAt: &past}, hidden: true, expires: defaultExpiry},
154+ {name: "existing with arguments", cmd: []string{"hidden=false", "expires=true"}, cur: &db.Post{Hidden: true}, expires: defaultExpiry},
155+ {name: "existing made permanent", cmd: []string{"expires=false"}, cur: &db.Post{ExpiresAt: &soon}},
156+ }
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ s := &pssh.SSHServerConnSession{Ctx: context.Background()}
160+ s.SetValue("command", tt.cmd)
161+ data := &db.Post{Filename: "a.txt", Slug: "a"}
162+ setMeta(s, tt.cur, data)
163+ if data.Hidden != tt.hidden {
164+ t.Errorf("hidden = %v, want %v", data.Hidden, tt.hidden)
165+ }
166+ switch {
167+ case tt.expires == 0 && data.ExpiresAt != nil:
168+ t.Errorf("expires at %v, want never", data.ExpiresAt)
169+ case tt.expires != 0 && (data.ExpiresAt == nil || time.Until(*data.ExpiresAt).Round(time.Minute) != tt.expires):
170+ t.Errorf("expires at %v, want in %v", data.ExpiresAt, tt.expires)
171+ }
172+ })
173+ }
174+}
175+
176+func TestWriteReadError(t *testing.T) {
177+ checksumErr := errors.New("rsync: file failed verification")
178+ tests := []struct {
179+ name string
180+ text string
181+ err error
182+ }{
183+ {name: "before contents", err: io.ErrClosedPipe},
184+ {name: "partial contents", text: "partial upload", err: io.ErrUnexpectedEOF},
185+ {name: "failed verification", text: "complete upload", err: checksumErr},
186+ }
187+ for _, tt := range tests {
188+ t.Run(tt.name, func(t *testing.T) {
189+ logger := slog.New(slog.DiscardHandler)
190+ session := &pssh.SSHServerConnSession{
191+ SSHServerConn: &pssh.SSHServerConn{
192+ Conn: &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
193+ Logger: logger,
194+ },
195+ Ctx: context.Background(),
196+ }
197+ user := &db.User{ID: "user-1", Name: "tester"}
198+ pssh.SetUser(session, user)
199+ posts := stub.NewPostsDB(logger, user)
200+ handler := NewUploadHandler(&Config{Space: "pastes", MaxAssetSize: 1024}, posts)
201+ reader := io.MultiReader(strings.NewReader(tt.text), iotest.ErrReader(tt.err))
202+
203+ message, err := handler.Write(session, &sendutils.FileEntry{Filepath: "/test.txt", Reader: reader})
204+ if !errors.Is(err, tt.err) {
205+ t.Fatalf("error = %v, want %v", err, tt.err)
206+ }
207+ if message != "" {
208+ t.Fatalf("failed upload returned a success message: %q", message)
209+ }
210+ if all, _ := posts.FindAllPostsByUser(user.ID, "pastes"); len(all) != 0 {
211+ t.Fatalf("failed upload stored %d posts", len(all))
212+ }
213+ })
214+ }
215+}
+1, -1
......@@ -36,7 +36,7 @@ func TestPrivateProjectDeniesWebAccess(t *testing.T) {
3636 defer func() {
3737 _ = pubsub.Close()
3838 }()
39- cfg := NewPgsConfig(logger, dbpool, st, pubsub)
39+ cfg := NewConfig(logger, dbpool, st, pubsub)
4040 cfg.Domain = "pgs.test"
4141 router := NewWebRouter(cfg)
4242 router.ServeHTTP(responseRecorder, request)
+3, -3
......@@ -6,7 +6,7 @@ import (
66 "github.com/picosh/pico/pkg/shared/router"
77 )
88
9-func handleAutoForm(w http.ResponseWriter, r *http.Request, cfg *PgsConfig) {
9+func handleAutoForm(w http.ResponseWriter, r *http.Request, cfg *Config) {
1010 formName := r.PathValue("fname")
1111 if r.Method != http.MethodPost {
1212 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
......@@ -65,7 +65,7 @@ type FormData struct {
6565 Error string
6666 }
6767
68-func serveAutoFormSubmitted(w http.ResponseWriter, r *http.Request, cfg *PgsConfig) {
68+func serveAutoFormSubmitted(w http.ResponseWriter, r *http.Request, cfg *Config) {
6969 errorMsg := r.URL.Query().Get("error")
7070 data := loginFormData{
7171 Error: errorMsg,
......@@ -73,7 +73,7 @@ func serveAutoFormSubmitted(w http.ResponseWriter, r *http.Request, cfg *PgsConf
7373
7474 w.WriteHeader(http.StatusUnprocessableEntity)
7575
76- ts, err := renderTemplate(cfg, []string{cfg.StaticPath("html/auto_form.page.tmpl")})
76+ ts, err := router.RenderTemplate(staticDir, []string{cfg.StaticPath("html/auto_form.page.tmpl")})
7777 if err != nil {
7878 cfg.Logger.Error("could not render auto form template", "err", err.Error())
7979 http.Error(w, "Server error", http.StatusInternalServerError)
+1, -1
......@@ -54,7 +54,7 @@ type Cmd struct {
5454 Write bool
5555 Width int
5656 Height int
57- Cfg *PgsConfig
57+ Cfg *Config
5858 }
5959
6060 func (c *Cmd) output(out string) {
+2, -2
......@@ -34,7 +34,7 @@ func TestCliPurges(t *testing.T) {
3434 if err != nil {
3535 t.Fatal(err)
3636 }
37- cfg := NewPgsConfig(logger, dbpool, st, discardPubsub{})
37+ cfg := NewConfig(logger, dbpool, st, discardPubsub{})
3838 cmd := &Cmd{User: user, Session: &fakeCmdSession{}, Log: logger, Store: st, Dbpool: dbpool, Write: true, Cfg: cfg}
3939
4040 tests := []struct {
......@@ -88,7 +88,7 @@ func TestRmProjectAssetsRemovesDirs(t *testing.T) {
8888 t.Fatal(err)
8989 }
9090
91- cfg := NewPgsConfig(logger, dbpool, st, discardPubsub{})
91+ cfg := NewConfig(logger, dbpool, st, discardPubsub{})
9292 cmd := &Cmd{User: user, Session: &fakeCmdSession{}, Log: logger, Store: st, Dbpool: dbpool, Write: true, Cfg: cfg}
9393 if err := cmd.RmProjectAssets("test"); err != nil {
9494 t.Fatal(err)
+19, -6
......@@ -2,6 +2,7 @@ package pgs
22
33 import (
44 "fmt"
5+ "html/template"
56 "log/slog"
67 "path/filepath"
78 "strconv"
......@@ -9,10 +10,11 @@ import (
910
1011 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
1112 "github.com/picosh/pico/pkg/shared"
13+ "github.com/picosh/pico/pkg/shared/router"
1214 "github.com/picosh/pico/pkg/storage"
1315 )
1416
15-type PgsConfig struct {
17+type Config struct {
1618 CacheTTL time.Duration
1719 CacheMaxItems int
1820 CacheMaxBodySize int64
......@@ -38,7 +40,7 @@ type PgsConfig struct {
3840 Pubsub PicoPubsub
3941 }
4042
41-func (c *PgsConfig) AssetURL(username, projectName, fpath string) string {
43+func (c *Config) AssetURL(username, projectName, fpath string) string {
4244 if username == projectName {
4345 return fmt.Sprintf(
4446 "%s://%s.%s/%s",
......@@ -59,8 +61,19 @@ func (c *PgsConfig) AssetURL(username, projectName, fpath string) string {
5961 )
6062 }
6163
62-func (c *PgsConfig) StaticPath(fname string) string {
63- return filepath.Join("pkg", "apps", "pgs", fname)
64+// staticDir holds pgs's templates and public files.
65+const staticDir = "pkg/apps/pgs"
66+
67+func (c *Config) StaticPath(fname string) string {
68+ return filepath.Join(staticDir, fname)
69+}
70+
71+func (c *Config) Static() router.Static {
72+ return router.Static{
73+ Dir: staticDir,
74+ Logger: c.Logger,
75+ Site: shared.SitePageData{Domain: template.URL(c.Domain), HomeURL: "/"},
76+ }
6477 }
6578
6679 var maxSize = uint64(50 * shared.MB)
......@@ -73,7 +86,7 @@ var cacheMaxBodySize = int64(0)
7386 // Needs to be small for caching files like _headers and _redirects.
7487 var maxSpecialFileSize = int64(5 * shared.KB)
7588
76-func NewPgsConfig(logger *slog.Logger, dbpool pgsdb.PgsDB, st storage.StorageServe, pubsub PicoPubsub) *PgsConfig {
89+func NewConfig(logger *slog.Logger, dbpool pgsdb.PgsDB, st storage.StorageServe, pubsub PicoPubsub) *Config {
7790 domain := shared.GetEnv("PGS_DOMAIN", "pgs.sh")
7891 port := shared.GetEnv("PGS_WEB_PORT", "3000")
7992 protocol := shared.GetEnv("PGS_PROTOCOL", "https")
......@@ -94,7 +107,7 @@ func NewPgsConfig(logger *slog.Logger, dbpool pgsdb.PgsDB, st storage.StorageSer
94107 sshHost := shared.GetEnv("PGS_SSH_HOST", "0.0.0.0")
95108 sshPort := shared.GetEnv("PGS_SSH_PORT", "2222")
96109
97- cfg := PgsConfig{
110+ cfg := Config{
98111 CacheTTL: cacheTTL,
99112 CacheMaxItems: cacheMaxItems,
100113 CacheMaxBodySize: cacheMaxBody,
+1, -1
......@@ -11,7 +11,7 @@ import (
1111
1212 const binRetentionDays = 14
1313
14-func BinCron(cfg *PgsConfig) {
14+func BinCron(cfg *Config) {
1515 logger := cfg.Logger
1616 storage := cfg.Storage
1717 db := cfg.DB
+1, -6
......@@ -7,14 +7,9 @@ import (
77 )
88
99 type PgsDB interface {
10- FindUser(userID string) (*db.User, error)
11- FindUserByName(name string) (*db.User, error)
12- FindUserByPubkey(pubkey string) (*db.User, error)
10+ db.AuthDB
1311 FindUsers() ([]*db.User, error)
1412
15- FindFeature(userID string, name string) (*db.FeatureFlag, error)
16- InsertAccessLog(*db.AccessLog) error
17-
1813 InsertProject(userID, name, projectDir string) (string, error)
1914 UpdateProject(userID, name string) error
2015 UpdateProjectAcl(userID, name string, acl db.ProjectAcl) error
+7, -33
......@@ -1,43 +1,17 @@
11 package pgs
22
33 import (
4- "fmt"
5- "strings"
6-
74 pgsdb "github.com/picosh/pico/pkg/apps/pgs/db"
85 "github.com/picosh/pico/pkg/db"
96 )
107
11-func setFeatureLimits(ff *db.FeatureFlag, cfg *PgsConfig) {
12- ff.Data.StorageMax = ff.FindStorageMax(cfg.MaxSize)
13- ff.Data.FileMax = ff.FindFileMax(cfg.MaxAssetSize)
14- ff.Data.SpecialFileMax = ff.FindSpecialFileMax(cfg.MaxSpecialFileSize)
15-}
16-
17-func findFeatureFlag(dbpool pgsdb.PgsDB, cfg *PgsConfig, userID string) (*db.FeatureFlag, error) {
18- ff, err := dbpool.FindFeature(userID, "plus")
19- if err == nil {
20- if ff.IsValid() {
21- setFeatureLimits(ff, cfg)
22- return ff, nil
23- }
24- err = fmt.Errorf("ERROR: your pico+ has expired: https://blog.pico.sh/ann-038-pico-invite-system")
25- }
26-
27- ffPgs, pgsErr := dbpool.FindFeature(userID, "pgs")
28- if pgsErr == nil {
29- if ffPgs.IsValid() {
30- setFeatureLimits(ffPgs, cfg)
31- return ffPgs, nil
32- }
33- pgsErr = fmt.Errorf("ERROR: your pgs access has expired: https://blog.pico.sh/ann-038-pico-invite-system")
34- }
35-
36- if err != nil && strings.Contains(err.Error(), "expired") {
8+// findFeatureFlag returns the flag that lets a user upload to pgs, with
9+// pgs's limits filled in.
10+func findFeatureFlag(dbpool pgsdb.PgsDB, cfg *Config, userID string) (*db.FeatureFlag, error) {
11+ ff, err := db.FindAccess(dbpool, userID, "pgs")
12+ if err != nil {
3713 return nil, err
3814 }
39- if pgsErr != nil && strings.Contains(pgsErr.Error(), "expired") {
40- return nil, pgsErr
41- }
42- return nil, fmt.Errorf("ERROR: uploading to pgs requires an invitation or pico+: https://blog.pico.sh/ann-038-pico-invite-system")
15+ ff.SetLimits(cfg.MaxSize, cfg.MaxAssetSize, cfg.MaxSpecialFileSize)
16+ return ff, nil
4317 }
+3, -3
......@@ -26,7 +26,7 @@ type loginFormData struct {
2626 }
2727
2828 // serveLoginFormWithConfig renders and serves the login form using templates.
29-func serveLoginFormWithConfig(w http.ResponseWriter, r *http.Request, project *db.Project, cfg *PgsConfig, logger *slog.Logger) {
29+func serveLoginFormWithConfig(w http.ResponseWriter, r *http.Request, project *db.Project, cfg *Config, logger *slog.Logger) {
3030 // Determine error message from query params
3131 errorMsg := ""
3232 if r.URL.Query().Get("error") == "invalid" {
......@@ -40,7 +40,7 @@ func serveLoginFormWithConfig(w http.ResponseWriter, r *http.Request, project *d
4040
4141 w.WriteHeader(http.StatusForbidden)
4242
43- ts, err := renderTemplate(cfg, []string{cfg.StaticPath("html/login.page.tmpl")})
43+ ts, err := router.RenderTemplate(staticDir, []string{cfg.StaticPath("html/login.page.tmpl")})
4444 if err != nil {
4545 logger.Error("could not render login template", "err", err.Error())
4646 http.Error(w, "Server error", http.StatusInternalServerError)
......@@ -55,7 +55,7 @@ func serveLoginFormWithConfig(w http.ResponseWriter, r *http.Request, project *d
5555 }
5656
5757 // handleLogin processes the login form submission.
58-func handleLogin(w http.ResponseWriter, r *http.Request, cfg *PgsConfig) {
58+func handleLogin(w http.ResponseWriter, r *http.Request, cfg *Config) {
5959 if r.Method != http.MethodPost {
6060 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
6161 return
+1, -1
......@@ -57,7 +57,7 @@ func newRsyncEnv(t *testing.T, st storage.StorageServe) *rsyncEnv {
5757 bucket := storagetest.Bucket(t, st, shared.GetAssetBucketName(dbpool.Users[0].ID))
5858
5959 t.Setenv("PGS_SSH_PORT", "0")
60- cfg := NewPgsConfig(logger, dbpool, st, discardPubsub{})
60+ cfg := NewConfig(logger, dbpool, st, discardPubsub{})
6161 cfg.Domain = "pgs.sh"
6262 cfg.WebProtocol = "https"
6363 done := make(chan error)
+2, -2
......@@ -17,7 +17,7 @@ import (
1717 "github.com/picosh/pico/pkg/tunkit"
1818 )
1919
20-func createSshServer(cfg *PgsConfig, ctx context.Context, cacheClearingQueue chan string) (*pssh.SSHServer, error) {
20+func createSshServer(cfg *Config, ctx context.Context, cacheClearingQueue chan string) (*pssh.SSHServer, error) {
2121 host := shared.GetEnv("PGS_HOST", "0.0.0.0")
2222 port := shared.GetEnv("PGS_SSH_PORT", "2222")
2323 promPort := shared.GetEnv("PGS_PROM_PORT", "9222")
......@@ -67,7 +67,7 @@ func createSshServer(cfg *PgsConfig, ctx context.Context, cacheClearingQueue cha
6767 return server, err
6868 }
6969
70-func StartSshServer(cfg *PgsConfig, killCh chan error) {
70+func StartSshServer(cfg *Config, killCh chan error) {
7171 ctx, cancel := context.WithCancel(context.Background())
7272 defer cancel()
7373
+5, -5
......@@ -24,7 +24,7 @@ import (
2424 "golang.org/x/crypto/ssh"
2525 )
2626
27-func StartSshServerForTesting(cfg *PgsConfig, killCh chan error, readyCh chan *pssh.SSHServer) {
27+func StartSshServerForTesting(cfg *Config, killCh chan error, readyCh chan *pssh.SSHServer) {
2828 ctx, cancel := context.WithCancel(context.Background())
2929 defer func() {
3030 // Cancel is deferred to avoid being called in error path
......@@ -87,7 +87,7 @@ func TestSshServerSftp(t *testing.T) {
8787 // Use dynamic port for tests to avoid port conflicts
8888 _ = os.Setenv("PGS_SSH_PORT", "0")
8989
90- cfg := NewPgsConfig(logger, dbpool, st, pubsub)
90+ cfg := NewConfig(logger, dbpool, st, pubsub)
9191 done := make(chan error)
9292 readyCh := make(chan *pssh.SSHServer)
9393 prometheus.DefaultRegisterer = prometheus.NewRegistry()
......@@ -190,7 +190,7 @@ func TestSshServerRsync(t *testing.T) {
190190 // Use dynamic port for tests to avoid port conflicts
191191 _ = os.Setenv("PGS_SSH_PORT", "0")
192192
193- cfg := NewPgsConfig(logger, dbpool, st, pubsub)
193+ cfg := NewConfig(logger, dbpool, st, pubsub)
194194 done := make(chan error)
195195 readyCh := make(chan *pssh.SSHServer)
196196 prometheus.DefaultRegisterer = prometheus.NewRegistry()
......@@ -442,7 +442,7 @@ func GenerateUser() UserSSH {
442442 }
443443 }
444444
445-func WriteFileWithSftp(cfg *PgsConfig, conn *ssh.Client) (*os.FileInfo, error) {
445+func WriteFileWithSftp(cfg *Config, conn *ssh.Client) (*os.FileInfo, error) {
446446 // open an SFTP session over an existing ssh connection.
447447 client, err := sftp.NewClient(conn)
448448 if err != nil {
......@@ -475,7 +475,7 @@ func WriteFileWithSftp(cfg *PgsConfig, conn *ssh.Client) (*os.FileInfo, error) {
475475 return &fi, nil
476476 }
477477
478-func WriteFilesMultProjectsWithSftp(cfg *PgsConfig, conn *ssh.Client) (*os.FileInfo, error) {
478+func WriteFilesMultProjectsWithSftp(cfg *Config, conn *ssh.Client) (*os.FileInfo, error) {
479479 // open an SFTP session over an existing ssh connection.
480480 client, err := sftp.NewClient(conn)
481481 if err != nil {
+2, -4
......@@ -1,7 +1,6 @@
11 package pgs
22
33 import (
4- "context"
54 "net/http"
65 "strings"
76 "time"
......@@ -29,8 +28,7 @@ func tunnelPerm(proj *db.Project) bool {
2928 }
3029
3130 func (web *TunnelWebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
32- ctx := r.Context()
33- ctx = context.WithValue(ctx, router.CtxSubdomainKey{}, web.subdomain)
31+ ctx := router.WithSubdomain(r.Context(), web.subdomain)
3432 web.UserRouter.ServeHTTP(w, r.WithContext(ctx))
3533 }
3634
......@@ -45,7 +43,7 @@ func getInfoFromUser(user string) (string, string) {
4543 return "", user
4644 }
4745
48-func CreateHttpHandler(cfg *PgsConfig) CtxHttpBridge {
46+func CreateHttpHandler(cfg *Config) CtxHttpBridge {
4947 return func(ctx *pssh.SSHServerConnSession) http.Handler {
5048 logger := cfg.Logger
5149 asUser, subdomain := getInfoFromUser(ctx.User())
+3, -3
......@@ -65,11 +65,11 @@ type FileData struct {
6565 }
6666
6767 type UploadAssetHandler struct {
68- Cfg *PgsConfig
68+ Cfg *Config
6969 CacheClearingQueue chan string
7070 }
7171
72-func NewUploadAssetHandler(cfg *PgsConfig, ch chan string, ctx context.Context) *UploadAssetHandler {
72+func NewUploadAssetHandler(cfg *Config, ch chan string, ctx context.Context) *UploadAssetHandler {
7373 go runCacheQueue(cfg, ctx)
7474 return &UploadAssetHandler{
7575 Cfg: cfg,
......@@ -513,7 +513,7 @@ func (h *UploadAssetHandler) writeAsset(s *pssh.SSHServerConnSession, reader io.
513513 // One message arrives per file that is written/deleted during uploads.
514514 // Repeated messages for the same site are grouped so that we only flush once
515515 // per site per 5 seconds.
516-func runCacheQueue(cfg *PgsConfig, ctx context.Context) {
516+func runCacheQueue(cfg *Config, ctx context.Context) {
517517 var pendingFlushes sync.Map
518518 tick := time.NewTicker(5 * time.Second)
519519 defer tick.Stop()
+5, -5
......@@ -59,7 +59,7 @@ func TestRsyncDeleteDirectory(t *testing.T) {
5959 _ = os.Setenv("PGS_SSH_PORT", "0")
6060 _ = os.Setenv("PGS_PROM_PORT", "0")
6161
62- cfg := NewPgsConfig(logger, dbpool, st, pubsub)
62+ cfg := NewConfig(logger, dbpool, st, pubsub)
6363 done := make(chan error)
6464 readyCh := make(chan *pssh.SSHServer)
6565 prometheus.DefaultRegisterer = prometheus.NewRegistry()
......@@ -250,7 +250,7 @@ func TestRsyncDeleteNestedEmptyDirectories(t *testing.T) {
250250 _ = os.Setenv("PGS_SSH_PORT", "0")
251251 _ = os.Setenv("PGS_PROM_PORT", "0")
252252
253- cfg := NewPgsConfig(logger, dbpool, st, pubsub)
253+ cfg := NewConfig(logger, dbpool, st, pubsub)
254254 done := make(chan error)
255255 readyCh := make(chan *pssh.SSHServer)
256256 prometheus.DefaultRegisterer = prometheus.NewRegistry()
......@@ -389,7 +389,7 @@ func setupPgsTestServer(t *testing.T, dbpool *pgsdb.MemoryDB) (string, func()) {
389389 _ = os.Setenv("PGS_SSH_PORT", "0")
390390 _ = os.Setenv("PGS_PROM_PORT", "0")
391391
392- cfg := NewPgsConfig(logger, dbpool, st, pubsub)
392+ cfg := NewConfig(logger, dbpool, st, pubsub)
393393 done := make(chan error)
394394 readyCh := make(chan *pssh.SSHServer)
395395 prometheus.DefaultRegisterer = prometheus.NewRegistry()
......@@ -550,7 +550,7 @@ func TestNoPlusFlagBlocksUpload(t *testing.T) {
550550
551551 func TestFindFeatureFlag(t *testing.T) {
552552 logger := slog.Default()
553- cfg := &PgsConfig{
553+ cfg := &Config{
554554 MaxSize: uint64(100 * shared.MB),
555555 MaxAssetSize: int64(10 * shared.MB),
556556 MaxSpecialFileSize: int64(5 * shared.KB),
......@@ -723,7 +723,7 @@ func TestSftpDirectories(t *testing.T) {
723723 t.Fatal(err)
724724 }
725725 _ = os.Setenv("PGS_SSH_PORT", "0")
726- cfg := NewPgsConfig(logger, dbpool, st, discardPubsub{})
726+ cfg := NewConfig(logger, dbpool, st, discardPubsub{})
727727 done := make(chan error)
728728 defer close(done)
729729 readyCh := make(chan *pssh.SSHServer)
+13, -101
......@@ -5,10 +5,8 @@ import (
55 "context"
66 "errors"
77 "fmt"
8- "html/template"
98 "log/slog"
109 "net/http"
11- "os"
1210 "path/filepath"
1311 "regexp"
1412 "strings"
......@@ -109,7 +107,7 @@ func (p *PromCacheMetrics) AddUpstreamRequest() {
109107 p.UpstreamReq.Add(1)
110108 }
111109
112-func NewPgsHttpCache(cfg *PgsConfig, upstream http.Handler) (*httpcache.HttpCache, *SiteCache) {
110+func NewPgsHttpCache(cfg *Config, upstream http.Handler) (*httpcache.HttpCache, *SiteCache) {
113111 ttl := cfg.CacheTTL
114112 metrics := NewPromCacheMetrics(cfg.Logger, prometheus.DefaultRegisterer)
115113 cache := NewSiteCache(cfg.CacheMaxItems, ttl, metrics.EvictCacheItem)
......@@ -135,7 +133,7 @@ func NewPgsHttpCache(cfg *PgsConfig, upstream http.Handler) (*httpcache.HttpCach
135133 return httpCache, cache
136134 }
137135
138-func StartApiServer(cfg *PgsConfig) {
136+func StartApiServer(cfg *Config) {
139137 ctx := context.Background()
140138
141139 router := NewWebRouter(cfg)
......@@ -158,7 +156,7 @@ func StartApiServer(cfg *PgsConfig) {
158156 type HasPerm = func(proj *db.Project) bool
159157
160158 type WebRouter struct {
161- Cfg *PgsConfig
159+ Cfg *Config
162160 RootRouter *http.ServeMux
163161 UserRouter *http.ServeMux
164162 RedirectsCache *expirable.LRU[string, []*RedirectRule]
......@@ -178,11 +176,11 @@ func (s *siteInfo) hasPicoPlus() bool {
178176 return s.plus != nil && s.plus.ExpiresAt.After(time.Now())
179177 }
180178
181-func NewWebRouter(cfg *PgsConfig) *WebRouter {
179+func NewWebRouter(cfg *Config) *WebRouter {
182180 return newWebRouter(cfg)
183181 }
184182
185-func newWebRouter(cfg *PgsConfig) *WebRouter {
183+func newWebRouter(cfg *Config) *WebRouter {
186184 router := &WebRouter{
187185 Cfg: cfg,
188186 RedirectsCache: expirable.NewLRU[string, []*RedirectRule](2048, nil, shared.CacheTimeout),
......@@ -194,23 +192,12 @@ func newWebRouter(cfg *PgsConfig) *WebRouter {
194192 }
195193
196194 func (web *WebRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
197- subdomain := router.GetSubdomainFromRequest(r, web.Cfg.Domain, web.Cfg.TxtPrefix)
198195 if web.RootRouter == nil || web.UserRouter == nil {
199196 web.Cfg.Logger.Error("routers not initialized")
200197 http.Error(w, "routers not initialized", http.StatusInternalServerError)
201198 return
202199 }
203-
204- var mux *http.ServeMux
205- if subdomain == "" {
206- mux = web.RootRouter
207- } else {
208- mux = web.UserRouter
209- }
210-
211- ctx := r.Context()
212- ctx = context.WithValue(ctx, router.CtxSubdomainKey{}, subdomain)
213- mux.ServeHTTP(w, r.WithContext(ctx))
200+ router.NewHostRouter(web.Cfg.Domain, web.Cfg.TxtPrefix, web.RootRouter, web.UserRouter).ServeHTTP(w, r)
214201 }
215202
216203 // clearSiteCache drops what is cached about a site: its database records and
......@@ -272,14 +259,15 @@ func (web *WebRouter) initRouters() {
272259 w.Header().Set("cache-control", "no-store")
273260 promhttp.Handler().ServeHTTP(w, r)
274261 })
275- rootRouter.Handle("GET /main.css", web.serveFile("main.css", "text/css"))
276- rootRouter.Handle("GET /favicon-16x16.png", web.serveFile("favicon-16x16.png", "image/png"))
277- rootRouter.Handle("GET /favicon.ico", web.serveFile("favicon.ico", "image/x-icon"))
278- rootRouter.Handle("GET /robots.txt", web.serveFile("robots.txt", "text/plain"))
262+ files := web.Cfg.Static()
263+ rootRouter.Handle("GET /main.css", files.File("main.css", "text/css"))
264+ rootRouter.Handle("GET /favicon-16x16.png", files.File("favicon-16x16.png", "image/png"))
265+ rootRouter.Handle("GET /favicon.ico", files.File("favicon.ico", "image/x-icon"))
266+ rootRouter.Handle("GET /robots.txt", files.File("robots.txt", "text/plain"))
279267
280268 rootRouter.Handle("GET /rss/updated", web.createRssHandler("updated_at"))
281269 rootRouter.Handle("GET /rss", web.createRssHandler("created_at"))
282- rootRouter.Handle("GET /{$}", web.createPageHandler("html/marketing.page.tmpl"))
270+ rootRouter.Handle("GET /{$}", files.Page("html/marketing.page.tmpl"))
283271 web.RootRouter = rootRouter
284272
285273 // subdomain or custom domains
......@@ -291,82 +279,6 @@ func (web *WebRouter) initRouters() {
291279 web.UserRouter = userRouter
292280 }
293281
294-func (web *WebRouter) serveFile(file string, contentType string) http.HandlerFunc {
295- return func(w http.ResponseWriter, r *http.Request) {
296- logger := web.Cfg.Logger
297- cfg := web.Cfg
298-
299- contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
300- if err != nil {
301- logger.Error(
302- "could not read file",
303- "fname", file,
304- "err", err.Error(),
305- )
306- http.Error(w, "file not found", 404)
307- }
308-
309- w.Header().Add("Content-Type", contentType)
310-
311- _, err = w.Write(contents)
312- if err != nil {
313- logger.Error(
314- "could not write http response",
315- "file", file,
316- "err", err.Error(),
317- )
318- }
319- }
320-}
321-
322-func renderTemplate(cfg *PgsConfig, templates []string) (*template.Template, error) {
323- files := make([]string, len(templates))
324- copy(files, templates)
325- files = append(
326- files,
327- cfg.StaticPath("html/footer.partial.tmpl"),
328- cfg.StaticPath("html/marketing-footer.partial.tmpl"),
329- cfg.StaticPath("html/base.layout.tmpl"),
330- )
331-
332- ts, err := template.New("base").ParseFiles(files...)
333- if err != nil {
334- return nil, err
335- }
336- return ts, nil
337-}
338-
339-func (web *WebRouter) createPageHandler(fname string) http.HandlerFunc {
340- return func(w http.ResponseWriter, r *http.Request) {
341- logger := web.Cfg.Logger
342- cfg := web.Cfg
343- ts, err := renderTemplate(cfg, []string{cfg.StaticPath(fname)})
344-
345- if err != nil {
346- logger.Error(
347- "could not render template",
348- "fname", fname,
349- "err", err.Error(),
350- )
351- http.Error(w, err.Error(), http.StatusInternalServerError)
352- return
353- }
354-
355- data := shared.PageData{
356- Site: shared.SitePageData{Domain: template.URL(cfg.Domain), HomeURL: "/"},
357- }
358- err = ts.Execute(w, data)
359- if err != nil {
360- logger.Error(
361- "could not execute template",
362- "fname", fname,
363- "err", err.Error(),
364- )
365- http.Error(w, err.Error(), http.StatusInternalServerError)
366- }
367- }
368-}
369-
370282 func (web *WebRouter) checkHandler(w http.ResponseWriter, r *http.Request) {
371283 dbpool := web.Cfg.DB
372284 cfg := web.Cfg
......@@ -430,7 +342,7 @@ func (web *WebRouter) checkHandler(w http.ResponseWriter, r *http.Request) {
430342 // CacheMgmt applies the purges published on the cache-drain pipe. onPurge,
431343 // when set, clears whatever else is cached about the site; it runs first so
432344 // the http cache isn't refilled from stale records.
433-func CacheMgmt(ctx context.Context, cfg *PgsConfig, cache *SiteCache, onPurge func(site string)) {
345+func CacheMgmt(ctx context.Context, cfg *Config, cache *SiteCache, onPurge func(site string)) {
434346 cfg.Logger.Info("cache mgmt initiated")
435347 for {
436348 scanner := bufio.NewScanner(cfg.Pubsub)
+2, -2
......@@ -105,13 +105,13 @@ func CreateSubCacheDrain(ctx context.Context, logger *slog.Logger) *pipe.Reconne
105105 // cached assets for a given subdomain are grouped under a single key (which is
106106 // separate from the "GET-https-example.com-/path" key used for serving files
107107 // from the cache).
108-func purgeCache(cfg *PgsConfig, writer io.Writer, surrogate string) error {
108+func purgeCache(cfg *Config, writer io.Writer, surrogate string) error {
109109 cfg.Logger.Info("purging cache", "surrogate", surrogate)
110110 _, err := writer.Write([]byte(surrogate + "\n"))
111111 return err
112112 }
113113
114-func purgeAllCache(cfg *PgsConfig, writer io.Writer) error {
114+func purgeAllCache(cfg *Config, writer io.Writer) error {
115115 return purgeCache(cfg, writer, "*")
116116 }
117117
+6, -6
......@@ -461,7 +461,7 @@ func TestApiBasic(t *testing.T) {
461461 defer func() {
462462 _ = pubsub.Close()
463463 }()
464- cfg := NewPgsConfig(logger, dbpool, st, pubsub)
464+ cfg := NewConfig(logger, dbpool, st, pubsub)
465465 cfg.Domain = "pgs.test"
466466 router := NewWebRouter(cfg)
467467 router.ServeHTTP(responseRecorder, request)
......@@ -543,7 +543,7 @@ func TestApiHttpPassNotCached(t *testing.T) {
543543 defer func() {
544544 _ = pubsub.Close()
545545 }()
546- cfg := NewPgsConfig(logger, dbpool, st, pubsub)
546+ cfg := NewConfig(logger, dbpool, st, pubsub)
547547 cfg.Domain = "pgs.test"
548548 router := NewWebRouter(cfg)
549549
......@@ -662,7 +662,7 @@ func TestDirectoryListing(t *testing.T) {
662662 defer func() {
663663 _ = pubsub.Close()
664664 }()
665- cfg := NewPgsConfig(logger, dbpool, st, pubsub)
665+ cfg := NewConfig(logger, dbpool, st, pubsub)
666666 cfg.Domain = "pgs.test"
667667 router := NewWebRouter(cfg)
668668 router.ServeHTTP(responseRecorder, request)
......@@ -754,7 +754,7 @@ func TestDirectoryListing(t *testing.T) {
754754 // defer func() {
755755 // _ = pubsub.Close()
756756 // }()
757-// cfg := NewPgsConfig(logger, dbpool, st, pubsub)
757+// cfg := NewConfig(logger, dbpool, st, pubsub)
758758 // cfg.Domain = "pgs.test"
759759 // router := NewWebRouter(cfg)
760760 // router.ServeHTTP(responseRecorder, request)
......@@ -795,7 +795,7 @@ func TestRulesCacheClearedByUploadPurge(t *testing.T) {
795795 if err != nil {
796796 t.Fatal(err)
797797 }
798- cfg := NewPgsConfig(logger, dbpool, st, discardPubsub{})
798+ cfg := NewConfig(logger, dbpool, st, discardPubsub{})
799799 cfg.Domain = "pgs.test"
800800 router := newWebRouter(cfg)
801801
......@@ -853,7 +853,7 @@ func TestSiteCache(t *testing.T) {
853853 if err != nil {
854854 t.Fatal(err)
855855 }
856- cfg := NewPgsConfig(logger, dbpool, st, discardPubsub{})
856+ cfg := NewConfig(logger, dbpool, st, discardPubsub{})
857857 cfg.Domain = "pgs.test"
858858 router := newWebRouter(cfg)
859859
+10, -2
......@@ -1,17 +1,25 @@
11 package pico
22
33 import (
4+ "log/slog"
45 "strings"
56
67 "github.com/picosh/pico/pkg/shared"
78 )
89
9-func NewConfigSite(service string) *shared.ConfigSite {
10+type Config struct {
11+ DbURL string
12+ Space string
13+ TunsSecret string
14+ Logger *slog.Logger
15+}
16+
17+func NewConfig(service string) *Config {
1018 dbURL := shared.GetEnv("DATABASE_URL", "")
1119 tuns := shared.GetEnv("TUNS_CONSOLE_SECRET", "")
1220 withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
1321
14- return &shared.ConfigSite{
22+ return &Config{
1523 DbURL: dbURL,
1624 Space: "pico",
1725 Logger: shared.CreateLogger(service, withPipe),
+2, -2
......@@ -20,10 +20,10 @@ import (
2020
2121 type UploadHandler struct {
2222 DBPool db.DB
23- Cfg *shared.ConfigSite
23+ Cfg *Config
2424 }
2525
26-func NewUploadHandler(dbpool db.DB, cfg *shared.ConfigSite) *UploadHandler {
26+func NewUploadHandler(dbpool db.DB, cfg *Config) *UploadHandler {
2727 return &UploadHandler{
2828 DBPool: dbpool,
2929 Cfg: cfg,
+6, -6
......@@ -43,7 +43,7 @@ func StartSshServer() {
4343 host := shared.GetEnv("PICO_HOST", "0.0.0.0")
4444 port := shared.GetEnv("PICO_SSH_PORT", "2222")
4545 promPort := shared.GetEnv("PICO_PROM_PORT", "9222")
46- cfg := NewConfigSite(appName)
46+ cfg := NewConfig(appName)
4747 logger := cfg.Logger
4848
4949 ctx, cancel := context.WithCancel(context.Background())
......@@ -99,11 +99,11 @@ func StartSshServer() {
9999 func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
100100 return func(sesh *pssh.SSHServerConnSession) error {
101101 shrd := &tui.SharedModel{
102- Session: sesh,
103- Cfg: cfg,
104- Dbpool: handler.DBPool,
105- PgsDB: pgsDB,
106- Logger: pssh.GetLogger(sesh),
102+ Session: sesh,
103+ TunsSecret: cfg.TunsSecret,
104+ Dbpool: handler.DBPool,
105+ PgsDB: pgsDB,
106+ Logger: pssh.GetLogger(sesh),
107107 }
108108 return pssh.PtyMdw(createTui(shrd), 200*time.Millisecond)(next)(sesh)
109109 }
+117, -154
......@@ -9,25 +9,22 @@ import (
99 "io"
1010 "net/http"
1111 "net/url"
12- "os"
1312 "regexp"
1413 "strings"
1514 "sync"
1615 "time"
1716
18- "github.com/google/uuid"
1917 "github.com/gorilla/websocket"
20- "github.com/picosh/pico/pkg/db"
2118 "github.com/picosh/pico/pkg/db/postgres"
2219 "github.com/picosh/pico/pkg/shared"
2320 "github.com/picosh/pico/pkg/shared/router"
2421 "github.com/picosh/utils/pipe"
2522 "github.com/prometheus/client_golang/prometheus/promhttp"
23+ "golang.org/x/crypto/ssh"
2624 )
2725
2826 var (
2927 cleanRegex = regexp.MustCompile(`[^0-9a-zA-Z,/]`)
30- sshClient *pipe.Client
3128 upgrader = websocket.Upgrader{
3229 CheckOrigin: func(r *http.Request) bool {
3330 return true
......@@ -35,41 +32,6 @@ var (
3532 }
3633 )
3734
38-func serveFile(file string, contentType string) http.HandlerFunc {
39- return func(w http.ResponseWriter, r *http.Request) {
40- logger := router.GetLogger(r)
41- cfg := router.GetCfg(r)
42-
43- contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
44- if err != nil {
45- logger.Error("could not read statis file", "err", err.Error())
46- http.Error(w, "file not found", 404)
47- }
48- w.Header().Add("Content-Type", contentType)
49-
50- _, err = w.Write(contents)
51- if err != nil {
52- logger.Error("could not write static file", "err", err.Error())
53- http.Error(w, "server error", http.StatusInternalServerError)
54- }
55- }
56-}
57-
58-func createStaticRoutes() []router.Route {
59- return []router.Route{
60- router.NewRoute("GET", "/main.css", serveFile("main.css", "text/css")),
61- router.NewRoute("GET", "/smol.css", serveFile("smol.css", "text/css")),
62- router.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
63- router.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
64- router.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
65- router.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
66- router.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
67- router.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
68- router.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
69- router.NewRoute("GET", "/anim.js", serveFile("anim.js", "text/javascript")),
70- }
71-}
72-
7335 type writeFlusher struct {
7436 responseWriter http.ResponseWriter
7537 controller *http.ResponseController
......@@ -85,12 +47,19 @@ func (w writeFlusher) Write(p []byte) (n int, err error) {
8547
8648 var _ io.Writer = writeFlusher{}
8749
88-func handleSub(pubsub bool) http.HandlerFunc {
50+const (
51+ // pongWait is how long a websocket client has to answer a ping.
52+ pongWait = 60 * time.Second
53+ pingPeriod = pongWait / 2
54+ writeWait = 10 * time.Second
55+)
56+
57+func (web *webRouter) handleSub(pubsub bool) http.HandlerFunc {
8958 return func(w http.ResponseWriter, r *http.Request) {
90- logger := router.GetLogger(r)
59+ logger := web.cfg.Logger
9160
9261 clientInfo := shared.NewPicoPipeClient()
93- topic, _ := url.PathUnescape(router.GetField(r, 0))
62+ topic, _ := url.PathUnescape(r.PathValue("topic"))
9463
9564 topic = cleanRegex.ReplaceAllString(topic, "")
9665
......@@ -107,22 +76,15 @@ func handleSub(pubsub bool) http.HandlerFunc {
10776 params += fmt.Sprintf(" -a=%s", cleanList)
10877 }
10978
110- id := uuid.NewString()
111-
112- p, err := sshClient.AddSession(id, fmt.Sprintf("sub %s %s", params, topic), 0, -1, -1)
79+ p, err := web.conn.run(r.Context(), fmt.Sprintf("sub %s %s", params, topic))
11380 if err != nil {
11481 logger.Error("sub error", "topic", topic, "info", clientInfo, "err", err.Error())
11582 http.Error(w, "server error", http.StatusInternalServerError)
11683 return
11784 }
118-
119- go func() {
120- <-r.Context().Done()
121- err := sshClient.RemoveSession(id)
122- if err != nil {
123- logger.Error("sub remove error", "topic", topic, "info", clientInfo, "err", err.Error())
124- }
125- }()
85+ defer func() { _ = p.Close() }()
86+ // A client that leaves while nothing is published ends the sub.
87+ defer context.AfterFunc(r.Context(), func() { _ = p.Close() })()
12688
12789 if mime := r.URL.Query().Get("mime"); mime != "" {
12890 w.Header().Add("Content-Type", r.URL.Query().Get("mime"))
......@@ -138,12 +100,12 @@ func handleSub(pubsub bool) http.HandlerFunc {
138100 }
139101 }
140102
141-func handlePub(pubsub bool) http.HandlerFunc {
103+func (web *webRouter) handlePub(pubsub bool) http.HandlerFunc {
142104 return func(w http.ResponseWriter, r *http.Request) {
143- logger := router.GetLogger(r)
105+ logger := web.cfg.Logger
144106
145107 clientInfo := shared.NewPicoPipeClient()
146- topic, _ := url.PathUnescape(router.GetField(r, 0))
108+ topic, _ := url.PathUnescape(r.PathValue("topic"))
147109
148110 topic = cleanRegex.ReplaceAllString(topic, "")
149111
......@@ -179,22 +141,14 @@ func handlePub(pubsub bool) http.HandlerFunc {
179141 params += " -e"
180142 }
181143
182- id := uuid.NewString()
183-
184- p, err := sshClient.AddSession(id, fmt.Sprintf("pub %s %s", params, topic), 0, -1, -1)
144+ p, err := web.conn.run(r.Context(), fmt.Sprintf("pub %s %s", params, topic))
185145 if err != nil {
186146 logger.Error("pub error", "topic", topic, "info", clientInfo, "err", err.Error())
187147 http.Error(w, "server error", http.StatusInternalServerError)
188148 return
189149 }
190-
191- go func() {
192- <-r.Context().Done()
193- err := sshClient.RemoveSession(id)
194- if err != nil {
195- logger.Error("pub remove error", "topic", topic, "info", clientInfo, "err", err.Error())
196- }
197- }()
150+ defer func() { _ = p.Close() }()
151+ defer context.AfterFunc(r.Context(), func() { _ = p.Close() })()
198152
199153 var scanErr error
200154 scanStatus := http.StatusInternalServerError
......@@ -243,30 +197,16 @@ func handlePub(pubsub bool) http.HandlerFunc {
243197 return
244198 }
245199
246- outer:
247- for {
248- select {
249- case <-r.Context().Done():
250- break outer
251- default:
252- messageToWrite := first
253- if prefix != "" {
254- messageToWrite = append([]byte(prefix), messageToWrite...)
255- }
256-
257- n, err := p.Write(messageToWrite)
258- if err != nil {
259- logger.Error("pub write error", "topic", topic, "info", clientInfo, "err", err.Error())
260- http.Error(w, "server error", http.StatusInternalServerError)
261- return
262- }
263-
264- if n > 0 {
265- break outer
266- }
200+ messageToWrite := first
201+ if prefix != "" {
202+ messageToWrite = append([]byte(prefix), messageToWrite...)
203+ }
267204
268- time.Sleep(10 * time.Millisecond)
269- }
205+ _, err = p.Write(messageToWrite)
206+ if err != nil {
207+ logger.Error("pub write error", "topic", topic, "info", clientInfo, "err", err.Error())
208+ http.Error(w, "server error", http.StatusInternalServerError)
209+ return
270210 }
271211
272212 _, err = io.Copy(p, reader)
......@@ -276,15 +216,23 @@ func handlePub(pubsub bool) http.HandlerFunc {
276216 return
277217 }
278218
279- w.WriteHeader(http.StatusOK)
219+ // pub sends the message once its stdin ends, and exits when it has.
220+ // Older pipe servers close the session without an exit status.
221+ _ = p.CloseWrite()
222+ var missing *ssh.ExitMissingError
223+ if err := p.Wait(); err != nil && !errors.As(err, &missing) {
224+ logger.Error("pub exit error", "topic", topic, "info", clientInfo, "err", err.Error())
225+ http.Error(w, "server error", http.StatusInternalServerError)
226+ return
227+ }
280228
281- time.Sleep(10 * time.Millisecond)
229+ w.WriteHeader(http.StatusOK)
282230 }
283231 }
284232
285-func handlePipe() http.HandlerFunc {
233+func (web *webRouter) handlePipe() http.HandlerFunc {
286234 return func(w http.ResponseWriter, r *http.Request) {
287- logger := router.GetLogger(r)
235+ logger := web.cfg.Logger
288236
289237 c, err := upgrader.Upgrade(w, r, nil)
290238 if err != nil {
......@@ -297,7 +245,7 @@ func handlePipe() http.HandlerFunc {
297245 }()
298246
299247 clientInfo := shared.NewPicoPipeClient()
300- topic, _ := url.PathUnescape(router.GetField(r, 0))
248+ topic, _ := url.PathUnescape(r.PathValue("topic"))
301249
302250 topic = cleanRegex.ReplaceAllString(topic, "")
303251
......@@ -325,22 +273,36 @@ func handlePipe() http.HandlerFunc {
325273
326274 prefix := r.URL.Query().Get("prefix")
327275
328- id := uuid.NewString()
329-
330- p, err := sshClient.AddSession(id, fmt.Sprintf("pipe %s %s", params, topic), 0, -1, -1)
276+ p, err := web.conn.run(r.Context(), fmt.Sprintf("pipe %s %s", params, topic))
331277 if err != nil {
332278 logger.Error("pipe error", "topic", topic, "info", clientInfo, "err", err.Error())
333- http.Error(w, "server error", http.StatusInternalServerError)
279+ _ = c.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "server error"), time.Now().Add(time.Second))
334280 return
335281 }
336-
282+ defer func() { _ = p.Close() }()
283+
284+ // A client that stops answering pings is gone, even if its
285+ // connection never closed.
286+ _ = c.SetReadDeadline(time.Now().Add(pongWait))
287+ c.SetPongHandler(func(string) error {
288+ return c.SetReadDeadline(time.Now().Add(pongWait))
289+ })
290+ done := make(chan struct{})
291+ defer close(done)
337292 go func() {
338- <-r.Context().Done()
339- err := sshClient.RemoveSession(id)
340- if err != nil {
341- logger.Error("pipe remove error", "topic", topic, "info", clientInfo, "err", err.Error())
293+ ticker := time.NewTicker(pingPeriod)
294+ defer ticker.Stop()
295+ for {
296+ select {
297+ case <-done:
298+ return
299+ case <-ticker.C:
300+ if err := c.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeWait)); err != nil {
301+ _ = c.Close()
302+ return
303+ }
304+ }
342305 }
343- _ = c.Close()
344306 }()
345307
346308 var wg sync.WaitGroup
......@@ -376,10 +338,9 @@ func handlePipe() http.HandlerFunc {
376338 }()
377339
378340 var messageBuffer []byte
341+ buf := make([]byte, 32*1024)
379342
380343 for {
381- buf := make([]byte, 32*1024)
382-
383344 n, err := p.Read(buf)
384345 if err != nil {
385346 logger.Error("pipe read error", "topic", topic, "info", clientInfo, "err", err.Error())
......@@ -434,9 +395,9 @@ func handlePipe() http.HandlerFunc {
434395 }
435396 }
436397
437-func rssHandler(cfg *shared.ConfigSite, dbpool db.DB) http.HandlerFunc {
398+func rssHandler(cfg *Config, dbpool DB) http.HandlerFunc {
438399 return func(w http.ResponseWriter, r *http.Request) {
439- apiToken, _ := url.PathUnescape(router.GetField(r, 0))
400+ apiToken, _ := url.PathUnescape(r.PathValue("token"))
440401 user, err := dbpool.FindUserByToken(apiToken)
441402 if err != nil {
442403 cfg.Logger.Error(
......@@ -471,62 +432,68 @@ func rssHandler(cfg *shared.ConfigSite, dbpool db.DB) http.HandlerFunc {
471432 }
472433 }
473434
474-func createMainRoutes(staticRoutes []router.Route, cfg *shared.ConfigSite, dbpool db.DB) []router.Route {
475- routes := []router.Route{
476- router.NewRoute("GET", "/", router.CreatePageHandler("html/marketing.page.tmpl")),
477- router.NewRoute("GET", "/check", router.CheckHandler),
478- router.NewRoute("GET", "/rss/(.+)", rssHandler(cfg, dbpool)),
479- router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
480- }
481-
482- pipeRoutes := []router.Route{
483- router.NewRoute("GET", "/topic/(.+)", handleSub(false)),
484- router.NewRoute("POST", "/topic/(.+)", handlePub(false)),
485- router.NewRoute("GET", "/pubsub/(.+)", handleSub(true)),
486- router.NewRoute("POST", "/pubsub/(.+)", handlePub(true)),
487- router.NewRoute("GET", "/pipe/(.+)", handlePipe()),
488- }
435+type webRouter struct {
436+ cfg *Config
437+ db DB
438+ conn *pipeConn
439+}
489440
490- for _, route := range pipeRoutes {
491- route.CorsEnabled = true
492- routes = append(routes, route)
441+func (web *webRouter) static(mux *http.ServeMux) {
442+ cfg := web.cfg
443+ files := cfg.Static()
444+ mux.HandleFunc("GET /favicon-16x16.png", files.File("favicon-16x16.png", "image/png"))
445+ mux.HandleFunc("GET /apple-touch-icon.png", files.File("apple-touch-icon.png", "image/png"))
446+ mux.HandleFunc("GET /favicon.ico", files.File("favicon.ico", "image/x-icon"))
447+ mux.HandleFunc("GET /robots.txt", files.File("robots.txt", "text/plain"))
448+ mux.HandleFunc("GET /anim.js", files.File("anim.js", "text/javascript"))
449+ if cfg.Debug {
450+ router.CreatePProfRoutesMux(mux)
493451 }
452+}
494453
495- routes = append(
496- routes,
497- staticRoutes...,
498- )
454+func (web *webRouter) mainMux() *http.ServeMux {
455+ mux := http.NewServeMux()
456+ mux.HandleFunc("GET /{$}", web.cfg.Static().Page("html/marketing.page.tmpl"))
457+ mux.HandleFunc("GET /check", router.CheckHandler(web.cfg.Domain, web.cfg.Space, web.db))
458+ mux.HandleFunc("GET /rss/{token...}", rssHandler(web.cfg, web.db))
459+ mux.Handle("GET /_metrics", promhttp.Handler())
460+
461+ mux.HandleFunc("GET /topic/{topic...}", router.Cors(web.handleSub(false)))
462+ mux.HandleFunc("POST /topic/{topic...}", router.Cors(web.handlePub(false)))
463+ mux.HandleFunc("OPTIONS /topic/{topic...}", router.CorsPreflight)
464+ mux.HandleFunc("GET /pubsub/{topic...}", router.Cors(web.handleSub(true)))
465+ mux.HandleFunc("POST /pubsub/{topic...}", router.Cors(web.handlePub(true)))
466+ mux.HandleFunc("OPTIONS /pubsub/{topic...}", router.CorsPreflight)
467+ mux.HandleFunc("GET /pipe/{topic...}", router.Cors(web.handlePipe()))
468+ mux.HandleFunc("OPTIONS /pipe/{topic...}", router.CorsPreflight)
469+
470+ web.static(mux)
471+ return mux
472+}
499473
500- return routes
474+// sitesMux serves only static files on subdomains.
475+func (web *webRouter) sitesMux() *http.ServeMux {
476+ mux := http.NewServeMux()
477+ web.static(mux)
478+ return mux
501479 }
502480
503481 func StartApiServer() {
504- cfg := NewConfigSite("pipe-web")
505- db := postgres.NewDB(cfg.DbURL, cfg.Logger)
482+ cfg := NewConfig("pipe-web")
483+ db := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
506484 defer func() {
507485 _ = db.Close()
508486 }()
509487 logger := cfg.Logger
510488
511- staticRoutes := createStaticRoutes()
512-
513- if cfg.Debug {
514- staticRoutes = router.CreatePProfRoutes(staticRoutes)
515- }
516-
517- mainRoutes := createMainRoutes(staticRoutes, cfg, db)
518- subdomainRoutes := staticRoutes
519-
520489 info := shared.NewPicoPipeClient()
521490
522- client, err := pipe.NewClient(context.Background(), logger.With("info", info), info)
491+ pingClient, err := pipe.NewClient(context.Background(), logger.With("info", info), info)
523492 if err != nil {
524493 panic(err)
525494 }
526495
527- sshClient = client
528-
529- pingSession, err := sshClient.AddSession("ping", "pub -b=false -c ping", 0, -1, -1)
496+ pingSession, err := pingClient.AddSession("ping", "pub -b=false -c ping", 0, -1, -1)
530497 if err != nil {
531498 panic(err)
532499 }
......@@ -542,12 +509,8 @@ func StartApiServer() {
542509 }
543510 }()
544511
545- apiConfig := &router.ApiConfig{
546- Cfg: cfg,
547- Dbpool: db,
548- }
549- handler := router.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
550- router := http.HandlerFunc(handler)
512+ web := &webRouter{cfg: cfg, db: db, conn: newPipeConn(info)}
513+ handler := router.NewHostRouter(cfg.Domain, cfg.Space, web.mainMux(), web.sitesMux())
551514
552515 portStr := fmt.Sprintf(":%s", cfg.Port)
553516 logger.Info(
......@@ -556,5 +519,5 @@ func StartApiServer() {
556519 "domain", cfg.Domain,
557520 )
558521
559- logger.Error("listen", "err", http.ListenAndServe(portStr, router).Error())
522+ logger.Error("listen", "err", http.ListenAndServe(portStr, handler).Error())
560523 }
+46, -0
......@@ -0,0 +1,46 @@
1+package pipe
2+
3+import (
4+ "log/slog"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/picosh/pico/pkg/shared/router"
10+)
11+
12+func TestRoutes(t *testing.T) {
13+ t.Chdir("../../..")
14+ t.Setenv("PICO_PIPE_ENABLED", "false")
15+ cfg := NewConfig("pipe-test")
16+ cfg.Logger = slog.New(slog.DiscardHandler)
17+ web := &webRouter{cfg: cfg, db: NewTestDB(cfg.Logger)}
18+ handler := router.NewHostRouter(cfg.Domain, cfg.Space, web.mainMux(), web.sitesMux())
19+
20+ site := "https://tester." + cfg.Domain
21+ main := "https://" + cfg.Domain
22+ for _, tt := range []struct {
23+ method string
24+ url string
25+ status int
26+ cors bool
27+ }{
28+ {method: http.MethodOptions, url: main + "/topic/a/b", status: http.StatusOK, cors: true},
29+ {method: http.MethodOptions, url: main + "/pubsub/a", status: http.StatusOK, cors: true},
30+ {method: http.MethodOptions, url: main + "/pipe/a", status: http.StatusOK, cors: true},
31+ {method: http.MethodPut, url: main + "/topic/a", status: http.StatusMethodNotAllowed},
32+ {method: http.MethodGet, url: main + "/favicon.ico", status: http.StatusOK},
33+ {method: http.MethodGet, url: main + "/missing", status: http.StatusNotFound},
34+ // Sites only serve the static files.
35+ {method: http.MethodGet, url: site + "/anim.js", status: http.StatusOK},
36+ {method: http.MethodGet, url: site + "/check", status: http.StatusNotFound},
37+ {method: http.MethodOptions, url: site + "/topic/a", status: http.StatusNotFound},
38+ } {
39+ res := httptest.NewRecorder()
40+ handler.ServeHTTP(res, httptest.NewRequest(tt.method, tt.url, nil))
41+ cors := res.Header().Get("Access-Control-Allow-Origin") == "*"
42+ if res.Code != tt.status || cors != tt.cors {
43+ t.Errorf("%s %s = %d cors %v, want %d cors %v", tt.method, tt.url, res.Code, cors, tt.status, tt.cors)
44+ }
45+ }
46+}
+6, -7
......@@ -21,7 +21,6 @@ import (
2121 "github.com/picosh/pico/pkg/db"
2222 "github.com/picosh/pico/pkg/pssh"
2323 psub "github.com/picosh/pico/pkg/pubsub"
24- "github.com/picosh/pico/pkg/shared"
2524 gossh "golang.org/x/crypto/ssh"
2625 )
2726
......@@ -43,7 +42,7 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
4342 uuidStr := uuid.NewString()
4443 isAdmin := false
4544 if user != nil {
46- isAdmin = handler.DBPool.HasFeatureByUser(user.ID, "admin")
45+ isAdmin = db.HasFeature(handler.DBPool, user.ID, "admin")
4746 if isAdmin && strings.HasPrefix(sesh.User(), "admin__") {
4847 uuidStr = fmt.Sprintf("admin-%s", uuidStr)
4948 }
......@@ -185,10 +184,10 @@ func Middleware(handler *CliHandler) pssh.SSHServerMiddleware {
185184 }
186185
187186 type CliHandler struct {
188- DBPool db.DB
187+ DBPool DB
189188 Logger *slog.Logger
190189 PubSub psub.PubSub
191- Cfg *shared.ConfigSite
190+ Cfg *Config
192191 Waiters *syncmap.Map[string, []string]
193192 Access *syncmap.Map[string, []string]
194193 }
......@@ -207,7 +206,7 @@ type CliCmd struct {
207206 user *db.User
208207 }
209208
210-func help(cfg *shared.ConfigSite, sesh *pssh.SSHServerConnSession) {
209+func help(cfg *Config, sesh *pssh.SSHServerConnSession) {
211210 data := fmt.Sprintf(`Command: ssh %s <command> [args...]
212211
213212 The simplest authenticated pubsub system. Send messages through
......@@ -548,7 +547,7 @@ func parseDuration(s string) (time.Duration, error) {
548547 return time.ParseDuration(s)
549548 }
550549
551-func MonitorRss(dbpool db.DB, user *db.User, domain string) (string, error) {
550+func MonitorRss(dbpool DB, user *db.User, domain string) (string, error) {
552551 if user == nil {
553552 return "", fmt.Errorf("access denied")
554553 }
......@@ -1153,7 +1152,7 @@ func (handler *CliHandler) pipe(cmd *CliCmd, topic string, clientID string) erro
11531152 return nil
11541153 }
11551154
1156-func toSshCmd(cfg *shared.ConfigSite) string {
1155+func toSshCmd(cfg *Config) string {
11571156 port := ""
11581157 if cfg.PortOverride != "22" {
11591158 port = fmt.Sprintf("-p %s ", cfg.PortOverride)
+31, -2
......@@ -1,19 +1,37 @@
11 package pipe
22
33 import (
4+ "html/template"
5+ "log/slog"
46 "strings"
57
68 "github.com/picosh/pico/pkg/shared"
9+ "github.com/picosh/pico/pkg/shared/router"
710 )
811
9-func NewConfigSite(service string) *shared.ConfigSite {
12+// staticDir holds pipe's templates and public files.
13+const staticDir = "pkg/apps/pipe"
14+
15+type Config struct {
16+ Debug bool
17+ Domain string
18+ Port string
19+ // PortOverride is the ssh port users connect to, when it isn't 22.
20+ PortOverride string
21+ Protocol string
22+ DbURL string
23+ Space string
24+ Logger *slog.Logger
25+}
26+
27+func NewConfig(service string) *Config {
1028 domain := shared.GetEnv("PIPE_DOMAIN", "pipe.pico.sh")
1129 port := shared.GetEnv("PIPE_WEB_PORT", "3000")
1230 dbURL := shared.GetEnv("DATABASE_URL", "")
1331 protocol := shared.GetEnv("PIPE_PROTOCOL", "https")
1432 withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
1533
16- return &shared.ConfigSite{
34+ return &Config{
1735 Domain: domain,
1836 Port: port,
1937 Protocol: protocol,
......@@ -22,3 +40,14 @@ func NewConfigSite(service string) *shared.ConfigSite {
2240 Space: "pipe",
2341 }
2442 }
43+
44+func (c *Config) Static() router.Static {
45+ return router.Static{
46+ Dir: staticDir,
47+ Logger: c.Logger,
48+ Site: shared.SitePageData{
49+ Domain: template.URL(c.Domain),
50+ HomeURL: template.URL("//" + c.Domain),
51+ },
52+ }
53+}
+257, -0
......@@ -0,0 +1,257 @@
1+package pipe
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "net"
8+ "os"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "github.com/picosh/utils/pipe"
14+ "golang.org/x/crypto/ssh"
15+)
16+
17+// dialTimeout bounds connecting to pipe, handshake included, and starting a
18+// command on it, so a server that stops answering can't hold up requests.
19+const dialTimeout = 10 * time.Second
20+
21+// pipeConn is pipe-web's ssh connection to pipe. Each request runs its
22+// command in a session of its own, which is never reopened, so the command
23+// ends when the request does.
24+type pipeConn struct {
25+ info *pipe.SSHClientInfo
26+ timeout time.Duration
27+
28+ mu sync.Mutex
29+ client *ssh.Client
30+ dialing *dial
31+}
32+
33+// dial is a connection being opened, shared by the requests waiting on it.
34+type dial struct {
35+ done chan struct{}
36+ client *ssh.Client
37+ err error
38+}
39+
40+func newPipeConn(info *pipe.SSHClientInfo) *pipeConn {
41+ return &pipeConn{info: info, timeout: dialTimeout}
42+}
43+
44+// connect returns the open connection, dialing a new one when there is none
45+// or when the open one is dead. A request that ends stops waiting, and the
46+// dial carries on for the requests after it.
47+func (c *pipeConn) connect(ctx context.Context, dead *ssh.Client) (*ssh.Client, error) {
48+ c.mu.Lock()
49+ if c.client != nil && c.client != dead {
50+ client := c.client
51+ c.mu.Unlock()
52+ return client, nil
53+ }
54+ if c.client != nil {
55+ _ = c.client.Close()
56+ c.client = nil
57+ }
58+ d := c.dialing
59+ if d == nil {
60+ d = &dial{done: make(chan struct{})}
61+ c.dialing = d
62+ go c.dial(d)
63+ }
64+ c.mu.Unlock()
65+
66+ select {
67+ case <-d.done:
68+ return d.client, d.err
69+ case <-ctx.Done():
70+ return nil, ctx.Err()
71+ }
72+}
73+
74+func (c *pipeConn) dial(d *dial) {
75+ d.client, d.err = dialSSH(c.info, c.timeout)
76+ c.mu.Lock()
77+ c.dialing = nil
78+ if d.err == nil {
79+ c.client = d.client
80+ }
81+ c.mu.Unlock()
82+ close(d.done)
83+}
84+
85+// run starts cmd on pipe. A connection that can't open a session is dialed
86+// again once.
87+func (c *pipeConn) run(ctx context.Context, cmd string) (*cmdSession, error) {
88+ client, err := c.connect(ctx, nil)
89+ if err != nil {
90+ return nil, err
91+ }
92+ s, err := c.start(ctx, client, cmd)
93+ if err == nil || ctx.Err() != nil {
94+ return s, err
95+ }
96+ client, err = c.connect(ctx, client)
97+ if err != nil {
98+ return nil, err
99+ }
100+ return c.start(ctx, client, cmd)
101+}
102+
103+type started struct {
104+ s *cmdSession
105+ err error
106+}
107+
108+// start runs startCmd until the request ends. A connection that can't
109+// start a command in time is closed, which also ends the startCmd waiting
110+// on it, and that deadline holds after the request has gone.
111+func (c *pipeConn) start(ctx context.Context, client *ssh.Client, cmd string) (*cmdSession, error) {
112+ done := make(chan started, 1)
113+ go func() {
114+ s, err := startCmd(client, cmd)
115+ done <- started{s, err}
116+ }()
117+ timer := time.NewTimer(c.timeout)
118+ select {
119+ case res := <-done:
120+ timer.Stop()
121+ return res.s, res.err
122+ case <-ctx.Done():
123+ go abandon(done, timer, client)
124+ return nil, ctx.Err()
125+ case <-timer.C:
126+ _ = client.Close()
127+ go closeStarted(done)
128+ return nil, fmt.Errorf("starting %q on pipe timed out", cmd)
129+ }
130+}
131+
132+// abandon closes a session no request is waiting for, or the connection if
133+// the session doesn't start in time.
134+func abandon(done <-chan started, timer *time.Timer, client *ssh.Client) {
135+ select {
136+ case res := <-done:
137+ timer.Stop()
138+ if res.s != nil {
139+ _ = res.s.Close()
140+ }
141+ case <-timer.C:
142+ _ = client.Close()
143+ closeStarted(done)
144+ }
145+}
146+
147+func closeStarted(done <-chan started) {
148+ if res := <-done; res.s != nil {
149+ _ = res.s.Close()
150+ }
151+}
152+
153+func dialSSH(info *pipe.SSHClientInfo, timeout time.Duration) (*ssh.Client, error) {
154+ signer, err := clientSigner(info)
155+ if err != nil {
156+ return nil, err
157+ }
158+ var auth []ssh.AuthMethod
159+ if signer != nil {
160+ auth = append(auth, ssh.PublicKeys(signer))
161+ }
162+ addr := info.RemoteHost
163+ if !strings.Contains(addr, ":") {
164+ addr += ":22"
165+ }
166+
167+ deadline := time.Now().Add(timeout)
168+ conn, err := (&net.Dialer{Deadline: deadline}).Dial("tcp", addr)
169+ if err != nil {
170+ return nil, err
171+ }
172+ _ = conn.SetDeadline(deadline)
173+ sshConn, chans, reqs, err := ssh.NewClientConn(conn, info.RemoteHostname, &ssh.ClientConfig{
174+ Auth: auth,
175+ HostKeyCallback: ssh.InsecureIgnoreHostKey(),
176+ User: info.RemoteUser,
177+ })
178+ if err != nil {
179+ _ = conn.Close()
180+ return nil, err
181+ }
182+ _ = conn.SetDeadline(time.Time{})
183+ return ssh.NewClient(sshConn, chans, reqs), nil
184+}
185+
186+// clientSigner loads the key, and the certificate for it if there is one.
187+// It returns nil when there is no key.
188+func clientSigner(info *pipe.SSHClientInfo) (ssh.Signer, error) {
189+ if info.KeyLocation == "" {
190+ return nil, nil
191+ }
192+ data, err := os.ReadFile(info.KeyLocation)
193+ if err != nil {
194+ return nil, err
195+ }
196+ var signer ssh.Signer
197+ if info.KeyPassphrase != "" {
198+ signer, err = ssh.ParsePrivateKeyWithPassphrase(data, []byte(info.KeyPassphrase))
199+ } else {
200+ signer, err = ssh.ParsePrivateKey(data)
201+ }
202+ if err != nil || info.CertificateLocation == "" {
203+ return signer, err
204+ }
205+ certData, err := os.ReadFile(info.CertificateLocation)
206+ if err != nil {
207+ return nil, err
208+ }
209+ pub, _, _, _, err := ssh.ParseAuthorizedKey(certData)
210+ if err != nil {
211+ return nil, fmt.Errorf("failed to parse certificate: %w", err)
212+ }
213+ cert, ok := pub.(*ssh.Certificate)
214+ if !ok {
215+ return nil, fmt.Errorf("%s is not an ssh certificate", info.CertificateLocation)
216+ }
217+ return ssh.NewCertSigner(cert, signer)
218+}
219+
220+// cmdSession is a command running on pipe, read from its stdout and written
221+// to its stdin.
222+type cmdSession struct {
223+ *ssh.Session
224+ stdin io.WriteCloser
225+ stdout io.Reader
226+}
227+
228+func startCmd(client *ssh.Client, cmd string) (*cmdSession, error) {
229+ session, err := client.NewSession()
230+ if err != nil {
231+ return nil, err
232+ }
233+ s := &cmdSession{Session: session}
234+ if s.stdin, err = session.StdinPipe(); err == nil {
235+ if s.stdout, err = session.StdoutPipe(); err == nil {
236+ err = session.Start(cmd)
237+ }
238+ }
239+ if err != nil {
240+ _ = session.Close()
241+ return nil, err
242+ }
243+ return s, nil
244+}
245+
246+func (s *cmdSession) Read(p []byte) (int, error) {
247+ return s.stdout.Read(p)
248+}
249+
250+func (s *cmdSession) Write(p []byte) (int, error) {
251+ return s.stdin.Write(p)
252+}
253+
254+// CloseWrite sends the command EOF on its stdin.
255+func (s *cmdSession) CloseWrite() error {
256+ return s.stdin.Close()
257+}
+227, -0
......@@ -0,0 +1,227 @@
1+package pipe
2+
3+import (
4+ "context"
5+ "crypto/ed25519"
6+ "crypto/rand"
7+ "errors"
8+ "io"
9+ "net"
10+ "runtime"
11+ "strings"
12+ "sync"
13+ "sync/atomic"
14+ "testing"
15+ "time"
16+
17+ "github.com/picosh/utils/pipe"
18+ "golang.org/x/crypto/ssh"
19+)
20+
21+// closers closes what a test server opened once the test ends.
22+type closers struct {
23+ mu sync.Mutex
24+ cs []io.Closer
25+}
26+
27+func newClosers(t *testing.T) *closers {
28+ c := &closers{}
29+ t.Cleanup(func() {
30+ c.mu.Lock()
31+ defer c.mu.Unlock()
32+ for _, cl := range c.cs {
33+ _ = cl.Close()
34+ }
35+ })
36+ return c
37+}
38+
39+func (c *closers) add(cl io.Closer) {
40+ c.mu.Lock()
41+ defer c.mu.Unlock()
42+ c.cs = append(c.cs, cl)
43+}
44+
45+// stalledConn is a pipe connection to a server that accepts connections and
46+// never answers.
47+func stalledConn(t *testing.T, timeout time.Duration) *pipeConn {
48+ t.Helper()
49+ ln, err := net.Listen("tcp", "127.0.0.1:0")
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ open := newClosers(t)
54+ open.add(ln)
55+ go func() {
56+ for {
57+ conn, err := ln.Accept()
58+ if err != nil {
59+ return
60+ }
61+ open.add(conn)
62+ }
63+ }()
64+ conn := newPipeConn(&pipe.SSHClientInfo{RemoteHost: ln.Addr().String()})
65+ conn.timeout = timeout
66+ return conn
67+}
68+
69+func TestStalledHandshakeEndsWithRequest(t *testing.T) {
70+ conn := stalledConn(t, time.Minute)
71+
72+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
73+ defer cancel()
74+ start := time.Now()
75+ if _, err := conn.run(ctx, "sub topic"); !errors.Is(err, context.DeadlineExceeded) {
76+ t.Fatalf("run = %v, want the request's deadline", err)
77+ }
78+ if took := time.Since(start); took > time.Second {
79+ t.Errorf("canceled request waited %v for the handshake", took)
80+ }
81+
82+ // The next request is not held up behind the canceled one.
83+ ctx, cancel = context.WithTimeout(context.Background(), 100*time.Millisecond)
84+ defer cancel()
85+ start = time.Now()
86+ if _, err := conn.run(ctx, "sub topic"); !errors.Is(err, context.DeadlineExceeded) {
87+ t.Fatalf("run = %v, want the request's deadline", err)
88+ }
89+ if took := time.Since(start); took > time.Second {
90+ t.Errorf("second request waited %v", took)
91+ }
92+}
93+
94+func TestStalledHandshakeTimesOut(t *testing.T) {
95+ conn := stalledConn(t, 200*time.Millisecond)
96+
97+ start := time.Now()
98+ _, err := conn.run(context.Background(), "sub topic")
99+ var netErr net.Error
100+ if !errors.As(err, &netErr) || !netErr.Timeout() {
101+ t.Fatalf("run = %v, want a timeout", err)
102+ }
103+ if took := time.Since(start); took > 2*time.Second {
104+ t.Errorf("handshake took %v, want about 200ms", took)
105+ }
106+}
107+
108+// stalledSSH is a pipe connection to an ssh server that completes the
109+// handshake and then stops answering: at opening a session when openStalls
110+// is set, or else at starting the command. It counts the connections made
111+// and the ones closed.
112+func stalledSSH(t *testing.T, timeout time.Duration, openStalls bool) (conn *pipeConn, conns, closed *atomic.Int32) {
113+ t.Helper()
114+ _, key, err := ed25519.GenerateKey(rand.Reader)
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ hostKey, err := ssh.NewSignerFromKey(key)
119+ if err != nil {
120+ t.Fatal(err)
121+ }
122+ cfg := &ssh.ServerConfig{NoClientAuth: true}
123+ cfg.AddHostKey(hostKey)
124+
125+ ln, err := net.Listen("tcp", "127.0.0.1:0")
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ open := newClosers(t)
130+ open.add(ln)
131+ conns, closed = &atomic.Int32{}, &atomic.Int32{}
132+ go func() {
133+ for {
134+ nc, err := ln.Accept()
135+ if err != nil {
136+ return
137+ }
138+ conns.Add(1)
139+ open.add(nc)
140+ go func() {
141+ sc, chans, reqs, err := ssh.NewServerConn(nc, cfg)
142+ if err != nil {
143+ return
144+ }
145+ go func() {
146+ _ = sc.Wait()
147+ closed.Add(1)
148+ }()
149+ go ssh.DiscardRequests(reqs)
150+ for nch := range chans {
151+ if openStalls {
152+ continue
153+ }
154+ ch, chReqs, err := nch.Accept()
155+ if err != nil {
156+ return
157+ }
158+ open.add(ch)
159+ // Never reply to the exec request.
160+ go func() {
161+ for range chReqs {
162+ }
163+ }()
164+ }
165+ }()
166+ }
167+ }()
168+ conn = newPipeConn(&pipe.SSHClientInfo{RemoteHost: ln.Addr().String()})
169+ conn.timeout = timeout
170+ return conn, conns, closed
171+}
172+
173+func TestStalledSessionEndsWithRequest(t *testing.T) {
174+ for name, openStalls := range map[string]bool{"open": true, "exec": false} {
175+ t.Run(name, func(t *testing.T) {
176+ conn, _, _ := stalledSSH(t, time.Minute, openStalls)
177+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
178+ defer cancel()
179+ start := time.Now()
180+ if _, err := conn.run(ctx, "sub topic"); !errors.Is(err, context.DeadlineExceeded) {
181+ t.Fatalf("run = %v, want the request's deadline", err)
182+ }
183+ if took := time.Since(start); took > time.Second {
184+ t.Errorf("canceled request waited %v for the session", took)
185+ }
186+ })
187+ }
188+}
189+
190+func TestStalledSessionRedials(t *testing.T) {
191+ for name, openStalls := range map[string]bool{"open": true, "exec": false} {
192+ t.Run(name, func(t *testing.T) {
193+ conn, conns, _ := stalledSSH(t, 200*time.Millisecond, openStalls)
194+ _, err := conn.run(context.Background(), "sub topic")
195+ if err == nil || !strings.Contains(err.Error(), "timed out") {
196+ t.Fatalf("run = %v, want a timeout", err)
197+ }
198+ if n := conns.Load(); n != 2 {
199+ t.Errorf("made %d connections, want the stalled one and a redial", n)
200+ }
201+ })
202+ }
203+}
204+
205+func TestCanceledStartStillTimesOut(t *testing.T) {
206+ for name, openStalls := range map[string]bool{"open": true, "exec": false} {
207+ t.Run(name, func(t *testing.T) {
208+ conn, _, closed := stalledSSH(t, 300*time.Millisecond, openStalls)
209+ before := runtime.NumGoroutine()
210+ for range 5 {
211+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
212+ _, err := conn.run(ctx, "sub topic")
213+ cancel()
214+ if !errors.Is(err, context.DeadlineExceeded) {
215+ t.Fatalf("run = %v, want the request's deadline", err)
216+ }
217+ }
218+ for range 100 {
219+ if closed.Load() == 1 && runtime.NumGoroutine() <= before {
220+ return
221+ }
222+ time.Sleep(20 * time.Millisecond)
223+ }
224+ t.Errorf("closed %d connections and have %d goroutines, want 1 and at most %d", closed.Load(), runtime.NumGoroutine(), before)
225+ })
226+ }
227+}
+107, -0
......@@ -0,0 +1,107 @@
1+package pipe
2+
3+import (
4+ "fmt"
5+ "time"
6+
7+ "github.com/picosh/pico/pkg/db"
8+ "github.com/picosh/pico/pkg/db/postgres"
9+)
10+
11+// DB is the storage pipe uses.
12+type DB interface {
13+ db.AuthDB
14+ FindUserByToken(token string) (*db.User, error)
15+ UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error
16+ UpdatePipeMonitorLastPing(userID, topic string, lastPing *time.Time) error
17+ RemovePipeMonitor(userID, topic string) error
18+ FindPipeMonitorByTopic(userID, topic string) (*db.PipeMonitor, error)
19+ FindPipeMonitorsByUser(userID string) ([]*db.PipeMonitor, error)
20+ InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error
21+ FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*db.PipeMonitorHistory, error)
22+}
23+
24+// PsqlDB is pipe's postgres storage: the queries apps share, and the ones
25+// only pipe makes.
26+type PsqlDB struct {
27+ *postgres.PsqlDB
28+}
29+
30+var _ DB = (*PsqlDB)(nil)
31+
32+func NewDB(base *postgres.PsqlDB) *PsqlDB {
33+ return &PsqlDB{PsqlDB: base}
34+}
35+
36+func (me *PsqlDB) UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error {
37+ durStr := fmt.Sprintf("%d seconds", int64(dur.Seconds()))
38+ _, err := me.Db.Exec(
39+ `INSERT INTO pipe_monitors (user_id, topic, window_dur, window_end)
40+ VALUES ($1, $2, $3::interval, $4)
41+ ON CONFLICT (user_id, topic) DO UPDATE SET window_dur = $3::interval, window_end = $4, updated_at = NOW();`,
42+ userID,
43+ topic,
44+ durStr,
45+ winEnd,
46+ )
47+ return err
48+}
49+
50+func (me *PsqlDB) UpdatePipeMonitorLastPing(userID, topic string, lastPing *time.Time) error {
51+ _, err := me.Db.Exec(
52+ `UPDATE pipe_monitors SET last_ping = $3, updated_at = NOW() WHERE user_id = $1 AND topic = $2;`,
53+ userID,
54+ topic,
55+ lastPing,
56+ )
57+ return err
58+}
59+
60+func (me *PsqlDB) RemovePipeMonitor(userID, topic string) error {
61+ _, err := me.Db.Exec(
62+ `DELETE FROM pipe_monitors WHERE user_id = $1 AND topic = $2;`,
63+ userID,
64+ topic,
65+ )
66+ return err
67+}
68+
69+func (me *PsqlDB) FindPipeMonitorByTopic(userID, topic string) (*db.PipeMonitor, error) {
70+ monitor := &db.PipeMonitor{}
71+ err := me.Db.Get(monitor, `SELECT id, user_id, topic, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors WHERE user_id = $1 AND topic = $2;`, userID, topic)
72+ if err != nil {
73+ return nil, err
74+ }
75+ return monitor, nil
76+}
77+
78+func (me *PsqlDB) FindPipeMonitorsByUser(userID string) ([]*db.PipeMonitor, error) {
79+ var monitors []*db.PipeMonitor
80+ err := me.Db.Select(&monitors, `SELECT id, user_id, topic, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors WHERE user_id = $1 ORDER BY topic;`, userID)
81+ if err != nil {
82+ return nil, err
83+ }
84+ return monitors, nil
85+}
86+
87+func (me *PsqlDB) InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error {
88+ durStr := fmt.Sprintf("%d seconds", int64(windowDur.Seconds()))
89+ _, err := me.Db.Exec(
90+ `INSERT INTO pipe_monitors_history (monitor_id, window_dur, window_end, last_ping) VALUES ($1, $2::interval, $3, $4)`,
91+ monitorID, durStr, windowEnd, lastPing,
92+ )
93+ return err
94+}
95+
96+func (me *PsqlDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*db.PipeMonitorHistory, error) {
97+ var history []*db.PipeMonitorHistory
98+ err := me.Db.Select(
99+ &history,
100+ `SELECT id, monitor_id, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors_history WHERE monitor_id = $1 AND last_ping <= $2 AND window_end >= $3 ORDER BY last_ping ASC`,
101+ monitorID, to, from,
102+ )
103+ if err != nil {
104+ return nil, err
105+ }
106+ return history, nil
107+}
+178, -0
......@@ -0,0 +1,178 @@
1+package pipe
2+
3+import (
4+ "log/slog"
5+ "testing"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db/postgres"
9+ "github.com/picosh/pico/pkg/db/postgres/pgtest"
10+)
11+
12+var testDB *PsqlDB
13+
14+func TestMain(m *testing.M) {
15+ pgtest.Main(m)
16+}
17+
18+func cleanupTestData(t *testing.T) {
19+ t.Helper()
20+ if testDB == nil {
21+ testDB = NewDB(postgres.NewDB(pgtest.URL(t), slog.New(slog.DiscardHandler)))
22+ }
23+ pgtest.Clean(t, testDB.Db)
24+}
25+
26+func TestUpsertPipeMonitor(t *testing.T) {
27+ cleanupTestData(t)
28+
29+ user, _ := testDB.RegisterUser("pipemonitorowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonitorowner", "comment", "")
30+
31+ winEnd := time.Now().Add(time.Hour)
32+ err := testDB.UpsertPipeMonitor(user.ID, "test-topic", 5*time.Minute, &winEnd)
33+ if err != nil {
34+ t.Fatalf("UpsertPipeMonitor failed: %v", err)
35+ }
36+
37+ monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "test-topic")
38+ if err != nil {
39+ t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
40+ }
41+ if monitor.Topic != "test-topic" {
42+ t.Errorf("expected topic 'test-topic', got '%s'", monitor.Topic)
43+ }
44+ if monitor.WindowDur != 5*time.Minute {
45+ t.Errorf("expected window_dur 5m, got %v", monitor.WindowDur)
46+ }
47+}
48+
49+func TestUpsertPipeMonitor_Update(t *testing.T) {
50+ cleanupTestData(t)
51+
52+ user, _ := testDB.RegisterUser("pipeupdateowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipeupdateowner", "comment", "")
53+
54+ winEnd1 := time.Now().Add(time.Hour)
55+ err := testDB.UpsertPipeMonitor(user.ID, "update-topic", 5*time.Minute, &winEnd1)
56+ if err != nil {
57+ t.Fatalf("first UpsertPipeMonitor failed: %v", err)
58+ }
59+
60+ winEnd2 := time.Now().Add(2 * time.Hour)
61+ err = testDB.UpsertPipeMonitor(user.ID, "update-topic", 10*time.Minute, &winEnd2)
62+ if err != nil {
63+ t.Fatalf("second UpsertPipeMonitor failed: %v", err)
64+ }
65+
66+ monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "update-topic")
67+ if err != nil {
68+ t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
69+ }
70+ if monitor.WindowDur != 10*time.Minute {
71+ t.Errorf("expected window_dur 10m after update, got %v", monitor.WindowDur)
72+ }
73+}
74+
75+func TestUpdatePipeMonitorLastPing(t *testing.T) {
76+ cleanupTestData(t)
77+
78+ user, _ := testDB.RegisterUser("pipepingowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipepingowner", "comment", "")
79+
80+ winEnd := time.Now().Add(time.Hour)
81+ err := testDB.UpsertPipeMonitor(user.ID, "ping-topic", 5*time.Minute, &winEnd)
82+ if err != nil {
83+ t.Fatalf("UpsertPipeMonitor failed: %v", err)
84+ }
85+
86+ lastPing := time.Now()
87+ err = testDB.UpdatePipeMonitorLastPing(user.ID, "ping-topic", &lastPing)
88+ if err != nil {
89+ t.Fatalf("UpdatePipeMonitorLastPing failed: %v", err)
90+ }
91+
92+ monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "ping-topic")
93+ if err != nil {
94+ t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
95+ }
96+ if monitor.LastPing == nil {
97+ t.Error("expected last_ping to be set, got nil")
98+ }
99+}
100+
101+func TestRemovePipeMonitor(t *testing.T) {
102+ cleanupTestData(t)
103+
104+ user, _ := testDB.RegisterUser("piperemoveowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI piperemoveowner", "comment", "")
105+
106+ winEnd := time.Now().Add(time.Hour)
107+ err := testDB.UpsertPipeMonitor(user.ID, "remove-topic", 5*time.Minute, &winEnd)
108+ if err != nil {
109+ t.Fatalf("UpsertPipeMonitor failed: %v", err)
110+ }
111+
112+ err = testDB.RemovePipeMonitor(user.ID, "remove-topic")
113+ if err != nil {
114+ t.Fatalf("RemovePipeMonitor failed: %v", err)
115+ }
116+
117+ _, err = testDB.FindPipeMonitorByTopic(user.ID, "remove-topic")
118+ if err == nil {
119+ t.Error("expected error finding removed monitor, got nil")
120+ }
121+}
122+
123+func TestFindPipeMonitorByTopic_NotFound(t *testing.T) {
124+ cleanupTestData(t)
125+
126+ user, _ := testDB.RegisterUser("pipenotfoundowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenotfoundowner", "comment", "")
127+
128+ _, err := testDB.FindPipeMonitorByTopic(user.ID, "nonexistent-topic")
129+ if err == nil {
130+ t.Error("expected error for nonexistent monitor, got nil")
131+ }
132+}
133+
134+func TestFindPipeMonitorsByUser(t *testing.T) {
135+ cleanupTestData(t)
136+
137+ user, _ := testDB.RegisterUser("pipemonlistowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonlistowner", "comment", "")
138+
139+ winEnd := time.Now().Add(time.Hour)
140+ _ = testDB.UpsertPipeMonitor(user.ID, "service-a", 5*time.Minute, &winEnd)
141+ _ = testDB.UpsertPipeMonitor(user.ID, "service-b", 10*time.Minute, &winEnd)
142+ _ = testDB.UpsertPipeMonitor(user.ID, "service-c", 1*time.Hour, &winEnd)
143+
144+ monitors, err := testDB.FindPipeMonitorsByUser(user.ID)
145+ if err != nil {
146+ t.Fatalf("FindPipeMonitorsByUser failed: %v", err)
147+ }
148+
149+ if len(monitors) != 3 {
150+ t.Errorf("expected 3 monitors, got %d", len(monitors))
151+ }
152+
153+ // Should be ordered by topic
154+ if monitors[0].Topic != "service-a" {
155+ t.Errorf("expected first topic 'service-a', got %s", monitors[0].Topic)
156+ }
157+ if monitors[1].Topic != "service-b" {
158+ t.Errorf("expected second topic 'service-b', got %s", monitors[1].Topic)
159+ }
160+ if monitors[2].Topic != "service-c" {
161+ t.Errorf("expected third topic 'service-c', got %s", monitors[2].Topic)
162+ }
163+}
164+
165+func TestFindPipeMonitorsByUser_Empty(t *testing.T) {
166+ cleanupTestData(t)
167+
168+ user, _ := testDB.RegisterUser("pipenomonitors", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenomonitors", "comment", "")
169+
170+ monitors, err := testDB.FindPipeMonitorsByUser(user.ID)
171+ if err != nil {
172+ t.Fatalf("FindPipeMonitorsByUser failed: %v", err)
173+ }
174+
175+ if len(monitors) != 0 {
176+ t.Errorf("expected 0 monitors for user with none, got %d", len(monitors))
177+ }
178+}
+2, -2
......@@ -21,13 +21,13 @@ func StartSshServer() {
2121 port := shared.GetEnv("PIPE_SSH_PORT", "2222")
2222 portOverride := shared.GetEnv("PIPE_SSH_PORT_OVERRIDE", port)
2323 promPort := shared.GetEnv("PIPE_PROM_PORT", "9222")
24- cfg := NewConfigSite(appName)
24+ cfg := NewConfig(appName)
2525 logger := cfg.Logger
2626
2727 ctx, cancel := context.WithCancel(context.Background())
2828 defer cancel()
2929
30- dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
30+ dbh := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
3131 defer func() {
3232 _ = dbh.Close()
3333 }()
+150, -30
......@@ -5,9 +5,11 @@ import (
55 "context"
66 "crypto/ed25519"
77 "crypto/rand"
8+ "errors"
89 "fmt"
910 "io"
1011 "log/slog"
12+ "net"
1113 "os"
1214 "strings"
1315 "sync"
......@@ -90,14 +92,6 @@ func (t *TestDB) FindFeature(userID, name string) (*db.FeatureFlag, error) {
9092 return nil, fmt.Errorf("feature not found")
9193 }
9294
93-func (t *TestDB) HasFeatureByUser(userID string, feature string) bool {
94- ff, err := t.FindFeature(userID, feature)
95- if err != nil {
96- return false
97- }
98- return ff.IsValid()
99-}
100-
10195 func (t *TestDB) InsertAccessLog(_ *db.AccessLog) error {
10296 return nil
10397 }
......@@ -237,7 +231,7 @@ func (t *TestDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ([
237231 }
238232
239233 type TestSSHServer struct {
240- Cfg *shared.ConfigSite
234+ Cfg *Config
241235 DBPool *TestDB
242236 PipeHandler *CliHandler
243237 Cancel context.CancelFunc
......@@ -254,7 +248,7 @@ func NewTestSSHServer(t *testing.T) *TestSSHServer {
254248
255249 dbpool := NewTestDB(logger)
256250
257- cfg := &shared.ConfigSite{
251+ cfg := &Config{
258252 Domain: "pipe.test",
259253 Port: "2225",
260254 PortOverride: "2225",
......@@ -316,7 +310,7 @@ func NewTestSSHServer(t *testing.T) *TestSSHServer {
316310 }
317311 }()
318312
319- time.Sleep(100 * time.Millisecond)
313+ waitForPort(t, cfg.Port, true)
320314
321315 return &TestSSHServer{
322316 Cfg: cfg,
......@@ -328,7 +322,50 @@ func NewTestSSHServer(t *testing.T) *TestSSHServer {
328322
329323 func (s *TestSSHServer) Shutdown() {
330324 s.Cancel()
331- time.Sleep(10 * time.Millisecond)
325+ waitForPort(nil, s.Cfg.Port, false)
326+}
327+
328+// waitForPort waits until the test server's port accepts connections, or
329+// until it stops accepting them so the next test can listen on it.
330+func waitForPort(t *testing.T, port string, open bool) {
331+ deadline := time.Now().Add(5 * time.Second)
332+ for time.Now().Before(deadline) {
333+ conn, err := net.DialTimeout("tcp", "localhost:"+port, 100*time.Millisecond)
334+ if err == nil {
335+ _ = conn.Close()
336+ }
337+ if (err == nil) == open {
338+ return
339+ }
340+ time.Sleep(5 * time.Millisecond)
341+ }
342+ if t != nil {
343+ t.Fatalf("port %s open = %v, want %v", port, !open, open)
344+ }
345+}
346+
347+// waitForClients waits until n subscribers and pipes are on their topics,
348+// so a message published next reaches them.
349+func (s *TestSSHServer) waitForClients(t *testing.T, n int) {
350+ t.Helper()
351+ ps := s.PipeHandler.PubSub.(*psub.Multicast)
352+ deadline := time.Now().Add(5 * time.Second)
353+ for {
354+ joined := 0
355+ for range ps.GetSubs() {
356+ joined++
357+ }
358+ for range ps.GetPipes() {
359+ joined++
360+ }
361+ if joined == n {
362+ return
363+ }
364+ if time.Now().After(deadline) {
365+ t.Fatalf("%d subscribers and pipes, want %d", joined, n)
366+ }
367+ time.Sleep(5 * time.Millisecond)
368+ }
332369 }
333370
334371 type UserSSH struct {
......@@ -539,7 +576,7 @@ func TestPubSub_BasicFlow(t *testing.T) {
539576 t.Fatalf("failed to start sub: %v", err)
540577 }
541578
542- time.Sleep(100 * time.Millisecond)
579+ server.waitForClients(t, 1)
543580
544581 testMessage := "hello from pub"
545582 _, err = user.RunCommandWithStdin(pubClient, "pub testtopic -c", testMessage)
......@@ -595,7 +632,7 @@ func TestPubSub_PublicTopic(t *testing.T) {
595632 t.Fatalf("failed to start sub: %v", err)
596633 }
597634
598- time.Sleep(100 * time.Millisecond)
635+ server.waitForClients(t, 1)
599636
600637 testMessage := "public message"
601638 _, err = alice.RunCommandWithStdin(pubClient, "pub publictopic -p -c", testMessage)
......@@ -656,7 +693,7 @@ func TestPipe_Bidirectional(t *testing.T) {
656693 t.Fatalf("failed to start alice pipe: %v", err)
657694 }
658695
659- time.Sleep(100 * time.Millisecond)
696+ server.waitForClients(t, 1)
660697
661698 bobSession, err := bobClient.NewSession()
662699 if err != nil {
......@@ -678,7 +715,7 @@ func TestPipe_Bidirectional(t *testing.T) {
678715 t.Fatalf("failed to start bob pipe: %v", err)
679716 }
680717
681- time.Sleep(100 * time.Millisecond)
718+ server.waitForClients(t, 2)
682719
683720 aliceMsg := "hello from alice\n"
684721 _, err = aliceStdin.Write([]byte(aliceMsg))
......@@ -798,7 +835,7 @@ func TestAccessControl_AllowedUserViaFullPath(t *testing.T) {
798835 t.Fatalf("failed to start alice sub: %v", err)
799836 }
800837
801- time.Sleep(100 * time.Millisecond)
838+ server.waitForClients(t, 1)
802839
803840 bobClient, err := bob.NewClient()
804841 if err != nil {
......@@ -908,7 +945,7 @@ func TestPubSub_BlockingWaitsForSubscriber(t *testing.T) {
908945 t.Fatalf("failed to start sub: %v", err)
909946 }
910947
911- time.Sleep(100 * time.Millisecond)
948+ server.waitForClients(t, 1)
912949
913950 // Now send the message
914951 testMessage := "blocking message"
......@@ -1036,7 +1073,7 @@ func TestSub_WaitsForPublisher(t *testing.T) {
10361073 }
10371074
10381075 // Subscriber is now waiting - give it a moment
1039- time.Sleep(100 * time.Millisecond)
1076+ server.waitForClients(t, 1)
10401077
10411078 // Now publish - subscriber should receive it
10421079 testMessage := "delayed publish"
......@@ -1097,7 +1134,7 @@ func TestSub_KeepAliveReceivesMultipleMessages(t *testing.T) {
10971134 t.Fatalf("failed to start sub: %v", err)
10981135 }
10991136
1100- time.Sleep(100 * time.Millisecond)
1137+ server.waitForClients(t, 1)
11011138
11021139 // Send first message
11031140 msg1 := "first message\n"
......@@ -1160,7 +1197,7 @@ func TestSub_WithoutKeepAliveExitsAfterPublisher(t *testing.T) {
11601197 t.Fatalf("failed to start sub: %v", err)
11611198 }
11621199
1163- time.Sleep(100 * time.Millisecond)
1200+ server.waitForClients(t, 1)
11641201
11651202 // Publish a message
11661203 testMessage := "single message"
......@@ -1227,7 +1264,7 @@ func TestPub_EmptyMessage(t *testing.T) {
12271264 t.Fatalf("failed to start sub: %v", err)
12281265 }
12291266
1230- time.Sleep(100 * time.Millisecond)
1267+ server.waitForClients(t, 1)
12311268
12321269 // Publish with -e flag (empty message) - should not require stdin
12331270 output, err := user.RunCommand(pubClient, "pub emptytopic -e -c")
......@@ -1291,7 +1328,7 @@ func TestPipe_AccessControl(t *testing.T) {
12911328 t.Fatalf("failed to start alice pipe: %v", err)
12921329 }
12931330
1294- time.Sleep(100 * time.Millisecond)
1331+ server.waitForClients(t, 1)
12951332
12961333 // Bob joins the pipe using alice's namespace
12971334 bobSession, err := bobClient.NewSession()
......@@ -1314,7 +1351,7 @@ func TestPipe_AccessControl(t *testing.T) {
13141351 t.Fatalf("failed to start bob pipe: %v", err)
13151352 }
13161353
1317- time.Sleep(100 * time.Millisecond)
1354+ server.waitForClients(t, 2)
13181355
13191356 // Alice sends message to bob
13201357 aliceMsg := "hello bob\n"
......@@ -1377,7 +1414,7 @@ func TestPipe_Replay(t *testing.T) {
13771414 t.Fatalf("failed to start pipe: %v", err)
13781415 }
13791416
1380- time.Sleep(100 * time.Millisecond)
1417+ server.waitForClients(t, 1)
13811418
13821419 // Send a message - with -r flag, should receive it back
13831420 testMsg := "echo back\n"
......@@ -1431,7 +1468,7 @@ func TestAccessControl_UnauthorizedUserDenied(t *testing.T) {
14311468 t.Fatalf("failed to start alice sub: %v", err)
14321469 }
14331470
1434- time.Sleep(100 * time.Millisecond)
1471+ server.waitForClients(t, 1)
14351472
14361473 // Charlie tries to publish to alice's restricted topic - should be denied
14371474 output, err := charlie.RunCommandWithStdin(charlieClient, "pub alice/restrictedtopic -c", "unauthorized message")
......@@ -1524,7 +1561,7 @@ func TestPubSub_MultipleSubscribers(t *testing.T) {
15241561 t.Fatalf("failed to start sub3: %v", err)
15251562 }
15261563
1527- time.Sleep(100 * time.Millisecond)
1564+ server.waitForClients(t, 3)
15281565
15291566 // Publish a single message
15301567 testMessage := "broadcast message"
......@@ -1995,7 +2032,7 @@ func TestPub_UpdatesMonitorLastPing(t *testing.T) {
19952032 t.Fatalf("failed to start sub: %v", err)
19962033 }
19972034
1998- time.Sleep(100 * time.Millisecond)
2035+ server.waitForClients(t, 1)
19992036
20002037 // Publish to the monitored topic
20012038 _, err = user.RunCommandWithStdin(pubClient, "pub ping-test -c", "health check")
......@@ -2093,7 +2130,7 @@ func TestStatus_PingResetsWindow(t *testing.T) {
20932130 t.Fatalf("failed to start sub: %v", err)
20942131 }
20952132
2096- time.Sleep(100 * time.Millisecond)
2133+ server.waitForClients(t, 1)
20972134
20982135 // Pub to trigger monitor update
20992136 _, err = user.RunCommandWithStdin(client, "pub reset-test -c", "ping")
......@@ -2154,7 +2191,7 @@ func TestStatus_HealthyImmediatelyAfterPing(t *testing.T) {
21542191 t.Fatalf("failed to start sub: %v", err)
21552192 }
21562193
2157- time.Sleep(100 * time.Millisecond)
2194+ server.waitForClients(t, 1)
21582195
21592196 // Pub to trigger ping
21602197 pubClient, err := user.NewClient()
......@@ -2415,3 +2452,86 @@ func TestSSH_WildcardSub(t *testing.T) {
24152452 t.Errorf("expected SSH wildcard subscriber output to contain 'prose-event', got: %q", output)
24162453 }
24172454 }
2455+
2456+// runStatus runs cmd with stdin and returns its exit status, or -1 when the
2457+// session ended without one.
2458+func runStatus(t *testing.T, client *ssh.Client, cmd, stdin string) int {
2459+ t.Helper()
2460+ session, err := client.NewSession()
2461+ if err != nil {
2462+ t.Fatal(err)
2463+ }
2464+ defer func() { _ = session.Close() }()
2465+ session.Stdin = strings.NewReader(stdin)
2466+ return exitStatus(t, session.Run(cmd))
2467+}
2468+
2469+func exitStatus(t *testing.T, err error) int {
2470+ t.Helper()
2471+ var exitErr *ssh.ExitError
2472+ var missing *ssh.ExitMissingError
2473+ switch {
2474+ case err == nil:
2475+ return 0
2476+ case errors.As(err, &exitErr):
2477+ return exitErr.ExitStatus()
2478+ case errors.As(err, &missing):
2479+ return -1
2480+ }
2481+ t.Fatalf("command failed: %v", err)
2482+ return 0
2483+}
2484+
2485+func TestCommandsExitWithStatus(t *testing.T) {
2486+ server := NewTestSSHServer(t)
2487+ defer server.Shutdown()
2488+
2489+ alice := GenerateUser("alice")
2490+ charlie := GenerateUser("charlie")
2491+ RegisterUserWithServer(server, alice)
2492+ RegisterUserWithServer(server, charlie)
2493+ aliceClient, err := alice.NewClient()
2494+ if err != nil {
2495+ t.Fatal(err)
2496+ }
2497+ defer func() { _ = aliceClient.Close() }()
2498+ charlieClient, err := charlie.NewClient()
2499+ if err != nil {
2500+ t.Fatal(err)
2501+ }
2502+ defer func() { _ = charlieClient.Close() }()
2503+
2504+ if got := runStatus(t, aliceClient, "pub nobody -b=false", "hello"); got != 0 {
2505+ t.Errorf("pub exited %d, want 0", got)
2506+ }
2507+
2508+ sub, err := aliceClient.NewSession()
2509+ if err != nil {
2510+ t.Fatal(err)
2511+ }
2512+ defer func() { _ = sub.Close() }()
2513+ if err := sub.Start("sub news -a alice"); err != nil {
2514+ t.Fatal(err)
2515+ }
2516+ server.waitForClients(t, 1)
2517+ if got := runStatus(t, aliceClient, "pub news", "hello"); got != 0 {
2518+ t.Errorf("pub to a sub exited %d, want 0", got)
2519+ }
2520+ if got := exitStatus(t, sub.Wait()); got != 0 {
2521+ t.Errorf("sub exited %d after its publisher left, want 0", got)
2522+ }
2523+
2524+ if err := (func() error {
2525+ s, err := aliceClient.NewSession()
2526+ if err != nil {
2527+ return err
2528+ }
2529+ return s.Start("sub restricted -p -a alice -k")
2530+ })(); err != nil {
2531+ t.Fatal(err)
2532+ }
2533+ server.waitForClients(t, 1)
2534+ if got := runStatus(t, charlieClient, "sub restricted -p", ""); got != 1 {
2535+ t.Errorf("denied sub exited %d, want 1", got)
2536+ }
2537+}
+108, -0
......@@ -0,0 +1,108 @@
1+package pipe
2+
3+import (
4+ "context"
5+ "encoding/pem"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+ "testing"
13+
14+ "github.com/gorilla/websocket"
15+ "github.com/picosh/utils/pipe"
16+)
17+
18+// newWebTest runs pipe-web against a test pipe server.
19+func newWebTest(t *testing.T) (*TestSSHServer, *httptest.Server) {
20+ t.Helper()
21+ server := NewTestSSHServer(t)
22+ t.Cleanup(server.Shutdown)
23+
24+ user := GenerateUser("pico")
25+ RegisterUserWithServer(server, user)
26+ keyPath := filepath.Join(t.TempDir(), "key")
27+ key := pem.EncodeToMemory(&pem.Block{Type: "OPENSSH PRIVATE KEY", Bytes: user.privateKey})
28+ if err := os.WriteFile(keyPath, key, 0o600); err != nil {
29+ t.Fatal(err)
30+ }
31+
32+ conn := newPipeConn(&pipe.SSHClientInfo{
33+ RemoteHost: "localhost:" + server.Cfg.Port,
34+ RemoteHostname: "localhost",
35+ RemoteUser: user.username,
36+ KeyLocation: keyPath,
37+ })
38+ web := &webRouter{cfg: server.Cfg, db: server.DBPool, conn: conn}
39+ ts := httptest.NewServer(web.mainMux())
40+ t.Cleanup(ts.Close)
41+ return server, ts
42+}
43+
44+func TestWebSubEndsWithRequest(t *testing.T) {
45+ server, ts := newWebTest(t)
46+
47+ ctx, cancel := context.WithCancel(context.Background())
48+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/topic/quiet", nil)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ go func() {
53+ res, err := http.DefaultClient.Do(req)
54+ if err == nil {
55+ _, _ = io.Copy(io.Discard, res.Body)
56+ _ = res.Body.Close()
57+ }
58+ }()
59+ server.waitForClients(t, 1)
60+
61+ cancel()
62+ server.waitForClients(t, 0)
63+}
64+
65+func TestWebPubReachesSub(t *testing.T) {
66+ server, ts := newWebTest(t)
67+
68+ received := make(chan string, 1)
69+ go func() {
70+ res, err := http.Get(ts.URL + "/topic/news")
71+ if err != nil {
72+ received <- err.Error()
73+ return
74+ }
75+ defer func() { _ = res.Body.Close() }()
76+ body, _ := io.ReadAll(res.Body)
77+ received <- string(body)
78+ }()
79+ server.waitForClients(t, 1)
80+
81+ res, err := http.Post(ts.URL+"/topic/news", "text/plain", strings.NewReader("hello"))
82+ if err != nil {
83+ t.Fatal(err)
84+ }
85+ _ = res.Body.Close()
86+ if res.StatusCode != http.StatusOK {
87+ t.Errorf("pub status = %d", res.StatusCode)
88+ }
89+ if got := <-received; got != "hello" {
90+ t.Errorf("sub got %q, want %q", got, "hello")
91+ }
92+ server.waitForClients(t, 0)
93+}
94+
95+func TestWebPipeEndsWhenClientLeaves(t *testing.T) {
96+ server, ts := newWebTest(t)
97+
98+ c, res, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(ts.URL, "http")+"/pipe/chat", nil)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ _ = res.Body.Close()
103+ server.waitForClients(t, 1)
104+
105+ // Drop the connection without a close frame, as a client that crashed.
106+ _ = c.NetConn().Close()
107+ server.waitForClients(t, 0)
108+}
+158, -184
......@@ -6,8 +6,8 @@ import (
66 "html/template"
77 "net/http"
88 "net/url"
9- "os"
109 "path/filepath"
10+ "regexp"
1111 "strings"
1212 "time"
1313
......@@ -118,11 +118,11 @@ func GetBlogName(username string) string {
118118 return fmt.Sprintf("%s's blog", username)
119119 }
120120
121-func blogStyleHandler(w http.ResponseWriter, r *http.Request) {
121+func (web *webRouter) blogStyleHandler(w http.ResponseWriter, r *http.Request) {
122122 username := router.GetUsernameFromRequest(r)
123- dbpool := router.GetDB(r)
124- logger := router.GetLogger(r)
125- cfg := router.GetCfg(r)
123+ dbpool := web.db
124+ logger := web.cfg.Logger
125+ cfg := web.cfg
126126
127127 user, err := dbpool.FindUserByName(username)
128128 if err != nil {
......@@ -148,11 +148,11 @@ func blogStyleHandler(w http.ResponseWriter, r *http.Request) {
148148 }
149149 }
150150
151-func blogHandler(w http.ResponseWriter, r *http.Request) {
151+func (web *webRouter) blogHandler(w http.ResponseWriter, r *http.Request) {
152152 username := router.GetUsernameFromRequest(r)
153- dbpool := router.GetDB(r)
154- logger := router.GetLogger(r)
155- cfg := router.GetCfg(r)
153+ dbpool := web.db
154+ logger := web.cfg.Logger
155+ cfg := web.cfg
156156
157157 user, err := dbpool.FindUserByName(username)
158158 if err != nil {
......@@ -179,12 +179,12 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
179179 return
180180 }
181181
182- ts, err := router.RenderTemplate(cfg, []string{
182+ ts, err := router.RenderTemplate(staticDir, []string{
183183 cfg.StaticPath("html/blog-default.partial.tmpl"),
184184 cfg.StaticPath("html/blog.page.tmpl"),
185185 })
186186
187- curl := shared.CreateURLFromRequest(cfg, r)
187+ curl := shared.CreateURLFromRequest(cfg.Protocol, cfg.Domain, r)
188188
189189 if err != nil {
190190 logger.Error("render template", "err", err.Error())
......@@ -218,9 +218,7 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
218218 u, _ := url.Parse(nav.URL)
219219 finURL := nav.URL
220220 if !u.IsAbs() {
221- finURL = cfg.FullPostURL(
222- curl,
223- readme.Username,
221+ finURL = curl.FullPostURL(readme.Username,
224222 nav.URL,
225223 )
226224 }
......@@ -245,8 +243,8 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
245243 postCollection := make([]PostItemData, 0, len(posts))
246244 for _, post := range posts {
247245 p := PostItemData{
248- URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
249- BlogURL: template.URL(cfg.FullBlogURL(curl, post.Username)),
246+ URL: template.URL(curl.FullPostURL(post.Username, post.Slug)),
247+ BlogURL: template.URL(curl.FullBlogURL(post.Username)),
250248 Title: shared.FilenameToTitle(post.Filename, post.Title),
251249 PublishAt: post.PublishAt.Format(time.DateOnly),
252250 PublishAtISO: post.PublishAt.Format(time.RFC3339),
......@@ -257,16 +255,16 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
257255 }
258256
259257 data := BlogPageData{
260- Site: *cfg.GetSiteData(),
258+ Site: cfg.SiteData(),
261259 PageTitle: headerTxt.Title,
262- URL: template.URL(cfg.FullBlogURL(curl, username)),
263- RSSURL: template.URL(cfg.RssBlogURL(curl, username, tag)),
260+ URL: template.URL(curl.FullBlogURL(username)),
261+ RSSURL: template.URL(curl.RssBlogURL(username, tag)),
264262 Readme: readmeTxt,
265263 Header: headerTxt,
266264 Username: username,
267265 Posts: postCollection,
268266 HasCSS: hasCSS,
269- CssURL: template.URL(cfg.CssURL(username)),
267+ CssURL: template.URL(cfg.CssURL()),
270268 HasFilter: tag != "",
271269 }
272270
......@@ -277,21 +275,14 @@ func blogHandler(w http.ResponseWriter, r *http.Request) {
277275 }
278276 }
279277
280-func postRawHandler(w http.ResponseWriter, r *http.Request) {
278+func (web *webRouter) postRawHandler(w http.ResponseWriter, r *http.Request) {
281279 username := router.GetUsernameFromRequest(r)
282- subdomain := router.GetSubdomain(r)
283- cfg := router.GetCfg(r)
284-
285- var slug string
286- if !cfg.IsSubdomains() || subdomain == "" {
287- slug, _ = url.PathUnescape(router.GetField(r, 1))
288- } else {
289- slug, _ = url.PathUnescape(router.GetField(r, 0))
290- }
280+ cfg := web.cfg
281+ slug, _ := url.PathUnescape(r.PathValue("slug"))
291282 slug = strings.TrimSuffix(slug, "/")
292283
293- dbpool := router.GetDB(r)
294- logger := router.GetLogger(r)
284+ dbpool := web.db
285+ logger := web.cfg.Logger
295286 logger = logger.With("slug", slug)
296287
297288 user, err := dbpool.FindUserByName(username)
......@@ -318,11 +309,11 @@ func postRawHandler(w http.ResponseWriter, r *http.Request) {
318309 }
319310 }
320311
321-func robotsHandler(w http.ResponseWriter, r *http.Request) {
312+func (web *webRouter) robotsHandler(w http.ResponseWriter, r *http.Request) {
322313 username := router.GetUsernameFromRequest(r)
323- cfg := router.GetCfg(r)
324- dbpool := router.GetDB(r)
325- logger := router.GetLogger(r)
314+ cfg := web.cfg
315+ dbpool := web.db
316+ logger := web.cfg.Logger
326317 user, err := dbpool.FindUserByName(username)
327318 if err != nil {
328319 logger.Info("blog not found", "user", username)
......@@ -344,21 +335,14 @@ func robotsHandler(w http.ResponseWriter, r *http.Request) {
344335 }
345336 }
346337
347-func postHandler(w http.ResponseWriter, r *http.Request) {
338+func (web *webRouter) postHandler(w http.ResponseWriter, r *http.Request) {
348339 username := router.GetUsernameFromRequest(r)
349- subdomain := router.GetSubdomain(r)
350- cfg := router.GetCfg(r)
351-
352- var slug string
353- if !cfg.IsSubdomains() || subdomain == "" {
354- slug, _ = url.PathUnescape(router.GetField(r, 1))
355- } else {
356- slug, _ = url.PathUnescape(router.GetField(r, 0))
357- }
340+ cfg := web.cfg
341+ slug, _ := url.PathUnescape(r.PathValue("slug"))
358342 slug = strings.TrimSuffix(slug, "/")
359343
360- dbpool := router.GetDB(r)
361- logger := router.GetLogger(r)
344+ dbpool := web.db
345+ logger := web.cfg.Logger
362346
363347 user, err := dbpool.FindUserByName(username)
364348 if err != nil {
......@@ -371,7 +355,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
371355 logger = logger.With("slug", slug)
372356
373357 blogName := GetBlogName(username)
374- curl := shared.CreateURLFromRequest(cfg, r)
358+ curl := shared.CreateURLFromRequest(cfg.Protocol, cfg.Domain, r)
375359
376360 favicon := ""
377361 ogImage := ""
......@@ -455,10 +439,10 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
455439 }
456440
457441 data = PostPageData{
458- Site: *cfg.GetSiteData(),
442+ Site: cfg.SiteData(),
459443 PageTitle: GetPostTitle(post),
460- URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
461- BlogURL: template.URL(cfg.FullBlogURL(curl, username)),
444+ URL: template.URL(curl.FullPostURL(post.Username, post.Slug)),
445+ BlogURL: template.URL(curl.FullBlogURL(username)),
462446 Description: post.Description,
463447 Title: shared.FilenameToTitle(post.Filename, post.Title),
464448 Slug: post.Slug,
......@@ -470,7 +454,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
470454 BlogName: blogName,
471455 Contents: contents,
472456 HasCSS: hasCSS,
473- CssURL: template.URL(cfg.CssURL(username)),
457+ CssURL: template.URL(cfg.CssURL()),
474458 Tags: tags,
475459 Image: template.URL(ogImage),
476460 ImageCard: ogImageCard,
......@@ -506,8 +490,8 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
506490 now := time.Now()
507491
508492 data = PostPageData{
509- Site: *cfg.GetSiteData(),
510- BlogURL: template.URL(cfg.FullBlogURL(curl, username)),
493+ Site: cfg.SiteData(),
494+ BlogURL: template.URL(curl.FullBlogURL(username)),
511495 PageTitle: title,
512496 Description: desc,
513497 Title: title,
......@@ -518,7 +502,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
518502 Username: username,
519503 BlogName: blogName,
520504 HasCSS: hasCSS,
521- CssURL: template.URL(cfg.CssURL(username)),
505+ CssURL: template.URL(cfg.CssURL()),
522506 Image: template.URL(ogImage),
523507 ImageCard: ogImageCard,
524508 Favicon: template.URL(favicon),
......@@ -529,7 +513,7 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
529513 w.WriteHeader(http.StatusNotFound)
530514 }
531515
532- ts, err := router.RenderTemplate(cfg, []string{
516+ ts, err := router.RenderTemplate(staticDir, []string{
533517 cfg.StaticPath("html/list.partial.tmpl"),
534518 cfg.StaticPath("html/post.page.tmpl"),
535519 })
......@@ -547,10 +531,10 @@ func postHandler(w http.ResponseWriter, r *http.Request) {
547531 }
548532 }
549533
550-func readHandler(w http.ResponseWriter, r *http.Request) {
551- dbpool := router.GetDB(r)
552- logger := router.GetLogger(r)
553- cfg := router.GetCfg(r)
534+func (web *webRouter) readHandler(w http.ResponseWriter, r *http.Request) {
535+ dbpool := web.db
536+ logger := web.cfg.Logger
537+ cfg := web.cfg
554538
555539 posts, err := dbpool.FindPopularPosts()
556540 if err != nil {
......@@ -559,7 +543,7 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
559543 return
560544 }
561545
562- ts, err := router.RenderTemplate(cfg, []string{
546+ ts, err := router.RenderTemplate(staticDir, []string{
563547 cfg.StaticPath("html/read.page.tmpl"),
564548 })
565549
......@@ -568,14 +552,14 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
568552 }
569553
570554 data := ReadPageData{
571- Site: *cfg.GetSiteData(),
555+ Site: cfg.SiteData(),
572556 }
573557
574- curl := shared.NewCreateURL(cfg)
558+ curl := shared.NewCreateURL(cfg.Protocol, cfg.Domain)
575559 for _, post := range posts {
576560 item := PostItemData{
577- URL: template.URL(cfg.FullPostURL(curl, post.Username, post.Slug)),
578- BlogURL: template.URL(cfg.FullBlogURL(curl, post.Username)),
561+ URL: template.URL(curl.FullPostURL(post.Username, post.Slug)),
562+ BlogURL: template.URL(curl.FullBlogURL(post.Username)),
579563 Title: shared.FilenameToTitle(post.Filename, post.Title),
580564 Description: post.Description,
581565 Username: post.Username,
......@@ -595,11 +579,11 @@ func readHandler(w http.ResponseWriter, r *http.Request) {
595579 }
596580 }
597581
598-func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
582+func (web *webRouter) rssBlogHandler(w http.ResponseWriter, r *http.Request) {
599583 username := router.GetUsernameFromRequest(r)
600- dbpool := router.GetDB(r)
601- logger := router.GetLogger(r)
602- cfg := router.GetCfg(r)
584+ dbpool := web.db
585+ logger := web.cfg.Logger
586+ cfg := web.cfg
603587
604588 user, err := dbpool.FindUserByName(username)
605589 if err != nil {
......@@ -656,8 +640,8 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
656640 }
657641 }
658642
659- curl := shared.CreateURLFromRequest(cfg, r)
660- blogUrl := cfg.FullBlogURL(curl, username)
643+ curl := shared.CreateURLFromRequest(cfg.Protocol, cfg.Domain, r)
644+ blogUrl := curl.FullBlogURL(username)
661645
662646 updatedAt := &time.Time{}
663647 if len(posts) > 0 {
......@@ -722,7 +706,7 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
722706
723707 }
724708
725- realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
709+ realUrl := curl.FullPostURL(post.Username, post.Slug)
726710 feedId := realUrl
727711
728712 item := &feeds.Item{
......@@ -756,10 +740,10 @@ func rssBlogHandler(w http.ResponseWriter, r *http.Request) {
756740 }
757741 }
758742
759-func rssHandler(w http.ResponseWriter, r *http.Request) {
760- dbpool := router.GetDB(r)
761- logger := router.GetLogger(r)
762- cfg := router.GetCfg(r)
743+func (web *webRouter) rssHandler(w http.ResponseWriter, r *http.Request) {
744+ dbpool := web.db
745+ logger := web.cfg.Logger
746+ cfg := web.cfg
763747
764748 posts, err := dbpool.FindPopularPosts()
765749 if err != nil {
......@@ -786,7 +770,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
786770 Created: time.Now(),
787771 }
788772
789- curl := shared.CreateURLFromRequest(cfg, r)
773+ curl := shared.CreateURLFromRequest(cfg.Protocol, cfg.Domain, r)
790774
791775 var feedItems []*feeds.Item
792776 for _, post := range posts {
......@@ -820,7 +804,7 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
820804
821805 }
822806
823- realUrl := cfg.FullPostURL(curl, post.Username, post.Slug)
807+ realUrl := curl.FullPostURL(post.Username, post.Slug)
824808 if !curl.Subdomain && !curl.UsernameInRoute {
825809 realUrl = fmt.Sprintf("%s://%s%s", cfg.Protocol, r.Host, realUrl)
826810 }
......@@ -857,62 +841,10 @@ func rssHandler(w http.ResponseWriter, r *http.Request) {
857841 }
858842 }
859843
860-func serveFile(file string, contentType string) http.HandlerFunc {
861- return func(w http.ResponseWriter, r *http.Request) {
862- logger := router.GetLogger(r)
863- cfg := router.GetCfg(r)
864-
865- contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
866- if err != nil {
867- logger.Error("read file", "err", err.Error())
868- http.Error(w, "file not found", 404)
869- }
870- w.Header().Add("Content-Type", contentType)
871-
872- _, err = w.Write(contents)
873- if err != nil {
874- logger.Error("write to response writer", "err", err.Error())
875- http.Error(w, "server error", 500)
876- }
877- }
878-}
879-
880-func createStaticRoutes() []router.Route {
881- return []router.Route{
882- router.NewRoute("GET", "/read.css", serveFile("read.css", "text/css")),
883- router.NewRoute("GET", "/reset.css", serveFile("reset.css", "text/css")),
884- router.NewRoute("GET", "/syntax.css", serveFile("syntax.css", "text/css")),
885- router.NewRoute("GET", "/card.png", serveFile("card.png", "image/png")),
886- router.NewRoute("GET", "/favicon-16x16.png", serveFile("favicon-16x16.png", "image/png")),
887- router.NewRoute("GET", "/favicon-32x32.png", serveFile("favicon-32x32.png", "image/png")),
888- router.NewRoute("GET", "/apple-touch-icon.png", serveFile("apple-touch-icon.png", "image/png")),
889- router.NewRoute("GET", "/favicon.ico", serveFile("favicon.ico", "image/x-icon")),
890- router.NewRoute("GET", "/robots.txt", serveFile("robots.txt", "text/plain")),
891- }
892-}
893-
894-func createMainRoutes(staticRoutes []router.Route) []router.Route {
895- routes := []router.Route{
896- router.NewRoute("GET", "/", readHandler),
897- router.NewRoute("GET", "/read", readHandler),
898- router.NewRoute("GET", "/check", router.CheckHandler),
899- router.NewRoute("GET", "/rss", rssHandler),
900- router.NewRoute("GET", "/rss.atom", rssHandler),
901- router.NewRoute("GET", "/_metrics", promhttp.Handler().ServeHTTP),
902- }
903-
904- routes = append(
905- routes,
906- staticRoutes...,
907- )
908-
909- return routes
910-}
911-
912-func imgRequest(w http.ResponseWriter, r *http.Request) {
913- logger := router.GetLogger(r)
914- st := router.GetStorage(r)
915- dbpool := router.GetDB(r)
844+func (web *webRouter) imgRequest(w http.ResponseWriter, r *http.Request) {
845+ logger := web.cfg.Logger
846+ st := web.storage
847+ dbpool := web.db
916848 username := router.GetUsernameFromRequest(r)
917849 user, err := dbpool.FindUserByName(username)
918850 if err != nil {
......@@ -922,8 +854,8 @@ func imgRequest(w http.ResponseWriter, r *http.Request) {
922854 }
923855 logger = shared.LoggerWithUser(logger, user)
924856
925- rawname := router.GetField(r, 0)
926- imgOpts := router.GetField(r, 1)
857+ rawname := r.PathValue("file")
858+ imgOpts := r.PathValue("opts")
927859 // we place all prose images inside a "prose" folder
928860 fname := filepath.Join("/prose", rawname)
929861
......@@ -946,41 +878,97 @@ func imgRequest(w http.ResponseWriter, r *http.Request) {
946878 imgproxy.ServeHTTP(w, r)
947879 }
948880
949-func createSubdomainRoutes(staticRoutes []router.Route) []router.Route {
950- routes := []router.Route{
951- router.NewRoute("GET", "/", blogHandler),
952- router.NewRoute("GET", "/_styles.css", blogStyleHandler),
953- router.NewRoute("GET", "/robots.txt", robotsHandler),
954- router.NewRoute("GET", "/rss", rssBlogHandler),
955- router.NewRoute("GET", "/rss.xml", rssBlogHandler),
956- router.NewRoute("GET", "/atom.xml", rssBlogHandler),
957- router.NewRoute("GET", "/feed.xml", rssBlogHandler),
958- router.NewRoute("GET", "/atom", rssBlogHandler),
959- router.NewRoute("GET", "/blog/index.xml", rssBlogHandler),
960- }
961-
962- routes = append(
963- routes,
964- staticRoutes...,
965- )
881+type webRouter struct {
882+ cfg *Config
883+ db DB
884+ storage storage.StorageServe
885+}
966886
967- routes = append(
968- routes,
969- router.NewRoute("GET", "/raw/(.+)", postRawHandler),
970- router.NewRoute("GET", "/(.+).md", postRawHandler),
971- router.NewRoute("GET", "/(.+).lxt", postRawHandler),
972- router.NewRoute("GET", `/(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))/(.+)`, imgRequest),
973- router.NewRoute("GET", `/(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))$`, imgRequest),
974- router.NewRoute("GET", "/(.+).html", postHandler),
975- router.NewRoute("GET", "/(.+)", postHandler),
976- )
887+func (web *webRouter) static(mux *http.ServeMux) {
888+ cfg := web.cfg
889+ files := cfg.Static()
890+ mux.HandleFunc("GET /read.css", files.File("read.css", "text/css"))
891+ mux.HandleFunc("GET /reset.css", files.File("reset.css", "text/css"))
892+ mux.HandleFunc("GET /syntax.css", files.File("syntax.css", "text/css"))
893+ mux.HandleFunc("GET /card.png", files.File("card.png", "image/png"))
894+ mux.HandleFunc("GET /favicon-16x16.png", files.File("favicon-16x16.png", "image/png"))
895+ mux.HandleFunc("GET /favicon.ico", files.File("favicon.ico", "image/x-icon"))
896+ if cfg.Debug {
897+ router.CreatePProfRoutesMux(mux)
898+ }
899+}
977900
978- return routes
901+// mainMux serves the discover feed and site pages.
902+func (web *webRouter) mainMux() *http.ServeMux {
903+ mux := http.NewServeMux()
904+ mux.HandleFunc("GET /{$}", web.readHandler)
905+ mux.HandleFunc("GET /read", web.readHandler)
906+ mux.HandleFunc("GET /check", router.CheckHandler(web.cfg.Domain, web.cfg.Space, web.db))
907+ mux.HandleFunc("GET /rss", web.rssHandler)
908+ mux.HandleFunc("GET /rss.atom", web.rssHandler)
909+ mux.Handle("GET /_metrics", promhttp.Handler())
910+ mux.HandleFunc("GET /robots.txt", web.cfg.Static().File("robots.txt", "text/plain"))
911+ web.static(mux)
912+ return mux
913+}
914+
915+// sitesMux serves a user's blog on their subdomain or custom domain.
916+func (web *webRouter) sitesMux() *http.ServeMux {
917+ mux := http.NewServeMux()
918+ mux.HandleFunc("GET /{$}", web.blogHandler)
919+ mux.HandleFunc("GET /_styles.css", web.blogStyleHandler)
920+ mux.HandleFunc("GET /robots.txt", web.robotsHandler)
921+ for _, feed := range []string{"/rss", "/rss.xml", "/atom.xml", "/feed.xml", "/atom", "/blog/index.xml"} {
922+ mux.HandleFunc("GET "+feed, web.rssBlogHandler)
923+ }
924+ web.static(mux)
925+ mux.HandleFunc("GET /{path...}", web.pathRoute)
926+ return mux
927+}
928+
929+var (
930+ imgWithOpts = regexp.MustCompile(`^(.+\.(?:jpg|jpeg|png|gif|webp|svg|ico))/(.+)$`)
931+ imgPath = regexp.MustCompile(`^.+\.(?:jpg|jpeg|png|gif|webp|svg|ico)$`)
932+)
933+
934+// pathRoute serves the site paths ServeMux patterns can't tell apart: a
935+// post's markdown, an image with or without processing options, and a post.
936+// A "/raw/{slug...}" pattern would redirect "/raw" away from a post named raw.
937+func (web *webRouter) pathRoute(w http.ResponseWriter, r *http.Request) {
938+ path := r.PathValue("path")
939+ if slug, ok := strings.CutPrefix(path, "raw/"); ok && slug != "" {
940+ r.SetPathValue("slug", slug)
941+ web.postRawHandler(w, r)
942+ return
943+ }
944+ for _, ext := range []string{".md", ".lxt"} {
945+ if slug, ok := strings.CutSuffix(path, ext); ok && slug != "" {
946+ r.SetPathValue("slug", slug)
947+ web.postRawHandler(w, r)
948+ return
949+ }
950+ }
951+ if m := imgWithOpts.FindStringSubmatch(path); m != nil {
952+ r.SetPathValue("file", m[1])
953+ r.SetPathValue("opts", m[2])
954+ web.imgRequest(w, r)
955+ return
956+ }
957+ if imgPath.MatchString(path) {
958+ r.SetPathValue("file", path)
959+ web.imgRequest(w, r)
960+ return
961+ }
962+ if slug, ok := strings.CutSuffix(path, ".html"); ok && slug != "" {
963+ path = slug
964+ }
965+ r.SetPathValue("slug", path)
966+ web.postHandler(w, r)
979967 }
980968
981969 func StartApiServer() {
982- cfg := NewConfigSite("prose-web")
983- dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
970+ cfg := NewConfig("prose-web")
971+ dbpool := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
984972 defer func() {
985973 _ = dbpool.Close()
986974 }()
......@@ -993,22 +981,8 @@ func StartApiServer() {
993981 return
994982 }
995983
996- staticRoutes := createStaticRoutes()
997-
998- if cfg.Debug {
999- staticRoutes = router.CreatePProfRoutes(staticRoutes)
1000- }
1001-
1002- mainRoutes := createMainRoutes(staticRoutes)
1003- subdomainRoutes := createSubdomainRoutes(staticRoutes)
1004-
1005- apiConfig := &router.ApiConfig{
1006- Cfg: cfg,
1007- Dbpool: dbpool,
1008- Storage: st,
1009- }
1010- handler := router.CreateServe(mainRoutes, subdomainRoutes, apiConfig)
1011- router := http.HandlerFunc(handler)
984+ web := &webRouter{cfg: cfg, db: dbpool, storage: st}
985+ handler := router.NewHostRouter(cfg.Domain, cfg.Space, web.mainMux(), web.sitesMux())
1012986
1013987 portStr := fmt.Sprintf(":%s", cfg.Port)
1014988 logger.Info(
......@@ -1017,5 +991,5 @@ func StartApiServer() {
1017991 "domain", cfg.Domain,
1018992 )
1019993
1020- logger.Error(http.ListenAndServe(portStr, router).Error())
994+ logger.Error(http.ListenAndServe(portStr, handler).Error())
1021995 }
+74, -0
......@@ -0,0 +1,74 @@
1+package prose
2+
3+import (
4+ "log/slog"
5+ "net/http"
6+ "net/http/httptest"
7+ "strings"
8+ "testing"
9+
10+ "github.com/picosh/pico/pkg/db"
11+ "github.com/picosh/pico/pkg/db/stub"
12+ "github.com/picosh/pico/pkg/postfs"
13+ "github.com/picosh/pico/pkg/shared/router"
14+ "github.com/picosh/pico/pkg/storage"
15+)
16+
17+func TestRoutes(t *testing.T) {
18+ t.Chdir("../../..")
19+ t.Setenv("PICO_PIPE_ENABLED", "false")
20+ logger := slog.New(slog.DiscardHandler)
21+ user := &db.User{ID: "user-1", Name: "tester"}
22+ posts := stub.NewPostsDB(logger, user)
23+ cfg := NewConfig("prose-test")
24+ cfg.Logger = logger
25+ for _, name := range []string{"hello", "raw"} {
26+ post := postfs.NewPost(name+".md", []byte("# "+name))
27+ post.UserID, post.Space, post.Title = user.ID, cfg.Space, name
28+ if _, err := posts.InsertPost(post); err != nil {
29+ t.Fatal(err)
30+ }
31+ }
32+ st, err := storage.NewStorageMemory(map[string]map[string]string{})
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ web := &webRouter{cfg: cfg, db: posts, storage: st}
37+ handler := router.NewHostRouter(cfg.Domain, cfg.Space, web.mainMux(), web.sitesMux())
38+
39+ site := "https://tester." + cfg.Domain
40+ for _, tt := range []struct {
41+ url string
42+ status int
43+ // body is what the response holds, "" for anything.
44+ body string
45+ // exact means the response is body and nothing else.
46+ exact bool
47+ }{
48+ {url: site + "/hello", status: http.StatusOK, body: "<html"},
49+ {url: site + "/hello.html", status: http.StatusOK, body: "<html"},
50+ {url: site + "/hello.md", status: http.StatusOK, body: "# hello"},
51+ {url: site + "/hello.lxt", status: http.StatusOK, body: "# hello"},
52+ {url: site + "/raw/hello", status: http.StatusOK, body: "# hello"},
53+ {url: site + "/raw", status: http.StatusOK, body: "<html"},
54+ {url: site + "/raw.md", status: http.StatusOK, body: "# raw"},
55+ {url: site + "/raw/raw", status: http.StatusOK, body: "# raw"},
56+ {url: site + "/missing", status: http.StatusNotFound},
57+ // Images are served from the user's bucket, which they don't have.
58+ {url: site + "/cat.png", status: http.StatusUnprocessableEntity, body: "bucket does not exist"},
59+ {url: site + "/cat.png/s:500", status: http.StatusUnprocessableEntity, body: "bucket does not exist"},
60+ // A site serves its own robots.txt, which this user hasn't uploaded.
61+ {url: site + "/robots.txt", status: http.StatusOK, exact: true},
62+ {url: "https://" + cfg.Domain + "/robots.txt", status: http.StatusOK, body: "User-agent"},
63+ {url: site + "/read.css", status: http.StatusOK},
64+ {url: "https://" + cfg.Domain + "/read.css", status: http.StatusOK},
65+ {url: "https://" + cfg.Domain + "/hello", status: http.StatusNotFound},
66+ } {
67+ res := httptest.NewRecorder()
68+ handler.ServeHTTP(res, httptest.NewRequest(http.MethodGet, tt.url, nil))
69+ got := res.Body.String()
70+ if res.Code != tt.status || !strings.Contains(got, tt.body) || (tt.exact && got != tt.body) {
71+ t.Errorf("%s = %d %.80q, want %d %q", tt.url, res.Code, got, tt.status, tt.body)
72+ }
73+ }
74+}
+48, -2
......@@ -1,14 +1,37 @@
11 package prose
22
33 import (
4+ "fmt"
5+ "html/template"
6+ "log/slog"
7+ "path/filepath"
48 "strings"
59
610 "github.com/picosh/pico/pkg/shared"
11+ "github.com/picosh/pico/pkg/shared/router"
712 )
813
914 var MAX_FILE_SIZE = 3 * shared.MB
1015
11-func NewConfigSite(service string) *shared.ConfigSite {
16+// staticDir holds prose's templates and public files.
17+const staticDir = "pkg/apps/prose"
18+
19+type Config struct {
20+ Debug bool
21+ Domain string
22+ Port string
23+ Protocol string
24+ DbURL string
25+ Space string
26+ AllowedExt []string
27+ HiddenPosts []string
28+ MaxSize uint64
29+ MaxAssetSize int64
30+ MaxSpecialFileSize int64
31+ Logger *slog.Logger
32+}
33+
34+func NewConfig(service string) *Config {
1235 debug := shared.GetEnv("PROSE_DEBUG", "0")
1336 domain := shared.GetEnv("PROSE_DOMAIN", "prose.sh")
1437 port := shared.GetEnv("PROSE_WEB_PORT", "3000")
......@@ -18,7 +41,7 @@ func NewConfigSite(service string) *shared.ConfigSite {
1841 maxImgSize := int64(10 * shared.MB)
1942 withPipe := strings.ToLower(shared.GetEnv("PICO_PIPE_ENABLED", "true")) == "true"
2043
21- return &shared.ConfigSite{
44+ return &Config{
2245 Debug: debug == "1",
2346 Domain: domain,
2447 Port: port,
......@@ -42,3 +65,26 @@ func NewConfigSite(service string) *shared.ConfigSite {
4265 MaxAssetSize: maxImgSize,
4366 }
4467 }
68+
69+func (c *Config) StaticPath(fname string) string {
70+ return filepath.Join(staticDir, fname)
71+}
72+
73+func (c *Config) SiteData() shared.SitePageData {
74+ return shared.SitePageData{
75+ Domain: template.URL(c.Domain),
76+ HomeURL: template.URL("//" + c.Domain),
77+ }
78+}
79+
80+func (c *Config) Static() router.Static {
81+ return router.Static{Dir: staticDir, Logger: c.Logger, Site: c.SiteData()}
82+}
83+
84+func (c *Config) ReadURL() string {
85+ return fmt.Sprintf("%s://%s", c.Protocol, c.Domain)
86+}
87+
88+func (c *Config) CssURL() string {
89+ return "/_styles.css"
90+}
+333, -0
......@@ -0,0 +1,333 @@
1+package prose
2+
3+import (
4+ "fmt"
5+ "math"
6+ "sync"
7+ "time"
8+
9+ "github.com/hashicorp/golang-lru/v2/expirable"
10+ "github.com/jmoiron/sqlx"
11+ "github.com/picosh/pico/pkg/db"
12+ "github.com/picosh/pico/pkg/db/postgres"
13+)
14+
15+// DB is the storage prose uses.
16+type DB interface {
17+ db.AuthDB
18+ db.PostDB
19+ db.TagDB
20+ FindUserPostsByTag(pager *db.Pager, tag, userID, space string) (*db.Paginate[*db.Post], error)
21+ FindPopularPosts() ([]*db.Post, error)
22+}
23+
24+// PsqlDB is prose's postgres storage: the queries apps share, and the ones
25+// only prose makes.
26+type PsqlDB struct {
27+ *postgres.PsqlDB
28+
29+ popularPostsCache *expirable.LRU[string, []*db.Post]
30+ popularPostsMu sync.Mutex
31+}
32+
33+var _ DB = (*PsqlDB)(nil)
34+
35+func NewDB(base *postgres.PsqlDB) *PsqlDB {
36+ return &PsqlDB{
37+ PsqlDB: base,
38+ popularPostsCache: expirable.NewLRU[string, []*db.Post](16, nil, 15*time.Minute),
39+ }
40+}
41+
42+func (me *PsqlDB) ClearPopularPostsCache() {
43+ if me.popularPostsCache != nil {
44+ me.popularPostsCache.Purge()
45+ }
46+}
47+
48+func (me *PsqlDB) FindPopularPosts() ([]*db.Post, error) {
49+ if me.popularPostsCache != nil {
50+ if posts, ok := me.popularPostsCache.Get("popular_posts"); ok {
51+ return posts, nil
52+ }
53+ }
54+
55+ me.popularPostsMu.Lock()
56+ defer me.popularPostsMu.Unlock()
57+
58+ if me.popularPostsCache != nil {
59+ if posts, ok := me.popularPostsCache.Get("popular_posts"); ok {
60+ return posts, nil
61+ }
62+ }
63+
64+ query := `
65+ WITH candidate_posts AS (
66+ SELECT
67+ posts.id,
68+ posts.user_id,
69+ posts.filename,
70+ posts.slug,
71+ posts.title,
72+ posts.text,
73+ posts.description,
74+ posts.publish_at,
75+ app_users.name AS username,
76+ posts.updated_at,
77+ posts.mime_type
78+ FROM posts
79+ LEFT JOIN app_users ON app_users.id = posts.user_id
80+ WHERE
81+ hidden = FALSE
82+ AND publish_at::date <= CURRENT_DATE
83+ AND publish_at >= NOW() - INTERVAL '30 days'
84+ AND cur_space = 'prose'
85+ AND EXISTS (
86+ SELECT 1 FROM feature_flags
87+ WHERE feature_flags.user_id = posts.user_id
88+ AND (name = 'plus' OR name = 'prose')
89+ AND expires_at > NOW()
90+ )
91+ ),
92+ post_visitors AS (
93+ SELECT post_id, COUNT(DISTINCT ip_address) AS unique_visitors
94+ FROM analytics_visits
95+ WHERE post_id IN (SELECT id FROM candidate_posts)
96+ AND status = 200
97+ AND created_at >= NOW() - INTERVAL '30 days'
98+ GROUP BY post_id
99+ ),
100+ scored_posts AS (
101+ SELECT
102+ cp.*,
103+ COALESCE(pv.unique_visitors, 0) AS score,
104+ (
105+ LOG(GREATEST(COALESCE(pv.unique_visitors, 0), 1)) +
106+ (EXTRACT(EPOCH FROM cp.publish_at) - 1700000000) / 345600.0
107+ ) AS rank_score,
108+ ROW_NUMBER() OVER (
109+ PARTITION BY cp.user_id
110+ ORDER BY (
111+ LOG(GREATEST(COALESCE(pv.unique_visitors, 0), 1)) +
112+ (EXTRACT(EPOCH FROM cp.publish_at) - 1700000000) / 345600.0
113+ ) DESC, cp.publish_at DESC
114+ ) AS user_rank
115+ FROM candidate_posts cp
116+ LEFT JOIN post_visitors pv ON pv.post_id = cp.id
117+ )
118+ SELECT
119+ id,
120+ user_id,
121+ filename,
122+ slug,
123+ title,
124+ text,
125+ description,
126+ publish_at,
127+ username,
128+ updated_at,
129+ mime_type,
130+ score
131+ FROM scored_posts
132+ WHERE user_rank <= 3
133+ ORDER BY rank_score DESC, publish_at DESC
134+ LIMIT 30`
135+ rs, err := me.Db.Queryx(query)
136+ if err != nil {
137+ return nil, err
138+ }
139+ defer func() { _ = rs.Close() }()
140+
141+ var posts []*db.Post
142+ for rs.Next() {
143+ post := &db.Post{}
144+ err := rs.Scan(
145+ &post.ID,
146+ &post.UserID,
147+ &post.Filename,
148+ &post.Slug,
149+ &post.Title,
150+ &post.Text,
151+ &post.Description,
152+ &post.PublishAt,
153+ &post.Username,
154+ &post.UpdatedAt,
155+ &post.MimeType,
156+ &post.Score,
157+ )
158+ if err != nil {
159+ return nil, err
160+ }
161+
162+ posts = append(posts, post)
163+ }
164+ if rs.Err() != nil {
165+ return nil, rs.Err()
166+ }
167+
168+ if me.popularPostsCache != nil {
169+ me.popularPostsCache.Add("popular_posts", posts)
170+ }
171+
172+ return posts, nil
173+}
174+
175+func (me *PsqlDB) FindUserPostsByTag(page *db.Pager, tag, userID, space string) (*db.Paginate[*db.Post], error) {
176+ var posts []*db.Post
177+ query := fmt.Sprintf(`
178+ SELECT %s
179+ FROM posts
180+ LEFT JOIN app_users ON app_users.id = posts.user_id
181+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
182+ WHERE
183+ hidden = FALSE AND
184+ user_id = $1 AND
185+ (post_tags.name = $2 OR hidden = true) AND
186+ publish_at::date <= CURRENT_DATE AND
187+ cur_space = $3
188+ ORDER BY publish_at DESC
189+ LIMIT $4 OFFSET $5`, postgres.SelectPost)
190+ err := me.Db.Select(
191+ &posts,
192+ query,
193+ userID,
194+ tag,
195+ space,
196+ page.Num,
197+ page.Num*page.Page,
198+ )
199+ if err != nil {
200+ return nil, err
201+ }
202+
203+ var count int
204+ err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
205+ if err != nil {
206+ return nil, err
207+ }
208+
209+ pager := &db.Paginate[*db.Post]{
210+ Data: posts,
211+ Total: int(math.Ceil(float64(count) / float64(page.Num))),
212+ }
213+ return pager, nil
214+}
215+
216+func (me *PsqlDB) FindPostsByTag(pager *db.Pager, tag, space string) (*db.Paginate[*db.Post], error) {
217+ query := `
218+ SELECT
219+ posts.id,
220+ user_id,
221+ filename,
222+ slug,
223+ title,
224+ text,
225+ description,
226+ publish_at,
227+ app_users.name as username,
228+ posts.updated_at,
229+ posts.mime_type
230+ FROM posts
231+ LEFT JOIN app_users ON app_users.id = posts.user_id
232+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
233+ WHERE
234+ post_tags.name = $3 AND
235+ publish_at::date <= CURRENT_DATE AND
236+ cur_space = $4
237+ ORDER BY publish_at DESC
238+ LIMIT $1 OFFSET $2`
239+ rs, err := me.Db.Queryx(
240+ query,
241+ pager.Num,
242+ pager.Num*pager.Page,
243+ tag,
244+ space,
245+ )
246+ if err != nil {
247+ return nil, err
248+ }
249+ defer func() { _ = rs.Close() }()
250+
251+ return me.postPager(rs, pager.Num, space, tag)
252+}
253+
254+func (me *PsqlDB) postPager(rs *sqlx.Rows, pageNum int, space string, tag string) (*db.Paginate[*db.Post], error) {
255+ var posts []*db.Post
256+ for rs.Next() {
257+ post := &db.Post{}
258+ err := rs.Scan(
259+ &post.ID,
260+ &post.UserID,
261+ &post.Filename,
262+ &post.Slug,
263+ &post.Title,
264+ &post.Text,
265+ &post.Description,
266+ &post.PublishAt,
267+ &post.Username,
268+ &post.UpdatedAt,
269+ &post.MimeType,
270+ )
271+ if err != nil {
272+ return nil, err
273+ }
274+
275+ posts = append(posts, post)
276+ }
277+ if rs.Err() != nil {
278+ return nil, rs.Err()
279+ }
280+
281+ var count int
282+ var err error
283+ if tag == "" {
284+ err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
285+ } else {
286+ err = me.Db.QueryRow(`
287+ SELECT count(posts.id)
288+ FROM posts
289+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
290+ WHERE hidden = FALSE AND cur_space=$1 and post_tags.name = $2`, space, tag).Scan(&count)
291+ }
292+ if err != nil {
293+ return nil, err
294+ }
295+
296+ pager := &db.Paginate[*db.Post]{
297+ Data: posts,
298+ Total: int(math.Ceil(float64(count) / float64(pageNum))),
299+ }
300+
301+ return pager, nil
302+}
303+
304+func (me *PsqlDB) FindPopularTags(space string) ([]string, error) {
305+ tags := make([]string, 0)
306+ query := `
307+ SELECT name, count(post_id) as "tally"
308+ FROM post_tags
309+ LEFT JOIN posts ON posts.id = post_id
310+ WHERE posts.cur_space = $1
311+ GROUP BY name
312+ ORDER BY tally DESC
313+ LIMIT 5`
314+ rs, err := me.Db.Queryx(query, space)
315+ if err != nil {
316+ return tags, err
317+ }
318+ defer func() { _ = rs.Close() }()
319+ for rs.Next() {
320+ name := ""
321+ tally := 0
322+ err := rs.Scan(&name, &tally)
323+ if err != nil {
324+ return tags, err
325+ }
326+
327+ tags = append(tags, name)
328+ }
329+ if rs.Err() != nil {
330+ return tags, rs.Err()
331+ }
332+ return tags, nil
333+}
+182, -0
......@@ -0,0 +1,182 @@
1+package prose
2+
3+import (
4+ "log/slog"
5+ "testing"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db"
9+ "github.com/picosh/pico/pkg/db/postgres"
10+ "github.com/picosh/pico/pkg/db/postgres/pgtest"
11+)
12+
13+var testDB *PsqlDB
14+
15+func TestMain(m *testing.M) {
16+ pgtest.Main(m)
17+}
18+
19+func cleanupTestData(t *testing.T) {
20+ t.Helper()
21+ if testDB == nil {
22+ testDB = NewDB(postgres.NewDB(pgtest.URL(t), slog.New(slog.DiscardHandler)))
23+ }
24+ pgtest.Clean(t, testDB.Db)
25+ testDB.ClearPopularPostsCache()
26+}
27+
28+func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
29+ t.Helper()
30+ return pgtest.InsertPost(t, testDB, post)
31+}
32+
33+func TestFindPopularPosts(t *testing.T) {
34+ cleanupTestData(t)
35+
36+ user, _ := testDB.RegisterUser("feedowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedowner", "comment", "")
37+
38+ now := time.Now()
39+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost.md", Slug: "feedpost", Title: "Feed Post", Space: "prose", PublishAt: &now})
40+
41+ // Before having feature flag, should not appear
42+ posts, err := testDB.FindPopularPosts()
43+ if err != nil {
44+ t.Fatalf("FindPopularPosts failed: %v", err)
45+ }
46+ if len(posts) != 0 {
47+ t.Errorf("expected 0 posts without feature flag, got %d", len(posts))
48+ }
49+
50+ // Add prose feature flag
51+ _, err = testDB.InsertFeature(user.ID, "prose", time.Now().Add(time.Hour*24))
52+ if err != nil {
53+ t.Fatalf("InsertFeature failed: %v", err)
54+ }
55+ testDB.ClearPopularPostsCache()
56+
57+ posts, err = testDB.FindPopularPosts()
58+ if err != nil {
59+ t.Fatalf("FindPopularPosts failed: %v", err)
60+ }
61+ if len(posts) != 1 {
62+ t.Fatalf("expected 1 post in popular posts, got %d", len(posts))
63+ }
64+ if posts[0].Score != 0 {
65+ t.Errorf("expected score 0, got %d", posts[0].Score)
66+ }
67+
68+ // Add visits
69+ _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.1", Status: 200})
70+ _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.2", Status: 200})
71+ _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.1", Status: 200}) // duplicate IP
72+
73+ // Before clearing cache, should return cached result (score 0)
74+ cachedPosts, err := testDB.FindPopularPosts()
75+ if err != nil {
76+ t.Fatalf("FindPopularPosts failed: %v", err)
77+ }
78+ if cachedPosts[0].Score != 0 {
79+ t.Errorf("expected cached score 0, got %d", cachedPosts[0].Score)
80+ }
81+
82+ // After clearing cache, should re-query and return updated score 2
83+ testDB.ClearPopularPostsCache()
84+ posts, err = testDB.FindPopularPosts()
85+ if err != nil {
86+ t.Fatalf("FindPopularPosts failed: %v", err)
87+ }
88+ if len(posts) != 1 {
89+ t.Fatalf("expected 1 post, got %d", len(posts))
90+ }
91+ if posts[0].Score != 2 {
92+ t.Errorf("expected score 2 (unique visitors), got %d", posts[0].Score)
93+ }
94+
95+ // Verify max 3 posts per user
96+ testDB.ClearPopularPostsCache()
97+ post2Time := now.Add(-1 * time.Hour)
98+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost2.md", Slug: "feedpost2", Title: "Feed Post 2", Space: "prose", PublishAt: &post2Time})
99+ post3Time := now.Add(-2 * time.Hour)
100+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost3.md", Slug: "feedpost3", Title: "Feed Post 3", Space: "prose", PublishAt: &post3Time})
101+ post4Time := now.Add(-3 * time.Hour)
102+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost4.md", Slug: "feedpost4", Title: "Feed Post 4", Space: "prose", PublishAt: &post4Time})
103+
104+ posts, err = testDB.FindPopularPosts()
105+ if err != nil {
106+ t.Fatalf("FindPopularPosts failed: %v", err)
107+ }
108+ if len(posts) != 3 {
109+ t.Errorf("expected max 3 posts for single user, got %d", len(posts))
110+ }
111+
112+ // Post older than 30 days should be excluded
113+ testDB.ClearPopularPostsCache()
114+ oldTime := now.Add(-31 * 24 * time.Hour)
115+ _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "oldpost.md", Slug: "oldpost", Title: "Old Post", Space: "prose", PublishAt: &oldTime})
116+
117+ posts, err = testDB.FindPopularPosts()
118+ if err != nil {
119+ t.Fatalf("FindPopularPosts failed: %v", err)
120+ }
121+ if len(posts) != 3 {
122+ t.Errorf("expected still 3 posts (old post excluded), got %d", len(posts))
123+ }
124+}
125+
126+// ============ Tags Tests ============
127+
128+func TestFindUserPostsByTag(t *testing.T) {
129+ cleanupTestData(t)
130+
131+ user, _ := testDB.RegisterUser("usertagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI usertagowner", "comment", "")
132+ now := time.Now()
133+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "usertag.md", Slug: "usertag", Title: "User Tag", Space: "prose", PublishAt: &now})
134+ _ = testDB.ReplaceTagsByPost([]string{"mytag"}, post.ID)
135+
136+ pager := &db.Pager{Num: 10, Page: 0}
137+ result, err := testDB.FindUserPostsByTag(pager, "mytag", user.ID, "prose")
138+ if err != nil {
139+ t.Fatalf("FindUserPostsByTag failed: %v", err)
140+ }
141+ if len(result.Data) != 1 {
142+ t.Errorf("expected 1 post with tag, got %d", len(result.Data))
143+ }
144+}
145+
146+func TestFindPostsByTag(t *testing.T) {
147+ cleanupTestData(t)
148+
149+ user, _ := testDB.RegisterUser("tagsearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagsearchowner", "comment", "")
150+ now := time.Now()
151+ post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "tagsearch.md", Slug: "tagsearch", Title: "Tag Search", Space: "prose", PublishAt: &now})
152+ _ = testDB.ReplaceTagsByPost([]string{"searchtag"}, post.ID)
153+
154+ pager := &db.Pager{Num: 10, Page: 0}
155+ result, err := testDB.FindPostsByTag(pager, "searchtag", "prose")
156+ if err != nil {
157+ t.Fatalf("FindPostsByTag failed: %v", err)
158+ }
159+ if len(result.Data) != 1 {
160+ t.Errorf("expected 1 post with tag, got %d", len(result.Data))
161+ }
162+}
163+
164+func TestFindPopularTags(t *testing.T) {
165+ cleanupTestData(t)
166+
167+ user, _ := testDB.RegisterUser("populartagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI populartagowner", "comment", "")
168+ post1 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop1.md", Slug: "pop1", Title: "Pop 1", Space: "prose"})
169+ post2 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop2.md", Slug: "pop2", Title: "Pop 2", Space: "prose"})
170+ _ = testDB.ReplaceTagsByPost([]string{"popular"}, post1.ID)
171+ _ = testDB.ReplaceTagsByPost([]string{"popular"}, post2.ID)
172+
173+ tags, err := testDB.FindPopularTags("prose")
174+ if err != nil {
175+ t.Fatalf("FindPopularTags failed: %v", err)
176+ }
177+ if len(tags) < 1 {
178+ t.Errorf("expected at least 1 popular tag, got %d", len(tags))
179+ }
180+}
181+
182+// ============ Aliases Tests ============
R pkg/filehandlers/imgs/handler.go => pkg/apps/prose/imgs.go
+13, -13
......@@ -1,4 +1,4 @@
1-package uploadimgs
1+package prose
22
33 import (
44 "bytes"
......@@ -19,8 +19,6 @@ import (
1919 "github.com/picosh/pico/pkg/storage"
2020 )
2121
22-var Space = "imgs"
23-
2422 type PostMetaData struct {
2523 Text []byte
2624 FileSize int
......@@ -32,12 +30,12 @@ type PostMetaData struct {
3230 }
3331
3432 type UploadImgHandler struct {
35- DBPool db.DB
36- Cfg *shared.ConfigSite
33+ DBPool DB
34+ Cfg *Config
3735 Storage storage.StorageServe
3836 }
3937
40-func NewUploadImgHandler(dbpool db.DB, cfg *shared.ConfigSite, storage storage.StorageServe) *UploadImgHandler {
38+func NewUploadImgHandler(dbpool DB, cfg *Config, storage storage.StorageServe) *UploadImgHandler {
4139 return &UploadImgHandler{
4240 DBPool: dbpool,
4341 Cfg: cfg,
......@@ -171,7 +169,13 @@ func (h *UploadImgHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.
171169 }
172170
173171 fileSize := binary.Size(text)
174- featureFlag := shared.FindPlusFF(h.DBPool, h.Cfg, user.ID)
172+ featureFlag, _ := h.DBPool.FindFeature(user.ID, "plus")
173+ // we have free tiers so users might not have a feature flag
174+ // in which case we set sane defaults
175+ if featureFlag == nil {
176+ featureFlag = db.NewFeatureFlag(user.ID, "plus", h.Cfg.MaxSize, h.Cfg.MaxAssetSize, h.Cfg.MaxSpecialFileSize)
177+ }
178+ featureFlag.SetLimits(h.Cfg.MaxSize, h.Cfg.MaxAssetSize, h.Cfg.MaxSpecialFileSize)
175179
176180 bucket, err := h.Storage.UpsertBucket(shared.GetAssetBucketName(user.ID))
177181 if err != nil {
......@@ -200,12 +204,8 @@ func (h *UploadImgHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.
200204 return "", err
201205 }
202206
203- curl := shared.NewCreateURL(h.Cfg)
204- url := h.Cfg.FullPostURL(
205- curl,
206- user.Name,
207- metadata.Filename,
208- )
207+ curl := shared.NewCreateURL(h.Cfg.Protocol, h.Cfg.Domain)
208+ url := curl.FullPostURL(user.Name, metadata.Filename)
209209 return url + shared.StorageUsage(metadata.TotalFileSize+fileSize, int(featureFlag.Data.StorageMax)), nil
210210 }
211211
+195, -0
......@@ -0,0 +1,195 @@
1+package prose
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "slices"
8+ "strings"
9+
10+ "github.com/picosh/pico/pkg/db"
11+ "github.com/picosh/pico/pkg/postfs"
12+ "github.com/picosh/pico/pkg/pssh"
13+ sendutils "github.com/picosh/pico/pkg/send/utils"
14+ "github.com/picosh/pico/pkg/shared"
15+)
16+
17+// postUploader stores prose's text files as posts.
18+type postUploader struct {
19+ Cfg *Config
20+ DB DB
21+}
22+
23+var (
24+ _ ReadWriteHandler = (*postUploader)(nil)
25+ _ sendutils.WriteValidator = (*postUploader)(nil)
26+ _ sendutils.FileRenamer = (*postUploader)(nil)
27+)
28+
29+func (h *postUploader) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
30+ return postfs.ListPosts(s, fpath, isDir, recursive, []string{h.Cfg.Space}, h.DB)
31+}
32+
33+func (h *postUploader) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
34+ return postfs.ReadPost(s, h.DB, h.Cfg.Space, entry)
35+}
36+
37+func (h *postUploader) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
38+ return postfs.DeletePost(s, h.DB, h.Cfg.Space, entry)
39+}
40+
41+// ValidateWrite checks a post's name before its contents arrive.
42+func (h *postUploader) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
43+ if _, err := postfs.SessionUser(s); err != nil {
44+ return err
45+ }
46+ if entry.Mode.IsDir() {
47+ return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
48+ }
49+ return h.validate(postfs.NewPost(filepath.Base(entry.Filepath), nil))
50+}
51+
52+func (h *postUploader) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
53+ user, err := postfs.SessionUser(s)
54+ if err != nil {
55+ return "", err
56+ }
57+ logger := pssh.GetLogger(s).With("filename", filepath.Base(entry.Filepath))
58+
59+ post, err := postfs.PostFromEntry(entry, user, h.Cfg.Space)
60+ if err != nil {
61+ return "", err
62+ }
63+ if err := h.validate(post); err != nil {
64+ logger.Error("file failed validation", "err", err.Error())
65+ return "", err
66+ }
67+
68+ cur, err := postfs.LookupPost(h.DB, user.ID, h.Cfg.Space, post.Filename)
69+ if err != nil {
70+ return "", err
71+ }
72+ if cur != nil {
73+ post.Data, post.PublishAt = cur.Data, cur.PublishAt
74+ }
75+ aliases, err := h.setMeta(post)
76+ if err != nil {
77+ logger.Error("file could not load meta", "err", err.Error())
78+ return "", err
79+ }
80+
81+ saved, changed, err := postfs.SavePost(h.DB, cur, post)
82+ if err != nil {
83+ logger.Error("post could not be saved", "err", err.Error())
84+ return "", err
85+ }
86+ logger.Info("saved post", "new", cur == nil, "changed", changed)
87+ // A new post has no tags or aliases to clear.
88+ if changed && (cur != nil || len(post.Tags) > 0) {
89+ if err := h.DB.ReplaceTagsByPost(post.Tags, saved.ID); err != nil {
90+ logger.Error("post could not replace tags", "err", err.Error())
91+ return "", fmt.Errorf("error for %s: %v", post.Filename, err)
92+ }
93+ }
94+ if changed && (cur != nil || len(aliases) > 0) {
95+ if err := h.DB.ReplaceAliasesByPost(aliases, saved.ID); err != nil {
96+ logger.Error("post could not replace aliases", "err", err.Error())
97+ return "", fmt.Errorf("error for %s: %v", post.Filename, err)
98+ }
99+ }
100+
101+ return shared.NewCreateURL(h.Cfg.Protocol, h.Cfg.Domain).FullPostURL(user.Name, post.Slug), nil
102+}
103+
104+// Rename moves a post to a new filename in place, so it keeps its ID,
105+// publish date and settings. A post at the new filename is replaced.
106+func (h *postUploader) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
107+ return postfs.RenamePost(s, h.DB, h.Cfg.Space, from, to, func(cur, next *db.Post) ([]string, error) {
108+ if err := h.validate(next); err != nil {
109+ return nil, err
110+ }
111+ return h.setMeta(next)
112+ })
113+}
114+
115+func (h *postUploader) validate(post *db.Post) error {
116+ if !shared.IsTextFile(post.Text) {
117+ return fmt.Errorf(
118+ "ERROR: (%s) invalid file must be plain text (utf-8), skipping",
119+ post.Filename,
120+ )
121+ }
122+
123+ fp := strings.Replace(post.Filename, "/", "", 1)
124+ // special styles css file we want to permit but no other css file.
125+ // sometimes the directory is provided in the filename, so we want to
126+ // remove that before we perform this check.
127+ if fp == "_styles.css" {
128+ return nil
129+ }
130+ // allow users to upload robots file
131+ if fp == "robots.txt" {
132+ return nil
133+ }
134+
135+ if !shared.IsExtAllowed(post.Filename, h.Cfg.AllowedExt) {
136+ extStr := strings.Join(h.Cfg.AllowedExt, ",")
137+ return fmt.Errorf(
138+ "ERROR: (%s) invalid file, format must be (%s), skipping",
139+ post.Filename,
140+ extStr,
141+ )
142+ }
143+
144+ if post.FileSize > MAX_FILE_SIZE {
145+ return fmt.Errorf(
146+ "ERROR: file (%s) has exceeded maximum file size (%d bytes)",
147+ post.Filename,
148+ MAX_FILE_SIZE,
149+ )
150+ }
151+
152+ return nil
153+}
154+
155+// setMeta fills in a post's title, description, tags, publish date and
156+// hidden flag from its text, and returns its aliases.
157+func (h *postUploader) setMeta(post *db.Post) ([]string, error) {
158+ var aliases []string
159+ switch filepath.Ext(post.Filename) {
160+ case ".lxt":
161+ parsedText := shared.ListParseText(post.Text)
162+ if parsedText.Title == "" {
163+ post.Title = shared.ToUpper(post.Slug)
164+ } else {
165+ post.Title = parsedText.Title
166+ }
167+ aliases = parsedText.Aliases
168+ post.Tags = parsedText.Tags
169+ post.Description = parsedText.Description
170+ if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
171+ post.PublishAt = parsedText.PublishAt
172+ }
173+ post.Hidden = parsedText.Hidden
174+ case ".md":
175+ parsedText, err := shared.ParseText(post.Text)
176+ if err != nil {
177+ return nil, fmt.Errorf("%s: %w", post.Filename, err)
178+ }
179+ if parsedText.Title == "" {
180+ post.Title = shared.ToUpper(post.Slug)
181+ } else {
182+ post.Title = parsedText.Title
183+ }
184+ aliases = parsedText.Aliases
185+ post.Tags = parsedText.Tags
186+ post.Description = parsedText.Description
187+ if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
188+ post.PublishAt = parsedText.PublishAt
189+ }
190+ post.Hidden = parsedText.Hidden
191+ }
192+
193+ post.Hidden = post.Hidden || slices.Contains(h.Cfg.HiddenPosts, post.Filename)
194+ return aliases, nil
195+}
+154, -0
......@@ -0,0 +1,154 @@
1+package prose
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "log/slog"
7+ "os"
8+ "path/filepath"
9+
10+ "github.com/picosh/pico/pkg/pssh"
11+ sendutils "github.com/picosh/pico/pkg/send/utils"
12+)
13+
14+// ReadWriteHandler serves the files with the extensions it is routed.
15+type ReadWriteHandler interface {
16+ List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error)
17+ Write(*pssh.SSHServerConnSession, *sendutils.FileEntry) (string, error)
18+ Read(*pssh.SSHServerConnSession, *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error)
19+ Delete(*pssh.SSHServerConnSession, *sendutils.FileEntry) error
20+}
21+
22+// fileRouter sends each file to the handler for its extension.
23+type fileRouter struct {
24+ FileMap map[string]ReadWriteHandler
25+ Space string
26+}
27+
28+var _ sendutils.CopyFromClientHandler = (*fileRouter)(nil)
29+
30+func newFileRouter(space string, mapper map[string]ReadWriteHandler) *fileRouter {
31+ return &fileRouter{
32+ Space: space,
33+ FileMap: mapper,
34+ }
35+}
36+
37+func (r *fileRouter) findHandler(fp string) (ReadWriteHandler, error) {
38+ fext := filepath.Ext(fp)
39+ handler, ok := r.FileMap[fext]
40+ if !ok {
41+ hand, hasFallback := r.FileMap["fallback"]
42+ if !hasFallback {
43+ return nil, fmt.Errorf("no corresponding handler for file extension: %s", fext)
44+ }
45+ handler = hand
46+ }
47+ return handler, nil
48+}
49+
50+func (r *fileRouter) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
51+ if entry.Mode.IsDir() {
52+ return "", os.ErrInvalid
53+ }
54+
55+ handler, err := r.findHandler(entry.Filepath)
56+ if err != nil {
57+ return "", err
58+ }
59+ return handler.Write(s, entry)
60+}
61+
62+func (r *fileRouter) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
63+ handler, err := r.findHandler(entry.Filepath)
64+ if err != nil {
65+ return err
66+ }
67+ if v, ok := handler.(sendutils.WriteValidator); ok {
68+ return v.ValidateWrite(s, entry)
69+ }
70+ return nil
71+}
72+
73+func (r *fileRouter) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
74+ handler, err := r.findHandler(entry.Filepath)
75+ if err != nil {
76+ return err
77+ }
78+ return handler.Delete(s, entry)
79+}
80+
81+// Rename moves a file in place when one handler serves both names.
82+func (r *fileRouter) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
83+ handler, err := r.findHandler(from.Filepath)
84+ if err != nil {
85+ return err
86+ }
87+ target, err := r.findHandler(to.Filepath)
88+ if err != nil {
89+ return err
90+ }
91+ renamer, ok := handler.(sendutils.FileRenamer)
92+ if !ok || handler != target {
93+ return errors.ErrUnsupported
94+ }
95+ return renamer.Rename(s, from, to)
96+}
97+
98+func (r *fileRouter) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
99+ handler, err := r.findHandler(entry.Filepath)
100+ if err != nil {
101+ return nil, nil, err
102+ }
103+ return handler.Read(s, entry)
104+}
105+
106+func (r *fileRouter) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
107+ files := []os.FileInfo{}
108+ // Several extensions can share a handler, and handlers can list the
109+ // same entries, such as the root directory.
110+ listed := map[ReadWriteHandler]bool{}
111+ seen := map[string]bool{}
112+ for _, handler := range r.FileMap {
113+ if listed[handler] {
114+ continue
115+ }
116+ listed[handler] = true
117+
118+ ff, err := handler.List(s, fpath, isDir, recursive)
119+ if err != nil {
120+ r.GetLogger(s).Error("handler list", "err", err)
121+ continue
122+ }
123+ for _, f := range ff {
124+ key := fmt.Sprintf("%s:%v", f.Name(), f.IsDir())
125+ if !seen[key] {
126+ seen[key] = true
127+ files = append(files, f)
128+ }
129+ }
130+ }
131+ return files, nil
132+}
133+
134+func (r *fileRouter) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
135+ return pssh.GetLogger(s)
136+}
137+
138+func (r *fileRouter) Validate(s *pssh.SSHServerConnSession) error {
139+ logger := pssh.GetLogger(s)
140+ user := pssh.GetUser(s)
141+
142+ if user == nil {
143+ err := fmt.Errorf("could not get user from ctx")
144+ logger.Error("error getting user from ctx", "err", err)
145+ return err
146+ }
147+
148+ logger.Info(
149+ "attempting to upload files",
150+ "user", user.Name,
151+ "space", r.Space,
152+ )
153+ return nil
154+}
+65, -0
......@@ -0,0 +1,65 @@
1+package prose
2+
3+import (
4+ "errors"
5+ "os"
6+ "testing"
7+
8+ "github.com/picosh/pico/pkg/pssh"
9+ sendutils "github.com/picosh/pico/pkg/send/utils"
10+)
11+
12+// listHandler lists names and records the renames it is sent.
13+type listHandler struct {
14+ ReadWriteHandler
15+ names []string
16+ renamed int
17+}
18+
19+func (h *listHandler) List(*pssh.SSHServerConnSession, string, bool, bool) ([]os.FileInfo, error) {
20+ files := []os.FileInfo{&sendutils.VirtualFile{FName: "/", FIsDir: true}}
21+ for _, name := range h.names {
22+ files = append(files, &sendutils.VirtualFile{FName: name})
23+ }
24+ return files, nil
25+}
26+
27+func (h *listHandler) Rename(*pssh.SSHServerConnSession, *sendutils.FileEntry, *sendutils.FileEntry) error {
28+ h.renamed++
29+ return nil
30+}
31+
32+func entry(name string) *sendutils.FileEntry {
33+ return &sendutils.FileEntry{Filepath: "/" + name}
34+}
35+
36+func TestRouterSharedHandler(t *testing.T) {
37+ posts := &listHandler{names: []string{"a.md"}}
38+ router := newFileRouter("prose", map[string]ReadWriteHandler{
39+ ".md": posts,
40+ ".lxt": posts,
41+ ".txt": &listHandler{names: []string{"a.md"}},
42+ "fallback": &listHandler{names: []string{"x.png"}},
43+ })
44+
45+ list, err := router.List(nil, "/", true, false)
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ var names []string
50+ for _, f := range list {
51+ names = append(names, f.Name())
52+ }
53+ if len(names) != 3 {
54+ t.Errorf("listed %q, want /, a.md and x.png once each", names)
55+ }
56+
57+ if err := router.Rename(nil, entry("a.md"), entry("a.lxt")); err != nil || posts.renamed != 1 {
58+ t.Fatalf("rename within a handler: %v, renamed %d", err, posts.renamed)
59+ }
60+ for _, to := range []string{"a.txt", "a.png"} {
61+ if err := router.Rename(nil, entry("a.lxt"), entry(to)); !errors.Is(err, errors.ErrUnsupported) {
62+ t.Errorf("rename to %s across handlers: %v", to, err)
63+ }
64+ }
65+}
+0, -129
......@@ -1,129 +0,0 @@
1-package prose
2-
3-import (
4- "fmt"
5- "path/filepath"
6- "strings"
7-
8- "slices"
9-
10- "github.com/picosh/pico/pkg/db"
11- "github.com/picosh/pico/pkg/filehandlers"
12- "github.com/picosh/pico/pkg/pssh"
13- "github.com/picosh/pico/pkg/shared"
14- pipeUtil "github.com/picosh/utils/pipe"
15-)
16-
17-type MarkdownHooks struct {
18- Cfg *shared.ConfigSite
19- Db db.DB
20- Pipe *pipeUtil.ReconnectReadWriteCloser
21-}
22-
23-func (p *MarkdownHooks) FileValidate(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) (bool, error) {
24- if !shared.IsTextFile(data.Text) {
25- err := fmt.Errorf(
26- "ERROR: (%s) invalid file must be plain text (utf-8), skipping",
27- data.Filename,
28- )
29- return false, err
30- }
31-
32- fp := strings.Replace(data.Filename, "/", "", 1)
33- // special styles css file we want to permit but no other css file.
34- // sometimes the directory is provided in the filename, so we want to
35- // remove that before we perform this check.
36- if fp == "_styles.css" {
37- return true, nil
38- }
39- // allow users to upload robots file
40- if fp == "robots.txt" {
41- return true, nil
42- }
43-
44- if !shared.IsExtAllowed(data.Filename, p.Cfg.AllowedExt) {
45- extStr := strings.Join(p.Cfg.AllowedExt, ",")
46- err := fmt.Errorf(
47- "ERROR: (%s) invalid file, format must be (%s), skipping",
48- data.Filename,
49- extStr,
50- )
51- return false, err
52- }
53-
54- if data.FileSize > MAX_FILE_SIZE {
55- return false, fmt.Errorf(
56- "ERROR: file (%s) has exceeded maximum file size (%d bytes)",
57- data.Filename,
58- MAX_FILE_SIZE,
59- )
60- }
61-
62- return true, nil
63-}
64-
65-func (p *MarkdownHooks) metaLxt(data *filehandlers.PostMetaData) error {
66- parsedText := shared.ListParseText(data.Text)
67-
68- if parsedText.Title == "" {
69- data.Title = shared.ToUpper(data.Slug)
70- } else {
71- data.Title = parsedText.Title
72- }
73-
74- data.Aliases = parsedText.Aliases
75- data.Tags = parsedText.Tags
76- data.Description = parsedText.Description
77-
78- if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
79- data.PublishAt = parsedText.PublishAt
80- }
81- data.Hidden = parsedText.Hidden
82-
83- return nil
84-}
85-
86-func (p *MarkdownHooks) metaMd(data *filehandlers.PostMetaData) error {
87- parsedText, err := shared.ParseText(data.Text)
88- if err != nil {
89- return fmt.Errorf("%s: %w", data.Filename, err)
90- }
91-
92- if parsedText.Title == "" {
93- data.Title = shared.ToUpper(data.Slug)
94- } else {
95- data.Title = parsedText.Title
96- }
97-
98- data.Aliases = parsedText.Aliases
99- data.Tags = parsedText.Tags
100- data.Description = parsedText.Description
101-
102- if parsedText.PublishAt != nil && !parsedText.PublishAt.IsZero() {
103- data.PublishAt = parsedText.PublishAt
104- }
105- data.Hidden = parsedText.Hidden
106-
107- return nil
108-}
109-
110-func (p *MarkdownHooks) FileMeta(s *pssh.SSHServerConnSession, data *filehandlers.PostMetaData) error {
111- ext := filepath.Ext(data.Filename)
112- switch ext {
113- case ".lxt":
114- err := p.metaLxt(data)
115- if err != nil {
116- return fmt.Errorf("%s: %w", data.Filename, err)
117- }
118- case ".md":
119- err := p.metaMd(data)
120- if err != nil {
121- return fmt.Errorf("%s: %w", data.Filename, err)
122- }
123- }
124-
125- isHiddenFilename := slices.Contains(p.Cfg.HiddenPosts, data.Filename)
126- data.Hidden = data.Hidden || isHiddenFilename
127-
128- return nil
129-}
+2, -2
......@@ -25,13 +25,13 @@ func StartSshServer() {
2525 host := shared.GetEnv("PROSE_HOST", "0.0.0.0")
2626 port := shared.GetEnv("PROSE_SSH_PORT", "2222")
2727 promPort := shared.GetEnv("PROSE_PROM_PORT", "9222")
28- cfg := NewConfigSite(appName)
28+ cfg := NewConfig(appName)
2929 logger := cfg.Logger
3030
3131 ctx, cancel := context.WithCancel(context.Background())
3232 defer cancel()
3333
34- dbh := postgres.NewDB(cfg.DbURL, cfg.Logger)
34+ dbh := NewDB(postgres.NewDB(cfg.DbURL, cfg.Logger))
3535 defer func() {
3636 _ = dbh.Close()
3737 }()
+15, -53
......@@ -3,14 +3,10 @@ package prose
33 import (
44 "fmt"
55 "log/slog"
6- "strings"
76
87 "github.com/picosh/pico/pkg/db"
9- "github.com/picosh/pico/pkg/filehandlers"
10- uploadimgs "github.com/picosh/pico/pkg/filehandlers/imgs"
118 "github.com/picosh/pico/pkg/pssh"
129 sendutils "github.com/picosh/pico/pkg/send/utils"
13- "github.com/picosh/pico/pkg/shared"
1410 "github.com/picosh/pico/pkg/storage"
1511 )
1612
......@@ -29,70 +25,35 @@ func setFeatureFlag(s *pssh.SSHServerConnSession, ff *db.FeatureFlag) {
2925 s.SetValue(ctxFeatureFlagKey{}, ff)
3026 }
3127
32-func setFeatureLimits(ff *db.FeatureFlag, cfg *shared.ConfigSite) {
33- ff.Data.StorageMax = ff.FindStorageMax(cfg.MaxSize)
34- ff.Data.FileMax = ff.FindFileMax(cfg.MaxAssetSize)
35- ff.Data.SpecialFileMax = ff.FindSpecialFileMax(cfg.MaxSpecialFileSize)
36-}
37-
38-func findFeatureFlag(dbpool db.DB, cfg *shared.ConfigSite, userID string) (*db.FeatureFlag, error) {
39- ff, err := dbpool.FindFeature(userID, "plus")
40- if err == nil {
41- if ff.IsValid() {
42- setFeatureLimits(ff, cfg)
43- return ff, nil
44- }
45- err = fmt.Errorf("ERROR: your pico+ has expired: https://blog.pico.sh/ann-038-pico-invite-system")
46- }
47-
48- ffProse, proseErr := dbpool.FindFeature(userID, "prose")
49- if proseErr == nil {
50- if ffProse.IsValid() {
51- setFeatureLimits(ffProse, cfg)
52- return ffProse, nil
53- }
54- proseErr = fmt.Errorf("ERROR: your prose access has expired: https://blog.pico.sh/ann-038-pico-invite-system")
55- }
56-
57- if err != nil && strings.Contains(err.Error(), "expired") {
58- return nil, err
59- }
60- if proseErr != nil && strings.Contains(proseErr.Error(), "expired") {
61- return nil, proseErr
62- }
63- return nil, fmt.Errorf("ERROR: uploading to prose requires an invitation or pico+: https://blog.pico.sh/ann-038-pico-invite-system")
64-}
65-
6628 type UploadHandler struct {
67- *filehandlers.FileHandlerRouter
68- Cfg *shared.ConfigSite
69- DB db.DB
29+ *fileRouter
30+ Cfg *Config
31+ DB DB
7032 }
7133
7234 var _ sendutils.CopyFromClientHandler = &UploadHandler{}
7335 var _ sendutils.CopyFromClientHandler = (*UploadHandler)(nil)
7436
75-func NewUploadHandler(cfg *shared.ConfigSite, dbpool db.DB, fileMap map[string]filehandlers.ReadWriteHandler) *UploadHandler {
76- router := filehandlers.NewFileHandlerRouter(cfg, dbpool, fileMap)
37+func NewUploadHandler(cfg *Config, dbpool DB, fileMap map[string]ReadWriteHandler) *UploadHandler {
38+ router := newFileRouter(cfg.Space, fileMap)
7739 return &UploadHandler{
78- FileHandlerRouter: router,
79- Cfg: cfg,
80- DB: dbpool,
40+ fileRouter: router,
41+ Cfg: cfg,
42+ DB: dbpool,
8143 }
8244 }
8345
8446 // NewFileHandler routes prose's text files to posts and the rest to images.
85-func NewFileHandler(cfg *shared.ConfigSite, dbpool db.DB, st storage.StorageServe) *UploadHandler {
86- hooks := &MarkdownHooks{Cfg: cfg, Db: dbpool}
47+func NewFileHandler(cfg *Config, dbpool DB, st storage.StorageServe) *UploadHandler {
8748 // One post handler serves every text extension, so a post can be
8849 // renamed in place from one to another.
89- posts := filehandlers.NewScpPostHandler(dbpool, cfg, hooks)
90- return NewUploadHandler(cfg, dbpool, map[string]filehandlers.ReadWriteHandler{
50+ posts := &postUploader{Cfg: cfg, DB: dbpool}
51+ return NewUploadHandler(cfg, dbpool, map[string]ReadWriteHandler{
9152 ".md": posts,
9253 ".txt": posts,
9354 ".css": posts,
9455 ".lxt": posts,
95- "fallback": uploadimgs.NewUploadImgHandler(dbpool, cfg, st),
56+ "fallback": NewUploadImgHandler(dbpool, cfg, st),
9657 })
9758 }
9859
......@@ -117,11 +78,12 @@ func (h *UploadHandler) Validate(s *pssh.SSHServerConnSession) error {
11778 return err
11879 }
11980
120- ff, err := findFeatureFlag(h.DB, h.Cfg, user.ID)
81+ ff, err := db.FindAccess(h.DB, user.ID, "prose")
12182 if err != nil {
12283 return err
12384 }
85+ ff.SetLimits(h.Cfg.MaxSize, h.Cfg.MaxAssetSize, h.Cfg.MaxSpecialFileSize)
12486 setFeatureFlag(s, ff)
12587
126- return h.FileHandlerRouter.Validate(s)
88+ return h.fileRouter.Validate(s)
12789 }
+45, -5
......@@ -4,13 +4,15 @@ import (
44 "context"
55 "fmt"
66 "log/slog"
7+ "slices"
8+ "strings"
79 "testing"
810 "time"
911
1012 "github.com/picosh/pico/pkg/db"
1113 "github.com/picosh/pico/pkg/db/stub"
12- "github.com/picosh/pico/pkg/filehandlers"
1314 "github.com/picosh/pico/pkg/pssh"
15+ sendutils "github.com/picosh/pico/pkg/send/utils"
1416 "github.com/picosh/pico/pkg/shared"
1517 "golang.org/x/crypto/ssh"
1618 )
......@@ -41,7 +43,7 @@ func (m *mockFeatureDB) setFeature(userID string, ff *db.FeatureFlag) {
4143 }
4244
4345 func TestUploadHandlerValidate(t *testing.T) {
44- cfg := NewConfigSite("prose-test")
46+ cfg := NewConfig("prose-test")
4547 validExpires := time.Now().Add(24 * time.Hour)
4648 userID := "user-1"
4749
......@@ -66,7 +68,7 @@ func TestUploadHandlerValidate(t *testing.T) {
6668
6769 t.Run("nil user in session fails validation", func(t *testing.T) {
6870 mockDB := newMockFeatureDB()
69- handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{})
71+ handler := NewUploadHandler(cfg, mockDB, map[string]ReadWriteHandler{})
7072
7173 sesh := createSession(nil)
7274 err := handler.Validate(sesh)
......@@ -77,7 +79,7 @@ func TestUploadHandlerValidate(t *testing.T) {
7779
7880 t.Run("user without feature flag fails validation", func(t *testing.T) {
7981 mockDB := newMockFeatureDB()
80- handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{})
82+ handler := NewUploadHandler(cfg, mockDB, map[string]ReadWriteHandler{})
8183
8284 user := &db.User{ID: userID, Name: "tester"}
8385 sesh := createSession(user)
......@@ -93,7 +95,7 @@ func TestUploadHandlerValidate(t *testing.T) {
9395 proseFF.ExpiresAt = &validExpires
9496 mockDB.setFeature(userID, proseFF)
9597
96- handler := NewUploadHandler(cfg, mockDB, map[string]filehandlers.ReadWriteHandler{})
98+ handler := NewUploadHandler(cfg, mockDB, map[string]ReadWriteHandler{})
9799
98100 user := &db.User{ID: userID, Name: "tester"}
99101 sesh := createSession(user)
......@@ -108,3 +110,41 @@ func TestUploadHandlerValidate(t *testing.T) {
108110 }
109111 })
110112 }
113+
114+func TestPostWriteTagsAndAliases(t *testing.T) {
115+ logger := slog.New(slog.DiscardHandler)
116+ session := &pssh.SSHServerConnSession{
117+ SSHServerConn: &pssh.SSHServerConn{
118+ Conn: &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
119+ Logger: logger,
120+ },
121+ Ctx: context.Background(),
122+ }
123+ user := &db.User{ID: "user-1", Name: "tester"}
124+ pssh.SetUser(session, user)
125+ posts := stub.NewPostsDB(logger, user)
126+ cfg := NewConfig("prose-test")
127+ h := &postUploader{Cfg: cfg, DB: posts}
128+ write := func(text string, mtime int64) *db.Post {
129+ t.Helper()
130+ _, err := h.Write(session, &sendutils.FileEntry{Filepath: "/hello.md", Reader: strings.NewReader(text), Mtime: mtime})
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ post, err := posts.FindPostWithFilename("hello.md", user.ID, cfg.Space)
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ return post
139+ }
140+
141+ post := write("---\ntitle: Hello\ntags: [a, b]\naliases: [hi]\n---\n# hello\n", 100)
142+ if post.Title != "Hello" || !slices.Equal(post.Tags, []string{"a", "b"}) || !slices.Equal(posts.Aliases(post.ID), []string{"hi"}) {
143+ t.Errorf("new post = %q tags %q aliases %q", post.Title, post.Tags, posts.Aliases(post.ID))
144+ }
145+
146+ post = write("# hello again\n", 200)
147+ if len(post.Tags) != 0 || len(posts.Aliases(post.ID)) != 0 {
148+ t.Errorf("updated post kept tags %q and aliases %q", post.Tags, posts.Aliases(post.ID))
149+ }
150+}
+108, -33
......@@ -7,6 +7,7 @@ import (
77 "errors"
88 "fmt"
99 "regexp"
10+ "strings"
1011 "time"
1112 )
1213
......@@ -576,33 +577,48 @@ var DenyList = []string{
576577 "nue",
577578 }
578579
579-type DB interface {
580+type UserDB interface {
580581 RegisterUser(name, pubkey, comment, ip string) (*User, error)
581- UpdatePublicKey(pubkeyID, name string) (*PublicKey, error)
582- InsertPublicKey(userID, pubkey, name string) error
583- FindKeysByUser(user *User) ([]*PublicKey, error)
584- RemoveKeys(pubkeyIDs []string) error
585-
586582 FindUsers() ([]*User, error)
587583 FindUserByName(name string) (*User, error)
588584 FindUserByKey(name string, pubkey string) (*User, error)
589585 FindUserByPubkey(pubkey string) (*User, error)
590586 FindUser(userID string) (*User, error)
587+ FindUserStats(userID string) (*UserStats, error)
588+}
589+
590+// AuthDB is what an SSH server needs to authenticate and log its users.
591+type AuthDB interface {
592+ FindUser(userID string) (*User, error)
593+ FindUserByPubkey(pubkey string) (*User, error)
594+ FindUserByName(name string) (*User, error)
595+ FindFeature(userID string, feature string) (*FeatureFlag, error)
596+ InsertAccessLog(log *AccessLog) error
597+}
591598
599+type KeyDB interface {
600+ UpdatePublicKey(pubkeyID, name string) (*PublicKey, error)
601+ InsertPublicKey(userID, pubkey, name string) error
602+ FindKeysByUser(user *User) ([]*PublicKey, error)
603+ RemoveKeys(pubkeyIDs []string) error
604+}
605+
606+type TokenDB interface {
592607 FindUserByToken(token string) (*User, error)
593608 FindTokensByUser(userID string) ([]*Token, error)
594609 InsertToken(userID, name string) (string, error)
595610 UpsertToken(userID, name string) (string, error)
596611 RemoveToken(tokenID string) error
612+}
597613
598- FindPosts() ([]*Post, error)
599- FindPost(postID string) (*Post, error)
614+// PostDB is the posts storage that prose, pastes and feeds share. Each
615+// keeps its posts in its own space.
616+type PostDB interface {
600617 FindPostsByUser(pager *Pager, userID string, space string) (*Paginate[*Post], error)
601618 FindAllPostsByUser(userID string, space string) ([]*Post, error)
602- FindUsersWithPost(space string) ([]*User, error)
603- FindExpiredPosts(space string) ([]*Post, error)
604619 FindPostWithFilename(filename string, userID string, space string) (*Post, error)
605620 FindPostWithSlug(slug string, userID string, space string) (*Post, error)
621+ FindPost(postID string) (*Post, error)
606622 InsertPost(post *Post) (*Post, error)
607623 UpdatePost(post *Post) (*Post, error)
608624 // RenamePost moves cur to next's filename, with the slug, title,
......@@ -612,62 +628,121 @@ type DB interface {
612628 // longer matches what the caller read.
613629 RenamePost(cur, next, replaced *Post, tags, aliases []string) (*Post, error)
614630 RemovePosts(postIDs []string) error
631+}
615632
616- FindPopularPosts() ([]*Post, error)
617-
633+type TagDB interface {
618634 ReplaceTagsByPost(tags []string, postID string) error
619- FindUserPostsByTag(pager *Pager, tag, userID, space string) (*Paginate[*Post], error)
620- FindPostsByTag(pager *Pager, tag, space string) (*Paginate[*Post], error)
621- FindPopularTags(space string) ([]string, error)
622635 ReplaceAliasesByPost(aliases []string, postID string) error
636+}
623637
638+type AnalyticsDB interface {
624639 InsertVisit(view *AnalyticsVisits) error
625640 VisitSummary(opts *SummaryOpts) (*SummaryVisits, error)
626641 FindVisitSiteList(opts *SummaryOpts) ([]*VisitUrl, error)
627642 VisitUrlNotFound(opts *SummaryOpts) ([]*VisitUrl, error)
643+}
628644
645+type FeatureDB interface {
629646 AddPicoPlusUser(username, email, paymentType, txId string) error
630647 AddFeatureUser(username, name string) error
631648 FindFeature(userID string, feature string) (*FeatureFlag, error)
632649 FindFeaturesByUser(userID string) ([]*FeatureFlag, error)
633- HasFeatureByUser(userID string, feature string) bool
634-
635650 InsertFeature(userID, name string, expiresAt time.Time) (*FeatureFlag, error)
636651 RemoveFeature(userID, names string) error
652+}
637653
638- InsertFeedItems(postID string, items []*FeedItem) error
639- FindFeedItemsByPostID(postID string) ([]*FeedItem, error)
640-
654+type ProjectDB interface {
641655 UpsertProject(userID, name, projectDir string) (*Project, error)
642656 FindProjectByName(userID, name string) (*Project, error)
657+}
643658
644- FindUserStats(userID string) (*UserStats, error)
645-
659+type TunsDB interface {
646660 InsertTunsEventLog(log *TunsEventLog) error
647661 FindTunsEventLogs(userID string) ([]*TunsEventLog, error)
648662 FindTunsEventLogsByAddr(userID, addr string) ([]*TunsEventLog, error)
663+}
649664
665+type AccessLogDB interface {
650666 InsertAccessLog(log *AccessLog) error
651667 FindAccessLogs(userID string, fromDate *time.Time) ([]*AccessLog, error)
652668 FindPubkeysInAccessLogs(userID string) ([]string, error)
653669 FindAccessLogsByPubkey(pubkey string, fromDate *time.Time) ([]*AccessLog, error)
670+}
654671
655- UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error
656- UpdatePipeMonitorLastPing(userID, topic string, lastPing *time.Time) error
657- RemovePipeMonitor(userID, topic string) error
658- FindPipeMonitorByTopic(userID, topic string) (*PipeMonitor, error)
659- FindPipeMonitorsByUser(userID string) ([]*PipeMonitor, error)
660-
661- InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error
662- FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*PipeMonitorHistory, error)
663-
672+type InviteDB interface {
664673 FindWhoInvitedUser(toUserID string) (string, error)
665674 FindInvitesByUser(userID string) ([]*Invite, error)
666675 InviteUser(fromUserID string, toUserID string) error
676+}
677+
678+// DB is the storage apps share. Each app declares the subset it uses, and
679+// keeps the queries only it makes in its own package.
680+type DB interface {
681+ UserDB
682+ KeyDB
683+ TokenDB
684+ PostDB
685+ TagDB
686+ AnalyticsDB
687+ FeatureDB
688+ ProjectDB
689+ TunsDB
690+ AccessLogDB
691+ InviteDB
692+
693+ FindPosts() ([]*Post, error)
667694
668695 Close() error
669696 }
670697
671-func HasPlusOrSpace(dbpool DB, userID, space string) bool {
672- return dbpool.HasFeatureByUser(userID, "plus") || dbpool.HasFeatureByUser(userID, space)
698+// SetLimits fills in the flag's storage, file and special file limits, using
699+// the defaults where the flag sets none.
700+func (ff *FeatureFlag) SetLimits(storageMax uint64, fileMax, specialFileMax int64) {
701+ ff.Data.StorageMax = ff.FindStorageMax(storageMax)
702+ ff.Data.FileMax = ff.FindFileMax(fileMax)
703+ ff.Data.SpecialFileMax = ff.FindSpecialFileMax(specialFileMax)
704+}
705+
706+// FindAccess returns the flag that lets a user use a service: pico+, or the
707+// service's own feature. The error says why a user has neither.
708+func FindAccess(dbpool interface {
709+ FindFeature(userID string, feature string) (*FeatureFlag, error)
710+}, userID, service string) (*FeatureFlag, error) {
711+ ff, err := dbpool.FindFeature(userID, "plus")
712+ if err == nil {
713+ if ff.IsValid() {
714+ return ff, nil
715+ }
716+ err = fmt.Errorf("ERROR: your pico+ has expired: https://blog.pico.sh/ann-038-pico-invite-system")
717+ }
718+
719+ ffService, serviceErr := dbpool.FindFeature(userID, service)
720+ if serviceErr == nil {
721+ if ffService.IsValid() {
722+ return ffService, nil
723+ }
724+ serviceErr = fmt.Errorf("ERROR: your %s access has expired: https://blog.pico.sh/ann-038-pico-invite-system", service)
725+ }
726+
727+ if err != nil && strings.Contains(err.Error(), "expired") {
728+ return nil, err
729+ }
730+ if serviceErr != nil && strings.Contains(serviceErr.Error(), "expired") {
731+ return nil, serviceErr
732+ }
733+ return nil, fmt.Errorf("ERROR: uploading to %s requires an invitation or pico+: https://blog.pico.sh/ann-038-pico-invite-system", service)
734+}
735+
736+// HasFeature reports whether a user has a feature that has not expired.
737+func HasFeature(dbpool interface {
738+ FindFeature(userID string, feature string) (*FeatureFlag, error)
739+}, userID, feature string) bool {
740+ ff, err := dbpool.FindFeature(userID, feature)
741+ return err == nil && ff.IsValid()
742+}
743+
744+func HasPlusOrSpace(dbpool interface {
745+ FindFeature(userID string, feature string) (*FeatureFlag, error)
746+}, userID, space string) bool {
747+ return HasFeature(dbpool, userID, "plus") || HasFeature(dbpool, userID, space)
673748 }
+45, -0
......@@ -0,0 +1,45 @@
1+package postgres
2+
3+import (
4+ "time"
5+
6+ "github.com/picosh/pico/pkg/db"
7+)
8+
9+func (me *PsqlDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.AccessLog, error) {
10+ var logs []*db.AccessLog
11+ err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE user_id=$1 AND created_at >= $2 ORDER BY created_at ASC`, userID, fromDate)
12+ if err != nil {
13+ return nil, err
14+ }
15+ return logs, nil
16+}
17+
18+func (me *PsqlDB) FindAccessLogsByPubkey(pubkey string, fromDate *time.Time) ([]*db.AccessLog, error) {
19+ var logs []*db.AccessLog
20+ err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE pubkey=$1 AND created_at >= $2 ORDER BY created_at ASC`, pubkey, fromDate)
21+ if err != nil {
22+ return nil, err
23+ }
24+ return logs, nil
25+}
26+
27+func (me *PsqlDB) FindPubkeysInAccessLogs(userID string) ([]string, error) {
28+ var pubkeys []string
29+ err := me.Db.Select(&pubkeys, `SELECT DISTINCT(pubkey) FROM access_logs WHERE user_id=$1`, userID)
30+ if err != nil {
31+ return nil, err
32+ }
33+ return pubkeys, nil
34+}
35+
36+func (me *PsqlDB) InsertAccessLog(log *db.AccessLog) error {
37+ _, err := me.Db.Exec(
38+ `INSERT INTO access_logs (user_id, service, pubkey, identity) VALUES ($1, $2, $3, $4);`,
39+ log.UserID,
40+ log.Service,
41+ log.Pubkey,
42+ log.Identity,
43+ )
44+ return err
45+}
+760, -0
......@@ -0,0 +1,760 @@
1+package postgres
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "sync"
7+ "time"
8+
9+ "github.com/picosh/pico/pkg/db"
10+)
11+
12+// mobileUserAgentExpr is a SQL expression to detect mobile user agents.
13+const mobileUserAgentExpr = `user_agent ILIKE '%mobile%' OR user_agent ILIKE '%android%' OR user_agent ILIKE '%iphone%' OR user_agent ILIKE '%ipad%' OR user_agent ILIKE '%ipod%' OR user_agent ILIKE '%blackberry%' OR user_agent ILIKE '%windows phone%'`
14+
15+func (me *PsqlDB) InsertVisit(visit *db.AnalyticsVisits) error {
16+ _, err := me.Db.Exec(
17+ `INSERT INTO analytics_visits (user_id, project_id, post_id, namespace, host, path, ip_address, user_agent, referer, status, content_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`,
18+ visit.UserID,
19+ newNullString(visit.ProjectID),
20+ newNullString(visit.PostID),
21+ newNullString(visit.Namespace),
22+ visit.Host,
23+ visit.Path,
24+ visit.IpAddress,
25+ visit.UserAgent,
26+ visit.Referer,
27+ visit.Status,
28+ visit.ContentType,
29+ )
30+ return err
31+}
32+
33+func visitFilterBy(opts *db.SummaryOpts) (string, string) {
34+ where := ""
35+ val := ""
36+ if opts.Host != "" {
37+ where = "host"
38+ val = opts.Host
39+ } else if opts.Path != "" {
40+ where = "path"
41+ val = opts.Path
42+ }
43+
44+ return where, val
45+}
46+
47+func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
48+ var intervals, currentIntervals []*db.VisitInterval
49+ var sumErr, rawErr error
50+
51+ var wg sync.WaitGroup
52+ wg.Add(2)
53+
54+ go func() {
55+ defer wg.Done()
56+ intervals, sumErr = me.visitUniqueFromSummary(opts)
57+ }()
58+ go func() {
59+ defer wg.Done()
60+ currentIntervals, rawErr = me.visitUniqueFromRaw(opts)
61+ }()
62+
63+ wg.Wait()
64+
65+ if sumErr != nil {
66+ return nil, fmt.Errorf("query summary visits: %w", sumErr)
67+ }
68+ if rawErr != nil {
69+ return nil, fmt.Errorf("query raw visits: %w", rawErr)
70+ }
71+
72+ // Merge: current month data may overlap with summary data, combine counts
73+ return mergeVisitIntervals(intervals, currentIntervals), nil
74+}
75+
76+// visitUniqueFromSummary reads unique visitor counts from analytics_monthly_visits for historical data.
77+func (me *PsqlDB) visitUniqueFromSummary(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
78+ now := time.Now()
79+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
80+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
81+
82+ // If origin is in the previous month or later, raw data covers it — no summary to fetch.
83+ if !opts.Origin.Before(previousMonthStart) {
84+ return nil, nil
85+ }
86+
87+ where := ""
88+ args := []interface{}{opts.UserID, opts.Origin, currentMonthStart}
89+ argIdx := 4
90+ if opts.Host != "" {
91+ where = "AND host = $" + fmt.Sprintf("%d", argIdx)
92+ args = append(args, opts.Host)
93+ }
94+
95+ query := fmt.Sprintf(`
96+ SELECT
97+ date_trunc('%s', visit_date)::timestamptz as interval_start,
98+ sum(unique_visits) as unique_visitors,
99+ sum(mobile_visits) as mobile_visits,
100+ sum(desktop_visits) as desktop_visits
101+ FROM analytics_monthly_visits
102+ WHERE user_id = $1 AND visit_date >= $2 AND visit_date < $3 %s
103+ GROUP BY interval_start
104+ ORDER BY interval_start`, opts.Interval, where)
105+
106+ rows, err := me.Db.Queryx(query, args...)
107+ if err != nil {
108+ return nil, err
109+ }
110+ defer func() { _ = rows.Close() }()
111+
112+ var intervals []*db.VisitInterval
113+ for rows.Next() {
114+ interval := &db.VisitInterval{}
115+ if err := rows.Scan(&interval.Interval, &interval.Visitors, &interval.MobileVisitors, &interval.DesktopVisitors); err != nil {
116+ return nil, err
117+ }
118+ intervals = append(intervals, interval)
119+ }
120+ return intervals, rows.Err()
121+}
122+
123+// visitUniqueFromRaw reads unique visitor counts from analytics_visits for the previous and current months.
124+// This covers the gap between the last aggregated month and the current month.
125+func (me *PsqlDB) visitUniqueFromRaw(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
126+ now := time.Now()
127+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
128+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
129+
130+ where, with := visitFilterBy(opts)
131+
132+ // Determine the effective start: max(origin, previousMonthStart)
133+ effectiveStart := previousMonthStart
134+ if opts.Origin.After(previousMonthStart) {
135+ effectiveStart = opts.Origin
136+ }
137+
138+ uniqueVisitors := fmt.Sprintf(`
139+ SELECT
140+ date_trunc('%s', created_at)::timestamptz as interval_start,
141+ count(DISTINCT CASE WHEN %s THEN ip_address END) as mobile_visitors,
142+ count(DISTINCT CASE WHEN NOT %s THEN ip_address END) as desktop_visitors,
143+ count(DISTINCT ip_address) as unique_visitors
144+ FROM analytics_visits
145+ WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND status <> 404
146+ GROUP BY interval_start
147+ ORDER BY interval_start`,
148+ opts.Interval, mobileUserAgentExpr, mobileUserAgentExpr, where)
149+
150+ rows, err := me.Db.Queryx(uniqueVisitors, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
151+ if err != nil {
152+ return nil, err
153+ }
154+ defer func() { _ = rows.Close() }()
155+
156+ var intervals []*db.VisitInterval
157+ for rows.Next() {
158+ interval := &db.VisitInterval{}
159+ if err := rows.Scan(&interval.Interval, &interval.MobileVisitors, &interval.DesktopVisitors, &interval.Visitors); err != nil {
160+ return nil, err
161+ }
162+ intervals = append(intervals, interval)
163+ }
164+ return intervals, rows.Err()
165+}
166+
167+// mergeVisitIntervals combines historical (summary table) and current (raw) intervals.
168+// Summary data is preferred when both sources have the same interval to avoid double-counting.
169+func mergeVisitIntervals(historical, current []*db.VisitInterval) []*db.VisitInterval {
170+ if len(historical) == 0 {
171+ return current
172+ }
173+ if len(current) == 0 {
174+ return historical
175+ }
176+
177+ // Build a map by interval timestamp for merging.
178+ // Summary data takes precedence over raw data for the same interval
179+ // (e.g. when a month is aggregated but raw data still exists in a local dump).
180+ intervalMap := make(map[int64]*db.VisitInterval)
181+ for _, ci := range current {
182+ ts := ci.Interval.Unix()
183+ intervalMap[ts] = ci
184+ }
185+ for _, hi := range historical {
186+ ts := hi.Interval.Unix()
187+ intervalMap[ts] = hi // summary overwrites raw if both exist
188+ }
189+
190+ // Sort by interval
191+ result := make([]*db.VisitInterval, 0, len(intervalMap))
192+ for _, iv := range intervalMap {
193+ result = append(result, iv)
194+ }
195+ sort.Slice(result, func(i, j int) bool {
196+ return result[i].Interval.Before(*result[j].Interval)
197+ })
198+ return result
199+}
200+
201+// mergeTopUrls combines historical and current top URLs, summing counts for overlapping URLs,
202+// then returns the top 10 by total count.
203+func mergeTopUrls(historical, current []*db.VisitUrl) []*db.VisitUrl {
204+ if len(historical) == 0 {
205+ return current
206+ }
207+ if len(current) == 0 {
208+ return historical
209+ }
210+
211+ // Build a map by URL for merging
212+ urlMap := make(map[string]*db.VisitUrl)
213+ for _, hu := range historical {
214+ urlMap[hu.Url] = hu
215+ }
216+
217+ for _, cu := range current {
218+ if existing, ok := urlMap[cu.Url]; ok {
219+ existing.Count += cu.Count
220+ } else {
221+ urlMap[cu.Url] = cu
222+ }
223+ }
224+
225+ // Sort by count descending and take top 10
226+ result := make([]*db.VisitUrl, 0, len(urlMap))
227+ for _, u := range urlMap {
228+ result = append(result, u)
229+ }
230+ sort.Slice(result, func(i, j int) bool {
231+ return result[i].Count > result[j].Count
232+ })
233+ if len(result) > 10 {
234+ result = result[:10]
235+ }
236+ return result
237+}
238+
239+// mergeTopReferers combines historical and current top referers, summing counts for overlapping referers,
240+// then returns the top 10 by total count.
241+func mergeTopReferers(historical, current []*db.VisitUrl) []*db.VisitUrl {
242+ return mergeTopUrls(historical, current) // Same logic as mergeTopUrls
243+}
244+
245+// mergeHosts combines historical and current hosts, summing counts for overlapping hosts,
246+// then returns sorted by total count descending.
247+func mergeHosts(historical, current []*db.VisitUrl) []*db.VisitUrl {
248+ if len(historical) == 0 {
249+ return current
250+ }
251+ if len(current) == 0 {
252+ return historical
253+ }
254+
255+ // Build a map by host for merging
256+ hostMap := make(map[string]*db.VisitUrl)
257+ for _, hu := range historical {
258+ hostMap[hu.Url] = hu
259+ }
260+
261+ for _, cu := range current {
262+ if existing, ok := hostMap[cu.Url]; ok {
263+ existing.Count += cu.Count
264+ } else {
265+ hostMap[cu.Url] = cu
266+ }
267+ }
268+
269+ // Sort by count descending
270+ result := make([]*db.VisitUrl, 0, len(hostMap))
271+ for _, h := range hostMap {
272+ result = append(result, h)
273+ }
274+ sort.Slice(result, func(i, j int) bool {
275+ return result[i].Count > result[j].Count
276+ })
277+ return result
278+}
279+
280+func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
281+ var historical, current []*db.VisitUrl
282+ var histErr, rawErr error
283+
284+ var wg sync.WaitGroup
285+ wg.Add(2)
286+
287+ go func() {
288+ defer wg.Done()
289+ historical, histErr = me.visitRefererFromSummary(opts)
290+ }()
291+ go func() {
292+ defer wg.Done()
293+ current, rawErr = me.visitRefererFromRaw(opts)
294+ }()
295+
296+ wg.Wait()
297+
298+ if histErr != nil {
299+ return nil, fmt.Errorf("query summary referers: %w", histErr)
300+ }
301+ if rawErr != nil {
302+ return nil, fmt.Errorf("query raw referers: %w", rawErr)
303+ }
304+
305+ return mergeTopReferers(historical, current), nil
306+}
307+
308+// visitRefererFromSummary reads top referers from analytics_monthly_top_referers for historical data.
309+func (me *PsqlDB) visitRefererFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
310+ now := time.Now()
311+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
312+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
313+
314+ // If origin is in the previous month or later, raw data covers it — no summary to fetch.
315+ if !opts.Origin.Before(previousMonthStart) {
316+ return nil, nil
317+ }
318+
319+ // Clamp origin to month boundary for summary table lookup
320+ originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
321+
322+ where := ""
323+ args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
324+ if opts.Host != "" {
325+ where = "AND host = $4"
326+ args = append(args, opts.Host)
327+ }
328+
329+ query := fmt.Sprintf(`
330+ SELECT referer, sum(unique_visits) as total_visits
331+ FROM analytics_monthly_top_referers
332+ WHERE user_id = $1 AND month >= $2 AND month < $3 %s
333+ GROUP BY referer
334+ ORDER BY total_visits DESC
335+ LIMIT 10`, where)
336+
337+ rows, err := me.Db.Queryx(query, args...)
338+ if err != nil {
339+ return nil, err
340+ }
341+ defer func() { _ = rows.Close() }()
342+
343+ var results []*db.VisitUrl
344+ for rows.Next() {
345+ result := &db.VisitUrl{}
346+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
347+ return nil, err
348+ }
349+ results = append(results, result)
350+ }
351+ return results, rows.Err()
352+}
353+
354+// visitRefererFromRaw reads top referers from analytics_visits for the previous and current months.
355+func (me *PsqlDB) visitRefererFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
356+ now := time.Now()
357+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
358+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
359+
360+ where, with := visitFilterBy(opts)
361+
362+ // Determine the effective start: max(origin, previousMonthStart)
363+ effectiveStart := previousMonthStart
364+ if opts.Origin.After(previousMonthStart) {
365+ effectiveStart = opts.Origin
366+ }
367+
368+ topUrls := fmt.Sprintf(`
369+ SELECT
370+ referer,
371+ count(DISTINCT ip_address) as referer_count
372+ FROM analytics_visits
373+ WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND referer <> '' AND status <> 404
374+ GROUP BY referer
375+ ORDER BY referer_count DESC
376+ LIMIT 10`, where)
377+
378+ rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
379+ if err != nil {
380+ return nil, err
381+ }
382+ defer func() { _ = rows.Close() }()
383+
384+ var results []*db.VisitUrl
385+ for rows.Next() {
386+ result := &db.VisitUrl{}
387+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
388+ return nil, err
389+ }
390+ results = append(results, result)
391+ }
392+ return results, rows.Err()
393+}
394+
395+func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
396+ var historical, current []*db.VisitUrl
397+ var histErr, rawErr error
398+
399+ var wg sync.WaitGroup
400+ wg.Add(2)
401+
402+ go func() {
403+ defer wg.Done()
404+ historical, histErr = me.visitUrlFromSummary(opts)
405+ }()
406+ go func() {
407+ defer wg.Done()
408+ current, rawErr = me.visitUrlFromRaw(opts)
409+ }()
410+
411+ wg.Wait()
412+
413+ if histErr != nil {
414+ return nil, fmt.Errorf("query summary urls: %w", histErr)
415+ }
416+ if rawErr != nil {
417+ return nil, fmt.Errorf("query raw urls: %w", rawErr)
418+ }
419+
420+ return mergeTopUrls(historical, current), nil
421+}
422+
423+// visitUrlFromSummary reads top URLs from analytics_monthly_top_urls for historical data.
424+func (me *PsqlDB) visitUrlFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
425+ now := time.Now()
426+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
427+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
428+
429+ // If origin is in the previous month or later, raw data covers it — no summary to fetch.
430+ if !opts.Origin.Before(previousMonthStart) {
431+ return nil, nil
432+ }
433+
434+ // Clamp origin to month boundary for summary table lookup
435+ originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
436+
437+ where := ""
438+ args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
439+ if opts.Host != "" {
440+ where = "AND host = $4"
441+ args = append(args, opts.Host)
442+ }
443+
444+ query := fmt.Sprintf(`
445+ SELECT path, sum(unique_visits) as total_visits
446+ FROM analytics_monthly_top_urls
447+ WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code <> 404 %s
448+ GROUP BY path
449+ ORDER BY total_visits DESC
450+ LIMIT 10`, where)
451+
452+ rows, err := me.Db.Queryx(query, args...)
453+ if err != nil {
454+ return nil, err
455+ }
456+ defer func() { _ = rows.Close() }()
457+
458+ var results []*db.VisitUrl
459+ for rows.Next() {
460+ result := &db.VisitUrl{}
461+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
462+ return nil, err
463+ }
464+ results = append(results, result)
465+ }
466+ return results, rows.Err()
467+}
468+
469+// visitUrlFromRaw reads top URLs from analytics_visits for the previous and current months.
470+func (me *PsqlDB) visitUrlFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
471+ now := time.Now()
472+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
473+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
474+
475+ where, with := visitFilterBy(opts)
476+
477+ // Determine the effective start: max(origin, previousMonthStart)
478+ effectiveStart := previousMonthStart
479+ if opts.Origin.After(previousMonthStart) {
480+ effectiveStart = opts.Origin
481+ }
482+
483+ topUrls := fmt.Sprintf(`
484+ SELECT
485+ path,
486+ count(DISTINCT ip_address) as path_count
487+ FROM analytics_visits
488+ WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status <> 404
489+ GROUP BY path
490+ ORDER BY path_count DESC
491+ LIMIT 10`, where)
492+
493+ rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
494+ if err != nil {
495+ return nil, err
496+ }
497+ defer func() { _ = rows.Close() }()
498+
499+ var results []*db.VisitUrl
500+ for rows.Next() {
501+ result := &db.VisitUrl{}
502+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
503+ return nil, err
504+ }
505+ results = append(results, result)
506+ }
507+ return results, rows.Err()
508+}
509+
510+func (me *PsqlDB) VisitUrlNotFound(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
511+ limit := opts.Limit
512+ if limit == 0 {
513+ limit = 10
514+ }
515+
516+ var historical, current []*db.VisitUrl
517+ var histErr, rawErr error
518+
519+ var wg sync.WaitGroup
520+ wg.Add(2)
521+
522+ go func() {
523+ defer wg.Done()
524+ historical, histErr = me.visitUrlNotFoundFromSummary(opts, limit)
525+ }()
526+ go func() {
527+ defer wg.Done()
528+ current, rawErr = me.visitUrlNotFoundFromRaw(opts, limit)
529+ }()
530+
531+ wg.Wait()
532+
533+ if histErr != nil {
534+ return nil, fmt.Errorf("query summary 404 urls: %w", histErr)
535+ }
536+ if rawErr != nil {
537+ return nil, fmt.Errorf("query raw 404 urls: %w", rawErr)
538+ }
539+
540+ return mergeTopUrls(historical, current), nil
541+}
542+
543+// visitUrlNotFoundFromSummary reads top 404 URLs from analytics_monthly_top_urls for historical data.
544+func (me *PsqlDB) visitUrlNotFoundFromSummary(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) {
545+ now := time.Now()
546+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
547+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
548+
549+ // If origin is in the previous month or later, raw data covers it — no summary to fetch.
550+ if !opts.Origin.Before(previousMonthStart) {
551+ return nil, nil
552+ }
553+
554+ // Clamp origin to month boundary for summary table lookup
555+ originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
556+
557+ where := ""
558+ args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
559+ argIdx := 4
560+ if opts.Host != "" {
561+ where = "AND host = $" + fmt.Sprintf("%d", argIdx)
562+ args = append(args, opts.Host)
563+ }
564+
565+ query := fmt.Sprintf(`
566+ SELECT path, sum(unique_visits) as total_visits
567+ FROM analytics_monthly_top_urls
568+ WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code = 404 %s
569+ GROUP BY path
570+ ORDER BY total_visits DESC
571+ LIMIT %d`, where, limit)
572+
573+ rows, err := me.Db.Queryx(query, args...)
574+ if err != nil {
575+ return nil, err
576+ }
577+ defer func() { _ = rows.Close() }()
578+
579+ var results []*db.VisitUrl
580+ for rows.Next() {
581+ result := &db.VisitUrl{}
582+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
583+ return nil, err
584+ }
585+ results = append(results, result)
586+ }
587+ return results, rows.Err()
588+}
589+
590+// visitUrlNotFoundFromRaw reads top 404 URLs from analytics_visits for the previous and current months.
591+func (me *PsqlDB) visitUrlNotFoundFromRaw(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) {
592+ now := time.Now()
593+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
594+ previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
595+
596+ where, with := visitFilterBy(opts)
597+
598+ // Determine the effective start: max(origin, previousMonthStart)
599+ effectiveStart := previousMonthStart
600+ if opts.Origin.After(previousMonthStart) {
601+ effectiveStart = opts.Origin
602+ }
603+
604+ topUrls := fmt.Sprintf(`
605+ SELECT
606+ path,
607+ count(DISTINCT ip_address) as path_count
608+ FROM analytics_visits
609+ WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status = 404
610+ GROUP BY path
611+ ORDER BY path_count DESC
612+ LIMIT %d`, where, limit)
613+
614+ rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
615+ if err != nil {
616+ return nil, err
617+ }
618+ defer func() { _ = rows.Close() }()
619+
620+ var results []*db.VisitUrl
621+ for rows.Next() {
622+ result := &db.VisitUrl{}
623+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
624+ return nil, err
625+ }
626+ results = append(results, result)
627+ }
628+ return results, rows.Err()
629+}
630+
631+func (me *PsqlDB) visitHost(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
632+ var historical, current []*db.VisitUrl
633+ var histErr, rawErr error
634+
635+ var wg sync.WaitGroup
636+ wg.Add(2)
637+
638+ go func() {
639+ defer wg.Done()
640+ historical, histErr = me.visitHostFromSummary(opts)
641+ }()
642+ go func() {
643+ defer wg.Done()
644+ current, rawErr = me.visitHostFromRaw(opts)
645+ }()
646+
647+ wg.Wait()
648+
649+ if histErr != nil {
650+ return nil, fmt.Errorf("query summary hosts: %w", histErr)
651+ }
652+ if rawErr != nil {
653+ return nil, fmt.Errorf("query raw hosts: %w", rawErr)
654+ }
655+
656+ return mergeHosts(historical, current), nil
657+}
658+
659+// visitHostFromSummary reads host data from analytics_user_sites for historical data.
660+func (me *PsqlDB) visitHostFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
661+ rows, err := me.Db.Queryx(`
662+ SELECT host, total_visits
663+ FROM analytics_user_sites
664+ WHERE user_id = $1 AND host <> ''
665+ ORDER BY total_visits DESC`, opts.UserID)
666+ if err != nil {
667+ return nil, err
668+ }
669+ defer func() { _ = rows.Close() }()
670+
671+ var results []*db.VisitUrl
672+ for rows.Next() {
673+ result := &db.VisitUrl{}
674+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
675+ return nil, err
676+ }
677+ results = append(results, result)
678+ }
679+ return results, rows.Err()
680+}
681+
682+// visitHostFromRaw reads hosts from analytics_visits for the current month that aren't in summary.
683+func (me *PsqlDB) visitHostFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
684+ now := time.Now()
685+ currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
686+
687+ rows, err := me.Db.Queryx(`
688+ SELECT host, count(DISTINCT ip_address) as host_count
689+ FROM analytics_visits
690+ WHERE created_at >= $1 AND user_id = $2 AND host <> ''
691+ GROUP BY host
692+ ORDER BY host_count DESC`, currentMonthStart, opts.UserID)
693+ if err != nil {
694+ return nil, err
695+ }
696+ defer func() { _ = rows.Close() }()
697+
698+ var results []*db.VisitUrl
699+ for rows.Next() {
700+ result := &db.VisitUrl{}
701+ if err := rows.Scan(&result.Url, &result.Count); err != nil {
702+ return nil, err
703+ }
704+ results = append(results, result)
705+ }
706+ return results, rows.Err()
707+}
708+
709+func (me *PsqlDB) VisitSummary(opts *db.SummaryOpts) (*db.SummaryVisits, error) {
710+ var (
711+ visitors []*db.VisitInterval
712+ urls []*db.VisitUrl
713+ refs []*db.VisitUrl
714+ notFound []*db.VisitUrl
715+ visitorsErr error
716+ urlsErr error
717+ refsErr error
718+ nfErr error
719+ )
720+
721+ var wg sync.WaitGroup
722+ wg.Add(4)
723+
724+ go func() {
725+ defer wg.Done()
726+ visitors, visitorsErr = me.visitUnique(opts)
727+ }()
728+ go func() {
729+ defer wg.Done()
730+ urls, urlsErr = me.visitUrl(opts)
731+ }()
732+ go func() {
733+ defer wg.Done()
734+ refs, refsErr = me.visitReferer(opts)
735+ }()
736+ go func() {
737+ defer wg.Done()
738+ notFound, nfErr = me.VisitUrlNotFound(opts)
739+ }()
740+
741+ wg.Wait()
742+
743+ // Return the first error encountered
744+ for _, err := range []error{visitorsErr, urlsErr, refsErr, nfErr} {
745+ if err != nil {
746+ return nil, err
747+ }
748+ }
749+
750+ return &db.SummaryVisits{
751+ Intervals: visitors,
752+ TopUrls: urls,
753+ TopReferers: refs,
754+ NotFoundUrls: notFound,
755+ }, nil
756+}
757+
758+func (me *PsqlDB) FindVisitSiteList(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
759+ return me.visitHost(opts)
760+}
+122, -0
......@@ -0,0 +1,122 @@
1+package postgres
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db"
9+)
10+
11+func (me *PsqlDB) FindFeature(userID string, feature string) (*db.FeatureFlag, error) {
12+ ff := &db.FeatureFlag{}
13+ err := me.Db.Get(ff, `SELECT * FROM feature_flags WHERE user_id = $1 AND name = $2 ORDER BY expires_at DESC LIMIT 1`, userID, feature)
14+ if err != nil {
15+ return nil, err
16+ }
17+ return ff, nil
18+}
19+
20+func (me *PsqlDB) FindFeaturesByUser(userID string) ([]*db.FeatureFlag, error) {
21+ var features []*db.FeatureFlag
22+ // https://stackoverflow.com/a/16920077
23+ query := `SELECT DISTINCT ON (name) *
24+ FROM feature_flags
25+ WHERE user_id=$1
26+ ORDER BY name, expires_at DESC;`
27+ err := me.Db.Select(&features, query, userID)
28+ if err != nil {
29+ return nil, err
30+ }
31+ return features, nil
32+}
33+
34+func (me *PsqlDB) InsertFeature(userID, name string, expiresAt time.Time) (*db.FeatureFlag, error) {
35+ var featureID string
36+ err := me.Db.QueryRow(
37+ `INSERT INTO feature_flags (user_id, name, expires_at) VALUES ($1, $2, $3) RETURNING id;`,
38+ userID,
39+ name,
40+ expiresAt,
41+ ).Scan(&featureID)
42+ if err != nil {
43+ return nil, err
44+ }
45+
46+ feature, err := me.FindFeature(userID, name)
47+ if err != nil {
48+ return nil, err
49+ }
50+
51+ return feature, nil
52+}
53+
54+func (me *PsqlDB) RemoveFeature(userID string, name string) error {
55+ _, err := me.Db.Exec(`DELETE FROM feature_flags WHERE user_id = $1 AND name = $2`, userID, name)
56+ return err
57+}
58+
59+func (me *PsqlDB) createFeatureExpiresAt(userID, name string) time.Time {
60+ ff, _ := me.FindFeature(userID, name)
61+ // if the feature flag has already expired we don't want to add a year to it since that will
62+ // not grant the user a full year
63+ if ff == nil || !ff.IsValid() {
64+ t := time.Now()
65+ return t.AddDate(1, 0, 0)
66+ }
67+ return ff.ExpiresAt.AddDate(1, 0, 0)
68+}
69+
70+func (me *PsqlDB) AddPicoPlusUser(username, email, paymentType, txId string) error {
71+ user, err := me.FindUserByName(username)
72+ if err != nil {
73+ return err
74+ }
75+
76+ tx, err := me.Db.Beginx()
77+ if err != nil {
78+ return err
79+ }
80+ defer func() {
81+ _ = tx.Rollback()
82+ }()
83+
84+ var paymentHistoryId sql.NullString
85+ if paymentType != "" {
86+ data := db.PaymentHistoryData{
87+ Notes: "",
88+ TxID: txId,
89+ }
90+
91+ err := tx.QueryRow(
92+ `INSERT INTO payment_history (user_id, payment_type, amount, data) VALUES ($1, $2, 24 * 1000000, $3) RETURNING id;`,
93+ user.ID,
94+ paymentType,
95+ data,
96+ ).Scan(&paymentHistoryId)
97+ if err != nil {
98+ return err
99+ }
100+ }
101+
102+ plus := me.createFeatureExpiresAt(user.ID, "plus")
103+ plusQuery := fmt.Sprintf(`INSERT INTO feature_flags (user_id, name, data, expires_at, payment_history_id)
104+ VALUES ($1, 'plus', '{"storage_max":5000000000, "file_max":100000000, "email": "%s"}'::jsonb, $2, $3);`, email)
105+ _, err = tx.Exec(plusQuery, user.ID, plus, paymentHistoryId)
106+ if err != nil {
107+ return err
108+ }
109+
110+ return tx.Commit()
111+}
112+
113+func (me *PsqlDB) AddFeatureUser(username, name string) error {
114+ user, err := me.FindUserByName(username)
115+ if err != nil {
116+ return err
117+ }
118+
119+ expiresAt := me.createFeatureExpiresAt(user.ID, name)
120+ _, err = me.InsertFeature(user.ID, name, expiresAt)
121+ return err
122+}
+87, -0
......@@ -0,0 +1,87 @@
1+package postgres
2+
3+import (
4+ "fmt"
5+ "time"
6+
7+ "github.com/picosh/pico/pkg/db"
8+)
9+
10+func (me *PsqlDB) FindWhoInvitedUser(toUserID string) (string, error) {
11+ var inviterName string
12+ query := `
13+ SELECT app_users.name
14+ FROM invites
15+ LEFT JOIN app_users ON app_users.id = invites.from_user_id
16+ WHERE to_user_id=$1
17+ `
18+ err := me.Db.Get(&inviterName, query, toUserID)
19+ if err != nil {
20+ return "", err
21+ }
22+
23+ return inviterName, nil
24+}
25+
26+func (me *PsqlDB) FindInvitesByUser(userID string) ([]*db.Invite, error) {
27+ var invites []*db.Invite
28+ query := `SELECT
29+ invites.id, invites.from_user_id, invites.to_user_id, invites.created_at,
30+ app_users.name as to_user_name
31+ FROM invites
32+ LEFT JOIN app_users ON app_users.id = invites.to_user_id
33+ WHERE from_user_id=$1`
34+ err := me.Db.Select(&invites, query, userID)
35+ if err != nil {
36+ return nil, err
37+ }
38+ return invites, nil
39+}
40+
41+// inviteFeatures are the services an invite grants access to.
42+var inviteFeatures = []string{"prose", "pgs", "pastes"}
43+
44+func (me *PsqlDB) hasActiveInvite(userID string) (bool, error) {
45+ var invited bool
46+ err := me.Db.Get(&invited, `SELECT EXISTS (SELECT 1 FROM invites WHERE to_user_id = $1)`, userID)
47+ if err != nil || !invited {
48+ return false, err
49+ }
50+ for _, name := range inviteFeatures {
51+ if db.HasFeature(me, userID, name) {
52+ return true, nil
53+ }
54+ }
55+ return false, nil
56+}
57+
58+func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error {
59+ canInvite := db.HasFeature(me, fromUserID, "plus")
60+ if !canInvite {
61+ invited, err := me.hasActiveInvite(fromUserID)
62+ if err != nil {
63+ return err
64+ }
65+ canInvite = invited
66+ }
67+
68+ if !canInvite {
69+ return fmt.Errorf("must have valid pico+ membership or an active invite from someone")
70+ }
71+
72+ _, err := me.Db.Exec(
73+ `INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`,
74+ fromUserID, toUserID,
75+ )
76+ if err != nil {
77+ return err
78+ }
79+
80+ expiresAt := time.Now().Add(5 * 365 * 24 * time.Hour)
81+ for _, name := range inviteFeatures {
82+ if _, err := me.InsertFeature(toUserID, name, expiresAt); err != nil {
83+ return err
84+ }
85+ }
86+ return nil
87+}
+109, -0
......@@ -0,0 +1,109 @@
1+package postgres
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/jmoiron/sqlx"
8+ "github.com/picosh/pico/pkg/db"
9+)
10+
11+func (me *PsqlDB) insertPublicKeyWithTx(userID, key, name string, tx *sqlx.Tx) error {
12+ pk, _ := me.findPublicKeyByKey(key)
13+ if pk != nil {
14+ return db.ErrPublicKeyTaken
15+ }
16+ query := `INSERT INTO public_keys (user_id, public_key, name) VALUES ($1, $2, $3)`
17+ _, err := tx.Exec(query, userID, key, name)
18+ return err
19+}
20+
21+func (me *PsqlDB) InsertPublicKey(userID, key, name string) error {
22+ pk, _ := me.findPublicKeyByKey(key)
23+ if pk != nil {
24+ return db.ErrPublicKeyTaken
25+ }
26+ query := `INSERT INTO public_keys (user_id, public_key, name) VALUES ($1, $2, $3)`
27+ _, err := me.Db.Exec(query, userID, key, name)
28+ return err
29+}
30+
31+func (me *PsqlDB) UpdatePublicKey(pubkeyID, name string) (*db.PublicKey, error) {
32+ pk, err := me.findPublicKey(pubkeyID)
33+ if err != nil {
34+ return nil, err
35+ }
36+
37+ query := `UPDATE public_keys SET name=$1 WHERE id=$2;`
38+ _, err = me.Db.Exec(query, name, pk.ID)
39+ if err != nil {
40+ return nil, err
41+ }
42+
43+ pk, err = me.findPublicKey(pubkeyID)
44+ if err != nil {
45+ return nil, err
46+ }
47+ return pk, nil
48+}
49+
50+func (me *PsqlDB) findPublicKeyByKey(key string) (*db.PublicKey, error) {
51+ var keys []*db.PublicKey
52+ rs, err := me.Db.Queryx(`SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE public_key = $1`, key)
53+ if err != nil {
54+ return nil, err
55+ }
56+ defer func() { _ = rs.Close() }()
57+
58+ for rs.Next() {
59+ pk := &db.PublicKey{}
60+ err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt)
61+ if err != nil {
62+ return nil, err
63+ }
64+
65+ keys = append(keys, pk)
66+ }
67+
68+ if rs.Err() != nil {
69+ return nil, rs.Err()
70+ }
71+
72+ if len(keys) == 0 {
73+ return nil, fmt.Errorf("pubkey not found in our database: [%s]", key)
74+ }
75+
76+ // When we run PublicKeyByKey and there are multiple public keys returned from the database
77+ // that should mean that we don't have the correct username for this public key.
78+ // When that happens we need to reject the authentication and ask the user to provide the correct
79+ // username when using ssh. So instead of `ssh <domain>` it should be `ssh user@<domain>`
80+ if len(keys) > 1 {
81+ return nil, &db.ErrMultiplePublicKeys{}
82+ }
83+
84+ return keys[0], nil
85+}
86+
87+func (me *PsqlDB) findPublicKey(pubkeyID string) (*db.PublicKey, error) {
88+ pk := &db.PublicKey{}
89+ err := me.Db.Get(pk, `SELECT * FROM public_keys WHERE id = $1`, pubkeyID)
90+ if err != nil {
91+ return nil, err
92+ }
93+ return pk, nil
94+}
95+
96+func (me *PsqlDB) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
97+ var keys []*db.PublicKey
98+ err := me.Db.Select(&keys, `SELECT * FROM public_keys WHERE user_id = $1 ORDER BY created_at ASC`, user.ID)
99+ if err != nil {
100+ return nil, err
101+ }
102+ return keys, nil
103+}
104+
105+func (me *PsqlDB) RemoveKeys(keyIDs []string) error {
106+ param := "{" + strings.Join(keyIDs, ",") + "}"
107+ _, err := me.Db.Exec(`DELETE FROM public_keys WHERE id = ANY($1::uuid[])`, param)
108+ return err
109+}
+192, -0
......@@ -0,0 +1,192 @@
1+// Package pgtest starts a postgres with pico's schema for tests.
2+package pgtest
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "net/url"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "runtime"
13+ "sort"
14+ "sync"
15+ "testing"
16+ "time"
17+
18+ "github.com/jmoiron/sqlx"
19+ _ "github.com/lib/pq"
20+ "github.com/picosh/pico/pkg/db"
21+ "github.com/testcontainers/testcontainers-go"
22+ "github.com/testcontainers/testcontainers-go/modules/postgres"
23+ "github.com/testcontainers/testcontainers-go/wait"
24+)
25+
26+var (
27+ once sync.Once
28+ dbURL string
29+ stop = func() {}
30+ startErr error
31+)
32+
33+// URL is the connection string of a postgres with pico's schema, started
34+// the first time a test in the package asks for one. It is the database
35+// TEST_DATABASE_URL names, or a container. A test skips when there is
36+// neither, and fails when REQUIRE_TESTCONTAINERS=1.
37+func URL(t testing.TB) string {
38+ t.Helper()
39+ once.Do(func() {
40+ var started func()
41+ dbURL, started, startErr = start(context.Background())
42+ if startErr == nil {
43+ stop = started
44+ }
45+ })
46+ if startErr != nil {
47+ if os.Getenv("REQUIRE_TESTCONTAINERS") == "1" {
48+ t.Fatalf("postgres: %v", startErr)
49+ }
50+ t.Skipf("postgres unavailable, set TEST_DATABASE_URL or start docker: %v", startErr)
51+ }
52+ return dbURL
53+}
54+
55+// Main runs a package's tests and stops the postgres they started.
56+func Main(m *testing.M) {
57+ code := m.Run()
58+ stop()
59+ os.Exit(code)
60+}
61+
62+// Clean deletes every row the tests wrote.
63+func Clean(t testing.TB, d *sqlx.DB) {
64+ t.Helper()
65+ tables := []string{
66+ "access_logs", "tuns_event_logs", "analytics_visits",
67+ "feed_items", "post_aliases", "post_tags", "posts",
68+ "projects", "feature_flags", "payment_history", "tokens",
69+ "public_keys", "pipe_monitors", "app_users",
70+ }
71+ for _, table := range tables {
72+ if _, err := d.Exec(fmt.Sprintf("DELETE FROM %s", table)); err != nil {
73+ t.Fatalf("failed to clean up %s: %v", table, err)
74+ }
75+ }
76+}
77+
78+// InsertPost inserts post, publishing and updating it now unless it says
79+// otherwise.
80+func InsertPost(t testing.TB, d interface {
81+ InsertPost(post *db.Post) (*db.Post, error)
82+}, post *db.Post) *db.Post {
83+ t.Helper()
84+ now := time.Now()
85+ if post.UpdatedAt == nil {
86+ post.UpdatedAt = &now
87+ }
88+ if post.PublishAt == nil {
89+ post.PublishAt = &now
90+ }
91+ created, err := d.InsertPost(post)
92+ if err != nil {
93+ t.Fatalf("InsertPost failed: %v", err)
94+ }
95+ return created
96+}
97+
98+func start(ctx context.Context) (string, func(), error) {
99+ // Packages test in parallel, so each gets its own database.
100+ if base := os.Getenv("TEST_DATABASE_URL"); base != "" {
101+ return createDatabase(base)
102+ }
103+
104+ if err := exec.Command("docker", "info").Run(); err != nil {
105+ return "", nil, fmt.Errorf("docker: %w", err)
106+ }
107+ container, err := postgres.Run(ctx,
108+ "postgres:14",
109+ postgres.WithDatabase("pico_test"),
110+ postgres.WithUsername("postgres"),
111+ postgres.WithPassword("postgres"),
112+ testcontainers.WithWaitStrategy(
113+ wait.ForLog("database system is ready to accept connections").
114+ WithOccurrence(2).
115+ WithStartupTimeout(30*time.Second)),
116+ )
117+ if err != nil {
118+ return "", nil, err
119+ }
120+ terminate := func() { _ = container.Terminate(ctx) }
121+ connStr, err := container.ConnectionString(ctx, "sslmode=disable")
122+ if err == nil {
123+ err = migrate(connStr)
124+ }
125+ if err != nil {
126+ terminate()
127+ return "", nil, err
128+ }
129+ return connStr, terminate, nil
130+}
131+
132+func createDatabase(base string) (string, func(), error) {
133+ server, err := sqlx.Connect("postgres", base)
134+ if err != nil {
135+ return "", nil, err
136+ }
137+ name := fmt.Sprintf("pico_test_%d_%d", os.Getpid(), time.Now().UnixNano())
138+ if _, err := server.Exec("CREATE DATABASE " + name); err != nil {
139+ _ = server.Close()
140+ return "", nil, err
141+ }
142+ drop := func() {
143+ _, _ = server.Exec("DROP DATABASE " + name + " WITH (FORCE)")
144+ _ = server.Close()
145+ }
146+ u, err := url.Parse(base)
147+ if err == nil {
148+ u.Path = "/" + name
149+ err = migrate(u.String())
150+ }
151+ if err != nil {
152+ drop()
153+ return "", nil, err
154+ }
155+ return u.String(), drop, nil
156+}
157+
158+func migrate(connStr string) error {
159+ d, err := sqlx.Connect("postgres", connStr)
160+ if err != nil {
161+ return err
162+ }
163+ defer func() { _ = d.Close() }()
164+
165+ _, filename, _, ok := runtime.Caller(0)
166+ if !ok {
167+ return errors.New("failed to get current file path")
168+ }
169+ migrationsDir := filepath.Join(filepath.Dir(filename), "..", "..", "..", "..", "sql", "migrations")
170+ entries, err := os.ReadDir(migrationsDir)
171+ if err != nil {
172+ return fmt.Errorf("failed to read migrations directory: %w", err)
173+ }
174+ // They're date-prefixed, so alphabetical order is correct.
175+ var migrationFiles []string
176+ for _, entry := range entries {
177+ if !entry.IsDir() && filepath.Ext(entry.Name()) == ".sql" {
178+ migrationFiles = append(migrationFiles, entry.Name())
179+ }
180+ }
181+ sort.Strings(migrationFiles)
182+ for _, name := range migrationFiles {
183+ content, err := os.ReadFile(filepath.Join(migrationsDir, name))
184+ if err != nil {
185+ return fmt.Errorf("failed to read migration %s: %w", name, err)
186+ }
187+ if _, err := d.Exec(string(content)); err != nil {
188+ return fmt.Errorf("failed to execute migration %s: %w", name, err)
189+ }
190+ }
191+ return nil
192+}
+353, -0
......@@ -0,0 +1,353 @@
1+package postgres
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "math"
7+ "strings"
8+
9+ "github.com/picosh/pico/pkg/db"
10+)
11+
12+var SelectPost = `
13+ posts.id, user_id, app_users.name, filename, slug, title, text, description,
14+ posts.created_at, publish_at, posts.updated_at, hidden, file_size, mime_type, shasum, data, expires_at, views`
15+
16+func CreatePostWithTagsByRow(r RowScanner) (*db.Post, error) {
17+ post := &db.Post{}
18+ tagStr := ""
19+ err := r.Scan(
20+ &post.ID,
21+ &post.UserID,
22+ &post.Username,
23+ &post.Filename,
24+ &post.Slug,
25+ &post.Title,
26+ &post.Text,
27+ &post.Description,
28+ &post.CreatedAt,
29+ &post.PublishAt,
30+ &post.UpdatedAt,
31+ &post.Hidden,
32+ &post.FileSize,
33+ &post.MimeType,
34+ &post.Shasum,
35+ &post.Data,
36+ &post.ExpiresAt,
37+ &post.Views,
38+ &tagStr,
39+ )
40+ if err != nil {
41+ return nil, err
42+ }
43+
44+ tags := strings.Split(tagStr, ",")
45+ for _, tag := range tags {
46+ tg := strings.TrimSpace(tag)
47+ if tg == "" {
48+ continue
49+ }
50+ post.Tags = append(post.Tags, tg)
51+ }
52+
53+ return post, nil
54+}
55+
56+func (me *PsqlDB) FindPostWithFilename(filename string, persona_id string, space string) (*db.Post, error) {
57+ query := fmt.Sprintf(`
58+ SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
59+ FROM posts
60+ LEFT JOIN app_users ON app_users.id = posts.user_id
61+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
62+ WHERE filename = $1 AND user_id = $2 AND cur_space = $3
63+ GROUP BY %s`, SelectPost, SelectPost)
64+ r := me.Db.QueryRow(query, filename, persona_id, space)
65+ post, err := CreatePostWithTagsByRow(r)
66+ if err != nil {
67+ return nil, err
68+ }
69+
70+ return post, nil
71+}
72+
73+func (me *PsqlDB) FindPostWithSlug(slug string, user_id string, space string) (*db.Post, error) {
74+ query := fmt.Sprintf(`
75+ SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
76+ FROM posts
77+ LEFT JOIN app_users ON app_users.id = posts.user_id
78+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
79+ WHERE slug = $1 AND user_id = $2 AND cur_space = $3
80+ GROUP BY %s`, SelectPost, SelectPost)
81+ r := me.Db.QueryRow(query, slug, user_id, space)
82+ post, err := CreatePostWithTagsByRow(r)
83+ if err != nil {
84+ // attempt to find post inside post_aliases
85+ alias := me.Db.QueryRow(
86+ `SELECT post_aliases.post_id FROM post_aliases
87+ INNER JOIN posts ON posts.id = post_aliases.post_id
88+ WHERE post_aliases.slug = $1 AND posts.user_id = $2`,
89+ slug, user_id,
90+ )
91+ postID := ""
92+ err := alias.Scan(&postID)
93+ if err != nil {
94+ return nil, err
95+ }
96+
97+ return me.FindPost(postID)
98+ }
99+
100+ return post, nil
101+}
102+
103+func (me *PsqlDB) FindPost(postID string) (*db.Post, error) {
104+ post := &db.Post{}
105+ query := fmt.Sprintf(`
106+ SELECT %s
107+ FROM posts
108+ LEFT JOIN app_users ON app_users.id = posts.user_id
109+ WHERE posts.id = $1`, SelectPost)
110+ err := me.Db.Get(post, query, postID)
111+ if err != nil {
112+ return nil, err
113+ }
114+ return post, nil
115+}
116+
117+func (me *PsqlDB) InsertPost(post *db.Post) (*db.Post, error) {
118+ var id string
119+ query := `
120+ INSERT INTO posts
121+ (user_id, filename, slug, title, text, description, publish_at, hidden, cur_space,
122+ file_size, mime_type, shasum, data, expires_at, updated_at)
123+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
124+ RETURNING id`
125+ err := me.Db.QueryRow(
126+ query,
127+ post.UserID,
128+ post.Filename,
129+ post.Slug,
130+ post.Title,
131+ post.Text,
132+ post.Description,
133+ post.PublishAt,
134+ post.Hidden,
135+ post.Space,
136+ post.FileSize,
137+ post.MimeType,
138+ post.Shasum,
139+ post.Data,
140+ post.ExpiresAt,
141+ post.UpdatedAt,
142+ ).Scan(&id)
143+ if err != nil {
144+ return nil, err
145+ }
146+
147+ return me.FindPost(id)
148+}
149+
150+func (me *PsqlDB) UpdatePost(post *db.Post) (*db.Post, error) {
151+ query := `
152+ UPDATE posts
153+ SET slug = $1, title = $2, text = $3, description = $4, updated_at = $5, publish_at = $6,
154+ file_size = $7, shasum = $8, data = $9, hidden = $11, expires_at = $12
155+ WHERE id = $10`
156+ _, err := me.Db.Exec(
157+ query,
158+ post.Slug,
159+ post.Title,
160+ post.Text,
161+ post.Description,
162+ post.UpdatedAt,
163+ post.PublishAt,
164+ post.FileSize,
165+ post.Shasum,
166+ post.Data,
167+ post.ID,
168+ post.Hidden,
169+ post.ExpiresAt,
170+ )
171+ if err != nil {
172+ return nil, err
173+ }
174+
175+ return me.FindPost(post.ID)
176+}
177+
178+func (me *PsqlDB) RenamePost(cur, next, replaced *db.Post, tags, aliases []string) (*db.Post, error) {
179+ tx, err := me.Db.Beginx()
180+ if err != nil {
181+ return nil, err
182+ }
183+ defer func() {
184+ _ = tx.Rollback()
185+ }()
186+
187+ affected := func(res sql.Result, err error) error {
188+ if err != nil {
189+ return err
190+ }
191+ n, err := res.RowsAffected()
192+ if err != nil {
193+ return err
194+ }
195+ if n == 0 {
196+ return db.ErrPostChanged
197+ }
198+ return nil
199+ }
200+
201+ // A save of the replaced post since it was read is kept.
202+ if replaced != nil {
203+ err := affected(tx.Exec(
204+ `DELETE FROM posts WHERE id = $1 AND filename = $2 AND text = $3 AND updated_at IS NOT DISTINCT FROM $4`,
205+ replaced.ID, replaced.Filename, replaced.Text, replaced.UpdatedAt,
206+ ))
207+ if err != nil {
208+ return nil, err
209+ }
210+ }
211+
212+ // The update only applies to the post as the caller read it, so a save
213+ // in between is never overwritten. It sets only what a new name can
214+ // change, which leaves data that other writers keep, like feed digests.
215+ query := `
216+ UPDATE posts
217+ SET filename = $1, slug = $2, title = $3, description = $4, publish_at = $5, hidden = $6,
218+ expires_at = $7, mime_type = $8
219+ WHERE id = $9 AND filename = $10 AND slug = $11 AND title = $12 AND text = $13
220+ AND description = $14 AND updated_at IS NOT DISTINCT FROM $15
221+ AND publish_at IS NOT DISTINCT FROM $16 AND hidden = $17
222+ AND expires_at IS NOT DISTINCT FROM $18`
223+ err = affected(tx.Exec(
224+ query,
225+ next.Filename,
226+ next.Slug,
227+ next.Title,
228+ next.Description,
229+ next.PublishAt,
230+ next.Hidden,
231+ next.ExpiresAt,
232+ next.MimeType,
233+ cur.ID,
234+ cur.Filename,
235+ cur.Slug,
236+ cur.Title,
237+ cur.Text,
238+ cur.Description,
239+ cur.UpdatedAt,
240+ cur.PublishAt,
241+ cur.Hidden,
242+ cur.ExpiresAt,
243+ ))
244+ if err != nil {
245+ return nil, err
246+ }
247+
248+ if err := me.removeTagsByPost(tx, cur.ID); err != nil {
249+ return nil, err
250+ }
251+ if _, err := me.insertTagsByPost(tx, tags, cur.ID); err != nil {
252+ return nil, err
253+ }
254+ if err := me.removeAliasesByPost(tx, cur.ID); err != nil {
255+ return nil, err
256+ }
257+ if _, err := me.insertAliasesByPost(tx, aliases, cur.ID); err != nil {
258+ return nil, err
259+ }
260+ if err := tx.Commit(); err != nil {
261+ return nil, err
262+ }
263+
264+ return me.FindPost(cur.ID)
265+}
266+
267+func (me *PsqlDB) RemovePosts(postIDs []string) error {
268+ param := "{" + strings.Join(postIDs, ",") + "}"
269+ _, err := me.Db.Exec(`DELETE FROM posts WHERE id = ANY($1::uuid[])`, param)
270+ return err
271+}
272+
273+func (me *PsqlDB) FindPostsByUser(page *db.Pager, userID string, space string) (*db.Paginate[*db.Post], error) {
274+ var posts []*db.Post
275+ query := fmt.Sprintf(`
276+ SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
277+ FROM posts
278+ LEFT JOIN app_users ON app_users.id = posts.user_id
279+ LEFT JOIN post_tags ON post_tags.post_id = posts.id
280+ WHERE
281+ hidden = FALSE AND
282+ user_id = $1 AND
283+ publish_at::date <= CURRENT_DATE AND
284+ cur_space = $2
285+ GROUP BY %s
286+ ORDER BY publish_at DESC, slug DESC
287+ LIMIT $3 OFFSET $4`, SelectPost, SelectPost)
288+ rs, err := me.Db.Queryx(
289+ query,
290+ userID,
291+ space,
292+ page.Num,
293+ page.Num*page.Page,
294+ )
295+ if err != nil {
296+ return nil, err
297+ }
298+ defer func() { _ = rs.Close() }()
299+ for rs.Next() {
300+ post, err := CreatePostWithTagsByRow(rs)
301+ if err != nil {
302+ return nil, err
303+ }
304+
305+ posts = append(posts, post)
306+ }
307+
308+ if rs.Err() != nil {
309+ return nil, rs.Err()
310+ }
311+
312+ var count int
313+ err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
314+ if err != nil {
315+ return nil, err
316+ }
317+
318+ pager := &db.Paginate[*db.Post]{
319+ Data: posts,
320+ Total: int(math.Ceil(float64(count) / float64(page.Num))),
321+ }
322+ return pager, nil
323+}
324+
325+func (me *PsqlDB) FindAllPostsByUser(userID string, space string) ([]*db.Post, error) {
326+ var posts []*db.Post
327+ query := fmt.Sprintf(`
328+ SELECT %s
329+ FROM posts
330+ LEFT JOIN app_users ON app_users.id = posts.user_id
331+ WHERE
332+ user_id = $1 AND
333+ cur_space = $2
334+ ORDER BY publish_at DESC`, SelectPost)
335+ err := me.Db.Select(&posts, query, userID, space)
336+ if err != nil {
337+ return nil, err
338+ }
339+ return posts, nil
340+}
341+
342+func (me *PsqlDB) FindPosts() ([]*db.Post, error) {
343+ var posts []*db.Post
344+ query := fmt.Sprintf(`
345+ SELECT %s
346+ FROM posts
347+ LEFT JOIN app_users ON app_users.id = posts.user_id`, SelectPost)
348+ err := me.Db.Select(&posts, query)
349+ if err != nil {
350+ return nil, err
351+ }
352+ return posts, nil
353+}
+77, -0
......@@ -0,0 +1,77 @@
1+package postgres
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "time"
7+
8+ "github.com/picosh/pico/pkg/db"
9+ "github.com/picosh/pico/pkg/shared"
10+)
11+
12+func (me *PsqlDB) InsertProject(userID, name, projectDir string) (string, error) {
13+ if !shared.IsValidSubdomain(name) {
14+ return "", fmt.Errorf("'%s' is not a valid project name, must match /^[a-z0-9-]+$/", name)
15+ }
16+
17+ var id string
18+ err := me.Db.QueryRow(`INSERT INTO projects (user_id, name, project_dir) VALUES ($1, $2, $3) RETURNING id;`, userID, name, projectDir).Scan(&id)
19+ if err != nil {
20+ return "", err
21+ }
22+ return id, nil
23+}
24+
25+func (me *PsqlDB) UpdateProject(userID, name string) error {
26+ _, err := me.Db.Exec(`UPDATE projects SET updated_at = $3 WHERE user_id = $1 AND name = $2;`, userID, name, time.Now())
27+ return err
28+}
29+
30+func (me *PsqlDB) FindProjectByName(userID, name string) (*db.Project, error) {
31+ project := &db.Project{}
32+ err := me.Db.Get(project, `SELECT * FROM projects WHERE user_id = $1 AND name = $2;`, userID, name)
33+ if err != nil {
34+ return nil, err
35+ }
36+ return project, nil
37+}
38+
39+func (me *PsqlDB) UpsertProject(userID, projectName, projectDir string) (*db.Project, error) {
40+ project, err := me.FindProjectByName(userID, projectName)
41+ if err == nil {
42+ // this just updates the `createdAt` timestamp, useful for book-keeping
43+ err = me.UpdateProject(userID, projectName)
44+ if err != nil {
45+ me.Logger.Error("could not update project", "err", err)
46+ return nil, err
47+ }
48+ return project, nil
49+ }
50+
51+ _, err = me.InsertProject(userID, projectName, projectName)
52+ if err != nil {
53+ me.Logger.Error("could not create project", "err", err)
54+ return nil, err
55+ }
56+ return me.FindProjectByName(userID, projectName)
57+}
58+
59+func (me *PsqlDB) findPagesStats(userID string) (*db.UserServiceStats, error) {
60+ stats := db.UserServiceStats{
61+ Service: "pgs",
62+ }
63+ // the aggregates are NULL when the user has no projects
64+ var first, latestCreated, latestUpdated sql.NullTime
65+ err := me.Db.QueryRow(
66+ `SELECT count(id), min(created_at), max(created_at), max(updated_at) FROM projects WHERE user_id=$1`,
67+ userID,
68+ ).Scan(&stats.Num, &first, &latestCreated, &latestUpdated)
69+ if err != nil {
70+ return nil, err
71+ }
72+ stats.FirstCreatedAt = first.Time
73+ stats.LastestCreatedAt = latestCreated.Time
74+ stats.LatestUpdatedAt = latestUpdated.Time
75+
76+ return &stats, nil
77+}
+1, -2356
......@@ -2,89 +2,27 @@ package postgres
22
33 import (
44 "database/sql"
5- "errors"
6- "fmt"
75 "log/slog"
8- "math"
9- "slices"
10- "sort"
11- "strings"
12- "sync"
13- "time"
146
15- "github.com/hashicorp/golang-lru/v2/expirable"
167 "github.com/jmoiron/sqlx"
178 _ "github.com/lib/pq"
18- "github.com/picosh/pico/pkg/db"
19- "github.com/picosh/pico/pkg/shared"
209 )
2110
22-// mobileUserAgentExpr is a SQL expression to detect mobile user agents.
23-const mobileUserAgentExpr = `user_agent ILIKE '%mobile%' OR user_agent ILIKE '%android%' OR user_agent ILIKE '%iphone%' OR user_agent ILIKE '%ipad%' OR user_agent ILIKE '%ipod%' OR user_agent ILIKE '%blackberry%' OR user_agent ILIKE '%windows phone%'`
24-
2511 var PAGER_SIZE = 15
2612
27-var SelectPost = `
28- posts.id, user_id, app_users.name, filename, slug, title, text, description,
29- posts.created_at, publish_at, posts.updated_at, hidden, file_size, mime_type, shasum, data, expires_at, views`
30-
3113 type PsqlDB struct {
3214 Logger *slog.Logger
3315 Db *sqlx.DB
34-
35- popularPostsCache *expirable.LRU[string, []*db.Post]
36- popularPostsMu sync.Mutex
3716 }
3817
3918 type RowScanner interface {
4019 Scan(dest ...any) error
4120 }
4221
43-func CreatePostWithTagsByRow(r RowScanner) (*db.Post, error) {
44- post := &db.Post{}
45- tagStr := ""
46- err := r.Scan(
47- &post.ID,
48- &post.UserID,
49- &post.Username,
50- &post.Filename,
51- &post.Slug,
52- &post.Title,
53- &post.Text,
54- &post.Description,
55- &post.CreatedAt,
56- &post.PublishAt,
57- &post.UpdatedAt,
58- &post.Hidden,
59- &post.FileSize,
60- &post.MimeType,
61- &post.Shasum,
62- &post.Data,
63- &post.ExpiresAt,
64- &post.Views,
65- &tagStr,
66- )
67- if err != nil {
68- return nil, err
69- }
70-
71- tags := strings.Split(tagStr, ",")
72- for _, tag := range tags {
73- tg := strings.TrimSpace(tag)
74- if tg == "" {
75- continue
76- }
77- post.Tags = append(post.Tags, tg)
78- }
79-
80- return post, nil
81-}
82-
8322 func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
8423 var err error
8524 d := &PsqlDB{
86- Logger: logger,
87- popularPostsCache: expirable.NewLRU[string, []*db.Post](16, nil, 15*time.Minute),
25+ Logger: logger,
8826 }
8927 d.Logger.Info("Connecting to postgres", "databaseUrl", databaseUrl)
9028
......@@ -96,797 +34,6 @@ func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
9634 return d
9735 }
9836
99-func (me *PsqlDB) ClearPopularPostsCache() {
100- if me.popularPostsCache != nil {
101- me.popularPostsCache.Purge()
102- }
103-}
104-
105-func (me *PsqlDB) shouldBlockSingup(ip string) error {
106- blocked := &db.BlockSignups{}
107- err := me.Db.Get(blocked, `SELECT * FROM block_signups WHERE ip = $1`, ip)
108- // an error in this case means the result is empty (no record found)
109- if err != nil {
110- return nil
111- }
112- return fmt.Errorf("your IP address has been blocked: %s", blocked.Reason)
113-}
114-
115-func (me *PsqlDB) RegisterUser(username, pubkey, comment, ip string) (*db.User, error) {
116- lowerName := strings.ToLower(username)
117- valid, err := me.validateName(lowerName)
118- if !valid {
119- return nil, err
120- }
121-
122- me.Logger.Info("checking if ip is in block list", "ip", ip, "username", username)
123- err = me.shouldBlockSingup(ip)
124- if err != nil {
125- me.Logger.Warn("user has been blocked from signing up", "ip", ip, "username", username, "err", err)
126- return nil, err
127- }
128-
129- tx, err := me.Db.Beginx()
130- if err != nil {
131- return nil, err
132- }
133- defer func() {
134- _ = tx.Rollback()
135- }()
136-
137- var id string
138- err = tx.QueryRow(`INSERT INTO app_users (name) VALUES($1) returning id`, lowerName).Scan(&id)
139- if err != nil {
140- return nil, err
141- }
142-
143- err = me.insertPublicKeyWithTx(id, pubkey, comment, tx)
144- if err != nil {
145- return nil, err
146- }
147-
148- err = tx.Commit()
149- if err != nil {
150- return nil, err
151- }
152-
153- return me.FindUserByKey(username, pubkey)
154-}
155-
156-func (me *PsqlDB) insertPublicKeyWithTx(userID, key, name string, tx *sqlx.Tx) error {
157- pk, _ := me.findPublicKeyByKey(key)
158- if pk != nil {
159- return db.ErrPublicKeyTaken
160- }
161- query := `INSERT INTO public_keys (user_id, public_key, name) VALUES ($1, $2, $3)`
162- _, err := tx.Exec(query, userID, key, name)
163- return err
164-}
165-
166-func (me *PsqlDB) InsertPublicKey(userID, key, name string) error {
167- pk, _ := me.findPublicKeyByKey(key)
168- if pk != nil {
169- return db.ErrPublicKeyTaken
170- }
171- query := `INSERT INTO public_keys (user_id, public_key, name) VALUES ($1, $2, $3)`
172- _, err := me.Db.Exec(query, userID, key, name)
173- return err
174-}
175-
176-func (me *PsqlDB) UpdatePublicKey(pubkeyID, name string) (*db.PublicKey, error) {
177- pk, err := me.findPublicKey(pubkeyID)
178- if err != nil {
179- return nil, err
180- }
181-
182- query := `UPDATE public_keys SET name=$1 WHERE id=$2;`
183- _, err = me.Db.Exec(query, name, pk.ID)
184- if err != nil {
185- return nil, err
186- }
187-
188- pk, err = me.findPublicKey(pubkeyID)
189- if err != nil {
190- return nil, err
191- }
192- return pk, nil
193-}
194-
195-func (me *PsqlDB) findPublicKeyByKey(key string) (*db.PublicKey, error) {
196- var keys []*db.PublicKey
197- rs, err := me.Db.Queryx(`SELECT id, user_id, name, public_key, created_at FROM public_keys WHERE public_key = $1`, key)
198- if err != nil {
199- return nil, err
200- }
201- defer func() { _ = rs.Close() }()
202-
203- for rs.Next() {
204- pk := &db.PublicKey{}
205- err := rs.Scan(&pk.ID, &pk.UserID, &pk.Name, &pk.Key, &pk.CreatedAt)
206- if err != nil {
207- return nil, err
208- }
209-
210- keys = append(keys, pk)
211- }
212-
213- if rs.Err() != nil {
214- return nil, rs.Err()
215- }
216-
217- if len(keys) == 0 {
218- return nil, fmt.Errorf("pubkey not found in our database: [%s]", key)
219- }
220-
221- // When we run PublicKeyByKey and there are multiple public keys returned from the database
222- // that should mean that we don't have the correct username for this public key.
223- // When that happens we need to reject the authentication and ask the user to provide the correct
224- // username when using ssh. So instead of `ssh <domain>` it should be `ssh user@<domain>`
225- if len(keys) > 1 {
226- return nil, &db.ErrMultiplePublicKeys{}
227- }
228-
229- return keys[0], nil
230-}
231-
232-func (me *PsqlDB) findPublicKey(pubkeyID string) (*db.PublicKey, error) {
233- pk := &db.PublicKey{}
234- err := me.Db.Get(pk, `SELECT * FROM public_keys WHERE id = $1`, pubkeyID)
235- if err != nil {
236- return nil, err
237- }
238- return pk, nil
239-}
240-
241-func (me *PsqlDB) FindKeysByUser(user *db.User) ([]*db.PublicKey, error) {
242- var keys []*db.PublicKey
243- err := me.Db.Select(&keys, `SELECT * FROM public_keys WHERE user_id = $1 ORDER BY created_at ASC`, user.ID)
244- if err != nil {
245- return nil, err
246- }
247- return keys, nil
248-}
249-
250-func (me *PsqlDB) RemoveKeys(keyIDs []string) error {
251- param := "{" + strings.Join(keyIDs, ",") + "}"
252- _, err := me.Db.Exec(`DELETE FROM public_keys WHERE id = ANY($1::uuid[])`, param)
253- return err
254-}
255-
256-func (me *PsqlDB) FindUsersWithPost(space string) ([]*db.User, error) {
257- var users []*db.User
258- rs, err := me.Db.Queryx(
259- `SELECT u.id, u.name, u.created_at
260- FROM app_users u
261- INNER JOIN posts ON u.id=posts.user_id
262- WHERE cur_space='feeds'
263- GROUP BY u.id, u.name, u.created_at
264- ORDER BY name ASC`,
265- )
266- if err != nil {
267- return users, err
268- }
269- defer func() { _ = rs.Close() }()
270- for rs.Next() {
271- var name sql.NullString
272- user := &db.User{}
273- err := rs.Scan(
274- &user.ID,
275- &name,
276- &user.CreatedAt,
277- )
278- if err != nil {
279- return users, err
280- }
281- user.Name = name.String
282-
283- users = append(users, user)
284- }
285- if rs.Err() != nil {
286- return users, rs.Err()
287- }
288- return users, nil
289-}
290-
291-func (me *PsqlDB) FindUserByKey(username string, key string) (*db.User, error) {
292- me.Logger.Info("attempting to find user with only public key", "key", key)
293- pk, err := me.findPublicKeyByKey(key)
294- if err == nil {
295- me.Logger.Info("found pubkey, looking for user", "key", key, "userId", pk.UserID)
296- user, err := me.FindUser(pk.UserID)
297- if err != nil {
298- return nil, err
299- }
300- user.PublicKey = pk
301- return user, nil
302- }
303-
304- if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
305- me.Logger.Info("detected multiple users with same public key", "user", username)
306- user, err := me.findUserForNameAndKey(username, key)
307- if err != nil {
308- me.Logger.Info("could not find user by username and public key", "user", username, "key", key)
309- // this is a little hacky but if we cannot find a user by name and public key
310- // then we return the multiple keys detected error so the user knows to specify their
311- // when logging in
312- return nil, &db.ErrMultiplePublicKeys{}
313- }
314- return user, nil
315- }
316-
317- return nil, err
318-}
319-
320-func (me *PsqlDB) FindUserByPubkey(key string) (*db.User, error) {
321- me.Logger.Info("attempting to find user with only public key", "key", key)
322- pk, err := me.findPublicKeyByKey(key)
323- if err != nil {
324- return nil, err
325- }
326-
327- me.Logger.Info("found pubkey, looking for user", "key", key, "userId", pk.UserID)
328- user, err := me.FindUser(pk.UserID)
329- if err != nil {
330- return nil, err
331- }
332- user.PublicKey = pk
333- return user, nil
334-}
335-
336-func (me *PsqlDB) FindUser(userID string) (*db.User, error) {
337- user := &db.User{}
338- err := me.Db.Get(user, `SELECT id, COALESCE(name, '') as name, created_at FROM app_users WHERE id = $1`, userID)
339- if err != nil {
340- return nil, err
341- }
342- return user, nil
343-}
344-
345-func (me *PsqlDB) validateName(name string) (bool, error) {
346- lower := strings.ToLower(name)
347- if slices.Contains(db.DenyList, lower) {
348- return false, fmt.Errorf("%s is on deny list: %w", lower, db.ErrNameDenied)
349- }
350- v := db.NameValidator.MatchString(lower)
351- if !v {
352- return false, fmt.Errorf("%s is invalid: %w", lower, db.ErrNameInvalid)
353- }
354- user, _ := me.FindUserByName(lower)
355- if user == nil {
356- return true, nil
357- }
358- return false, fmt.Errorf("%s already taken: %w", lower, db.ErrNameTaken)
359-}
360-
361-func (me *PsqlDB) FindUserByName(name string) (*db.User, error) {
362- user := &db.User{}
363- err := me.Db.Get(user, `SELECT * FROM app_users WHERE name = $1`, strings.ToLower(name))
364- if err != nil {
365- return nil, err
366- }
367- return user, nil
368-}
369-
370-func (me *PsqlDB) findUserForNameAndKey(name string, key string) (*db.User, error) {
371- user := &db.User{}
372- pk := &db.PublicKey{}
373-
374- r := me.Db.QueryRow(`SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`, strings.ToLower(name), key)
375- err := r.Scan(&user.ID, &user.Name, &user.CreatedAt, &pk.ID, &pk.Key, &pk.CreatedAt)
376- if err != nil {
377- return nil, err
378- }
379-
380- user.PublicKey = pk
381- return user, nil
382-}
383-
384-func (me *PsqlDB) FindUserByToken(token string) (*db.User, error) {
385- user := &db.User{}
386- err := me.Db.Get(user, `
387- SELECT app_users.id, app_users.name, app_users.created_at
388- FROM app_users
389- LEFT JOIN tokens ON tokens.user_id = app_users.id
390- WHERE tokens.token = $1 AND tokens.expires_at > NOW()`, token)
391- if err != nil {
392- return nil, err
393- }
394- return user, nil
395-}
396-
397-func (me *PsqlDB) FindPostWithFilename(filename string, persona_id string, space string) (*db.Post, error) {
398- query := fmt.Sprintf(`
399- SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
400- FROM posts
401- LEFT JOIN app_users ON app_users.id = posts.user_id
402- LEFT JOIN post_tags ON post_tags.post_id = posts.id
403- WHERE filename = $1 AND user_id = $2 AND cur_space = $3
404- GROUP BY %s`, SelectPost, SelectPost)
405- r := me.Db.QueryRow(query, filename, persona_id, space)
406- post, err := CreatePostWithTagsByRow(r)
407- if err != nil {
408- return nil, err
409- }
410-
411- return post, nil
412-}
413-
414-func (me *PsqlDB) FindPostWithSlug(slug string, user_id string, space string) (*db.Post, error) {
415- query := fmt.Sprintf(`
416- SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
417- FROM posts
418- LEFT JOIN app_users ON app_users.id = posts.user_id
419- LEFT JOIN post_tags ON post_tags.post_id = posts.id
420- WHERE slug = $1 AND user_id = $2 AND cur_space = $3
421- GROUP BY %s`, SelectPost, SelectPost)
422- r := me.Db.QueryRow(query, slug, user_id, space)
423- post, err := CreatePostWithTagsByRow(r)
424- if err != nil {
425- // attempt to find post inside post_aliases
426- alias := me.Db.QueryRow(
427- `SELECT post_aliases.post_id FROM post_aliases
428- INNER JOIN posts ON posts.id = post_aliases.post_id
429- WHERE post_aliases.slug = $1 AND posts.user_id = $2`,
430- slug, user_id,
431- )
432- postID := ""
433- err := alias.Scan(&postID)
434- if err != nil {
435- return nil, err
436- }
437-
438- return me.FindPost(postID)
439- }
440-
441- return post, nil
442-}
443-
444-func (me *PsqlDB) FindPost(postID string) (*db.Post, error) {
445- post := &db.Post{}
446- query := fmt.Sprintf(`
447- SELECT %s
448- FROM posts
449- LEFT JOIN app_users ON app_users.id = posts.user_id
450- WHERE posts.id = $1`, SelectPost)
451- err := me.Db.Get(post, query, postID)
452- if err != nil {
453- return nil, err
454- }
455- return post, nil
456-}
457-
458-func (me *PsqlDB) postPager(rs *sqlx.Rows, pageNum int, space string, tag string) (*db.Paginate[*db.Post], error) {
459- var posts []*db.Post
460- for rs.Next() {
461- post := &db.Post{}
462- err := rs.Scan(
463- &post.ID,
464- &post.UserID,
465- &post.Filename,
466- &post.Slug,
467- &post.Title,
468- &post.Text,
469- &post.Description,
470- &post.PublishAt,
471- &post.Username,
472- &post.UpdatedAt,
473- &post.MimeType,
474- )
475- if err != nil {
476- return nil, err
477- }
478-
479- posts = append(posts, post)
480- }
481- if rs.Err() != nil {
482- return nil, rs.Err()
483- }
484-
485- var count int
486- var err error
487- if tag == "" {
488- err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
489- } else {
490- err = me.Db.QueryRow(`
491- SELECT count(posts.id)
492- FROM posts
493- LEFT JOIN post_tags ON post_tags.post_id = posts.id
494- WHERE hidden = FALSE AND cur_space=$1 and post_tags.name = $2`, space, tag).Scan(&count)
495- }
496- if err != nil {
497- return nil, err
498- }
499-
500- pager := &db.Paginate[*db.Post]{
501- Data: posts,
502- Total: int(math.Ceil(float64(count) / float64(pageNum))),
503- }
504-
505- return pager, nil
506-}
507-
508-func (me *PsqlDB) FindPopularPosts() ([]*db.Post, error) {
509- if me.popularPostsCache != nil {
510- if posts, ok := me.popularPostsCache.Get("popular_posts"); ok {
511- return posts, nil
512- }
513- }
514-
515- me.popularPostsMu.Lock()
516- defer me.popularPostsMu.Unlock()
517-
518- if me.popularPostsCache != nil {
519- if posts, ok := me.popularPostsCache.Get("popular_posts"); ok {
520- return posts, nil
521- }
522- }
523-
524- query := `
525- WITH candidate_posts AS (
526- SELECT
527- posts.id,
528- posts.user_id,
529- posts.filename,
530- posts.slug,
531- posts.title,
532- posts.text,
533- posts.description,
534- posts.publish_at,
535- app_users.name AS username,
536- posts.updated_at,
537- posts.mime_type
538- FROM posts
539- LEFT JOIN app_users ON app_users.id = posts.user_id
540- WHERE
541- hidden = FALSE
542- AND publish_at::date <= CURRENT_DATE
543- AND publish_at >= NOW() - INTERVAL '30 days'
544- AND cur_space = 'prose'
545- AND EXISTS (
546- SELECT 1 FROM feature_flags
547- WHERE feature_flags.user_id = posts.user_id
548- AND (name = 'plus' OR name = 'prose')
549- AND expires_at > NOW()
550- )
551- ),
552- post_visitors AS (
553- SELECT post_id, COUNT(DISTINCT ip_address) AS unique_visitors
554- FROM analytics_visits
555- WHERE post_id IN (SELECT id FROM candidate_posts)
556- AND status = 200
557- AND created_at >= NOW() - INTERVAL '30 days'
558- GROUP BY post_id
559- ),
560- scored_posts AS (
561- SELECT
562- cp.*,
563- COALESCE(pv.unique_visitors, 0) AS score,
564- (
565- LOG(GREATEST(COALESCE(pv.unique_visitors, 0), 1)) +
566- (EXTRACT(EPOCH FROM cp.publish_at) - 1700000000) / 345600.0
567- ) AS rank_score,
568- ROW_NUMBER() OVER (
569- PARTITION BY cp.user_id
570- ORDER BY (
571- LOG(GREATEST(COALESCE(pv.unique_visitors, 0), 1)) +
572- (EXTRACT(EPOCH FROM cp.publish_at) - 1700000000) / 345600.0
573- ) DESC, cp.publish_at DESC
574- ) AS user_rank
575- FROM candidate_posts cp
576- LEFT JOIN post_visitors pv ON pv.post_id = cp.id
577- )
578- SELECT
579- id,
580- user_id,
581- filename,
582- slug,
583- title,
584- text,
585- description,
586- publish_at,
587- username,
588- updated_at,
589- mime_type,
590- score
591- FROM scored_posts
592- WHERE user_rank <= 3
593- ORDER BY rank_score DESC, publish_at DESC
594- LIMIT 30`
595- rs, err := me.Db.Queryx(query)
596- if err != nil {
597- return nil, err
598- }
599- defer func() { _ = rs.Close() }()
600-
601- var posts []*db.Post
602- for rs.Next() {
603- post := &db.Post{}
604- err := rs.Scan(
605- &post.ID,
606- &post.UserID,
607- &post.Filename,
608- &post.Slug,
609- &post.Title,
610- &post.Text,
611- &post.Description,
612- &post.PublishAt,
613- &post.Username,
614- &post.UpdatedAt,
615- &post.MimeType,
616- &post.Score,
617- )
618- if err != nil {
619- return nil, err
620- }
621-
622- posts = append(posts, post)
623- }
624- if rs.Err() != nil {
625- return nil, rs.Err()
626- }
627-
628- if me.popularPostsCache != nil {
629- me.popularPostsCache.Add("popular_posts", posts)
630- }
631-
632- return posts, nil
633-}
634-
635-func (me *PsqlDB) InsertPost(post *db.Post) (*db.Post, error) {
636- var id string
637- query := `
638- INSERT INTO posts
639- (user_id, filename, slug, title, text, description, publish_at, hidden, cur_space,
640- file_size, mime_type, shasum, data, expires_at, updated_at)
641- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
642- RETURNING id`
643- err := me.Db.QueryRow(
644- query,
645- post.UserID,
646- post.Filename,
647- post.Slug,
648- post.Title,
649- post.Text,
650- post.Description,
651- post.PublishAt,
652- post.Hidden,
653- post.Space,
654- post.FileSize,
655- post.MimeType,
656- post.Shasum,
657- post.Data,
658- post.ExpiresAt,
659- post.UpdatedAt,
660- ).Scan(&id)
661- if err != nil {
662- return nil, err
663- }
664-
665- return me.FindPost(id)
666-}
667-
668-func (me *PsqlDB) UpdatePost(post *db.Post) (*db.Post, error) {
669- query := `
670- UPDATE posts
671- SET slug = $1, title = $2, text = $3, description = $4, updated_at = $5, publish_at = $6,
672- file_size = $7, shasum = $8, data = $9, hidden = $11, expires_at = $12
673- WHERE id = $10`
674- _, err := me.Db.Exec(
675- query,
676- post.Slug,
677- post.Title,
678- post.Text,
679- post.Description,
680- post.UpdatedAt,
681- post.PublishAt,
682- post.FileSize,
683- post.Shasum,
684- post.Data,
685- post.ID,
686- post.Hidden,
687- post.ExpiresAt,
688- )
689- if err != nil {
690- return nil, err
691- }
692-
693- return me.FindPost(post.ID)
694-}
695-
696-func (me *PsqlDB) RenamePost(cur, next, replaced *db.Post, tags, aliases []string) (*db.Post, error) {
697- tx, err := me.Db.Beginx()
698- if err != nil {
699- return nil, err
700- }
701- defer func() {
702- _ = tx.Rollback()
703- }()
704-
705- affected := func(res sql.Result, err error) error {
706- if err != nil {
707- return err
708- }
709- n, err := res.RowsAffected()
710- if err != nil {
711- return err
712- }
713- if n == 0 {
714- return db.ErrPostChanged
715- }
716- return nil
717- }
718-
719- // A save of the replaced post since it was read is kept.
720- if replaced != nil {
721- err := affected(tx.Exec(
722- `DELETE FROM posts WHERE id = $1 AND filename = $2 AND text = $3 AND updated_at IS NOT DISTINCT FROM $4`,
723- replaced.ID, replaced.Filename, replaced.Text, replaced.UpdatedAt,
724- ))
725- if err != nil {
726- return nil, err
727- }
728- }
729-
730- // The update only applies to the post as the caller read it, so a save
731- // in between is never overwritten. It sets only what a new name can
732- // change, which leaves data that other writers keep, like feed digests.
733- query := `
734- UPDATE posts
735- SET filename = $1, slug = $2, title = $3, description = $4, publish_at = $5, hidden = $6,
736- expires_at = $7, mime_type = $8
737- WHERE id = $9 AND filename = $10 AND slug = $11 AND title = $12 AND text = $13
738- AND description = $14 AND updated_at IS NOT DISTINCT FROM $15
739- AND publish_at IS NOT DISTINCT FROM $16 AND hidden = $17
740- AND expires_at IS NOT DISTINCT FROM $18`
741- err = affected(tx.Exec(
742- query,
743- next.Filename,
744- next.Slug,
745- next.Title,
746- next.Description,
747- next.PublishAt,
748- next.Hidden,
749- next.ExpiresAt,
750- next.MimeType,
751- cur.ID,
752- cur.Filename,
753- cur.Slug,
754- cur.Title,
755- cur.Text,
756- cur.Description,
757- cur.UpdatedAt,
758- cur.PublishAt,
759- cur.Hidden,
760- cur.ExpiresAt,
761- ))
762- if err != nil {
763- return nil, err
764- }
765-
766- if err := me.removeTagsByPost(tx, cur.ID); err != nil {
767- return nil, err
768- }
769- if _, err := me.insertTagsByPost(tx, tags, cur.ID); err != nil {
770- return nil, err
771- }
772- if err := me.removeAliasesByPost(tx, cur.ID); err != nil {
773- return nil, err
774- }
775- if _, err := me.insertAliasesByPost(tx, aliases, cur.ID); err != nil {
776- return nil, err
777- }
778- if err := tx.Commit(); err != nil {
779- return nil, err
780- }
781-
782- return me.FindPost(cur.ID)
783-}
784-
785-func (me *PsqlDB) RemovePosts(postIDs []string) error {
786- param := "{" + strings.Join(postIDs, ",") + "}"
787- _, err := me.Db.Exec(`DELETE FROM posts WHERE id = ANY($1::uuid[])`, param)
788- return err
789-}
790-
791-func (me *PsqlDB) FindPostsByUser(page *db.Pager, userID string, space string) (*db.Paginate[*db.Post], error) {
792- var posts []*db.Post
793- query := fmt.Sprintf(`
794- SELECT %s, STRING_AGG(coalesce(post_tags.name, ''), ',') tags
795- FROM posts
796- LEFT JOIN app_users ON app_users.id = posts.user_id
797- LEFT JOIN post_tags ON post_tags.post_id = posts.id
798- WHERE
799- hidden = FALSE AND
800- user_id = $1 AND
801- publish_at::date <= CURRENT_DATE AND
802- cur_space = $2
803- GROUP BY %s
804- ORDER BY publish_at DESC, slug DESC
805- LIMIT $3 OFFSET $4`, SelectPost, SelectPost)
806- rs, err := me.Db.Queryx(
807- query,
808- userID,
809- space,
810- page.Num,
811- page.Num*page.Page,
812- )
813- if err != nil {
814- return nil, err
815- }
816- defer func() { _ = rs.Close() }()
817- for rs.Next() {
818- post, err := CreatePostWithTagsByRow(rs)
819- if err != nil {
820- return nil, err
821- }
822-
823- posts = append(posts, post)
824- }
825-
826- if rs.Err() != nil {
827- return nil, rs.Err()
828- }
829-
830- var count int
831- err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
832- if err != nil {
833- return nil, err
834- }
835-
836- pager := &db.Paginate[*db.Post]{
837- Data: posts,
838- Total: int(math.Ceil(float64(count) / float64(page.Num))),
839- }
840- return pager, nil
841-}
842-
843-func (me *PsqlDB) FindAllPostsByUser(userID string, space string) ([]*db.Post, error) {
844- var posts []*db.Post
845- query := fmt.Sprintf(`
846- SELECT %s
847- FROM posts
848- LEFT JOIN app_users ON app_users.id = posts.user_id
849- WHERE
850- user_id = $1 AND
851- cur_space = $2
852- ORDER BY publish_at DESC`, SelectPost)
853- err := me.Db.Select(&posts, query, userID, space)
854- if err != nil {
855- return nil, err
856- }
857- return posts, nil
858-}
859-
860-func (me *PsqlDB) FindPosts() ([]*db.Post, error) {
861- var posts []*db.Post
862- query := fmt.Sprintf(`
863- SELECT %s
864- FROM posts
865- LEFT JOIN app_users ON app_users.id = posts.user_id`, SelectPost)
866- err := me.Db.Select(&posts, query)
867- if err != nil {
868- return nil, err
869- }
870- return posts, nil
871-}
872-
873-func (me *PsqlDB) FindExpiredPosts(space string) ([]*db.Post, error) {
874- var posts []*db.Post
875- query := fmt.Sprintf(`
876- SELECT %s
877- FROM posts
878- LEFT JOIN app_users ON app_users.id = posts.user_id
879- WHERE
880- cur_space = $1 AND
881- expires_at <= now();
882- `, SelectPost)
883- err := me.Db.Select(&posts, query, space)
884- if err != nil {
885- return nil, err
886- }
887- return posts, nil
888-}
889-
89037 func (me *PsqlDB) Close() error {
89138 me.Logger.Info("Closing db")
89239 return me.Db.Close()
......@@ -901,1505 +48,3 @@ func newNullString(s string) sql.NullString {
90148 Valid: true,
90249 }
90350 }
904-
905-func (me *PsqlDB) InsertVisit(visit *db.AnalyticsVisits) error {
906- _, err := me.Db.Exec(
907- `INSERT INTO analytics_visits (user_id, project_id, post_id, namespace, host, path, ip_address, user_agent, referer, status, content_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`,
908- visit.UserID,
909- newNullString(visit.ProjectID),
910- newNullString(visit.PostID),
911- newNullString(visit.Namespace),
912- visit.Host,
913- visit.Path,
914- visit.IpAddress,
915- visit.UserAgent,
916- visit.Referer,
917- visit.Status,
918- visit.ContentType,
919- )
920- return err
921-}
922-
923-func visitFilterBy(opts *db.SummaryOpts) (string, string) {
924- where := ""
925- val := ""
926- if opts.Host != "" {
927- where = "host"
928- val = opts.Host
929- } else if opts.Path != "" {
930- where = "path"
931- val = opts.Path
932- }
933-
934- return where, val
935-}
936-
937-func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
938- var intervals, currentIntervals []*db.VisitInterval
939- var sumErr, rawErr error
940-
941- var wg sync.WaitGroup
942- wg.Add(2)
943-
944- go func() {
945- defer wg.Done()
946- intervals, sumErr = me.visitUniqueFromSummary(opts)
947- }()
948- go func() {
949- defer wg.Done()
950- currentIntervals, rawErr = me.visitUniqueFromRaw(opts)
951- }()
952-
953- wg.Wait()
954-
955- if sumErr != nil {
956- return nil, fmt.Errorf("query summary visits: %w", sumErr)
957- }
958- if rawErr != nil {
959- return nil, fmt.Errorf("query raw visits: %w", rawErr)
960- }
961-
962- // Merge: current month data may overlap with summary data, combine counts
963- return mergeVisitIntervals(intervals, currentIntervals), nil
964-}
965-
966-// visitUniqueFromSummary reads unique visitor counts from analytics_monthly_visits for historical data.
967-func (me *PsqlDB) visitUniqueFromSummary(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
968- now := time.Now()
969- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
970- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
971-
972- // If origin is in the previous month or later, raw data covers it — no summary to fetch.
973- if !opts.Origin.Before(previousMonthStart) {
974- return nil, nil
975- }
976-
977- where := ""
978- args := []interface{}{opts.UserID, opts.Origin, currentMonthStart}
979- argIdx := 4
980- if opts.Host != "" {
981- where = "AND host = $" + fmt.Sprintf("%d", argIdx)
982- args = append(args, opts.Host)
983- }
984-
985- query := fmt.Sprintf(`
986- SELECT
987- date_trunc('%s', visit_date)::timestamptz as interval_start,
988- sum(unique_visits) as unique_visitors,
989- sum(mobile_visits) as mobile_visits,
990- sum(desktop_visits) as desktop_visits
991- FROM analytics_monthly_visits
992- WHERE user_id = $1 AND visit_date >= $2 AND visit_date < $3 %s
993- GROUP BY interval_start
994- ORDER BY interval_start`, opts.Interval, where)
995-
996- rows, err := me.Db.Queryx(query, args...)
997- if err != nil {
998- return nil, err
999- }
1000- defer func() { _ = rows.Close() }()
1001-
1002- var intervals []*db.VisitInterval
1003- for rows.Next() {
1004- interval := &db.VisitInterval{}
1005- if err := rows.Scan(&interval.Interval, &interval.Visitors, &interval.MobileVisitors, &interval.DesktopVisitors); err != nil {
1006- return nil, err
1007- }
1008- intervals = append(intervals, interval)
1009- }
1010- return intervals, rows.Err()
1011-}
1012-
1013-// visitUniqueFromRaw reads unique visitor counts from analytics_visits for the previous and current months.
1014-// This covers the gap between the last aggregated month and the current month.
1015-func (me *PsqlDB) visitUniqueFromRaw(opts *db.SummaryOpts) ([]*db.VisitInterval, error) {
1016- now := time.Now()
1017- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1018- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1019-
1020- where, with := visitFilterBy(opts)
1021-
1022- // Determine the effective start: max(origin, previousMonthStart)
1023- effectiveStart := previousMonthStart
1024- if opts.Origin.After(previousMonthStart) {
1025- effectiveStart = opts.Origin
1026- }
1027-
1028- uniqueVisitors := fmt.Sprintf(`
1029- SELECT
1030- date_trunc('%s', created_at)::timestamptz as interval_start,
1031- count(DISTINCT CASE WHEN %s THEN ip_address END) as mobile_visitors,
1032- count(DISTINCT CASE WHEN NOT %s THEN ip_address END) as desktop_visitors,
1033- count(DISTINCT ip_address) as unique_visitors
1034- FROM analytics_visits
1035- WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND status <> 404
1036- GROUP BY interval_start
1037- ORDER BY interval_start`,
1038- opts.Interval, mobileUserAgentExpr, mobileUserAgentExpr, where)
1039-
1040- rows, err := me.Db.Queryx(uniqueVisitors, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
1041- if err != nil {
1042- return nil, err
1043- }
1044- defer func() { _ = rows.Close() }()
1045-
1046- var intervals []*db.VisitInterval
1047- for rows.Next() {
1048- interval := &db.VisitInterval{}
1049- if err := rows.Scan(&interval.Interval, &interval.MobileVisitors, &interval.DesktopVisitors, &interval.Visitors); err != nil {
1050- return nil, err
1051- }
1052- intervals = append(intervals, interval)
1053- }
1054- return intervals, rows.Err()
1055-}
1056-
1057-// mergeVisitIntervals combines historical (summary table) and current (raw) intervals.
1058-// Summary data is preferred when both sources have the same interval to avoid double-counting.
1059-func mergeVisitIntervals(historical, current []*db.VisitInterval) []*db.VisitInterval {
1060- if len(historical) == 0 {
1061- return current
1062- }
1063- if len(current) == 0 {
1064- return historical
1065- }
1066-
1067- // Build a map by interval timestamp for merging.
1068- // Summary data takes precedence over raw data for the same interval
1069- // (e.g. when a month is aggregated but raw data still exists in a local dump).
1070- intervalMap := make(map[int64]*db.VisitInterval)
1071- for _, ci := range current {
1072- ts := ci.Interval.Unix()
1073- intervalMap[ts] = ci
1074- }
1075- for _, hi := range historical {
1076- ts := hi.Interval.Unix()
1077- intervalMap[ts] = hi // summary overwrites raw if both exist
1078- }
1079-
1080- // Sort by interval
1081- result := make([]*db.VisitInterval, 0, len(intervalMap))
1082- for _, iv := range intervalMap {
1083- result = append(result, iv)
1084- }
1085- sort.Slice(result, func(i, j int) bool {
1086- return result[i].Interval.Before(*result[j].Interval)
1087- })
1088- return result
1089-}
1090-
1091-// mergeTopUrls combines historical and current top URLs, summing counts for overlapping URLs,
1092-// then returns the top 10 by total count.
1093-func mergeTopUrls(historical, current []*db.VisitUrl) []*db.VisitUrl {
1094- if len(historical) == 0 {
1095- return current
1096- }
1097- if len(current) == 0 {
1098- return historical
1099- }
1100-
1101- // Build a map by URL for merging
1102- urlMap := make(map[string]*db.VisitUrl)
1103- for _, hu := range historical {
1104- urlMap[hu.Url] = hu
1105- }
1106-
1107- for _, cu := range current {
1108- if existing, ok := urlMap[cu.Url]; ok {
1109- existing.Count += cu.Count
1110- } else {
1111- urlMap[cu.Url] = cu
1112- }
1113- }
1114-
1115- // Sort by count descending and take top 10
1116- result := make([]*db.VisitUrl, 0, len(urlMap))
1117- for _, u := range urlMap {
1118- result = append(result, u)
1119- }
1120- sort.Slice(result, func(i, j int) bool {
1121- return result[i].Count > result[j].Count
1122- })
1123- if len(result) > 10 {
1124- result = result[:10]
1125- }
1126- return result
1127-}
1128-
1129-// mergeTopReferers combines historical and current top referers, summing counts for overlapping referers,
1130-// then returns the top 10 by total count.
1131-func mergeTopReferers(historical, current []*db.VisitUrl) []*db.VisitUrl {
1132- return mergeTopUrls(historical, current) // Same logic as mergeTopUrls
1133-}
1134-
1135-// mergeHosts combines historical and current hosts, summing counts for overlapping hosts,
1136-// then returns sorted by total count descending.
1137-func mergeHosts(historical, current []*db.VisitUrl) []*db.VisitUrl {
1138- if len(historical) == 0 {
1139- return current
1140- }
1141- if len(current) == 0 {
1142- return historical
1143- }
1144-
1145- // Build a map by host for merging
1146- hostMap := make(map[string]*db.VisitUrl)
1147- for _, hu := range historical {
1148- hostMap[hu.Url] = hu
1149- }
1150-
1151- for _, cu := range current {
1152- if existing, ok := hostMap[cu.Url]; ok {
1153- existing.Count += cu.Count
1154- } else {
1155- hostMap[cu.Url] = cu
1156- }
1157- }
1158-
1159- // Sort by count descending
1160- result := make([]*db.VisitUrl, 0, len(hostMap))
1161- for _, h := range hostMap {
1162- result = append(result, h)
1163- }
1164- sort.Slice(result, func(i, j int) bool {
1165- return result[i].Count > result[j].Count
1166- })
1167- return result
1168-}
1169-
1170-func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1171- var historical, current []*db.VisitUrl
1172- var histErr, rawErr error
1173-
1174- var wg sync.WaitGroup
1175- wg.Add(2)
1176-
1177- go func() {
1178- defer wg.Done()
1179- historical, histErr = me.visitRefererFromSummary(opts)
1180- }()
1181- go func() {
1182- defer wg.Done()
1183- current, rawErr = me.visitRefererFromRaw(opts)
1184- }()
1185-
1186- wg.Wait()
1187-
1188- if histErr != nil {
1189- return nil, fmt.Errorf("query summary referers: %w", histErr)
1190- }
1191- if rawErr != nil {
1192- return nil, fmt.Errorf("query raw referers: %w", rawErr)
1193- }
1194-
1195- return mergeTopReferers(historical, current), nil
1196-}
1197-
1198-// visitRefererFromSummary reads top referers from analytics_monthly_top_referers for historical data.
1199-func (me *PsqlDB) visitRefererFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1200- now := time.Now()
1201- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1202- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1203-
1204- // If origin is in the previous month or later, raw data covers it — no summary to fetch.
1205- if !opts.Origin.Before(previousMonthStart) {
1206- return nil, nil
1207- }
1208-
1209- // Clamp origin to month boundary for summary table lookup
1210- originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
1211-
1212- where := ""
1213- args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
1214- if opts.Host != "" {
1215- where = "AND host = $4"
1216- args = append(args, opts.Host)
1217- }
1218-
1219- query := fmt.Sprintf(`
1220- SELECT referer, sum(unique_visits) as total_visits
1221- FROM analytics_monthly_top_referers
1222- WHERE user_id = $1 AND month >= $2 AND month < $3 %s
1223- GROUP BY referer
1224- ORDER BY total_visits DESC
1225- LIMIT 10`, where)
1226-
1227- rows, err := me.Db.Queryx(query, args...)
1228- if err != nil {
1229- return nil, err
1230- }
1231- defer func() { _ = rows.Close() }()
1232-
1233- var results []*db.VisitUrl
1234- for rows.Next() {
1235- result := &db.VisitUrl{}
1236- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1237- return nil, err
1238- }
1239- results = append(results, result)
1240- }
1241- return results, rows.Err()
1242-}
1243-
1244-// visitRefererFromRaw reads top referers from analytics_visits for the previous and current months.
1245-func (me *PsqlDB) visitRefererFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1246- now := time.Now()
1247- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1248- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1249-
1250- where, with := visitFilterBy(opts)
1251-
1252- // Determine the effective start: max(origin, previousMonthStart)
1253- effectiveStart := previousMonthStart
1254- if opts.Origin.After(previousMonthStart) {
1255- effectiveStart = opts.Origin
1256- }
1257-
1258- topUrls := fmt.Sprintf(`
1259- SELECT
1260- referer,
1261- count(DISTINCT ip_address) as referer_count
1262- FROM analytics_visits
1263- WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND referer <> '' AND status <> 404
1264- GROUP BY referer
1265- ORDER BY referer_count DESC
1266- LIMIT 10`, where)
1267-
1268- rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
1269- if err != nil {
1270- return nil, err
1271- }
1272- defer func() { _ = rows.Close() }()
1273-
1274- var results []*db.VisitUrl
1275- for rows.Next() {
1276- result := &db.VisitUrl{}
1277- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1278- return nil, err
1279- }
1280- results = append(results, result)
1281- }
1282- return results, rows.Err()
1283-}
1284-
1285-func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1286- var historical, current []*db.VisitUrl
1287- var histErr, rawErr error
1288-
1289- var wg sync.WaitGroup
1290- wg.Add(2)
1291-
1292- go func() {
1293- defer wg.Done()
1294- historical, histErr = me.visitUrlFromSummary(opts)
1295- }()
1296- go func() {
1297- defer wg.Done()
1298- current, rawErr = me.visitUrlFromRaw(opts)
1299- }()
1300-
1301- wg.Wait()
1302-
1303- if histErr != nil {
1304- return nil, fmt.Errorf("query summary urls: %w", histErr)
1305- }
1306- if rawErr != nil {
1307- return nil, fmt.Errorf("query raw urls: %w", rawErr)
1308- }
1309-
1310- return mergeTopUrls(historical, current), nil
1311-}
1312-
1313-// visitUrlFromSummary reads top URLs from analytics_monthly_top_urls for historical data.
1314-func (me *PsqlDB) visitUrlFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1315- now := time.Now()
1316- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1317- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1318-
1319- // If origin is in the previous month or later, raw data covers it — no summary to fetch.
1320- if !opts.Origin.Before(previousMonthStart) {
1321- return nil, nil
1322- }
1323-
1324- // Clamp origin to month boundary for summary table lookup
1325- originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
1326-
1327- where := ""
1328- args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
1329- if opts.Host != "" {
1330- where = "AND host = $4"
1331- args = append(args, opts.Host)
1332- }
1333-
1334- query := fmt.Sprintf(`
1335- SELECT path, sum(unique_visits) as total_visits
1336- FROM analytics_monthly_top_urls
1337- WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code <> 404 %s
1338- GROUP BY path
1339- ORDER BY total_visits DESC
1340- LIMIT 10`, where)
1341-
1342- rows, err := me.Db.Queryx(query, args...)
1343- if err != nil {
1344- return nil, err
1345- }
1346- defer func() { _ = rows.Close() }()
1347-
1348- var results []*db.VisitUrl
1349- for rows.Next() {
1350- result := &db.VisitUrl{}
1351- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1352- return nil, err
1353- }
1354- results = append(results, result)
1355- }
1356- return results, rows.Err()
1357-}
1358-
1359-// visitUrlFromRaw reads top URLs from analytics_visits for the previous and current months.
1360-func (me *PsqlDB) visitUrlFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1361- now := time.Now()
1362- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1363- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1364-
1365- where, with := visitFilterBy(opts)
1366-
1367- // Determine the effective start: max(origin, previousMonthStart)
1368- effectiveStart := previousMonthStart
1369- if opts.Origin.After(previousMonthStart) {
1370- effectiveStart = opts.Origin
1371- }
1372-
1373- topUrls := fmt.Sprintf(`
1374- SELECT
1375- path,
1376- count(DISTINCT ip_address) as path_count
1377- FROM analytics_visits
1378- WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status <> 404
1379- GROUP BY path
1380- ORDER BY path_count DESC
1381- LIMIT 10`, where)
1382-
1383- rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
1384- if err != nil {
1385- return nil, err
1386- }
1387- defer func() { _ = rows.Close() }()
1388-
1389- var results []*db.VisitUrl
1390- for rows.Next() {
1391- result := &db.VisitUrl{}
1392- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1393- return nil, err
1394- }
1395- results = append(results, result)
1396- }
1397- return results, rows.Err()
1398-}
1399-
1400-func (me *PsqlDB) VisitUrlNotFound(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1401- limit := opts.Limit
1402- if limit == 0 {
1403- limit = 10
1404- }
1405-
1406- var historical, current []*db.VisitUrl
1407- var histErr, rawErr error
1408-
1409- var wg sync.WaitGroup
1410- wg.Add(2)
1411-
1412- go func() {
1413- defer wg.Done()
1414- historical, histErr = me.visitUrlNotFoundFromSummary(opts, limit)
1415- }()
1416- go func() {
1417- defer wg.Done()
1418- current, rawErr = me.visitUrlNotFoundFromRaw(opts, limit)
1419- }()
1420-
1421- wg.Wait()
1422-
1423- if histErr != nil {
1424- return nil, fmt.Errorf("query summary 404 urls: %w", histErr)
1425- }
1426- if rawErr != nil {
1427- return nil, fmt.Errorf("query raw 404 urls: %w", rawErr)
1428- }
1429-
1430- return mergeTopUrls(historical, current), nil
1431-}
1432-
1433-// visitUrlNotFoundFromSummary reads top 404 URLs from analytics_monthly_top_urls for historical data.
1434-func (me *PsqlDB) visitUrlNotFoundFromSummary(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) {
1435- now := time.Now()
1436- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1437- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1438-
1439- // If origin is in the previous month or later, raw data covers it — no summary to fetch.
1440- if !opts.Origin.Before(previousMonthStart) {
1441- return nil, nil
1442- }
1443-
1444- // Clamp origin to month boundary for summary table lookup
1445- originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC)
1446-
1447- where := ""
1448- args := []interface{}{opts.UserID, originMonthStart, currentMonthStart}
1449- argIdx := 4
1450- if opts.Host != "" {
1451- where = "AND host = $" + fmt.Sprintf("%d", argIdx)
1452- args = append(args, opts.Host)
1453- }
1454-
1455- query := fmt.Sprintf(`
1456- SELECT path, sum(unique_visits) as total_visits
1457- FROM analytics_monthly_top_urls
1458- WHERE user_id = $1 AND month >= $2 AND month < $3 AND status_code = 404 %s
1459- GROUP BY path
1460- ORDER BY total_visits DESC
1461- LIMIT %d`, where, limit)
1462-
1463- rows, err := me.Db.Queryx(query, args...)
1464- if err != nil {
1465- return nil, err
1466- }
1467- defer func() { _ = rows.Close() }()
1468-
1469- var results []*db.VisitUrl
1470- for rows.Next() {
1471- result := &db.VisitUrl{}
1472- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1473- return nil, err
1474- }
1475- results = append(results, result)
1476- }
1477- return results, rows.Err()
1478-}
1479-
1480-// visitUrlNotFoundFromRaw reads top 404 URLs from analytics_visits for the previous and current months.
1481-func (me *PsqlDB) visitUrlNotFoundFromRaw(opts *db.SummaryOpts, limit int) ([]*db.VisitUrl, error) {
1482- now := time.Now()
1483- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1484- previousMonthStart := currentMonthStart.AddDate(0, -1, 0)
1485-
1486- where, with := visitFilterBy(opts)
1487-
1488- // Determine the effective start: max(origin, previousMonthStart)
1489- effectiveStart := previousMonthStart
1490- if opts.Origin.After(previousMonthStart) {
1491- effectiveStart = opts.Origin
1492- }
1493-
1494- topUrls := fmt.Sprintf(`
1495- SELECT
1496- path,
1497- count(DISTINCT ip_address) as path_count
1498- FROM analytics_visits
1499- WHERE created_at >= $1 AND created_at < $2 AND %s = $3 AND user_id = $4 AND path <> '' AND status = 404
1500- GROUP BY path
1501- ORDER BY path_count DESC
1502- LIMIT %d`, where, limit)
1503-
1504- rows, err := me.Db.Queryx(topUrls, effectiveStart, currentMonthStart.AddDate(0, 1, 0), with, opts.UserID)
1505- if err != nil {
1506- return nil, err
1507- }
1508- defer func() { _ = rows.Close() }()
1509-
1510- var results []*db.VisitUrl
1511- for rows.Next() {
1512- result := &db.VisitUrl{}
1513- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1514- return nil, err
1515- }
1516- results = append(results, result)
1517- }
1518- return results, rows.Err()
1519-}
1520-
1521-func (me *PsqlDB) visitHost(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1522- var historical, current []*db.VisitUrl
1523- var histErr, rawErr error
1524-
1525- var wg sync.WaitGroup
1526- wg.Add(2)
1527-
1528- go func() {
1529- defer wg.Done()
1530- historical, histErr = me.visitHostFromSummary(opts)
1531- }()
1532- go func() {
1533- defer wg.Done()
1534- current, rawErr = me.visitHostFromRaw(opts)
1535- }()
1536-
1537- wg.Wait()
1538-
1539- if histErr != nil {
1540- return nil, fmt.Errorf("query summary hosts: %w", histErr)
1541- }
1542- if rawErr != nil {
1543- return nil, fmt.Errorf("query raw hosts: %w", rawErr)
1544- }
1545-
1546- return mergeHosts(historical, current), nil
1547-}
1548-
1549-// visitHostFromSummary reads host data from analytics_user_sites for historical data.
1550-func (me *PsqlDB) visitHostFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1551- rows, err := me.Db.Queryx(`
1552- SELECT host, total_visits
1553- FROM analytics_user_sites
1554- WHERE user_id = $1 AND host <> ''
1555- ORDER BY total_visits DESC`, opts.UserID)
1556- if err != nil {
1557- return nil, err
1558- }
1559- defer func() { _ = rows.Close() }()
1560-
1561- var results []*db.VisitUrl
1562- for rows.Next() {
1563- result := &db.VisitUrl{}
1564- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1565- return nil, err
1566- }
1567- results = append(results, result)
1568- }
1569- return results, rows.Err()
1570-}
1571-
1572-// visitHostFromRaw reads hosts from analytics_visits for the current month that aren't in summary.
1573-func (me *PsqlDB) visitHostFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1574- now := time.Now()
1575- currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
1576-
1577- rows, err := me.Db.Queryx(`
1578- SELECT host, count(DISTINCT ip_address) as host_count
1579- FROM analytics_visits
1580- WHERE created_at >= $1 AND user_id = $2 AND host <> ''
1581- GROUP BY host
1582- ORDER BY host_count DESC`, currentMonthStart, opts.UserID)
1583- if err != nil {
1584- return nil, err
1585- }
1586- defer func() { _ = rows.Close() }()
1587-
1588- var results []*db.VisitUrl
1589- for rows.Next() {
1590- result := &db.VisitUrl{}
1591- if err := rows.Scan(&result.Url, &result.Count); err != nil {
1592- return nil, err
1593- }
1594- results = append(results, result)
1595- }
1596- return results, rows.Err()
1597-}
1598-
1599-func (me *PsqlDB) VisitSummary(opts *db.SummaryOpts) (*db.SummaryVisits, error) {
1600- var (
1601- visitors []*db.VisitInterval
1602- urls []*db.VisitUrl
1603- refs []*db.VisitUrl
1604- notFound []*db.VisitUrl
1605- visitorsErr error
1606- urlsErr error
1607- refsErr error
1608- nfErr error
1609- )
1610-
1611- var wg sync.WaitGroup
1612- wg.Add(4)
1613-
1614- go func() {
1615- defer wg.Done()
1616- visitors, visitorsErr = me.visitUnique(opts)
1617- }()
1618- go func() {
1619- defer wg.Done()
1620- urls, urlsErr = me.visitUrl(opts)
1621- }()
1622- go func() {
1623- defer wg.Done()
1624- refs, refsErr = me.visitReferer(opts)
1625- }()
1626- go func() {
1627- defer wg.Done()
1628- notFound, nfErr = me.VisitUrlNotFound(opts)
1629- }()
1630-
1631- wg.Wait()
1632-
1633- // Return the first error encountered
1634- for _, err := range []error{visitorsErr, urlsErr, refsErr, nfErr} {
1635- if err != nil {
1636- return nil, err
1637- }
1638- }
1639-
1640- return &db.SummaryVisits{
1641- Intervals: visitors,
1642- TopUrls: urls,
1643- TopReferers: refs,
1644- NotFoundUrls: notFound,
1645- }, nil
1646-}
1647-
1648-func (me *PsqlDB) FindVisitSiteList(opts *db.SummaryOpts) ([]*db.VisitUrl, error) {
1649- return me.visitHost(opts)
1650-}
1651-
1652-func (me *PsqlDB) FindUsers() ([]*db.User, error) {
1653- var users []*db.User
1654- err := me.Db.Select(&users, `SELECT id, COALESCE(name, '') as name, created_at FROM app_users ORDER BY name ASC`)
1655- if err != nil {
1656- return nil, err
1657- }
1658- return users, nil
1659-}
1660-
1661-func (me *PsqlDB) removeTagsByPost(tx *sqlx.Tx, postID string) error {
1662- _, err := tx.Exec(`DELETE FROM post_tags WHERE post_id = $1`, postID)
1663- return err
1664-}
1665-
1666-func (me *PsqlDB) insertTagsByPost(tx *sqlx.Tx, tags []string, postID string) ([]string, error) {
1667- ids := make([]string, 0)
1668- for _, tag := range tags {
1669- id := ""
1670- err := tx.QueryRow(`INSERT INTO post_tags (post_id, name) VALUES($1, $2) RETURNING id;`, postID, tag).Scan(&id)
1671- if err != nil {
1672- return nil, err
1673- }
1674- ids = append(ids, id)
1675- }
1676-
1677- return ids, nil
1678-}
1679-
1680-func (me *PsqlDB) ReplaceTagsByPost(tags []string, postID string) error {
1681- tx, err := me.Db.Beginx()
1682- if err != nil {
1683- return err
1684- }
1685- defer func() {
1686- _ = tx.Rollback()
1687- }()
1688-
1689- err = me.removeTagsByPost(tx, postID)
1690- if err != nil {
1691- return err
1692- }
1693-
1694- _, err = me.insertTagsByPost(tx, tags, postID)
1695- if err != nil {
1696- return err
1697- }
1698-
1699- err = tx.Commit()
1700- return err
1701-}
1702-
1703-func (me *PsqlDB) removeAliasesByPost(tx *sqlx.Tx, postID string) error {
1704- _, err := tx.Exec(`DELETE FROM post_aliases WHERE post_id = $1`, postID)
1705- return err
1706-}
1707-
1708-func (me *PsqlDB) insertAliasesByPost(tx *sqlx.Tx, aliases []string, postID string) ([]string, error) {
1709- // hardcoded
1710- denyList := []string{
1711- "rss",
1712- "rss.xml",
1713- "rss.atom",
1714- "atom.xml",
1715- "feed.xml",
1716- "smol.css",
1717- "main.css",
1718- "syntax.css",
1719- "card.png",
1720- "favicon-16x16.png",
1721- "favicon-32x32.png",
1722- "apple-touch-icon.png",
1723- "favicon.ico",
1724- "robots.txt",
1725- "atom",
1726- "blog/index.xml",
1727- }
1728-
1729- ids := make([]string, 0)
1730- for _, alias := range aliases {
1731- if slices.Contains(denyList, alias) {
1732- me.Logger.Info(
1733- "name is in the deny list for aliases because it conflicts with a static route, skipping",
1734- "alias", alias,
1735- )
1736- continue
1737- }
1738- id := ""
1739- err := tx.QueryRow(`INSERT INTO post_aliases (post_id, slug) VALUES($1, $2) RETURNING id;`, postID, alias).Scan(&id)
1740- if err != nil {
1741- return nil, err
1742- }
1743- ids = append(ids, id)
1744- }
1745-
1746- return ids, nil
1747-}
1748-
1749-func (me *PsqlDB) ReplaceAliasesByPost(aliases []string, postID string) error {
1750- tx, err := me.Db.Beginx()
1751- if err != nil {
1752- return err
1753- }
1754- defer func() {
1755- _ = tx.Rollback()
1756- }()
1757-
1758- err = me.removeAliasesByPost(tx, postID)
1759- if err != nil {
1760- return err
1761- }
1762-
1763- _, err = me.insertAliasesByPost(tx, aliases, postID)
1764- if err != nil {
1765- return err
1766- }
1767-
1768- err = tx.Commit()
1769- return err
1770-}
1771-
1772-func (me *PsqlDB) FindUserPostsByTag(page *db.Pager, tag, userID, space string) (*db.Paginate[*db.Post], error) {
1773- var posts []*db.Post
1774- query := fmt.Sprintf(`
1775- SELECT %s
1776- FROM posts
1777- LEFT JOIN app_users ON app_users.id = posts.user_id
1778- LEFT JOIN post_tags ON post_tags.post_id = posts.id
1779- WHERE
1780- hidden = FALSE AND
1781- user_id = $1 AND
1782- (post_tags.name = $2 OR hidden = true) AND
1783- publish_at::date <= CURRENT_DATE AND
1784- cur_space = $3
1785- ORDER BY publish_at DESC
1786- LIMIT $4 OFFSET $5`, SelectPost)
1787- err := me.Db.Select(
1788- &posts,
1789- query,
1790- userID,
1791- tag,
1792- space,
1793- page.Num,
1794- page.Num*page.Page,
1795- )
1796- if err != nil {
1797- return nil, err
1798- }
1799-
1800- var count int
1801- err = me.Db.QueryRow(`SELECT count(id) FROM posts WHERE hidden = FALSE AND cur_space=$1`, space).Scan(&count)
1802- if err != nil {
1803- return nil, err
1804- }
1805-
1806- pager := &db.Paginate[*db.Post]{
1807- Data: posts,
1808- Total: int(math.Ceil(float64(count) / float64(page.Num))),
1809- }
1810- return pager, nil
1811-}
1812-
1813-func (me *PsqlDB) FindPostsByTag(pager *db.Pager, tag, space string) (*db.Paginate[*db.Post], error) {
1814- query := `
1815- SELECT
1816- posts.id,
1817- user_id,
1818- filename,
1819- slug,
1820- title,
1821- text,
1822- description,
1823- publish_at,
1824- app_users.name as username,
1825- posts.updated_at,
1826- posts.mime_type
1827- FROM posts
1828- LEFT JOIN app_users ON app_users.id = posts.user_id
1829- LEFT JOIN post_tags ON post_tags.post_id = posts.id
1830- WHERE
1831- post_tags.name = $3 AND
1832- publish_at::date <= CURRENT_DATE AND
1833- cur_space = $4
1834- ORDER BY publish_at DESC
1835- LIMIT $1 OFFSET $2`
1836- rs, err := me.Db.Queryx(
1837- query,
1838- pager.Num,
1839- pager.Num*pager.Page,
1840- tag,
1841- space,
1842- )
1843- if err != nil {
1844- return nil, err
1845- }
1846- defer func() { _ = rs.Close() }()
1847-
1848- return me.postPager(rs, pager.Num, space, tag)
1849-}
1850-
1851-func (me *PsqlDB) FindPopularTags(space string) ([]string, error) {
1852- tags := make([]string, 0)
1853- query := `
1854- SELECT name, count(post_id) as "tally"
1855- FROM post_tags
1856- LEFT JOIN posts ON posts.id = post_id
1857- WHERE posts.cur_space = $1
1858- GROUP BY name
1859- ORDER BY tally DESC
1860- LIMIT 5`
1861- rs, err := me.Db.Queryx(query, space)
1862- if err != nil {
1863- return tags, err
1864- }
1865- defer func() { _ = rs.Close() }()
1866- for rs.Next() {
1867- name := ""
1868- tally := 0
1869- err := rs.Scan(&name, &tally)
1870- if err != nil {
1871- return tags, err
1872- }
1873-
1874- tags = append(tags, name)
1875- }
1876- if rs.Err() != nil {
1877- return tags, rs.Err()
1878- }
1879- return tags, nil
1880-}
1881-
1882-func (me *PsqlDB) FindFeature(userID string, feature string) (*db.FeatureFlag, error) {
1883- ff := &db.FeatureFlag{}
1884- err := me.Db.Get(ff, `SELECT * FROM feature_flags WHERE user_id = $1 AND name = $2 ORDER BY expires_at DESC LIMIT 1`, userID, feature)
1885- if err != nil {
1886- return nil, err
1887- }
1888- return ff, nil
1889-}
1890-
1891-func (me *PsqlDB) FindFeaturesByUser(userID string) ([]*db.FeatureFlag, error) {
1892- var features []*db.FeatureFlag
1893- // https://stackoverflow.com/a/16920077
1894- query := `SELECT DISTINCT ON (name) *
1895- FROM feature_flags
1896- WHERE user_id=$1
1897- ORDER BY name, expires_at DESC;`
1898- err := me.Db.Select(&features, query, userID)
1899- if err != nil {
1900- return nil, err
1901- }
1902- return features, nil
1903-}
1904-
1905-func (me *PsqlDB) HasFeatureByUser(userID string, feature string) bool {
1906- ff, err := me.FindFeature(userID, feature)
1907- if err != nil {
1908- return false
1909- }
1910- return ff.IsValid()
1911-}
1912-
1913-func (me *PsqlDB) InsertFeedItems(postID string, items []*db.FeedItem) error {
1914- tx, err := me.Db.Beginx()
1915- if err != nil {
1916- return err
1917- }
1918- defer func() {
1919- _ = tx.Rollback()
1920- }()
1921-
1922- for _, item := range items {
1923- _, err := tx.Exec(
1924- `INSERT INTO feed_items (post_id, guid, data) VALUES ($1, $2, $3) RETURNING id;`,
1925- item.PostID,
1926- item.GUID,
1927- item.Data,
1928- )
1929- if err != nil {
1930- return fmt.Errorf(
1931- "post id:%s, link:%s, guid:%s, err:%w",
1932- item.PostID, item.Data.Link, item.GUID, err,
1933- )
1934- }
1935- }
1936-
1937- err = tx.Commit()
1938- return err
1939-}
1940-
1941-func (me *PsqlDB) FindFeedItemsByPostID(postID string) ([]*db.FeedItem, error) {
1942- var items []*db.FeedItem
1943- err := me.Db.Select(&items, `SELECT * FROM feed_items WHERE post_id=$1`, postID)
1944- if err != nil {
1945- return nil, err
1946- }
1947- return items, nil
1948-}
1949-
1950-func (me *PsqlDB) InsertProject(userID, name, projectDir string) (string, error) {
1951- if !shared.IsValidSubdomain(name) {
1952- return "", fmt.Errorf("'%s' is not a valid project name, must match /^[a-z0-9-]+$/", name)
1953- }
1954-
1955- var id string
1956- err := me.Db.QueryRow(`INSERT INTO projects (user_id, name, project_dir) VALUES ($1, $2, $3) RETURNING id;`, userID, name, projectDir).Scan(&id)
1957- if err != nil {
1958- return "", err
1959- }
1960- return id, nil
1961-}
1962-
1963-func (me *PsqlDB) UpdateProject(userID, name string) error {
1964- _, err := me.Db.Exec(`UPDATE projects SET updated_at = $3 WHERE user_id = $1 AND name = $2;`, userID, name, time.Now())
1965- return err
1966-}
1967-
1968-func (me *PsqlDB) FindProjectByName(userID, name string) (*db.Project, error) {
1969- project := &db.Project{}
1970- err := me.Db.Get(project, `SELECT * FROM projects WHERE user_id = $1 AND name = $2;`, userID, name)
1971- if err != nil {
1972- return nil, err
1973- }
1974- return project, nil
1975-}
1976-
1977-func (me *PsqlDB) InsertToken(userID, name string) (string, error) {
1978- var token string
1979- err := me.Db.QueryRow(`INSERT INTO tokens (user_id, name) VALUES($1, $2) RETURNING token;`, userID, name).Scan(&token)
1980- if err != nil {
1981- return "", err
1982- }
1983- return token, nil
1984-}
1985-
1986-func (me *PsqlDB) UpsertToken(userID, name string) (string, error) {
1987- token, _ := me.findTokenByName(userID, name)
1988- if token != "" {
1989- return token, nil
1990- }
1991-
1992- token, err := me.InsertToken(userID, name)
1993- return token, err
1994-}
1995-
1996-func (me *PsqlDB) findTokenByName(userID, name string) (string, error) {
1997- var token string
1998- err := me.Db.QueryRow(`SELECT token FROM tokens WHERE user_id = $1 AND name = $2`, userID, name).Scan(&token)
1999- if err != nil {
2000- return "", err
2001- }
2002- return token, nil
2003-}
2004-
2005-func (me *PsqlDB) RemoveToken(tokenID string) error {
2006- _, err := me.Db.Exec(`DELETE FROM tokens WHERE id = $1`, tokenID)
2007- return err
2008-}
2009-
2010-func (me *PsqlDB) FindTokensByUser(userID string) ([]*db.Token, error) {
2011- var tokens []*db.Token
2012- err := me.Db.Select(&tokens, `SELECT * FROM tokens WHERE user_id = $1`, userID)
2013- if err != nil {
2014- return nil, err
2015- }
2016- return tokens, nil
2017-}
2018-
2019-func (me *PsqlDB) InsertFeature(userID, name string, expiresAt time.Time) (*db.FeatureFlag, error) {
2020- var featureID string
2021- err := me.Db.QueryRow(
2022- `INSERT INTO feature_flags (user_id, name, expires_at) VALUES ($1, $2, $3) RETURNING id;`,
2023- userID,
2024- name,
2025- expiresAt,
2026- ).Scan(&featureID)
2027- if err != nil {
2028- return nil, err
2029- }
2030-
2031- feature, err := me.FindFeature(userID, name)
2032- if err != nil {
2033- return nil, err
2034- }
2035-
2036- return feature, nil
2037-}
2038-
2039-func (me *PsqlDB) RemoveFeature(userID string, name string) error {
2040- _, err := me.Db.Exec(`DELETE FROM feature_flags WHERE user_id = $1 AND name = $2`, userID, name)
2041- return err
2042-}
2043-
2044-func (me *PsqlDB) createFeatureExpiresAt(userID, name string) time.Time {
2045- ff, _ := me.FindFeature(userID, name)
2046- // if the feature flag has already expired we don't want to add a year to it since that will
2047- // not grant the user a full year
2048- if ff == nil || !ff.IsValid() {
2049- t := time.Now()
2050- return t.AddDate(1, 0, 0)
2051- }
2052- return ff.ExpiresAt.AddDate(1, 0, 0)
2053-}
2054-
2055-func (me *PsqlDB) AddPicoPlusUser(username, email, paymentType, txId string) error {
2056- user, err := me.FindUserByName(username)
2057- if err != nil {
2058- return err
2059- }
2060-
2061- tx, err := me.Db.Beginx()
2062- if err != nil {
2063- return err
2064- }
2065- defer func() {
2066- _ = tx.Rollback()
2067- }()
2068-
2069- var paymentHistoryId sql.NullString
2070- if paymentType != "" {
2071- data := db.PaymentHistoryData{
2072- Notes: "",
2073- TxID: txId,
2074- }
2075-
2076- err := tx.QueryRow(
2077- `INSERT INTO payment_history (user_id, payment_type, amount, data) VALUES ($1, $2, 24 * 1000000, $3) RETURNING id;`,
2078- user.ID,
2079- paymentType,
2080- data,
2081- ).Scan(&paymentHistoryId)
2082- if err != nil {
2083- return err
2084- }
2085- }
2086-
2087- plus := me.createFeatureExpiresAt(user.ID, "plus")
2088- plusQuery := fmt.Sprintf(`INSERT INTO feature_flags (user_id, name, data, expires_at, payment_history_id)
2089- VALUES ($1, 'plus', '{"storage_max":5000000000, "file_max":100000000, "email": "%s"}'::jsonb, $2, $3);`, email)
2090- _, err = tx.Exec(plusQuery, user.ID, plus, paymentHistoryId)
2091- if err != nil {
2092- return err
2093- }
2094-
2095- return tx.Commit()
2096-}
2097-
2098-func (me *PsqlDB) AddFeatureUser(username, name string) error {
2099- user, err := me.FindUserByName(username)
2100- if err != nil {
2101- return err
2102- }
2103-
2104- expiresAt := me.createFeatureExpiresAt(user.ID, name)
2105- _, err = me.InsertFeature(user.ID, name, expiresAt)
2106- return err
2107-}
2108-
2109-func (me *PsqlDB) UpsertProject(userID, projectName, projectDir string) (*db.Project, error) {
2110- project, err := me.FindProjectByName(userID, projectName)
2111- if err == nil {
2112- // this just updates the `createdAt` timestamp, useful for book-keeping
2113- err = me.UpdateProject(userID, projectName)
2114- if err != nil {
2115- me.Logger.Error("could not update project", "err", err)
2116- return nil, err
2117- }
2118- return project, nil
2119- }
2120-
2121- _, err = me.InsertProject(userID, projectName, projectName)
2122- if err != nil {
2123- me.Logger.Error("could not create project", "err", err)
2124- return nil, err
2125- }
2126- return me.FindProjectByName(userID, projectName)
2127-}
2128-
2129-func (me *PsqlDB) findPagesStats(userID string) (*db.UserServiceStats, error) {
2130- stats := db.UserServiceStats{
2131- Service: "pgs",
2132- }
2133- // the aggregates are NULL when the user has no projects
2134- var first, latestCreated, latestUpdated sql.NullTime
2135- err := me.Db.QueryRow(
2136- `SELECT count(id), min(created_at), max(created_at), max(updated_at) FROM projects WHERE user_id=$1`,
2137- userID,
2138- ).Scan(&stats.Num, &first, &latestCreated, &latestUpdated)
2139- if err != nil {
2140- return nil, err
2141- }
2142- stats.FirstCreatedAt = first.Time
2143- stats.LastestCreatedAt = latestCreated.Time
2144- stats.LatestUpdatedAt = latestUpdated.Time
2145-
2146- return &stats, nil
2147-}
2148-
2149-func (me *PsqlDB) InsertTunsEventLog(log *db.TunsEventLog) error {
2150- _, err := me.Db.Exec(
2151- `INSERT INTO tuns_event_logs
2152- (user_id, server_id, remote_addr, event_type, tunnel_type, connection_type, tunnel_id)
2153- VALUES
2154- ($1, $2, $3, $4, $5, $6, $7)`,
2155- log.UserId, log.ServerID, log.RemoteAddr, log.EventType, log.TunnelType,
2156- log.ConnectionType, log.TunnelID,
2157- )
2158- return err
2159-}
2160-
2161-func (me *PsqlDB) FindTunsEventLogsByAddr(userID, addr string) ([]*db.TunsEventLog, error) {
2162- var logs []*db.TunsEventLog
2163- err := me.Db.Select(&logs,
2164- `SELECT * FROM tuns_event_logs WHERE user_id=$1 AND tunnel_id=$2 ORDER BY created_at DESC`, userID, addr)
2165- if err != nil {
2166- return nil, err
2167- }
2168- return logs, nil
2169-}
2170-
2171-func (me *PsqlDB) FindTunsEventLogs(userID string) ([]*db.TunsEventLog, error) {
2172- var logs []*db.TunsEventLog
2173- err := me.Db.Select(&logs,
2174- `SELECT * FROM tuns_event_logs WHERE user_id=$1 ORDER BY created_at DESC`, userID)
2175- if err != nil {
2176- return nil, err
2177- }
2178- return logs, nil
2179-}
2180-
2181-func (me *PsqlDB) FindUserStats(userID string) (*db.UserStats, error) {
2182- stats := db.UserStats{}
2183- rs, err := me.Db.Queryx(`SELECT cur_space, count(id), min(created_at), max(created_at), max(updated_at) FROM posts WHERE user_id=$1 GROUP BY cur_space`, userID)
2184- if err != nil {
2185- return nil, err
2186- }
2187- defer func() { _ = rs.Close() }()
2188-
2189- for rs.Next() {
2190- stat := db.UserServiceStats{}
2191- err := rs.Scan(&stat.Service, &stat.Num, &stat.FirstCreatedAt, &stat.LastestCreatedAt, &stat.LatestUpdatedAt)
2192- if err != nil {
2193- return nil, err
2194- }
2195- switch stat.Service {
2196- case "prose":
2197- stats.Prose = stat
2198- case "pastes":
2199- stats.Pastes = stat
2200- case "feeds":
2201- stats.Feeds = stat
2202- }
2203- }
2204-
2205- if rs.Err() != nil {
2206- return nil, rs.Err()
2207- }
2208-
2209- pgs, err := me.findPagesStats(userID)
2210- if err != nil {
2211- return nil, err
2212- }
2213- stats.Pages = *pgs
2214- return &stats, err
2215-}
2216-
2217-func (me *PsqlDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.AccessLog, error) {
2218- var logs []*db.AccessLog
2219- err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE user_id=$1 AND created_at >= $2 ORDER BY created_at ASC`, userID, fromDate)
2220- if err != nil {
2221- return nil, err
2222- }
2223- return logs, nil
2224-}
2225-
2226-func (me *PsqlDB) FindAccessLogsByPubkey(pubkey string, fromDate *time.Time) ([]*db.AccessLog, error) {
2227- var logs []*db.AccessLog
2228- err := me.Db.Select(&logs, `SELECT * FROM access_logs WHERE pubkey=$1 AND created_at >= $2 ORDER BY created_at ASC`, pubkey, fromDate)
2229- if err != nil {
2230- return nil, err
2231- }
2232- return logs, nil
2233-}
2234-
2235-func (me *PsqlDB) FindPubkeysInAccessLogs(userID string) ([]string, error) {
2236- var pubkeys []string
2237- err := me.Db.Select(&pubkeys, `SELECT DISTINCT(pubkey) FROM access_logs WHERE user_id=$1`, userID)
2238- if err != nil {
2239- return nil, err
2240- }
2241- return pubkeys, nil
2242-}
2243-
2244-func (me *PsqlDB) InsertAccessLog(log *db.AccessLog) error {
2245- _, err := me.Db.Exec(
2246- `INSERT INTO access_logs (user_id, service, pubkey, identity) VALUES ($1, $2, $3, $4);`,
2247- log.UserID,
2248- log.Service,
2249- log.Pubkey,
2250- log.Identity,
2251- )
2252- return err
2253-}
2254-
2255-func (me *PsqlDB) UpsertPipeMonitor(userID, topic string, dur time.Duration, winEnd *time.Time) error {
2256- durStr := fmt.Sprintf("%d seconds", int64(dur.Seconds()))
2257- _, err := me.Db.Exec(
2258- `INSERT INTO pipe_monitors (user_id, topic, window_dur, window_end)
2259- VALUES ($1, $2, $3::interval, $4)
2260- ON CONFLICT (user_id, topic) DO UPDATE SET window_dur = $3::interval, window_end = $4, updated_at = NOW();`,
2261- userID,
2262- topic,
2263- durStr,
2264- winEnd,
2265- )
2266- return err
2267-}
2268-
2269-func (me *PsqlDB) UpdatePipeMonitorLastPing(userID, topic string, lastPing *time.Time) error {
2270- _, err := me.Db.Exec(
2271- `UPDATE pipe_monitors SET last_ping = $3, updated_at = NOW() WHERE user_id = $1 AND topic = $2;`,
2272- userID,
2273- topic,
2274- lastPing,
2275- )
2276- return err
2277-}
2278-
2279-func (me *PsqlDB) RemovePipeMonitor(userID, topic string) error {
2280- _, err := me.Db.Exec(
2281- `DELETE FROM pipe_monitors WHERE user_id = $1 AND topic = $2;`,
2282- userID,
2283- topic,
2284- )
2285- return err
2286-}
2287-
2288-func (me *PsqlDB) FindPipeMonitorByTopic(userID, topic string) (*db.PipeMonitor, error) {
2289- monitor := &db.PipeMonitor{}
2290- err := me.Db.Get(monitor, `SELECT id, user_id, topic, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors WHERE user_id = $1 AND topic = $2;`, userID, topic)
2291- if err != nil {
2292- return nil, err
2293- }
2294- return monitor, nil
2295-}
2296-
2297-func (me *PsqlDB) FindPipeMonitorsByUser(userID string) ([]*db.PipeMonitor, error) {
2298- var monitors []*db.PipeMonitor
2299- err := me.Db.Select(&monitors, `SELECT id, user_id, topic, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors WHERE user_id = $1 ORDER BY topic;`, userID)
2300- if err != nil {
2301- return nil, err
2302- }
2303- return monitors, nil
2304-}
2305-
2306-func (me *PsqlDB) InsertPipeMonitorHistory(monitorID string, windowDur time.Duration, windowEnd, lastPing *time.Time) error {
2307- durStr := fmt.Sprintf("%d seconds", int64(windowDur.Seconds()))
2308- _, err := me.Db.Exec(
2309- `INSERT INTO pipe_monitors_history (monitor_id, window_dur, window_end, last_ping) VALUES ($1, $2::interval, $3, $4)`,
2310- monitorID, durStr, windowEnd, lastPing,
2311- )
2312- return err
2313-}
2314-
2315-func (me *PsqlDB) FindPipeMonitorHistory(monitorID string, from, to time.Time) ([]*db.PipeMonitorHistory, error) {
2316- var history []*db.PipeMonitorHistory
2317- err := me.Db.Select(
2318- &history,
2319- `SELECT id, monitor_id, (EXTRACT(EPOCH FROM window_dur) * 1000000000)::bigint as window_dur, window_end, last_ping, created_at, updated_at FROM pipe_monitors_history WHERE monitor_id = $1 AND last_ping <= $2 AND window_end >= $3 ORDER BY last_ping ASC`,
2320- monitorID, to, from,
2321- )
2322- if err != nil {
2323- return nil, err
2324- }
2325- return history, nil
2326-}
2327-
2328-func (me *PsqlDB) FindWhoInvitedUser(toUserID string) (string, error) {
2329- var inviterName string
2330- query := `
2331- SELECT app_users.name
2332- FROM invites
2333- LEFT JOIN app_users ON app_users.id = invites.from_user_id
2334- WHERE to_user_id=$1
2335- `
2336- err := me.Db.Get(&inviterName, query, toUserID)
2337- if err != nil {
2338- return "", err
2339- }
2340-
2341- return inviterName, nil
2342-}
2343-
2344-func (me *PsqlDB) FindInvitesByUser(userID string) ([]*db.Invite, error) {
2345- var invites []*db.Invite
2346- query := `SELECT
2347- invites.id, invites.from_user_id, invites.to_user_id, invites.created_at,
2348- app_users.name as to_user_name
2349- FROM invites
2350- LEFT JOIN app_users ON app_users.id = invites.to_user_id
2351- WHERE from_user_id=$1`
2352- err := me.Db.Select(&invites, query, userID)
2353- if err != nil {
2354- return nil, err
2355- }
2356- return invites, nil
2357-}
2358-
2359-// inviteFeatures are the services an invite grants access to.
2360-var inviteFeatures = []string{"prose", "pgs", "pastes"}
2361-
2362-func (me *PsqlDB) hasActiveInvite(userID string) (bool, error) {
2363- var invited bool
2364- err := me.Db.Get(&invited, `SELECT EXISTS (SELECT 1 FROM invites WHERE to_user_id = $1)`, userID)
2365- if err != nil || !invited {
2366- return false, err
2367- }
2368- for _, name := range inviteFeatures {
2369- if me.HasFeatureByUser(userID, name) {
2370- return true, nil
2371- }
2372- }
2373- return false, nil
2374-}
2375-
2376-func (me *PsqlDB) InviteUser(fromUserID string, toUserID string) error {
2377- canInvite := me.HasFeatureByUser(fromUserID, "plus")
2378- if !canInvite {
2379- invited, err := me.hasActiveInvite(fromUserID)
2380- if err != nil {
2381- return err
2382- }
2383- canInvite = invited
2384- }
2385-
2386- if !canInvite {
2387- return fmt.Errorf("must have valid pico+ membership or an active invite from someone")
2388- }
2389-
2390- _, err := me.Db.Exec(
2391- `INSERT INTO invites (from_user_id, to_user_id) VALUES ($1, $2)`,
2392- fromUserID, toUserID,
2393- )
2394- if err != nil {
2395- return err
2396- }
2397-
2398- expiresAt := time.Now().Add(5 * 365 * 24 * time.Hour)
2399- for _, name := range inviteFeatures {
2400- if _, err := me.InsertFeature(toUserID, name, expiresAt); err != nil {
2401- return err
2402- }
2403- }
2404- return nil
2405-}
+14, -595
......@@ -1,195 +1,36 @@
11 package postgres
22
33 import (
4- "context"
54 "errors"
6- "fmt"
75 "log/slog"
86 "os"
9- "os/exec"
10- "path/filepath"
11- "runtime"
127 "slices"
13- "sort"
148 "testing"
159 "time"
1610
17- "github.com/jmoiron/sqlx"
18- _ "github.com/lib/pq"
1911 "github.com/picosh/pico/pkg/db"
20- "github.com/testcontainers/testcontainers-go"
21- "github.com/testcontainers/testcontainers-go/modules/postgres"
22- "github.com/testcontainers/testcontainers-go/wait"
12+ "github.com/picosh/pico/pkg/db/postgres/pgtest"
2313 )
2414
2515 var testDB *PsqlDB
26-var testLogger *slog.Logger
27-var skipTests bool
28-
29-func setupContainerRuntime() bool {
30- // Check if DATABASE_URL is set for external postgres (CI/CD or manual testing)
31- if os.Getenv("TEST_DATABASE_URL") != "" {
32- return true
33- }
34-
35- // Try docker
36- if cmd := exec.Command("docker", "info"); cmd.Run() == nil {
37- return true
38- }
39-
40- return false
41-}
4216
4317 func TestMain(m *testing.M) {
44- ctx := context.Background()
45- testLogger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
46-
47- // REQUIRE_TESTCONTAINERS=1 makes the build fail if containers can't start
48- requireContainers := os.Getenv("REQUIRE_TESTCONTAINERS") == "1"
49-
50- // Check for external database URL first (for CI/CD or manual testing)
51- if dbURL := os.Getenv("TEST_DATABASE_URL"); dbURL != "" {
52- testDB = NewDB(dbURL, testLogger)
53- if err := setupTestSchema(testDB.Db); err != nil {
54- panic(fmt.Sprintf("failed to setup schema: %s", err))
55- }
56- code := m.Run()
57- _ = testDB.Close()
58- os.Exit(code)
59- }
60-
61- if !setupContainerRuntime() {
62- fmt.Println("Container runtime not available, skipping integration tests")
63- fmt.Println("To run tests, either:")
64- fmt.Println(" - Set TEST_DATABASE_URL to a postgres connection string")
65- fmt.Println(" - Start docker daemon")
66- skipTests = true
67- exitCode := 0
68- if requireContainers {
69- exitCode = 1
70- }
71- os.Exit(exitCode)
72- }
73-
74- pgContainer, err := postgres.Run(ctx,
75- "postgres:14",
76- postgres.WithDatabase("pico_test"),
77- postgres.WithUsername("postgres"),
78- postgres.WithPassword("postgres"),
79- testcontainers.WithWaitStrategy(
80- wait.ForLog("database system is ready to accept connections").
81- WithOccurrence(2).
82- WithStartupTimeout(30*time.Second)),
83- )
84- if err != nil {
85- fmt.Printf("Failed to start postgres container (Docker may not be running): %s\n", err)
86- skipTests = true
87- exitCode := 0
88- if requireContainers {
89- exitCode = 1
90- }
91- os.Exit(exitCode)
92- }
93-
94- connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
95- if err != nil {
96- panic(fmt.Sprintf("failed to get connection string: %s", err))
97- }
98-
99- testDB = NewDB(connStr, testLogger)
100-
101- if err := setupTestSchema(testDB.Db); err != nil {
102- panic(fmt.Sprintf("failed to setup schema: %s", err))
103- }
104-
105- code := m.Run()
106-
107- _ = testDB.Close()
108- _ = pgContainer.Terminate(ctx)
109-
110- os.Exit(code)
111-}
112-
113-func getProjectRoot() string {
114- _, filename, _, ok := runtime.Caller(0)
115- if !ok {
116- panic("failed to get current file path")
117- }
118- // storage_test.go is in pkg/db/postgres/, so go up 4 levels to get project root
119- return filepath.Join(filepath.Dir(filename), "..", "..", "..")
120-}
121-
122-func setupTestSchema(db *sqlx.DB) error {
123- projectRoot := getProjectRoot()
124- migrationsDir := filepath.Join(projectRoot, "sql", "migrations")
125-
126- // Read all migration files
127- entries, err := os.ReadDir(migrationsDir)
128- if err != nil {
129- return fmt.Errorf("failed to read migrations directory: %w", err)
130- }
131-
132- // Sort by filename (they're date-prefixed, so alphabetical order is correct)
133- var migrationFiles []string
134- for _, entry := range entries {
135- if !entry.IsDir() && filepath.Ext(entry.Name()) == ".sql" {
136- migrationFiles = append(migrationFiles, entry.Name())
137- }
138- }
139- sort.Strings(migrationFiles)
140-
141- // Execute each migration in order
142- for _, filename := range migrationFiles {
143- migrationPath := filepath.Join(migrationsDir, filename)
144- content, err := os.ReadFile(migrationPath)
145- if err != nil {
146- return fmt.Errorf("failed to read migration %s: %w", filename, err)
147- }
148-
149- _, err = db.Exec(string(content))
150- if err != nil {
151- return fmt.Errorf("failed to execute migration %s: %w", filename, err)
152- }
153- }
154-
155- return nil
18+ pgtest.Main(m)
15619 }
15720
15821 func cleanupTestData(t *testing.T) {
15922 t.Helper()
160- testDB.ClearPopularPostsCache()
161- tables := []string{
162- "access_logs", "tuns_event_logs", "analytics_visits",
163- "feed_items", "post_aliases", "post_tags", "posts",
164- "projects", "feature_flags", "payment_history", "tokens",
165- "public_keys", "pipe_monitors", "app_users",
166- }
167- for _, table := range tables {
168- _, err := testDB.Db.Exec(fmt.Sprintf("DELETE FROM %s", table))
169- if err != nil {
170- t.Fatalf("failed to clean up %s: %v", table, err)
171- }
23+ if testDB == nil {
24+ testDB = NewDB(pgtest.URL(t), slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})))
17225 }
26+ pgtest.Clean(t, testDB.Db)
17327 }
17428
17529 func mustInsertPost(t *testing.T, post *db.Post) *db.Post {
17630 t.Helper()
177- now := time.Now()
178- if post.UpdatedAt == nil {
179- post.UpdatedAt = &now
180- }
181- if post.PublishAt == nil {
182- post.PublishAt = &now
183- }
184- created, err := testDB.InsertPost(post)
185- if err != nil {
186- t.Fatalf("InsertPost failed: %v", err)
187- }
188- return created
31+ return pgtest.InsertPost(t, testDB, post)
18932 }
19033
191-// ============ User Management Tests ============
192-
19334 func TestRegisterUser_Success(t *testing.T) {
19435 cleanupTestData(t)
19536
......@@ -842,141 +683,6 @@ func TestFindAllPostsByUser(t *testing.T) {
842683 }
843684 }
844685
845-func TestFindUsersWithPost(t *testing.T) {
846- cleanupTestData(t)
847-
848- user, _ := testDB.RegisterUser("feedspostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedspostowner", "comment", "")
849-
850- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
851-
852- users, err := testDB.FindUsersWithPost("feeds")
853- if err != nil {
854- t.Fatalf("FindUsersWithPost failed: %v", err)
855- }
856- if len(users) != 1 {
857- t.Errorf("expected 1 user, got %d", len(users))
858- }
859-}
860-
861-func TestFindExpiredPosts(t *testing.T) {
862- cleanupTestData(t)
863-
864- user, _ := testDB.RegisterUser("expiredpostowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI expiredpostowner", "comment", "")
865-
866- expired := time.Now().Add(-24 * time.Hour)
867- _ = mustInsertPost(t, &db.Post{
868- UserID: user.ID,
869- Filename: "expired.txt",
870- Slug: "expired",
871- Title: "Expired",
872- Space: "pastes",
873- ExpiresAt: &expired,
874- })
875-
876- posts, err := testDB.FindExpiredPosts("pastes")
877- if err != nil {
878- t.Fatalf("FindExpiredPosts failed: %v", err)
879- }
880- if len(posts) != 1 {
881- t.Errorf("expected 1 expired post, got %d", len(posts))
882- }
883-}
884-
885-func TestFindPopularPosts(t *testing.T) {
886- cleanupTestData(t)
887-
888- user, _ := testDB.RegisterUser("feedowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedowner", "comment", "")
889-
890- now := time.Now()
891- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost.md", Slug: "feedpost", Title: "Feed Post", Space: "prose", PublishAt: &now})
892-
893- // Before having feature flag, should not appear
894- posts, err := testDB.FindPopularPosts()
895- if err != nil {
896- t.Fatalf("FindPopularPosts failed: %v", err)
897- }
898- if len(posts) != 0 {
899- t.Errorf("expected 0 posts without feature flag, got %d", len(posts))
900- }
901-
902- // Add prose feature flag
903- _, err = testDB.InsertFeature(user.ID, "prose", time.Now().Add(time.Hour*24))
904- if err != nil {
905- t.Fatalf("InsertFeature failed: %v", err)
906- }
907- testDB.ClearPopularPostsCache()
908-
909- posts, err = testDB.FindPopularPosts()
910- if err != nil {
911- t.Fatalf("FindPopularPosts failed: %v", err)
912- }
913- if len(posts) != 1 {
914- t.Fatalf("expected 1 post in popular posts, got %d", len(posts))
915- }
916- if posts[0].Score != 0 {
917- t.Errorf("expected score 0, got %d", posts[0].Score)
918- }
919-
920- // Add visits
921- _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.1", Status: 200})
922- _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.2", Status: 200})
923- _ = testDB.InsertVisit(&db.AnalyticsVisits{UserID: user.ID, PostID: post.ID, IpAddress: "192.168.1.1", Status: 200}) // duplicate IP
924-
925- // Before clearing cache, should return cached result (score 0)
926- cachedPosts, err := testDB.FindPopularPosts()
927- if err != nil {
928- t.Fatalf("FindPopularPosts failed: %v", err)
929- }
930- if cachedPosts[0].Score != 0 {
931- t.Errorf("expected cached score 0, got %d", cachedPosts[0].Score)
932- }
933-
934- // After clearing cache, should re-query and return updated score 2
935- testDB.ClearPopularPostsCache()
936- posts, err = testDB.FindPopularPosts()
937- if err != nil {
938- t.Fatalf("FindPopularPosts failed: %v", err)
939- }
940- if len(posts) != 1 {
941- t.Fatalf("expected 1 post, got %d", len(posts))
942- }
943- if posts[0].Score != 2 {
944- t.Errorf("expected score 2 (unique visitors), got %d", posts[0].Score)
945- }
946-
947- // Verify max 3 posts per user
948- testDB.ClearPopularPostsCache()
949- post2Time := now.Add(-1 * time.Hour)
950- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost2.md", Slug: "feedpost2", Title: "Feed Post 2", Space: "prose", PublishAt: &post2Time})
951- post3Time := now.Add(-2 * time.Hour)
952- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost3.md", Slug: "feedpost3", Title: "Feed Post 3", Space: "prose", PublishAt: &post3Time})
953- post4Time := now.Add(-3 * time.Hour)
954- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedpost4.md", Slug: "feedpost4", Title: "Feed Post 4", Space: "prose", PublishAt: &post4Time})
955-
956- posts, err = testDB.FindPopularPosts()
957- if err != nil {
958- t.Fatalf("FindPopularPosts failed: %v", err)
959- }
960- if len(posts) != 3 {
961- t.Errorf("expected max 3 posts for single user, got %d", len(posts))
962- }
963-
964- // Post older than 30 days should be excluded
965- testDB.ClearPopularPostsCache()
966- oldTime := now.Add(-31 * 24 * time.Hour)
967- _ = mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "oldpost.md", Slug: "oldpost", Title: "Old Post", Space: "prose", PublishAt: &oldTime})
968-
969- posts, err = testDB.FindPopularPosts()
970- if err != nil {
971- t.Fatalf("FindPopularPosts failed: %v", err)
972- }
973- if len(posts) != 3 {
974- t.Errorf("expected still 3 posts (old post excluded), got %d", len(posts))
975- }
976-}
977-
978-// ============ Tags Tests ============
979-
980686 func TestReplaceTagsByPost(t *testing.T) {
981687 cleanupTestData(t)
982688
......@@ -994,62 +700,6 @@ func TestReplaceTagsByPost(t *testing.T) {
994700 }
995701 }
996702
997-func TestFindUserPostsByTag(t *testing.T) {
998- cleanupTestData(t)
999-
1000- user, _ := testDB.RegisterUser("usertagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI usertagowner", "comment", "")
1001- now := time.Now()
1002- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "usertag.md", Slug: "usertag", Title: "User Tag", Space: "prose", PublishAt: &now})
1003- _ = testDB.ReplaceTagsByPost([]string{"mytag"}, post.ID)
1004-
1005- pager := &db.Pager{Num: 10, Page: 0}
1006- result, err := testDB.FindUserPostsByTag(pager, "mytag", user.ID, "prose")
1007- if err != nil {
1008- t.Fatalf("FindUserPostsByTag failed: %v", err)
1009- }
1010- if len(result.Data) != 1 {
1011- t.Errorf("expected 1 post with tag, got %d", len(result.Data))
1012- }
1013-}
1014-
1015-func TestFindPostsByTag(t *testing.T) {
1016- cleanupTestData(t)
1017-
1018- user, _ := testDB.RegisterUser("tagsearchowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI tagsearchowner", "comment", "")
1019- now := time.Now()
1020- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "tagsearch.md", Slug: "tagsearch", Title: "Tag Search", Space: "prose", PublishAt: &now})
1021- _ = testDB.ReplaceTagsByPost([]string{"searchtag"}, post.ID)
1022-
1023- pager := &db.Pager{Num: 10, Page: 0}
1024- result, err := testDB.FindPostsByTag(pager, "searchtag", "prose")
1025- if err != nil {
1026- t.Fatalf("FindPostsByTag failed: %v", err)
1027- }
1028- if len(result.Data) != 1 {
1029- t.Errorf("expected 1 post with tag, got %d", len(result.Data))
1030- }
1031-}
1032-
1033-func TestFindPopularTags(t *testing.T) {
1034- cleanupTestData(t)
1035-
1036- user, _ := testDB.RegisterUser("populartagowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI populartagowner", "comment", "")
1037- post1 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop1.md", Slug: "pop1", Title: "Pop 1", Space: "prose"})
1038- post2 := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "pop2.md", Slug: "pop2", Title: "Pop 2", Space: "prose"})
1039- _ = testDB.ReplaceTagsByPost([]string{"popular"}, post1.ID)
1040- _ = testDB.ReplaceTagsByPost([]string{"popular"}, post2.ID)
1041-
1042- tags, err := testDB.FindPopularTags("prose")
1043- if err != nil {
1044- t.Fatalf("FindPopularTags failed: %v", err)
1045- }
1046- if len(tags) < 1 {
1047- t.Errorf("expected at least 1 popular tag, got %d", len(tags))
1048- }
1049-}
1050-
1051-// ============ Aliases Tests ============
1052-
1053703 func TestReplaceAliasesByPost(t *testing.T) {
1054704 cleanupTestData(t)
1055705
......@@ -1231,21 +881,21 @@ func TestFindFeaturesByUser(t *testing.T) {
1231881 }
1232882 }
1233883
1234-func TestHasFeatureByUser(t *testing.T) {
884+func TestHasFeature(t *testing.T) {
1235885 cleanupTestData(t)
1236886
1237887 user, _ := testDB.RegisterUser("hasfeatureowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI hasfeatureowner", "comment", "")
1238888 expiresAt := time.Now().Add(365 * 24 * time.Hour)
1239889 _, _ = testDB.InsertFeature(user.ID, "plus", expiresAt)
1240890
1241- has := testDB.HasFeatureByUser(user.ID, "plus")
891+ has := db.HasFeature(testDB, user.ID, "plus")
1242892 if !has {
1243- t.Error("expected HasFeatureByUser to return true")
893+ t.Error("expected HasFeature to return true")
1244894 }
1245895
1246- hasNot := testDB.HasFeatureByUser(user.ID, "nonexistent")
896+ hasNot := db.HasFeature(testDB, user.ID, "nonexistent")
1247897 if hasNot {
1248- t.Error("expected HasFeatureByUser to return false for nonexistent feature")
898+ t.Error("expected HasFeature to return false for nonexistent feature")
1249899 }
1250900 }
1251901
......@@ -1300,47 +950,6 @@ func TestAddFeatureUser(t *testing.T) {
1300950 }
1301951 }
1302952
1303-// ============ Feed Items Tests ============
1304-
1305-func TestInsertFeedItems(t *testing.T) {
1306- cleanupTestData(t)
1307-
1308- user, _ := testDB.RegisterUser("feeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feeditemsowner", "comment", "")
1309- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feed.txt", Slug: "feed", Title: "Feed", Space: "feeds"})
1310-
1311- items := []*db.FeedItem{
1312- {PostID: post.ID, GUID: "guid-1", Data: db.FeedItemData{Title: "Item 1", Link: "http://example.com/1"}},
1313- {PostID: post.ID, GUID: "guid-2", Data: db.FeedItemData{Title: "Item 2", Link: "http://example.com/2"}},
1314- }
1315-
1316- err := testDB.InsertFeedItems(post.ID, items)
1317- if err != nil {
1318- t.Fatalf("InsertFeedItems failed: %v", err)
1319- }
1320-}
1321-
1322-func TestFindFeedItemsByPostID(t *testing.T) {
1323- cleanupTestData(t)
1324-
1325- user, _ := testDB.RegisterUser("findfeeditemsowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI findfeeditemsowner", "comment", "")
1326- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "findfeed.txt", Slug: "findfeed", Title: "Find Feed", Space: "feeds"})
1327-
1328- items := []*db.FeedItem{
1329- {PostID: post.ID, GUID: "find-guid-1", Data: db.FeedItemData{Title: "Find Item 1"}},
1330- }
1331- _ = testDB.InsertFeedItems(post.ID, items)
1332-
1333- found, err := testDB.FindFeedItemsByPostID(post.ID)
1334- if err != nil {
1335- t.Fatalf("FindFeedItemsByPostID failed: %v", err)
1336- }
1337- if len(found) != 1 {
1338- t.Errorf("expected 1 feed item, got %d", len(found))
1339- }
1340-}
1341-
1342-// ============ Projects Tests ============
1343-
1344953 func TestUpsertProject(t *testing.T) {
1345954 cleanupTestData(t)
1346955
......@@ -1623,40 +1232,6 @@ func TestProjectAcl_JSONBRoundtrip(t *testing.T) {
16231232 _ = project
16241233 }
16251234
1626-func TestFeedItemData_JSONBRoundtrip(t *testing.T) {
1627- cleanupTestData(t)
1628-
1629- user, _ := testDB.RegisterUser("feedjsonbowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI feedjsonbowner", "comment", "")
1630- post := mustInsertPost(t, &db.Post{UserID: user.ID, Filename: "feedjsonb.txt", Slug: "feedjsonb", Title: "Feed JSONB", Space: "feeds"})
1631-
1632- now := time.Now().Truncate(time.Second)
1633- items := []*db.FeedItem{
1634- {
1635- PostID: post.ID,
1636- GUID: "jsonb-guid",
1637- Data: db.FeedItemData{
1638- Title: "JSONB Item",
1639- Description: "Description",
1640- Content: "Content",
1641- Link: "http://example.com",
1642- PublishedAt: &now,
1643- },
1644- },
1645- }
1646- _ = testDB.InsertFeedItems(post.ID, items)
1647-
1648- found, err := testDB.FindFeedItemsByPostID(post.ID)
1649- if err != nil {
1650- t.Fatalf("FindFeedItemsByPostID failed: %v", err)
1651- }
1652- if len(found) != 1 {
1653- t.Fatalf("expected 1 item, got %d", len(found))
1654- }
1655- if found[0].Data.Title != "JSONB Item" {
1656- t.Errorf("expected title 'JSONB Item', got '%s'", found[0].Data.Title)
1657- }
1658-}
1659-
16601235 func TestFeatureFlagData_JSONBRoundtrip(t *testing.T) {
16611236 cleanupTestData(t)
16621237
......@@ -1699,162 +1274,6 @@ func TestPaymentHistoryData_JSONBRoundtrip(t *testing.T) {
16991274 }
17001275 }
17011276
1702-// ============ Pipe Monitor Tests ============
1703-
1704-func TestUpsertPipeMonitor(t *testing.T) {
1705- cleanupTestData(t)
1706-
1707- user, _ := testDB.RegisterUser("pipemonitorowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonitorowner", "comment", "")
1708-
1709- winEnd := time.Now().Add(time.Hour)
1710- err := testDB.UpsertPipeMonitor(user.ID, "test-topic", 5*time.Minute, &winEnd)
1711- if err != nil {
1712- t.Fatalf("UpsertPipeMonitor failed: %v", err)
1713- }
1714-
1715- monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "test-topic")
1716- if err != nil {
1717- t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
1718- }
1719- if monitor.Topic != "test-topic" {
1720- t.Errorf("expected topic 'test-topic', got '%s'", monitor.Topic)
1721- }
1722- if monitor.WindowDur != 5*time.Minute {
1723- t.Errorf("expected window_dur 5m, got %v", monitor.WindowDur)
1724- }
1725-}
1726-
1727-func TestUpsertPipeMonitor_Update(t *testing.T) {
1728- cleanupTestData(t)
1729-
1730- user, _ := testDB.RegisterUser("pipeupdateowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipeupdateowner", "comment", "")
1731-
1732- winEnd1 := time.Now().Add(time.Hour)
1733- err := testDB.UpsertPipeMonitor(user.ID, "update-topic", 5*time.Minute, &winEnd1)
1734- if err != nil {
1735- t.Fatalf("first UpsertPipeMonitor failed: %v", err)
1736- }
1737-
1738- winEnd2 := time.Now().Add(2 * time.Hour)
1739- err = testDB.UpsertPipeMonitor(user.ID, "update-topic", 10*time.Minute, &winEnd2)
1740- if err != nil {
1741- t.Fatalf("second UpsertPipeMonitor failed: %v", err)
1742- }
1743-
1744- monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "update-topic")
1745- if err != nil {
1746- t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
1747- }
1748- if monitor.WindowDur != 10*time.Minute {
1749- t.Errorf("expected window_dur 10m after update, got %v", monitor.WindowDur)
1750- }
1751-}
1752-
1753-func TestUpdatePipeMonitorLastPing(t *testing.T) {
1754- cleanupTestData(t)
1755-
1756- user, _ := testDB.RegisterUser("pipepingowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipepingowner", "comment", "")
1757-
1758- winEnd := time.Now().Add(time.Hour)
1759- err := testDB.UpsertPipeMonitor(user.ID, "ping-topic", 5*time.Minute, &winEnd)
1760- if err != nil {
1761- t.Fatalf("UpsertPipeMonitor failed: %v", err)
1762- }
1763-
1764- lastPing := time.Now()
1765- err = testDB.UpdatePipeMonitorLastPing(user.ID, "ping-topic", &lastPing)
1766- if err != nil {
1767- t.Fatalf("UpdatePipeMonitorLastPing failed: %v", err)
1768- }
1769-
1770- monitor, err := testDB.FindPipeMonitorByTopic(user.ID, "ping-topic")
1771- if err != nil {
1772- t.Fatalf("FindPipeMonitorByTopic failed: %v", err)
1773- }
1774- if monitor.LastPing == nil {
1775- t.Error("expected last_ping to be set, got nil")
1776- }
1777-}
1778-
1779-func TestRemovePipeMonitor(t *testing.T) {
1780- cleanupTestData(t)
1781-
1782- user, _ := testDB.RegisterUser("piperemoveowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI piperemoveowner", "comment", "")
1783-
1784- winEnd := time.Now().Add(time.Hour)
1785- err := testDB.UpsertPipeMonitor(user.ID, "remove-topic", 5*time.Minute, &winEnd)
1786- if err != nil {
1787- t.Fatalf("UpsertPipeMonitor failed: %v", err)
1788- }
1789-
1790- err = testDB.RemovePipeMonitor(user.ID, "remove-topic")
1791- if err != nil {
1792- t.Fatalf("RemovePipeMonitor failed: %v", err)
1793- }
1794-
1795- _, err = testDB.FindPipeMonitorByTopic(user.ID, "remove-topic")
1796- if err == nil {
1797- t.Error("expected error finding removed monitor, got nil")
1798- }
1799-}
1800-
1801-func TestFindPipeMonitorByTopic_NotFound(t *testing.T) {
1802- cleanupTestData(t)
1803-
1804- user, _ := testDB.RegisterUser("pipenotfoundowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenotfoundowner", "comment", "")
1805-
1806- _, err := testDB.FindPipeMonitorByTopic(user.ID, "nonexistent-topic")
1807- if err == nil {
1808- t.Error("expected error for nonexistent monitor, got nil")
1809- }
1810-}
1811-
1812-func TestFindPipeMonitorsByUser(t *testing.T) {
1813- cleanupTestData(t)
1814-
1815- user, _ := testDB.RegisterUser("pipemonlistowner", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipemonlistowner", "comment", "")
1816-
1817- winEnd := time.Now().Add(time.Hour)
1818- _ = testDB.UpsertPipeMonitor(user.ID, "service-a", 5*time.Minute, &winEnd)
1819- _ = testDB.UpsertPipeMonitor(user.ID, "service-b", 10*time.Minute, &winEnd)
1820- _ = testDB.UpsertPipeMonitor(user.ID, "service-c", 1*time.Hour, &winEnd)
1821-
1822- monitors, err := testDB.FindPipeMonitorsByUser(user.ID)
1823- if err != nil {
1824- t.Fatalf("FindPipeMonitorsByUser failed: %v", err)
1825- }
1826-
1827- if len(monitors) != 3 {
1828- t.Errorf("expected 3 monitors, got %d", len(monitors))
1829- }
1830-
1831- // Should be ordered by topic
1832- if monitors[0].Topic != "service-a" {
1833- t.Errorf("expected first topic 'service-a', got %s", monitors[0].Topic)
1834- }
1835- if monitors[1].Topic != "service-b" {
1836- t.Errorf("expected second topic 'service-b', got %s", monitors[1].Topic)
1837- }
1838- if monitors[2].Topic != "service-c" {
1839- t.Errorf("expected third topic 'service-c', got %s", monitors[2].Topic)
1840- }
1841-}
1842-
1843-func TestFindPipeMonitorsByUser_Empty(t *testing.T) {
1844- cleanupTestData(t)
1845-
1846- user, _ := testDB.RegisterUser("pipenomonitors", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI pipenomonitors", "comment", "")
1847-
1848- monitors, err := testDB.FindPipeMonitorsByUser(user.ID)
1849- if err != nil {
1850- t.Fatalf("FindPipeMonitorsByUser failed: %v", err)
1851- }
1852-
1853- if len(monitors) != 0 {
1854- t.Errorf("expected 0 monitors for user with none, got %d", len(monitors))
1855- }
1856-}
1857-
18581277 func TestInviteUser_PlusMember(t *testing.T) {
18591278 cleanupTestData(t)
18601279
......@@ -1867,7 +1286,7 @@ func TestInviteUser_PlusMember(t *testing.T) {
18671286 }
18681287
18691288 for _, name := range []string{"prose", "pgs", "pastes"} {
1870- if !testDB.HasFeatureByUser(to.ID, name) {
1289+ if !db.HasFeature(testDB, to.ID, name) {
18711290 t.Errorf("expected invitee to have %s", name)
18721291 }
18731292 }
......@@ -1895,7 +1314,7 @@ func TestInviteUser_InvitedUserCanInvite(t *testing.T) {
18951314 if err := testDB.InviteUser(mid.ID, last.ID); err != nil {
18961315 t.Fatalf("InviteUser from invited user failed: %v", err)
18971316 }
1898- if !testDB.HasFeatureByUser(last.ID, "pastes") {
1317+ if !db.HasFeature(testDB, last.ID, "pastes") {
18991318 t.Error("expected second invitee to have pastes")
19001319 }
19011320 }
......@@ -1909,7 +1328,7 @@ func TestInviteUser_Unauthorized(t *testing.T) {
19091328 if err := testDB.InviteUser(from.ID, to.ID); err == nil {
19101329 t.Fatal("expected error for user without pico+ or invite")
19111330 }
1912- if testDB.HasFeatureByUser(to.ID, "prose") {
1331+ if db.HasFeature(testDB, to.ID, "prose") {
19131332 t.Error("expected invitee not to have prose")
19141333 }
19151334 }
+118, -0
......@@ -0,0 +1,118 @@
1+package postgres
2+
3+import (
4+ "slices"
5+
6+ "github.com/jmoiron/sqlx"
7+)
8+
9+func (me *PsqlDB) removeTagsByPost(tx *sqlx.Tx, postID string) error {
10+ _, err := tx.Exec(`DELETE FROM post_tags WHERE post_id = $1`, postID)
11+ return err
12+}
13+
14+func (me *PsqlDB) insertTagsByPost(tx *sqlx.Tx, tags []string, postID string) ([]string, error) {
15+ ids := make([]string, 0)
16+ for _, tag := range tags {
17+ id := ""
18+ err := tx.QueryRow(`INSERT INTO post_tags (post_id, name) VALUES($1, $2) RETURNING id;`, postID, tag).Scan(&id)
19+ if err != nil {
20+ return nil, err
21+ }
22+ ids = append(ids, id)
23+ }
24+
25+ return ids, nil
26+}
27+
28+func (me *PsqlDB) ReplaceTagsByPost(tags []string, postID string) error {
29+ tx, err := me.Db.Beginx()
30+ if err != nil {
31+ return err
32+ }
33+ defer func() {
34+ _ = tx.Rollback()
35+ }()
36+
37+ err = me.removeTagsByPost(tx, postID)
38+ if err != nil {
39+ return err
40+ }
41+
42+ _, err = me.insertTagsByPost(tx, tags, postID)
43+ if err != nil {
44+ return err
45+ }
46+
47+ err = tx.Commit()
48+ return err
49+}
50+
51+func (me *PsqlDB) removeAliasesByPost(tx *sqlx.Tx, postID string) error {
52+ _, err := tx.Exec(`DELETE FROM post_aliases WHERE post_id = $1`, postID)
53+ return err
54+}
55+
56+func (me *PsqlDB) insertAliasesByPost(tx *sqlx.Tx, aliases []string, postID string) ([]string, error) {
57+ // hardcoded
58+ denyList := []string{
59+ "rss",
60+ "rss.xml",
61+ "rss.atom",
62+ "atom.xml",
63+ "feed.xml",
64+ "smol.css",
65+ "main.css",
66+ "syntax.css",
67+ "card.png",
68+ "favicon-16x16.png",
69+ "favicon-32x32.png",
70+ "apple-touch-icon.png",
71+ "favicon.ico",
72+ "robots.txt",
73+ "atom",
74+ "blog/index.xml",
75+ }
76+
77+ ids := make([]string, 0)
78+ for _, alias := range aliases {
79+ if slices.Contains(denyList, alias) {
80+ me.Logger.Info(
81+ "name is in the deny list for aliases because it conflicts with a static route, skipping",
82+ "alias", alias,
83+ )
84+ continue
85+ }
86+ id := ""
87+ err := tx.QueryRow(`INSERT INTO post_aliases (post_id, slug) VALUES($1, $2) RETURNING id;`, postID, alias).Scan(&id)
88+ if err != nil {
89+ return nil, err
90+ }
91+ ids = append(ids, id)
92+ }
93+
94+ return ids, nil
95+}
96+
97+func (me *PsqlDB) ReplaceAliasesByPost(aliases []string, postID string) error {
98+ tx, err := me.Db.Beginx()
99+ if err != nil {
100+ return err
101+ }
102+ defer func() {
103+ _ = tx.Rollback()
104+ }()
105+
106+ err = me.removeAliasesByPost(tx, postID)
107+ if err != nil {
108+ return err
109+ }
110+
111+ _, err = me.insertAliasesByPost(tx, aliases, postID)
112+ if err != nil {
113+ return err
114+ }
115+
116+ err = tx.Commit()
117+ return err
118+}
+60, -0
......@@ -0,0 +1,60 @@
1+package postgres
2+
3+import (
4+ "github.com/picosh/pico/pkg/db"
5+)
6+
7+func (me *PsqlDB) FindUserByToken(token string) (*db.User, error) {
8+ user := &db.User{}
9+ err := me.Db.Get(user, `
10+ SELECT app_users.id, app_users.name, app_users.created_at
11+ FROM app_users
12+ LEFT JOIN tokens ON tokens.user_id = app_users.id
13+ WHERE tokens.token = $1 AND tokens.expires_at > NOW()`, token)
14+ if err != nil {
15+ return nil, err
16+ }
17+ return user, nil
18+}
19+
20+func (me *PsqlDB) InsertToken(userID, name string) (string, error) {
21+ var token string
22+ err := me.Db.QueryRow(`INSERT INTO tokens (user_id, name) VALUES($1, $2) RETURNING token;`, userID, name).Scan(&token)
23+ if err != nil {
24+ return "", err
25+ }
26+ return token, nil
27+}
28+
29+func (me *PsqlDB) UpsertToken(userID, name string) (string, error) {
30+ token, _ := me.findTokenByName(userID, name)
31+ if token != "" {
32+ return token, nil
33+ }
34+
35+ token, err := me.InsertToken(userID, name)
36+ return token, err
37+}
38+
39+func (me *PsqlDB) findTokenByName(userID, name string) (string, error) {
40+ var token string
41+ err := me.Db.QueryRow(`SELECT token FROM tokens WHERE user_id = $1 AND name = $2`, userID, name).Scan(&token)
42+ if err != nil {
43+ return "", err
44+ }
45+ return token, nil
46+}
47+
48+func (me *PsqlDB) RemoveToken(tokenID string) error {
49+ _, err := me.Db.Exec(`DELETE FROM tokens WHERE id = $1`, tokenID)
50+ return err
51+}
52+
53+func (me *PsqlDB) FindTokensByUser(userID string) ([]*db.Token, error) {
54+ var tokens []*db.Token
55+ err := me.Db.Select(&tokens, `SELECT * FROM tokens WHERE user_id = $1`, userID)
56+ if err != nil {
57+ return nil, err
58+ }
59+ return tokens, nil
60+}
+37, -0
......@@ -0,0 +1,37 @@
1+package postgres
2+
3+import (
4+ "github.com/picosh/pico/pkg/db"
5+)
6+
7+func (me *PsqlDB) InsertTunsEventLog(log *db.TunsEventLog) error {
8+ _, err := me.Db.Exec(
9+ `INSERT INTO tuns_event_logs
10+ (user_id, server_id, remote_addr, event_type, tunnel_type, connection_type, tunnel_id)
11+ VALUES
12+ ($1, $2, $3, $4, $5, $6, $7)`,
13+ log.UserId, log.ServerID, log.RemoteAddr, log.EventType, log.TunnelType,
14+ log.ConnectionType, log.TunnelID,
15+ )
16+ return err
17+}
18+
19+func (me *PsqlDB) FindTunsEventLogsByAddr(userID, addr string) ([]*db.TunsEventLog, error) {
20+ var logs []*db.TunsEventLog
21+ err := me.Db.Select(&logs,
22+ `SELECT * FROM tuns_event_logs WHERE user_id=$1 AND tunnel_id=$2 ORDER BY created_at DESC`, userID, addr)
23+ if err != nil {
24+ return nil, err
25+ }
26+ return logs, nil
27+}
28+
29+func (me *PsqlDB) FindTunsEventLogs(userID string) ([]*db.TunsEventLog, error) {
30+ var logs []*db.TunsEventLog
31+ err := me.Db.Select(&logs,
32+ `SELECT * FROM tuns_event_logs WHERE user_id=$1 ORDER BY created_at DESC`, userID)
33+ if err != nil {
34+ return nil, err
35+ }
36+ return logs, nil
37+}
+199, -0
......@@ -0,0 +1,199 @@
1+package postgres
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "slices"
7+ "strings"
8+
9+ "github.com/picosh/pico/pkg/db"
10+)
11+
12+func (me *PsqlDB) shouldBlockSingup(ip string) error {
13+ blocked := &db.BlockSignups{}
14+ err := me.Db.Get(blocked, `SELECT * FROM block_signups WHERE ip = $1`, ip)
15+ // an error in this case means the result is empty (no record found)
16+ if err != nil {
17+ return nil
18+ }
19+ return fmt.Errorf("your IP address has been blocked: %s", blocked.Reason)
20+}
21+
22+func (me *PsqlDB) RegisterUser(username, pubkey, comment, ip string) (*db.User, error) {
23+ lowerName := strings.ToLower(username)
24+ valid, err := me.validateName(lowerName)
25+ if !valid {
26+ return nil, err
27+ }
28+
29+ me.Logger.Info("checking if ip is in block list", "ip", ip, "username", username)
30+ err = me.shouldBlockSingup(ip)
31+ if err != nil {
32+ me.Logger.Warn("user has been blocked from signing up", "ip", ip, "username", username, "err", err)
33+ return nil, err
34+ }
35+
36+ tx, err := me.Db.Beginx()
37+ if err != nil {
38+ return nil, err
39+ }
40+ defer func() {
41+ _ = tx.Rollback()
42+ }()
43+
44+ var id string
45+ err = tx.QueryRow(`INSERT INTO app_users (name) VALUES($1) returning id`, lowerName).Scan(&id)
46+ if err != nil {
47+ return nil, err
48+ }
49+
50+ err = me.insertPublicKeyWithTx(id, pubkey, comment, tx)
51+ if err != nil {
52+ return nil, err
53+ }
54+
55+ err = tx.Commit()
56+ if err != nil {
57+ return nil, err
58+ }
59+
60+ return me.FindUserByKey(username, pubkey)
61+}
62+
63+func (me *PsqlDB) FindUserByKey(username string, key string) (*db.User, error) {
64+ me.Logger.Info("attempting to find user with only public key", "key", key)
65+ pk, err := me.findPublicKeyByKey(key)
66+ if err == nil {
67+ me.Logger.Info("found pubkey, looking for user", "key", key, "userId", pk.UserID)
68+ user, err := me.FindUser(pk.UserID)
69+ if err != nil {
70+ return nil, err
71+ }
72+ user.PublicKey = pk
73+ return user, nil
74+ }
75+
76+ if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
77+ me.Logger.Info("detected multiple users with same public key", "user", username)
78+ user, err := me.findUserForNameAndKey(username, key)
79+ if err != nil {
80+ me.Logger.Info("could not find user by username and public key", "user", username, "key", key)
81+ // this is a little hacky but if we cannot find a user by name and public key
82+ // then we return the multiple keys detected error so the user knows to specify their
83+ // when logging in
84+ return nil, &db.ErrMultiplePublicKeys{}
85+ }
86+ return user, nil
87+ }
88+
89+ return nil, err
90+}
91+
92+func (me *PsqlDB) FindUserByPubkey(key string) (*db.User, error) {
93+ me.Logger.Info("attempting to find user with only public key", "key", key)
94+ pk, err := me.findPublicKeyByKey(key)
95+ if err != nil {
96+ return nil, err
97+ }
98+
99+ me.Logger.Info("found pubkey, looking for user", "key", key, "userId", pk.UserID)
100+ user, err := me.FindUser(pk.UserID)
101+ if err != nil {
102+ return nil, err
103+ }
104+ user.PublicKey = pk
105+ return user, nil
106+}
107+
108+func (me *PsqlDB) FindUser(userID string) (*db.User, error) {
109+ user := &db.User{}
110+ err := me.Db.Get(user, `SELECT id, COALESCE(name, '') as name, created_at FROM app_users WHERE id = $1`, userID)
111+ if err != nil {
112+ return nil, err
113+ }
114+ return user, nil
115+}
116+
117+func (me *PsqlDB) validateName(name string) (bool, error) {
118+ lower := strings.ToLower(name)
119+ if slices.Contains(db.DenyList, lower) {
120+ return false, fmt.Errorf("%s is on deny list: %w", lower, db.ErrNameDenied)
121+ }
122+ v := db.NameValidator.MatchString(lower)
123+ if !v {
124+ return false, fmt.Errorf("%s is invalid: %w", lower, db.ErrNameInvalid)
125+ }
126+ user, _ := me.FindUserByName(lower)
127+ if user == nil {
128+ return true, nil
129+ }
130+ return false, fmt.Errorf("%s already taken: %w", lower, db.ErrNameTaken)
131+}
132+
133+func (me *PsqlDB) FindUserByName(name string) (*db.User, error) {
134+ user := &db.User{}
135+ err := me.Db.Get(user, `SELECT * FROM app_users WHERE name = $1`, strings.ToLower(name))
136+ if err != nil {
137+ return nil, err
138+ }
139+ return user, nil
140+}
141+
142+func (me *PsqlDB) findUserForNameAndKey(name string, key string) (*db.User, error) {
143+ user := &db.User{}
144+ pk := &db.PublicKey{}
145+
146+ r := me.Db.QueryRow(`SELECT app_users.id, app_users.name, app_users.created_at, public_keys.id as pk_id, public_keys.public_key, public_keys.created_at as pk_created_at FROM app_users LEFT JOIN public_keys ON public_keys.user_id = app_users.id WHERE app_users.name = $1 AND public_keys.public_key = $2`, strings.ToLower(name), key)
147+ err := r.Scan(&user.ID, &user.Name, &user.CreatedAt, &pk.ID, &pk.Key, &pk.CreatedAt)
148+ if err != nil {
149+ return nil, err
150+ }
151+
152+ user.PublicKey = pk
153+ return user, nil
154+}
155+
156+func (me *PsqlDB) FindUsers() ([]*db.User, error) {
157+ var users []*db.User
158+ err := me.Db.Select(&users, `SELECT id, COALESCE(name, '') as name, created_at FROM app_users ORDER BY name ASC`)
159+ if err != nil {
160+ return nil, err
161+ }
162+ return users, nil
163+}
164+
165+func (me *PsqlDB) FindUserStats(userID string) (*db.UserStats, error) {
166+ stats := db.UserStats{}
167+ rs, err := me.Db.Queryx(`SELECT cur_space, count(id), min(created_at), max(created_at), max(updated_at) FROM posts WHERE user_id=$1 GROUP BY cur_space`, userID)
168+ if err != nil {
169+ return nil, err
170+ }
171+ defer func() { _ = rs.Close() }()
172+
173+ for rs.Next() {
174+ stat := db.UserServiceStats{}
175+ err := rs.Scan(&stat.Service, &stat.Num, &stat.FirstCreatedAt, &stat.LastestCreatedAt, &stat.LatestUpdatedAt)
176+ if err != nil {
177+ return nil, err
178+ }
179+ switch stat.Service {
180+ case "prose":
181+ stats.Prose = stat
182+ case "pastes":
183+ stats.Pastes = stat
184+ case "feeds":
185+ stats.Feeds = stat
186+ }
187+ }
188+
189+ if rs.Err() != nil {
190+ return nil, rs.Err()
191+ }
192+
193+ pgs, err := me.findPagesStats(userID)
194+ if err != nil {
195+ return nil, err
196+ }
197+ stats.Pages = *pgs
198+ return &stats, err
199+}
+0, -4
......@@ -160,10 +160,6 @@ func (me *StubDB) FindFeaturesByUser(userID string) ([]*db.FeatureFlag, error) {
160160 return []*db.FeatureFlag{}, errNotImpl
161161 }
162162
163-func (me *StubDB) HasFeatureByUser(userID string, feature string) bool {
164- return false
165-}
166-
167163 func (me *StubDB) InsertFeedItems(postID string, items []*db.FeedItem) error {
168164 return errNotImpl
169165 }
+0, -395
......@@ -1,395 +0,0 @@
1-package filehandlers
2-
3-import (
4- "database/sql"
5- "encoding/binary"
6- "errors"
7- "fmt"
8- "io"
9- "net/http"
10- "os"
11- "path/filepath"
12- "strings"
13- "time"
14-
15- "github.com/picosh/pico/pkg/db"
16- "github.com/picosh/pico/pkg/pssh"
17- sendutils "github.com/picosh/pico/pkg/send/utils"
18- "github.com/picosh/pico/pkg/shared"
19-)
20-
21-type PostMetaData struct {
22- *db.Post
23- Cur *db.Post
24- Tags []string
25- User *db.User
26- FileEntry *sendutils.FileEntry
27- Aliases []string
28-}
29-
30-type ScpFileHooks interface {
31- FileValidate(s *pssh.SSHServerConnSession, data *PostMetaData) (bool, error)
32- FileMeta(s *pssh.SSHServerConnSession, data *PostMetaData) error
33-}
34-
35-type ScpUploadHandler struct {
36- DBPool db.DB
37- Cfg *shared.ConfigSite
38- Hooks ScpFileHooks
39-}
40-
41-func NewScpPostHandler(dbpool db.DB, cfg *shared.ConfigSite, hooks ScpFileHooks) *ScpUploadHandler {
42- return &ScpUploadHandler{
43- DBPool: dbpool,
44- Cfg: cfg,
45- Hooks: hooks,
46- }
47-}
48-
49-func (r *ScpUploadHandler) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
50- return BaseList(s, fpath, isDir, recursive, []string{r.Cfg.Space}, r.DBPool)
51-}
52-
53-func (h *ScpUploadHandler) Read(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
54- logger := pssh.GetLogger(s)
55- user := pssh.GetUser(s)
56-
57- if user == nil {
58- err := fmt.Errorf("could not get user from ctx")
59- logger.Error("error getting user from ctx", "err", err)
60- return nil, nil, err
61- }
62-
63- cleanFilename := filepath.Base(entry.Filepath)
64-
65- if cleanFilename == "" || cleanFilename == "." {
66- return nil, nil, os.ErrNotExist
67- }
68-
69- post, err := h.DBPool.FindPostWithFilename(cleanFilename, user.ID, h.Cfg.Space)
70- if err != nil {
71- return nil, nil, err
72- }
73-
74- fileInfo := &sendutils.VirtualFile{
75- FName: post.Filename,
76- FIsDir: false,
77- FSize: int64(post.FileSize),
78- FModTime: *post.UpdatedAt,
79- }
80-
81- reader := sendutils.NopReadAndReaderAtCloser(strings.NewReader(post.Text))
82-
83- return fileInfo, reader, nil
84-}
85-
86-// ValidateWrite runs the hooks' validation on the file before its
87-// contents arrive.
88-func (h *ScpUploadHandler) ValidateWrite(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
89- user := pssh.GetUser(s)
90- if user == nil {
91- return fmt.Errorf("could not get user from ctx")
92- }
93- if entry.Mode.IsDir() {
94- return fmt.Errorf("file entry is directory, but only files are supported: %s", entry.Filepath)
95- }
96- filename := filepath.Base(entry.Filepath)
97- metadata := PostMetaData{
98- Post: &db.Post{Filename: filename, Slug: shared.SanitizeFileExt(filename)},
99- User: user,
100- FileEntry: entry,
101- }
102- if valid, err := h.Hooks.FileValidate(s, &metadata); !valid {
103- return err
104- }
105- return nil
106-}
107-
108-func (h *ScpUploadHandler) Write(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) (string, error) {
109- logger := pssh.GetLogger(s)
110- user := pssh.GetUser(s)
111-
112- if user == nil {
113- err := fmt.Errorf("could not get user from ctx")
114- logger.Error("error getting user from ctx", "err", err)
115- return "", err
116- }
117-
118- userID := user.ID
119- filename := filepath.Base(entry.Filepath)
120-
121- logger = logger.With(
122- "filename", filename,
123- )
124-
125- if entry.Mode.IsDir() {
126- return "", fmt.Errorf("file entry is directory, but only files are supported: %s", filename)
127- }
128-
129- origText, err := io.ReadAll(entry.Reader)
130- if err != nil {
131- return "", fmt.Errorf("could not read file (%s): %w", filename, err)
132- }
133-
134- nextPost := newPost(filename, origText)
135- metadata := PostMetaData{
136- Post: &nextPost,
137- User: user,
138- FileEntry: entry,
139- }
140-
141- valid, err := h.Hooks.FileValidate(s, &metadata)
142- if !valid {
143- logger.Error("file failed validation", "err", err.Error())
144- return "", err
145- }
146-
147- post, err := h.DBPool.FindPostWithFilename(metadata.Filename, metadata.User.ID, h.Cfg.Space)
148- if err != nil {
149- logger.Error("unable to load post, continuing", "err", err.Error())
150- }
151-
152- if post != nil {
153- metadata.Cur = post
154- metadata.Data = post.Data
155- metadata.PublishAt = post.PublishAt
156- }
157-
158- err = h.Hooks.FileMeta(s, &metadata)
159- if err != nil {
160- logger.Error("file could not load meta", "err", err.Error())
161- return "", err
162- }
163-
164- modTime := time.Now()
165-
166- if entry.Mtime > 0 {
167- modTime = time.Unix(entry.Mtime, 0)
168- }
169-
170- if post == nil {
171- logger.Info("file not found, adding record")
172- insertPost := db.Post{
173- UserID: userID,
174- Space: h.Cfg.Space,
175-
176- Data: metadata.Data,
177- Description: metadata.Description,
178- Filename: metadata.Filename,
179- FileSize: metadata.FileSize,
180- Hidden: metadata.Hidden,
181- MimeType: metadata.MimeType,
182- PublishAt: metadata.PublishAt,
183- Shasum: metadata.Shasum,
184- Slug: metadata.Slug,
185- Text: metadata.Text,
186- Title: metadata.Title,
187- ExpiresAt: metadata.ExpiresAt,
188- UpdatedAt: &modTime,
189- }
190- post, err = h.DBPool.InsertPost(&insertPost)
191- if err != nil {
192- logger.Error("post could not be created", "err", err.Error())
193- return "", fmt.Errorf("error for %s: %v", filename, err)
194- }
195-
196- if len(metadata.Aliases) > 0 {
197- logger.Info(
198- "found post aliases, replacing with old aliases",
199- "aliases",
200- strings.Join(metadata.Aliases, ","),
201- )
202- err = h.DBPool.ReplaceAliasesByPost(metadata.Aliases, post.ID)
203- if err != nil {
204- logger.Error("post could not replace aliases", "err", err.Error())
205- return "", fmt.Errorf("error for %s: %v", filename, err)
206- }
207- }
208-
209- if len(metadata.Tags) > 0 {
210- logger.Info(
211- "found post tags, replacing with old tags",
212- "tags", strings.Join(metadata.Tags, ","),
213- )
214- err = h.DBPool.ReplaceTagsByPost(metadata.Tags, post.ID)
215- if err != nil {
216- logger.Error("post could not replace tags", "err", err.Error())
217- return "", fmt.Errorf("error for %s: %v", filename, err)
218- }
219- }
220- } else {
221- if metadata.Text == post.Text && modTime.Equal(*post.UpdatedAt) {
222- logger.Info("file found, but text is identical, skipping")
223- curl := shared.NewCreateURL(h.Cfg)
224- return h.Cfg.FullPostURL(curl, user.Name, metadata.Slug), nil
225- }
226-
227- logger.Info("file found, updating record")
228-
229- updatePost := db.Post{
230- ID: post.ID,
231-
232- Data: metadata.Data,
233- FileSize: metadata.FileSize,
234- Description: metadata.Description,
235- PublishAt: metadata.PublishAt,
236- Slug: metadata.Slug,
237- Shasum: metadata.Shasum,
238- Text: metadata.Text,
239- Title: metadata.Title,
240- Hidden: metadata.Hidden,
241- ExpiresAt: metadata.ExpiresAt,
242- UpdatedAt: &modTime,
243- }
244- _, err = h.DBPool.UpdatePost(&updatePost)
245- if err != nil {
246- logger.Error("post could not be updated", "err", err.Error())
247- return "", fmt.Errorf("error for %s: %v", filename, err)
248- }
249-
250- logger.Info(
251- "found post tags, replacing with old tags",
252- "tags", strings.Join(metadata.Tags, ","),
253- )
254- err = h.DBPool.ReplaceTagsByPost(metadata.Tags, post.ID)
255- if err != nil {
256- logger.Error("post could not replace tags", "err", err.Error())
257- return "", fmt.Errorf("error for %s: %v", filename, err)
258- }
259-
260- logger.Info(
261- "found post aliases, replacing with old aliases",
262- "aliases", strings.Join(metadata.Aliases, ","),
263- )
264- err = h.DBPool.ReplaceAliasesByPost(metadata.Aliases, post.ID)
265- if err != nil {
266- logger.Error("post could not replace aliases", "err", err.Error())
267- return "", fmt.Errorf("error for %s: %v", filename, err)
268- }
269- }
270-
271- curl := shared.NewCreateURL(h.Cfg)
272- return h.Cfg.FullPostURL(curl, user.Name, metadata.Slug), nil
273-}
274-
275-// newPost is the post a file makes before the hooks fill in the rest.
276-func newPost(filename string, text []byte) db.Post {
277- mimeType := http.DetectContentType(text)
278- // DetectContentType does not detect markdown
279- if filepath.Ext(filename) == ".md" {
280- mimeType = "text/markdown; charset=UTF-8"
281- }
282- now := time.Now()
283- return db.Post{
284- Filename: filename,
285- Slug: shared.SanitizeFileExt(filename),
286- PublishAt: &now,
287- Text: string(text),
288- MimeType: mimeType,
289- FileSize: binary.Size(text),
290- Shasum: shared.Shasum(text),
291- }
292-}
293-
294-// Rename moves a post to a new filename in place, so it keeps its ID,
295-// publish date and settings. A post at the new filename is replaced.
296-func (h *ScpUploadHandler) Rename(s *pssh.SSHServerConnSession, from, to *sendutils.FileEntry) error {
297- user := pssh.GetUser(s)
298- if user == nil {
299- return fmt.Errorf("could not get user from ctx")
300- }
301- fromName, toName := filepath.Base(from.Filepath), filepath.Base(to.Filepath)
302- // A save to either post while it is being renamed starts it over.
303- var err error
304- for range 3 {
305- if err = h.rename(s, user, fromName, toName, to); !errors.Is(err, db.ErrPostChanged) {
306- break
307- }
308- }
309- if err != nil {
310- return err
311- }
312- pssh.GetLogger(s).Info("renamed post", "from", fromName, "to", toName)
313- return nil
314-}
315-
316-func (h *ScpUploadHandler) rename(s *pssh.SSHServerConnSession, user *db.User, fromName, toName string, to *sendutils.FileEntry) error {
317- post, err := h.DBPool.FindPostWithFilename(fromName, user.ID, h.Cfg.Space)
318- if errors.Is(err, sql.ErrNoRows) {
319- return os.ErrNotExist
320- }
321- if err != nil {
322- return err
323- }
324- if fromName == toName {
325- return nil
326- }
327- replaced, err := h.DBPool.FindPostWithFilename(toName, user.ID, h.Cfg.Space)
328- if errors.Is(err, sql.ErrNoRows) {
329- replaced = nil
330- } else if err != nil {
331- return err
332- }
333-
334- next := newPost(toName, []byte(post.Text))
335- metadata := PostMetaData{
336- Post: &next,
337- Cur: post,
338- User: user,
339- FileEntry: to,
340- }
341- if valid, err := h.Hooks.FileValidate(s, &metadata); !valid {
342- return err
343- }
344- metadata.Data = post.Data
345- metadata.PublishAt = post.PublishAt
346- if err := h.Hooks.FileMeta(s, &metadata); err != nil {
347- return err
348- }
349- if other, err := h.DBPool.FindPostWithSlug(metadata.Slug, user.ID, h.Cfg.Space); err == nil &&
350- other.ID != post.ID && (replaced == nil || other.ID != replaced.ID) {
351- return fmt.Errorf("%s: %s already uses its slug (%s): %w", toName, other.Filename, metadata.Slug, os.ErrExist)
352- }
353-
354- metadata.UpdatedAt = post.UpdatedAt
355- // The new name can parse the text differently, as .md and .lxt do.
356- _, err = h.DBPool.RenamePost(post, metadata.Post, replaced, metadata.Tags, metadata.Aliases)
357- if err != nil && !errors.Is(err, db.ErrPostChanged) {
358- return fmt.Errorf("error for %s: %w", toName, err)
359- }
360- return err
361-}
362-
363-func (h *ScpUploadHandler) Delete(s *pssh.SSHServerConnSession, entry *sendutils.FileEntry) error {
364- logger := pssh.GetLogger(s)
365- user := pssh.GetUser(s)
366-
367- if user == nil {
368- err := fmt.Errorf("could not get user from ctx")
369- logger.Error("error getting user from ctx", "err", err)
370- return err
371- }
372-
373- userID := user.ID
374- filename := filepath.Base(entry.Filepath)
375- logger = logger.With(
376- "filename", filename,
377- )
378-
379- post, err := h.DBPool.FindPostWithFilename(filename, userID, h.Cfg.Space)
380- if err != nil {
381- return err
382- }
383-
384- if post == nil {
385- return os.ErrNotExist
386- }
387-
388- err = h.DBPool.RemovePosts([]string{post.ID})
389- logger.Info("removing record")
390- if err != nil {
391- logger.Error("post could not remove", "err", err.Error())
392- return fmt.Errorf("error for %s: %v", filename, err)
393- }
394- return nil
395-}
+0, -71
......@@ -1,71 +0,0 @@
1-package filehandlers
2-
3-import (
4- "context"
5- "errors"
6- "io"
7- "log/slog"
8- "strings"
9- "testing"
10- "testing/iotest"
11-
12- "github.com/picosh/pico/pkg/db"
13- "github.com/picosh/pico/pkg/db/stub"
14- "github.com/picosh/pico/pkg/pssh"
15- sendutils "github.com/picosh/pico/pkg/send/utils"
16- "github.com/picosh/pico/pkg/shared"
17- "golang.org/x/crypto/ssh"
18-)
19-
20-type uploadHooks struct {
21- validated bool
22-}
23-
24-func (h *uploadHooks) FileValidate(*pssh.SSHServerConnSession, *PostMetaData) (bool, error) {
25- h.validated = true
26- return false, errors.New("unexpected validation after a failed read")
27-}
28-
29-func (h *uploadHooks) FileMeta(*pssh.SSHServerConnSession, *PostMetaData) error {
30- return nil
31-}
32-
33-func TestPostUploadReadError(t *testing.T) {
34- checksumErr := errors.New("rsync: file failed verification")
35- tests := []struct {
36- name string
37- text string
38- err error
39- }{
40- {name: "before contents", err: io.ErrClosedPipe},
41- {name: "partial contents", text: "partial upload", err: io.ErrUnexpectedEOF},
42- {name: "failed verification", text: "complete upload", err: checksumErr},
43- }
44- for _, tt := range tests {
45- t.Run(tt.name, func(t *testing.T) {
46- logger := slog.New(slog.DiscardHandler)
47- session := &pssh.SSHServerConnSession{
48- SSHServerConn: &pssh.SSHServerConn{
49- Conn: &ssh.ServerConn{Permissions: &ssh.Permissions{Extensions: map[string]string{}}},
50- Logger: logger,
51- },
52- Ctx: context.Background(),
53- }
54- pssh.SetUser(session, &db.User{ID: "user-1", Name: "tester"})
55- hooks := &uploadHooks{}
56- handler := NewScpPostHandler(stub.NewStubDB(logger), &shared.ConfigSite{Space: "pastes"}, hooks)
57- reader := io.MultiReader(strings.NewReader(tt.text), iotest.ErrReader(tt.err))
58-
59- message, err := handler.Write(session, &sendutils.FileEntry{Filepath: "/test.txt", Reader: reader})
60- if !errors.Is(err, tt.err) {
61- t.Fatalf("error = %v, want %v", err, tt.err)
62- }
63- if message != "" {
64- t.Fatalf("failed upload returned a success message: %q", message)
65- }
66- if hooks.validated {
67- t.Fatal("failed upload reached validation")
68- }
69- })
70- }
71-}
+0, -230
......@@ -1,230 +0,0 @@
1-package filehandlers
2-
3-import (
4- "database/sql"
5- "errors"
6- "fmt"
7- "log/slog"
8- "os"
9- "path/filepath"
10-
11- "github.com/picosh/pico/pkg/db"
12- "github.com/picosh/pico/pkg/pssh"
13- "github.com/picosh/pico/pkg/send/utils"
14- "github.com/picosh/pico/pkg/shared"
15-)
16-
17-type ReadWriteHandler interface {
18- List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error)
19- Write(*pssh.SSHServerConnSession, *utils.FileEntry) (string, error)
20- Read(*pssh.SSHServerConnSession, *utils.FileEntry) (os.FileInfo, utils.ReadAndReaderAtCloser, error)
21- Delete(*pssh.SSHServerConnSession, *utils.FileEntry) error
22-}
23-
24-type FileHandlerRouter struct {
25- FileMap map[string]ReadWriteHandler
26- Cfg *shared.ConfigSite
27- DBPool db.DB
28- Spaces []string
29-}
30-
31-var _ utils.CopyFromClientHandler = &FileHandlerRouter{} // Verify implementation
32-var _ utils.CopyFromClientHandler = (*FileHandlerRouter)(nil) // Verify implementation
33-
34-func NewFileHandlerRouter(cfg *shared.ConfigSite, dbpool db.DB, mapper map[string]ReadWriteHandler) *FileHandlerRouter {
35- return &FileHandlerRouter{
36- Cfg: cfg,
37- DBPool: dbpool,
38- FileMap: mapper,
39- Spaces: []string{cfg.Space},
40- }
41-}
42-
43-func (r *FileHandlerRouter) findHandler(fp string) (ReadWriteHandler, error) {
44- fext := filepath.Ext(fp)
45- handler, ok := r.FileMap[fext]
46- if !ok {
47- hand, hasFallback := r.FileMap["fallback"]
48- if !hasFallback {
49- return nil, fmt.Errorf("no corresponding handler for file extension: %s", fext)
50- }
51- handler = hand
52- }
53- return handler, nil
54-}
55-
56-func (r *FileHandlerRouter) Write(s *pssh.SSHServerConnSession, entry *utils.FileEntry) (string, error) {
57- if entry.Mode.IsDir() {
58- return "", os.ErrInvalid
59- }
60-
61- handler, err := r.findHandler(entry.Filepath)
62- if err != nil {
63- return "", err
64- }
65- return handler.Write(s, entry)
66-}
67-
68-func (r *FileHandlerRouter) ValidateWrite(s *pssh.SSHServerConnSession, entry *utils.FileEntry) error {
69- handler, err := r.findHandler(entry.Filepath)
70- if err != nil {
71- return err
72- }
73- if v, ok := handler.(utils.WriteValidator); ok {
74- return v.ValidateWrite(s, entry)
75- }
76- return nil
77-}
78-
79-func (r *FileHandlerRouter) Delete(s *pssh.SSHServerConnSession, entry *utils.FileEntry) error {
80- handler, err := r.findHandler(entry.Filepath)
81- if err != nil {
82- return err
83- }
84- return handler.Delete(s, entry)
85-}
86-
87-// Rename moves a file in place when one handler serves both names.
88-func (r *FileHandlerRouter) Rename(s *pssh.SSHServerConnSession, from, to *utils.FileEntry) error {
89- handler, err := r.findHandler(from.Filepath)
90- if err != nil {
91- return err
92- }
93- target, err := r.findHandler(to.Filepath)
94- if err != nil {
95- return err
96- }
97- renamer, ok := handler.(utils.FileRenamer)
98- if !ok || handler != target {
99- return errors.ErrUnsupported
100- }
101- return renamer.Rename(s, from, to)
102-}
103-
104-func (r *FileHandlerRouter) Read(s *pssh.SSHServerConnSession, entry *utils.FileEntry) (os.FileInfo, utils.ReadAndReaderAtCloser, error) {
105- handler, err := r.findHandler(entry.Filepath)
106- if err != nil {
107- return nil, nil, err
108- }
109- return handler.Read(s, entry)
110-}
111-
112-func (r *FileHandlerRouter) List(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool) ([]os.FileInfo, error) {
113- files := []os.FileInfo{}
114- // Several extensions can share a handler, and handlers can list the
115- // same entries, such as the root directory.
116- listed := map[ReadWriteHandler]bool{}
117- seen := map[string]bool{}
118- for _, handler := range r.FileMap {
119- if listed[handler] {
120- continue
121- }
122- listed[handler] = true
123-
124- ff, err := handler.List(s, fpath, isDir, recursive)
125- if err != nil {
126- r.GetLogger(s).Error("handler list", "err", err)
127- continue
128- }
129- for _, f := range ff {
130- key := fmt.Sprintf("%s:%v", f.Name(), f.IsDir())
131- if !seen[key] {
132- seen[key] = true
133- files = append(files, f)
134- }
135- }
136- }
137- return files, nil
138-}
139-
140-func (r *FileHandlerRouter) GetLogger(s *pssh.SSHServerConnSession) *slog.Logger {
141- return pssh.GetLogger(s)
142-}
143-
144-func (r *FileHandlerRouter) Validate(s *pssh.SSHServerConnSession) error {
145- logger := pssh.GetLogger(s)
146- user := pssh.GetUser(s)
147-
148- if user == nil {
149- err := fmt.Errorf("could not get user from ctx")
150- logger.Error("error getting user from ctx", "err", err)
151- return err
152- }
153-
154- logger.Info(
155- "attempting to upload files",
156- "user", user.Name,
157- "space", r.Cfg.Space,
158- )
159- return nil
160-}
161-
162-func BaseList(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool, spaces []string, dbpool db.DB) ([]os.FileInfo, error) {
163- var fileList []os.FileInfo
164- logger := pssh.GetLogger(s)
165- user := pssh.GetUser(s)
166-
167- var err error
168-
169- if user == nil {
170- err = fmt.Errorf("could not get user from ctx")
171- logger.Error("error getting user from ctx", "err", err)
172- return fileList, err
173- }
174-
175- cleanFilename := filepath.Base(fpath)
176-
177- var post *db.Post
178- var posts []*db.Post
179-
180- if cleanFilename == "" || cleanFilename == "." || cleanFilename == "/" {
181- name := cleanFilename
182- if name == "" {
183- name = "/"
184- }
185-
186- fileList = append(fileList, &utils.VirtualFile{
187- FName: name,
188- FIsDir: true,
189- })
190-
191- for _, space := range spaces {
192- curPosts, e := dbpool.FindAllPostsByUser(user.ID, space)
193- if e != nil {
194- err = e
195- break
196- }
197- posts = append(posts, curPosts...)
198- }
199- } else {
200- for _, space := range spaces {
201- p, e := dbpool.FindPostWithFilename(cleanFilename, user.ID, space)
202- if e != nil {
203- err = e
204- continue
205- }
206- post = p
207- }
208-
209- posts = append(posts, post)
210- }
211-
212- if err != nil && !errors.Is(err, sql.ErrNoRows) {
213- return nil, err
214- }
215-
216- for _, post := range posts {
217- if post == nil {
218- continue
219- }
220-
221- fileList = append(fileList, &utils.VirtualFile{
222- FName: post.Filename,
223- FIsDir: false,
224- FSize: int64(post.FileSize),
225- FModTime: *post.UpdatedAt,
226- })
227- }
228-
229- return fileList, nil
230-}
+316, -0
......@@ -0,0 +1,316 @@
1+// Package postfs serves an app's posts as files over scp, sftp and sshfs.
2+package postfs
3+
4+import (
5+ "database/sql"
6+ "encoding/binary"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "os"
12+ "path/filepath"
13+ "strings"
14+ "time"
15+
16+ "github.com/picosh/pico/pkg/db"
17+ "github.com/picosh/pico/pkg/pssh"
18+ sendutils "github.com/picosh/pico/pkg/send/utils"
19+ "github.com/picosh/pico/pkg/shared"
20+)
21+
22+// SessionUser is the user an upload session belongs to.
23+func SessionUser(s *pssh.SSHServerConnSession) (*db.User, error) {
24+ user := pssh.GetUser(s)
25+ if user == nil {
26+ err := fmt.Errorf("could not get user from ctx")
27+ pssh.GetLogger(s).Error("error getting user from ctx", "err", err)
28+ return nil, err
29+ }
30+ return user, nil
31+}
32+
33+// NewPost is the post a file makes before an app fills in the rest.
34+func NewPost(filename string, text []byte) *db.Post {
35+ mimeType := http.DetectContentType(text)
36+ // DetectContentType does not detect markdown
37+ if filepath.Ext(filename) == ".md" {
38+ mimeType = "text/markdown; charset=UTF-8"
39+ }
40+ now := time.Now()
41+ return &db.Post{
42+ Filename: filename,
43+ Slug: shared.SanitizeFileExt(filename),
44+ PublishAt: &now,
45+ UpdatedAt: &now,
46+ Text: string(text),
47+ MimeType: mimeType,
48+ FileSize: binary.Size(text),
49+ Shasum: shared.Shasum(text),
50+ }
51+}
52+
53+// PostFromEntry reads an uploaded file into a new post for user in space.
54+func PostFromEntry(entry *sendutils.FileEntry, user *db.User, space string) (*db.Post, error) {
55+ if entry.Mode.IsDir() {
56+ return nil, os.ErrInvalid
57+ }
58+ filename := filepath.Base(entry.Filepath)
59+ text, err := io.ReadAll(entry.Reader)
60+ if err != nil {
61+ return nil, fmt.Errorf("could not read file (%s): %w", filename, err)
62+ }
63+ post := NewPost(filename, text)
64+ post.UserID = user.ID
65+ post.Space = space
66+ if entry.Mtime > 0 {
67+ modTime := time.Unix(entry.Mtime, 0)
68+ post.UpdatedAt = &modTime
69+ }
70+ return post, nil
71+}
72+
73+// LookupPost finds a user's post by filename, or nil when there is none.
74+func LookupPost(dbpool db.PostDB, userID, space, filename string) (*db.Post, error) {
75+ post, err := dbpool.FindPostWithFilename(filename, userID, space)
76+ if errors.Is(err, sql.ErrNoRows) {
77+ return nil, nil
78+ }
79+ return post, err
80+}
81+
82+// SavePost inserts next, or updates cur with it. It reports false without
83+// writing when cur already has next's text and mod time.
84+func SavePost(dbpool db.PostDB, cur, next *db.Post) (*db.Post, bool, error) {
85+ if cur == nil {
86+ post, err := dbpool.InsertPost(&db.Post{
87+ UserID: next.UserID,
88+ Space: next.Space,
89+ Data: next.Data,
90+ Description: next.Description,
91+ Filename: next.Filename,
92+ FileSize: next.FileSize,
93+ Hidden: next.Hidden,
94+ MimeType: next.MimeType,
95+ PublishAt: next.PublishAt,
96+ Shasum: next.Shasum,
97+ Slug: next.Slug,
98+ Text: next.Text,
99+ Title: next.Title,
100+ ExpiresAt: next.ExpiresAt,
101+ UpdatedAt: next.UpdatedAt,
102+ })
103+ if err != nil {
104+ return nil, false, fmt.Errorf("error for %s: %v", next.Filename, err)
105+ }
106+ return post, true, nil
107+ }
108+
109+ if next.Text == cur.Text && next.UpdatedAt.Equal(*cur.UpdatedAt) {
110+ return cur, false, nil
111+ }
112+ post, err := dbpool.UpdatePost(&db.Post{
113+ ID: cur.ID,
114+ Data: next.Data,
115+ FileSize: next.FileSize,
116+ Description: next.Description,
117+ PublishAt: next.PublishAt,
118+ Slug: next.Slug,
119+ Shasum: next.Shasum,
120+ Text: next.Text,
121+ Title: next.Title,
122+ Hidden: next.Hidden,
123+ ExpiresAt: next.ExpiresAt,
124+ UpdatedAt: next.UpdatedAt,
125+ })
126+ if err != nil {
127+ return nil, false, fmt.Errorf("error for %s: %v", next.Filename, err)
128+ }
129+ return post, true, nil
130+}
131+
132+// ReadPost returns a user's post as a file.
133+func ReadPost(s *pssh.SSHServerConnSession, dbpool db.PostDB, space string, entry *sendutils.FileEntry) (os.FileInfo, sendutils.ReadAndReaderAtCloser, error) {
134+ user, err := SessionUser(s)
135+ if err != nil {
136+ return nil, nil, err
137+ }
138+
139+ cleanFilename := filepath.Base(entry.Filepath)
140+ if cleanFilename == "" || cleanFilename == "." {
141+ return nil, nil, os.ErrNotExist
142+ }
143+
144+ post, err := dbpool.FindPostWithFilename(cleanFilename, user.ID, space)
145+ if err != nil {
146+ return nil, nil, err
147+ }
148+
149+ fileInfo := &sendutils.VirtualFile{
150+ FName: post.Filename,
151+ FIsDir: false,
152+ FSize: int64(post.FileSize),
153+ FModTime: *post.UpdatedAt,
154+ }
155+ reader := sendutils.NopReadAndReaderAtCloser(strings.NewReader(post.Text))
156+ return fileInfo, reader, nil
157+}
158+
159+// DeletePost removes a user's post.
160+func DeletePost(s *pssh.SSHServerConnSession, dbpool db.PostDB, space string, entry *sendutils.FileEntry) error {
161+ user, err := SessionUser(s)
162+ if err != nil {
163+ return err
164+ }
165+
166+ filename := filepath.Base(entry.Filepath)
167+ logger := pssh.GetLogger(s).With("filename", filename)
168+
169+ post, err := dbpool.FindPostWithFilename(filename, user.ID, space)
170+ if err != nil {
171+ return err
172+ }
173+ if post == nil {
174+ return os.ErrNotExist
175+ }
176+
177+ logger.Info("removing record")
178+ if err := dbpool.RemovePosts([]string{post.ID}); err != nil {
179+ logger.Error("post could not remove", "err", err.Error())
180+ return fmt.Errorf("error for %s: %v", filename, err)
181+ }
182+ return nil
183+}
184+
185+// RenameFunc makes the post that cur becomes under the filename next
186+// already has, with next.Tags and the returned aliases.
187+type RenameFunc func(cur, next *db.Post) (aliases []string, err error)
188+
189+// RenamePost moves a user's post to a new filename in place, so it keeps its
190+// ID, publish date and settings. A post at the new filename is replaced.
191+func RenamePost(s *pssh.SSHServerConnSession, dbpool db.PostDB, space string, from, to *sendutils.FileEntry, rename RenameFunc) error {
192+ user, err := SessionUser(s)
193+ if err != nil {
194+ return err
195+ }
196+ fromName, toName := filepath.Base(from.Filepath), filepath.Base(to.Filepath)
197+ // A save to either post while it is being renamed starts it over.
198+ for range 3 {
199+ if err = renamePost(dbpool, user, space, fromName, toName, rename); !errors.Is(err, db.ErrPostChanged) {
200+ break
201+ }
202+ }
203+ if err != nil {
204+ return err
205+ }
206+ pssh.GetLogger(s).Info("renamed post", "from", fromName, "to", toName)
207+ return nil
208+}
209+
210+func renamePost(dbpool db.PostDB, user *db.User, space, fromName, toName string, rename RenameFunc) error {
211+ post, err := dbpool.FindPostWithFilename(fromName, user.ID, space)
212+ if errors.Is(err, sql.ErrNoRows) {
213+ return os.ErrNotExist
214+ }
215+ if err != nil {
216+ return err
217+ }
218+ if fromName == toName {
219+ return nil
220+ }
221+ replaced, err := LookupPost(dbpool, user.ID, space, toName)
222+ if err != nil {
223+ return err
224+ }
225+
226+ next := NewPost(toName, []byte(post.Text))
227+ next.UserID, next.Space = post.UserID, post.Space
228+ next.Data, next.PublishAt = post.Data, post.PublishAt
229+ aliases, err := rename(post, next)
230+ if err != nil {
231+ return err
232+ }
233+ if other, err := dbpool.FindPostWithSlug(next.Slug, user.ID, space); err == nil &&
234+ other.ID != post.ID && (replaced == nil || other.ID != replaced.ID) {
235+ return fmt.Errorf("%s: %s already uses its slug (%s): %w", toName, other.Filename, next.Slug, os.ErrExist)
236+ }
237+
238+ next.UpdatedAt = post.UpdatedAt
239+ // The new name can parse the text differently, as .md and .lxt do.
240+ _, err = dbpool.RenamePost(post, next, replaced, next.Tags, aliases)
241+ if err != nil && !errors.Is(err, db.ErrPostChanged) {
242+ return fmt.Errorf("error for %s: %w", toName, err)
243+ }
244+ return err
245+}
246+
247+// ListPosts lists a user's posts in spaces, or the one at fpath.
248+func ListPosts(s *pssh.SSHServerConnSession, fpath string, isDir bool, recursive bool, spaces []string, dbpool db.PostDB) ([]os.FileInfo, error) {
249+ var fileList []os.FileInfo
250+ logger := pssh.GetLogger(s)
251+ user := pssh.GetUser(s)
252+
253+ var err error
254+
255+ if user == nil {
256+ err = fmt.Errorf("could not get user from ctx")
257+ logger.Error("error getting user from ctx", "err", err)
258+ return fileList, err
259+ }
260+
261+ cleanFilename := filepath.Base(fpath)
262+
263+ var post *db.Post
264+ var posts []*db.Post
265+
266+ if cleanFilename == "" || cleanFilename == "." || cleanFilename == "/" {
267+ name := cleanFilename
268+ if name == "" {
269+ name = "/"
270+ }
271+
272+ fileList = append(fileList, &sendutils.VirtualFile{
273+ FName: name,
274+ FIsDir: true,
275+ })
276+
277+ for _, space := range spaces {
278+ curPosts, e := dbpool.FindAllPostsByUser(user.ID, space)
279+ if e != nil {
280+ err = e
281+ break
282+ }
283+ posts = append(posts, curPosts...)
284+ }
285+ } else {
286+ for _, space := range spaces {
287+ p, e := dbpool.FindPostWithFilename(cleanFilename, user.ID, space)
288+ if e != nil {
289+ err = e
290+ continue
291+ }
292+ post = p
293+ }
294+
295+ posts = append(posts, post)
296+ }
297+
298+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
299+ return nil, err
300+ }
301+
302+ for _, post := range posts {
303+ if post == nil {
304+ continue
305+ }
306+
307+ fileList = append(fileList, &sendutils.VirtualFile{
308+ FName: post.Filename,
309+ FIsDir: false,
310+ FSize: int64(post.FileSize),
311+ FModTime: *post.UpdatedAt,
312+ })
313+ }
314+
315+ return fileList, nil
316+}
R pkg/filehandlers/rename_test.go => pkg/postfs/rename_test.go
+25, -78
......@@ -1,4 +1,4 @@
1-package filehandlers
1+package postfs
22
33 import (
44 "context"
......@@ -15,36 +15,30 @@ import (
1515 "github.com/picosh/pico/pkg/db/stub"
1616 "github.com/picosh/pico/pkg/pssh"
1717 sendutils "github.com/picosh/pico/pkg/send/utils"
18- "github.com/picosh/pico/pkg/shared"
1918 "golang.org/x/crypto/ssh"
2019 )
2120
22-// extHooks accepts .md and .lxt files, and tags each with its extension.
23-type extHooks struct {
24- // meta runs while a post's metadata is read, as a save from another
25- // session could.
26- meta func()
27-}
28-
29-func (extHooks) FileValidate(_ *pssh.SSHServerConnSession, data *PostMetaData) (bool, error) {
30- if ext := filepath.Ext(data.Filename); ext != ".md" && ext != ".lxt" {
31- return false, fmt.Errorf("%s: invalid extension", data.Filename)
21+// extRename accepts .md and .lxt files, and tags each with its extension.
22+// meta runs while the renamed post is made, as a save from another session
23+// could.
24+func extRename(meta func()) RenameFunc {
25+ return func(cur, next *db.Post) ([]string, error) {
26+ ext := filepath.Ext(next.Filename)
27+ if ext != ".md" && ext != ".lxt" {
28+ return nil, fmt.Errorf("%s: invalid extension", next.Filename)
29+ }
30+ if meta != nil {
31+ meta()
32+ }
33+ next.Tags = []string{ext}
34+ next.Title = next.Slug
35+ next.Hidden = cur.Hidden
36+ return []string{next.Slug + ext}, nil
3237 }
33- return true, nil
3438 }
3539
36-func (h extHooks) FileMeta(_ *pssh.SSHServerConnSession, data *PostMetaData) error {
37- if h.meta != nil {
38- h.meta()
39- }
40- ext := filepath.Ext(data.Filename)
41- data.Tags = []string{ext}
42- data.Aliases = []string{data.Slug + ext}
43- data.Title = data.Slug
44- if data.Cur != nil {
45- data.Hidden = data.Cur.Hidden
46- }
47- return nil
40+func entry(name string) *sendutils.FileEntry {
41+ return &sendutils.FileEntry{Filepath: "/" + name}
4842 }
4943
5044 func newPostsSession(t *testing.T) (*pssh.SSHServerConnSession, *stub.PostsDB) {
......@@ -64,10 +58,10 @@ func newPostsSession(t *testing.T) (*pssh.SSHServerConnSession, *stub.PostsDB) {
6458
6559 func insertPost(t *testing.T, posts *stub.PostsDB, filename, text string, hidden bool) *db.Post {
6660 t.Helper()
67- p := newPost(filename, []byte(text))
61+ p := NewPost(filename, []byte(text))
6862 publish := time.Date(2020, 1, 2, 0, 0, 0, 0, time.UTC)
6963 p.UserID, p.Space, p.Hidden, p.PublishAt, p.UpdatedAt = "user-1", "prose", hidden, &publish, &publish
70- created, err := posts.InsertPost(&p)
64+ created, err := posts.InsertPost(p)
7165 if err != nil {
7266 t.Fatal(err)
7367 }
......@@ -76,12 +70,11 @@ func insertPost(t *testing.T, posts *stub.PostsDB, filename, text string, hidden
7670
7771 func TestPostRename(t *testing.T) {
7872 session, posts := newPostsSession(t)
79- h := NewScpPostHandler(posts, &shared.ConfigSite{Space: "prose"}, extHooks{})
8073 a := insertPost(t, posts, "a.md", "# a", true)
8174 b := insertPost(t, posts, "b.md", "# b", false)
8275 insertPost(t, posts, "c.md", "# c", false)
8376 rename := func(from, to string) error {
84- return h.Rename(session, &sendutils.FileEntry{Filepath: "/" + from}, &sendutils.FileEntry{Filepath: "/" + to})
77+ return RenamePost(session, posts, "prose", entry(from), entry(to), extRename(nil))
8578 }
8679
8780 if err := rename("a.md", "a.lxt"); err != nil {
......@@ -122,53 +115,10 @@ func TestPostRename(t *testing.T) {
122115 }
123116 }
124117
125-// otherHandler is a handler of a different kind, like prose's images.
126-type otherHandler struct{ ReadWriteHandler }
127-
128-func (otherHandler) List(*pssh.SSHServerConnSession, string, bool, bool) ([]os.FileInfo, error) {
129- return []os.FileInfo{&sendutils.VirtualFile{FName: "/", FIsDir: true}, &sendutils.VirtualFile{FName: "x.png"}}, nil
130-}
131-
132-func TestRouterSharedHandler(t *testing.T) {
133- session, posts := newPostsSession(t)
134- cfg := &shared.ConfigSite{Space: "prose"}
135- postHandler := NewScpPostHandler(posts, cfg, extHooks{})
136- router := NewFileHandlerRouter(cfg, posts, map[string]ReadWriteHandler{
137- ".md": postHandler,
138- ".lxt": postHandler,
139- ".txt": NewScpPostHandler(posts, cfg, extHooks{}),
140- "fallback": otherHandler{},
141- })
142- insertPost(t, posts, "a.md", "# a", false)
143-
144- list, err := router.List(session, "/", true, false)
145- if err != nil {
146- t.Fatal(err)
147- }
148- var names []string
149- for _, f := range list {
150- names = append(names, f.Name())
151- }
152- if len(names) != 3 {
153- t.Errorf("listed %q, want /, a.md and x.png once each", names)
154- }
155-
156- entry := func(name string) *sendutils.FileEntry { return &sendutils.FileEntry{Filepath: "/" + name} }
157- if err := router.Rename(session, entry("a.md"), entry("a.lxt")); err != nil {
158- t.Fatal(err)
159- }
160- for _, to := range []string{"a.txt", "a.png"} {
161- if err := router.Rename(session, entry("a.lxt"), entry(to)); !errors.Is(err, errors.ErrUnsupported) {
162- t.Errorf("rename to %s across handlers: %v", to, err)
163- }
164- }
165-}
166-
167118 func TestPostRenameRelations(t *testing.T) {
168119 session, posts := newPostsSession(t)
169- h := NewScpPostHandler(posts, &shared.ConfigSite{Space: "prose"}, extHooks{})
170120 a := insertPost(t, posts, "a.md", "# a", false)
171- if err := h.Rename(session, &sendutils.FileEntry{Filepath: "/a.md"}, &sendutils.FileEntry{Filepath: "/a.lxt"}); err != nil {
121+ if err := RenamePost(session, posts, "prose", entry("a.md"), entry("a.lxt"), extRename(nil)); err != nil {
172122 t.Fatal(err)
173123 }
174124 got, err := posts.FindPost(a.ID)
......@@ -215,9 +165,7 @@ func TestPostRenameConcurrentSave(t *testing.T) {
215165 t.Fatal(err)
216166 }
217167 }
218- h := NewScpPostHandler(posts, &shared.ConfigSite{Space: "prose"}, extHooks{meta: save})
219-
220- err := h.Rename(session, &sendutils.FileEntry{Filepath: "/a.md"}, &sendutils.FileEntry{Filepath: "/b.md"})
168+ err := RenamePost(session, posts, "prose", entry("a.md"), entry("b.md"), extRename(save))
221169 if !errors.Is(err, tt.want) {
222170 t.Fatalf("rename: %v, want %v", err, tt.want)
223171 }
......@@ -274,8 +222,7 @@ func TestPostRenameMovedDestination(t *testing.T) {
274222 t.Fatal(err)
275223 }
276224 }
277- h := NewScpPostHandler(posts, &shared.ConfigSite{Space: "prose"}, extHooks{meta: move})
278- if err := h.Rename(session, &sendutils.FileEntry{Filepath: "/a.md"}, &sendutils.FileEntry{Filepath: "/b.md"}); err != nil {
225+ if err := RenamePost(session, posts, "prose", entry("a.md"), entry("b.md"), extRename(move)); err != nil {
279226 t.Fatal(err)
280227 }
281228 for name, id := range map[string]string{"b.md": a.ID, "f.md": b.ID} {
R pkg/filehandlers/sftp_test.go => pkg/postfs/sftp_test.go
+10, -7
......@@ -1,4 +1,4 @@
1-package filehandlers_test
1+package postfs_test
22
33 import (
44 "context"
......@@ -50,24 +50,27 @@ func newPostsEnv(t *testing.T, app string) *postsEnv {
5050 posts := stub.NewPostsDB(logger, &db.User{ID: "user-1", Name: "tester"})
5151 posts.Pubkey = shared.KeyForKeyText(signer.PublicKey())
5252
53- var cfg *shared.ConfigSite
53+ var space string
5454 var handler interface {
5555 utils.CopyFromClientHandler
5656 pssh.GetLoggerInterface
5757 }
5858 switch app {
5959 case "prose":
60- cfg = prose.NewConfigSite("prose-test")
60+ cfg := prose.NewConfig("prose-test")
61+ cfg.Logger = logger
6162 st, err := storage.NewStorageMemory(map[string]map[string]string{})
6263 if err != nil {
6364 t.Fatal(err)
6465 }
6566 handler = prose.NewFileHandler(cfg, posts, st)
67+ space = cfg.Space
6668 case "pastes":
67- cfg = pastes.NewConfigSite("pastes-test")
68- handler = pastes.NewFileHandler(cfg, posts)
69+ cfg := pastes.NewConfig("pastes-test")
70+ cfg.Logger = logger
71+ handler = pastes.NewUploadHandler(cfg, posts)
72+ space = cfg.Space
6973 }
70- cfg.Logger = logger
7174
7275 ctx, cancel := context.WithCancel(context.Background())
7376 t.Cleanup(cancel)
......@@ -107,7 +110,7 @@ func newPostsEnv(t *testing.T, app string) *postsEnv {
107110 if err := os.WriteFile(key, pem.EncodeToMemory(b), 0o600); err != nil {
108111 t.Fatal(err)
109112 }
110- return &postsEnv{t: t, db: posts, space: cfg.Space, port: port, key: key}
113+ return &postsEnv{t: t, db: posts, space: space, port: port, key: key}
111114 }
112115
113116 func (e *postsEnv) sshOpts() []string {
+3, -0
......@@ -490,6 +490,9 @@ func NewSSHServer(ctx context.Context, logger *slog.Logger, config *SSHServerCon
490490 }
491491
492492 ctx, cancelFunc := context.WithCancel(sc.Ctx)
493+ // The client closing the session ends its command, even while
494+ // the connection stays open for other sessions.
495+ defer cancelFunc()
493496
494497 sesh := &SSHServerConnSession{
495498 Channel: channel,
+69, -0
......@@ -640,3 +640,72 @@ func GenerateKey() UserSSH {
640640 signer: userSigner,
641641 }
642642 }
643+
644+// A client that closes a session but keeps its connection, like pipe-web,
645+// ends the command running in it.
646+func TestSessionContextEndsWhenClientCloses(t *testing.T) {
647+ ctx, cancel := context.WithCancel(context.Background())
648+ defer cancel()
649+
650+ user := GenerateKey()
651+ started := make(chan struct{})
652+ ended := make(chan struct{})
653+ server := pssh.NewSSHServer(ctx, slog.New(slog.DiscardHandler), &pssh.SSHServerConfig{
654+ ListenAddr: "127.0.0.1:0",
655+ Middleware: []pssh.SSHServerMiddleware{
656+ func(next pssh.SSHServerHandler) pssh.SSHServerHandler {
657+ return func(sesh *pssh.SSHServerConnSession) error {
658+ close(started)
659+ <-sesh.Context().Done()
660+ close(ended)
661+ return next(sesh)
662+ }
663+ },
664+ },
665+ ServerConfig: &ssh.ServerConfig{
666+ NoClientAuth: true,
667+ NoClientAuthCallback: func(ssh.ConnMetadata) (*ssh.Permissions, error) {
668+ return &ssh.Permissions{Extensions: map[string]string{}}, nil
669+ },
670+ },
671+ })
672+ server.Config.AddHostKey(user.signer)
673+ go func() { _ = server.ListenAndServe() }()
674+
675+ var addr string
676+ for range 50 {
677+ server.Mu.Lock()
678+ if server.Listener != nil {
679+ addr = server.Listener.Addr().String()
680+ }
681+ server.Mu.Unlock()
682+ if addr != "" {
683+ break
684+ }
685+ time.Sleep(10 * time.Millisecond)
686+ }
687+ if addr == "" {
688+ t.Fatal("server listener not ready")
689+ }
690+
691+ client, err := ssh.Dial("tcp", addr, &ssh.ClientConfig{User: "user", HostKeyCallback: ssh.InsecureIgnoreHostKey()})
692+ if err != nil {
693+ t.Fatal(err)
694+ }
695+ defer func() { _ = client.Close() }()
696+ session, err := client.NewSession()
697+ if err != nil {
698+ t.Fatal(err)
699+ }
700+ if err := session.Start("wait"); err != nil {
701+ t.Fatal(err)
702+ }
703+ <-started
704+ _ = session.Close()
705+
706+ select {
707+ case <-ended:
708+ case <-time.After(2 * time.Second):
709+ t.Fatal("command still running after the client closed its session")
710+ }
711+}
+4, -1
......@@ -129,7 +129,7 @@ func (b *BaseBroker) Connect(client *Client, channels []*Channel) (error, error)
129129 }
130130 }
131131 if otherChannels == 0 {
132- cl.Cleanup()
132+ cl.Finish()
133133 }
134134 }
135135 }
......@@ -236,6 +236,9 @@ func (b *BaseBroker) Connect(client *Client, channels []*Channel) (error, error)
236236 }
237237 case <-client.Done:
238238 break mainLoop
239+ case <-client.finish:
240+ client.Cleanup()
241+ break mainLoop
239242 }
240243 }
241244 }()
+13, -3
......@@ -15,6 +15,7 @@ func NewClient(ID string, rw io.ReadWriter, direction ChannelDirection, blockWri
1515 Direction: direction,
1616 Channels: syncmap.New[string, *Channel](),
1717 Done: make(chan struct{}),
18+ finish: make(chan struct{}),
1819 Data: make(chan ChannelMessage),
1920 Replay: replay,
2021 BlockWrite: blockWrite,
......@@ -39,17 +40,26 @@ type Client struct {
3940 KeepAlive bool
4041 once sync.Once
4142 onceData sync.Once
43+ finish chan struct{}
44+ finishOnce sync.Once
4245 }
4346
4447 func (c *Client) GetChannels() iter.Seq2[string, *Channel] {
4548 return c.Channels.Range
4649 }
4750
51+// Cleanup ends the client. Its ReadWriter stays open for whoever opened it
52+// to close, so an ssh session can still send its exit status.
4853 func (c *Client) Cleanup() {
4954 c.once.Do(func() {
5055 close(c.Done)
51- if closer, ok := c.ReadWriter.(io.Closer); ok {
52- _ = closer.Close()
53- }
56+ })
57+}
58+
59+// Finish ends a subscriber after it writes the message it was last sent, so
60+// a publisher that leaves doesn't cut off its own last message.
61+func (c *Client) Finish() {
62+ c.finishOnce.Do(func() {
63+ close(c.finish)
5464 })
5565 }
+55, -0
......@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "fmt"
7+ "io"
78 "log/slog"
89 "sync"
910 "testing"
......@@ -221,3 +222,57 @@ func TestDispatcherSingleSubscriber(t *testing.T) {
221222 }
222223 })
223224 }
225+
226+// slowSession is a subscriber whose writes take a while and fail once it is
227+// closed, like an ssh session.
228+type slowSession struct {
229+ Buffer
230+ mu sync.Mutex
231+ closed bool
232+}
233+
234+func (s *slowSession) Write(p []byte) (int, error) {
235+ time.Sleep(20 * time.Millisecond)
236+ s.mu.Lock()
237+ defer s.mu.Unlock()
238+ if s.closed {
239+ return 0, io.ErrClosedPipe
240+ }
241+ return s.Buffer.Write(p)
242+}
243+
244+func (s *slowSession) Close() error {
245+ s.mu.Lock()
246+ defer s.mu.Unlock()
247+ s.closed = true
248+ return nil
249+}
250+
251+func TestSubReceivesLastMessageWhenPublisherLeaves(t *testing.T) {
252+ cast := NewMulticast(slog.Default())
253+ ctx, cancel := context.WithCancel(context.Background())
254+ defer cancel()
255+
256+ sub := &slowSession{}
257+ subDone := make(chan struct{})
258+ go func() {
259+ _ = cast.Sub(ctx, "sub", sub, []*Channel{NewChannel("topic")}, false)
260+ close(subDone)
261+ }()
262+ for joined := 0; joined == 0; time.Sleep(time.Millisecond) {
263+ for range cast.GetSubs() {
264+ joined++
265+ }
266+ }
267+
268+ _ = cast.Pub(ctx, "pub", &Buffer{b: *bytes.NewBufferString("last")}, []*Channel{NewChannel("topic")}, false)
269+
270+ select {
271+ case <-subDone:
272+ case <-time.After(5 * time.Second):
273+ t.Fatal("subscriber is still connected after the publisher left")
274+ }
275+ if got := sub.String(); got != "last" {
276+ t.Errorf("subscriber got %q, want %q", got, "last")
277+ }
278+}
+26, -189
......@@ -8,7 +8,6 @@ import (
88 "net/http"
99 "net/url"
1010 "os"
11- "path"
1211 "strings"
1312 "time"
1413
......@@ -28,169 +27,72 @@ type PageData struct {
2827 Site SitePageData
2928 }
3029
31-type ConfigSite struct {
32- Debug bool
33- SendgridKey string
34- Domain string
35- Port string
36- PortOverride string
37- Protocol string
38- DbURL string
39- Space string
40- Issuer string
41- Secret string
42- SecretWebhook string
43- AllowedExt []string
44- HiddenPosts []string
45- MaxSize uint64
46- MaxAssetSize int64
47- MaxSpecialFileSize int64
48- Logger *slog.Logger
49- TunsSecret string
50-}
51-
52-func NewConfigSite() *ConfigSite {
53- return &ConfigSite{}
54-}
55-
30+// CreateURL builds links to users' sites on Domain, as seen from the host a
31+// request came in on.
5632 type CreateURL struct {
33+ Protocol string
34+ Domain string
5735 Subdomain bool
5836 UsernameInRoute bool
5937 HostDomain string
60- AppDomain string
61- Username string
62- Cfg *ConfigSite
6338 }
6439
65-func NewCreateURL(cfg *ConfigSite) *CreateURL {
40+// NewCreateURL builds links that point at users' subdomains.
41+func NewCreateURL(protocol, domain string) *CreateURL {
6642 return &CreateURL{
67- Cfg: cfg,
68- Subdomain: cfg.IsSubdomains(),
43+ Protocol: protocol,
44+ Domain: domain,
45+ Subdomain: true,
6946 }
7047 }
7148
72-func CreateURLFromRequest(cfg *ConfigSite, r *http.Request) *CreateURL {
49+func CreateURLFromRequest(protocol, domain string, r *http.Request) *CreateURL {
7350 hostDomain := strings.Split(r.Host, ":")[0]
74- appDomain := strings.Split(cfg.Domain, ":")[0]
51+ appDomain := strings.Split(domain, ":")[0]
7552
76- onSubdomain := cfg.IsSubdomains() && strings.Contains(hostDomain, appDomain)
77- withUserName := !cfg.IsCustomdomains() || (!onSubdomain && hostDomain == appDomain)
53+ onSubdomain := strings.Contains(hostDomain, appDomain)
54+ withUserName := !onSubdomain && hostDomain == appDomain
7855
7956 return &CreateURL{
80- Cfg: cfg,
81- AppDomain: appDomain,
57+ Protocol: protocol,
58+ Domain: domain,
8259 HostDomain: hostDomain,
8360 Subdomain: onSubdomain,
8461 UsernameInRoute: withUserName,
8562 }
8663 }
8764
88-func (c *ConfigSite) GetSiteData() *SitePageData {
89- return &SitePageData{
90- Domain: template.URL(c.Domain),
91- HomeURL: template.URL(c.HomeURL()),
92- }
93-}
94-
95-func (c *ConfigSite) IsSubdomains() bool {
96- return true
97-}
98-
99-func (c *ConfigSite) IsCustomdomains() bool {
100- return true
101-}
102-
103-func (c *ConfigSite) HomeURL() string {
104- if c.IsSubdomains() || c.IsCustomdomains() {
105- return fmt.Sprintf("//%s", c.Domain)
106- }
107-
108- return "/"
109-}
110-
111-func (c *ConfigSite) ReadURL() string {
112- if c.IsSubdomains() || c.IsCustomdomains() {
113- return fmt.Sprintf("%s://%s", c.Protocol, c.Domain)
114- }
115-
116- return "/"
117-}
118-
119-func (c *ConfigSite) StaticPath(fname string) string {
120- return path.Join("pkg", "apps", c.Space, fname)
121-}
122-
123-func (c *ConfigSite) BlogURL(username string) string {
124- if c.IsSubdomains() {
125- return fmt.Sprintf("%s://%s.%s", c.Protocol, username, c.Domain)
126- }
127-
128- return fmt.Sprintf("/%s", username)
129-}
130-
131-func (c *ConfigSite) CssURL(username string) string {
132- if c.IsSubdomains() || c.IsCustomdomains() {
133- return "/_styles.css"
134- }
135-
136- return fmt.Sprintf("/%s/styles.css", username)
137-}
138-
139-func (c *ConfigSite) PostURL(username, slug string) string {
140- fname := url.PathEscape(slug)
141- if c.IsSubdomains() {
142- return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
143- }
144-
145- return fmt.Sprintf("/%s/%s", username, fname)
146-
147-}
148-
149-func (c *ConfigSite) RawPostURL(username, slug string) string {
150- fname := url.PathEscape(slug)
151- if c.IsSubdomains() {
152- return fmt.Sprintf("%s://%s.%s/raw/%s", c.Protocol, username, c.Domain, fname)
153- }
154-
155- return fmt.Sprintf("/raw/%s/%s", username, fname)
156-}
157-
158-func (c *ConfigSite) ImgFullURL(username, slug string) string {
159- fname := url.PathEscape(strings.TrimLeft(slug, "/"))
160- return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
161-}
162-
163-func (c *ConfigSite) FullBlogURL(curl *CreateURL, username string) string {
164- if c.IsSubdomains() && curl.Subdomain {
65+func (c *CreateURL) FullBlogURL(username string) string {
66+ if c.Subdomain {
16567 return fmt.Sprintf("%s://%s.%s", c.Protocol, username, c.Domain)
16668 }
16769
168- if curl.UsernameInRoute {
70+ if c.UsernameInRoute {
16971 return fmt.Sprintf("/%s", username)
17072 }
17173
172- return fmt.Sprintf("%s://%s", c.Protocol, curl.HostDomain)
74+ return fmt.Sprintf("%s://%s", c.Protocol, c.HostDomain)
17375 }
17476
175-func (c *ConfigSite) FullPostURL(curl *CreateURL, username, slug string) string {
77+func (c *CreateURL) FullPostURL(username, slug string) string {
17678 fname := url.PathEscape(strings.TrimLeft(slug, "/"))
17779
178- if curl.Subdomain && c.IsSubdomains() {
80+ if c.Subdomain {
17981 return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
18082 }
18183
182- if curl.UsernameInRoute {
84+ if c.UsernameInRoute {
18385 return fmt.Sprintf("%s://%s/%s/%s", c.Protocol, c.Domain, username, fname)
18486 }
18587
186- return fmt.Sprintf("%s://%s/%s", c.Protocol, curl.HostDomain, fname)
88+ return fmt.Sprintf("%s://%s/%s", c.Protocol, c.HostDomain, fname)
18789 }
18890
189-func (c *ConfigSite) RssBlogURL(curl *CreateURL, username, tag string) string {
91+func (c *CreateURL) RssBlogURL(username, tag string) string {
19092 url := ""
191- if c.IsSubdomains() && curl.Subdomain {
93+ if c.Subdomain {
19294 url = fmt.Sprintf("%s://%s.%s/rss", c.Protocol, username, c.Domain)
193- } else if curl.UsernameInRoute {
95+ } else if c.UsernameInRoute {
19496 url = fmt.Sprintf("/%s/rss", username)
19597 } else {
19698 url = "/rss"
......@@ -203,71 +105,6 @@ func (c *ConfigSite) RssBlogURL(curl *CreateURL, username, tag string) string {
203105 return url
204106 }
205107
206-func (c *ConfigSite) ImgURL(curl *CreateURL, username string, slug string) string {
207- fname := url.PathEscape(strings.TrimLeft(slug, "/"))
208- if c.IsSubdomains() && curl.Subdomain {
209- return fmt.Sprintf("%s://%s.%s/%s", c.Protocol, username, c.Domain, fname)
210- }
211-
212- if curl.UsernameInRoute {
213- return fmt.Sprintf("/%s/%s", username, fname)
214- }
215-
216- return fmt.Sprintf("/%s", fname)
217-}
218-
219-func (c *ConfigSite) ImgPostURL(curl *CreateURL, username string, slug string) string {
220- fname := url.PathEscape(strings.TrimLeft(slug, "/"))
221- if c.IsSubdomains() && curl.Subdomain {
222- return fmt.Sprintf("%s://%s.%s/p/%s", c.Protocol, username, c.Domain, fname)
223- }
224-
225- if curl.UsernameInRoute {
226- return fmt.Sprintf("/%s/p/%s", username, fname)
227- }
228-
229- return fmt.Sprintf("/p/%s", fname)
230-}
231-
232-func (c *ConfigSite) ImgOrigURL(curl *CreateURL, username string, slug string) string {
233- fname := url.PathEscape(strings.TrimLeft(slug, "/"))
234- if c.IsSubdomains() && curl.Subdomain {
235- return fmt.Sprintf("%s://%s.%s/o/%s", c.Protocol, username, c.Domain, fname)
236- }
237-
238- if curl.UsernameInRoute {
239- return fmt.Sprintf("/%s/o/%s", username, fname)
240- }
241-
242- return fmt.Sprintf("/o/%s", fname)
243-}
244-
245-func (c *ConfigSite) TagURL(curl *CreateURL, username, tag string) string {
246- tg := url.PathEscape(tag)
247- return fmt.Sprintf("%s?tag=%s", c.FullBlogURL(curl, username), tg)
248-}
249-
250-func (c *ConfigSite) AssetURL(username, projectName, fpath string) string {
251- if username == projectName {
252- return fmt.Sprintf(
253- "%s://%s.%s/%s",
254- c.Protocol,
255- username,
256- c.Domain,
257- fpath,
258- )
259- }
260-
261- return fmt.Sprintf(
262- "%s://%s-%s.%s/%s",
263- c.Protocol,
264- username,
265- projectName,
266- c.Domain,
267- fpath,
268- )
269-}
270-
271108 func CreateLogger(space string, withPipe bool) *slog.Logger {
272109 logger := slog.New(
273110 slog.NewTextHandler(
+5, -3
......@@ -131,12 +131,14 @@ func CleanHost(raw string) (string, error) {
131131
132132 var ErrAnalyticsDisabled = errors.New("owner does not have site analytics enabled")
133133
134-func AnalyticsVisitFromVisit(visit *db.AnalyticsVisits, dbpool db.DB, secret string) error {
134+func AnalyticsVisitFromVisit(visit *db.AnalyticsVisits, dbpool interface {
135+ FindFeature(userID string, feature string) (*db.FeatureFlag, error)
136+}, secret string) error {
135137 if visit.PostID != "" {
136- if !dbpool.HasFeatureByUser(visit.UserID, "plus") && !dbpool.HasFeatureByUser(visit.UserID, "prose") {
138+ if !db.HasPlusOrSpace(dbpool, visit.UserID, "prose") {
137139 return ErrAnalyticsDisabled
138140 }
139- } else if !dbpool.HasFeatureByUser(visit.UserID, "analytics") {
141+ } else if !db.HasFeature(dbpool, visit.UserID, "analytics") {
140142 return ErrAnalyticsDisabled
141143 }
142144
+56, -51
......@@ -1,12 +1,14 @@
11 package router
22
33 import (
4- "fmt"
54 "html/template"
5+ "log/slog"
66 "net/http"
77 "os"
8+ "path/filepath"
89 "strings"
910
11+ "github.com/picosh/pico/pkg/db"
1012 "github.com/picosh/pico/pkg/shared"
1113 )
1214
......@@ -40,57 +42,37 @@ func UnauthorizedHandler(w http.ResponseWriter, r *http.Request) {
4042 http.Error(w, "You do not have access to this site", http.StatusUnauthorized)
4143 }
4244
43-func CheckHandler(w http.ResponseWriter, r *http.Request) {
44- dbpool := GetDB(r)
45- cfg := GetCfg(r)
46-
47- if cfg.IsCustomdomains() {
45+// CheckHandler reports whether a custom domain points at a user's site on
46+// domain, through its TXT record for txtPrefix.
47+func CheckHandler(domain, txtPrefix string, users interface {
48+ FindUserByName(name string) (*db.User, error)
49+}) http.HandlerFunc {
50+ return func(w http.ResponseWriter, r *http.Request) {
4851 hostDomain := r.URL.Query().Get("domain")
49- appDomain := strings.Split(cfg.Domain, ":")[0]
52+ appDomain := strings.Split(domain, ":")[0]
5053
5154 if !strings.Contains(hostDomain, appDomain) {
52- subdomain := GetCustomDomain(hostDomain, cfg.Space)
55+ subdomain := GetCustomDomain(hostDomain, txtPrefix)
5356 if subdomain != "" {
54- u, err := dbpool.FindUserByName(subdomain)
57+ u, err := users.FindUserByName(subdomain)
5558 if u != nil && err == nil {
5659 w.WriteHeader(http.StatusOK)
5760 return
5861 }
5962 }
6063 }
61- }
62-
63- w.WriteHeader(http.StatusNotFound)
64-}
6564
66-func GetUsernameFromRequest(r *http.Request) string {
67- subdomain := GetSubdomain(r)
68- cfg := GetCfg(r)
69-
70- if !cfg.IsSubdomains() || subdomain == "" {
71- return GetField(r, 0)
65+ w.WriteHeader(http.StatusNotFound)
7266 }
73- return subdomain
7467 }
7568
76-func ServeFile(file string, contentType string) http.HandlerFunc {
77- return func(w http.ResponseWriter, r *http.Request) {
78- logger := GetLogger(r)
79- cfg := GetCfg(r)
80-
81- contents, err := os.ReadFile(cfg.StaticPath(fmt.Sprintf("public/%s", file)))
82- if err != nil {
83- logger.Error(err.Error())
84- http.Error(w, "file not found", 404)
85- }
86-
87- w.Header().Add("Content-Type", contentType)
88-
89- _, err = w.Write(contents)
90- if err != nil {
91- logger.Error(err.Error())
92- }
69+// GetUsernameFromRequest is the user whose site a request is for: the
70+// subdomain, or the path's {user} on the main domain.
71+func GetUsernameFromRequest(r *http.Request) string {
72+ if subdomain := GetSubdomain(r); subdomain != "" {
73+ return subdomain
9374 }
75+ return r.PathValue("user")
9476 }
9577
9678 func minus(a, b int) int {
......@@ -111,14 +93,16 @@ var FuncMap = template.FuncMap{
11193 "intRange": intRange,
11294 }
11395
114-func RenderTemplate(cfg *shared.ConfigSite, templates []string) (*template.Template, error) {
96+// RenderTemplate parses templates with the layout and footers in an app's
97+// dir.
98+func RenderTemplate(dir string, templates []string) (*template.Template, error) {
11599 files := make([]string, len(templates))
116100 copy(files, templates)
117101 files = append(
118102 files,
119- cfg.StaticPath("html/footer.partial.tmpl"),
120- cfg.StaticPath("html/marketing-footer.partial.tmpl"),
121- cfg.StaticPath("html/base.layout.tmpl"),
103+ filepath.Join(dir, "html/footer.partial.tmpl"),
104+ filepath.Join(dir, "html/marketing-footer.partial.tmpl"),
105+ filepath.Join(dir, "html/base.layout.tmpl"),
122106 )
123107
124108 ts, err := template.New("base").Funcs(FuncMap).ParseFiles(files...)
......@@ -128,24 +112,45 @@ func RenderTemplate(cfg *shared.ConfigSite, templates []string) (*template.Templ
128112 return ts, nil
129113 }
130114
131-func CreatePageHandler(fname string) http.HandlerFunc {
115+// Static serves an app's files from Dir/public and its pages from Dir/html.
116+type Static struct {
117+ Dir string
118+ Logger *slog.Logger
119+ Site shared.SitePageData
120+}
121+
122+// File serves the file name in Dir/public.
123+func (s Static) File(name string, contentType string) http.HandlerFunc {
132124 return func(w http.ResponseWriter, r *http.Request) {
133- logger := GetLogger(r)
134- cfg := GetCfg(r)
135- ts, err := RenderTemplate(cfg, []string{cfg.StaticPath(fname)})
125+ contents, err := os.ReadFile(filepath.Join(s.Dir, "public", name))
126+ if err != nil {
127+ s.Logger.Error("could not read file", "fname", name, "err", err.Error())
128+ http.Error(w, "file not found", http.StatusNotFound)
129+ return
130+ }
131+
132+ w.Header().Add("Content-Type", contentType)
136133
134+ _, err = w.Write(contents)
135+ if err != nil {
136+ s.Logger.Error("could not write http response", "file", name, "err", err.Error())
137+ }
138+ }
139+}
140+
141+// Page renders the template fname in Dir with the site's data.
142+func (s Static) Page(fname string) http.HandlerFunc {
143+ return func(w http.ResponseWriter, r *http.Request) {
144+ ts, err := RenderTemplate(s.Dir, []string{filepath.Join(s.Dir, fname)})
137145 if err != nil {
138- logger.Error(err.Error())
146+ s.Logger.Error("could not render template", "fname", fname, "err", err.Error())
139147 http.Error(w, err.Error(), http.StatusInternalServerError)
140148 return
141149 }
142150
143- data := shared.PageData{
144- Site: *cfg.GetSiteData(),
145- }
146- err = ts.Execute(w, data)
151+ err = ts.Execute(w, shared.PageData{Site: s.Site})
147152 if err != nil {
148- logger.Error(err.Error())
153+ s.Logger.Error("could not execute template", "fname", fname, "err", err.Error())
149154 http.Error(w, err.Error(), http.StatusInternalServerError)
150155 }
151156 }
+41, -149
......@@ -4,124 +4,41 @@ import (
44 "context"
55 "errors"
66 "fmt"
7- "log/slog"
87 "net"
98 "net/http"
109 "net/http/pprof"
11- "regexp"
1210 "strings"
1311 "time"
1412
1513 "github.com/hashicorp/golang-lru/v2/expirable"
16- "github.com/picosh/pico/pkg/db"
1714 "github.com/picosh/pico/pkg/shared"
18- "github.com/picosh/pico/pkg/storage"
1915 )
2016
21-type Route struct {
22- Method string
23- Regex *regexp.Regexp
24- Handler http.HandlerFunc
25- CorsEnabled bool
26-}
27-
28-func NewRoute(method, pattern string, handler http.HandlerFunc) Route {
29- return Route{
30- method,
31- regexp.MustCompile("^" + pattern + "$"),
32- handler,
33- false,
34- }
35-}
36-
37-func CreatePProfRoutes(routes []Route) []Route {
38- return append(routes,
39- NewRoute("GET", "/debug/pprof/cmdline", pprof.Cmdline),
40- NewRoute("GET", "/debug/pprof/profile", pprof.Profile),
41- NewRoute("GET", "/debug/pprof/symbol", pprof.Symbol),
42- NewRoute("GET", "/debug/pprof/trace", pprof.Trace),
43- NewRoute("GET", "/debug/pprof/(.*)", pprof.Index),
44- NewRoute("POST", "/debug/pprof/cmdline", pprof.Cmdline),
45- NewRoute("POST", "/debug/pprof/profile", pprof.Profile),
46- NewRoute("POST", "/debug/pprof/symbol", pprof.Symbol),
47- NewRoute("POST", "/debug/pprof/trace", pprof.Trace),
48- NewRoute("POST", "/debug/pprof/(.*)", pprof.Index),
49- )
50-}
51-
17+// CreatePProfRoutesMux serves the profiler on mux.
5218 func CreatePProfRoutesMux(mux *http.ServeMux) {
53- mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
54- mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
55- mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
56- mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
57- mux.HandleFunc("GET /debug/pprof/(.*)", pprof.Index)
58- mux.HandleFunc("POST /debug/pprof/cmdline", pprof.Cmdline)
59- mux.HandleFunc("POST /debug/pprof/profile", pprof.Profile)
60- mux.HandleFunc("POST /debug/pprof/symbol", pprof.Symbol)
61- mux.HandleFunc("POST /debug/pprof/trace", pprof.Trace)
62- mux.HandleFunc("POST /debug/pprof/(.*)", pprof.Index)
63-}
64-
65-type ApiConfig struct {
66- Cfg *shared.ConfigSite
67- Dbpool db.DB
68- Storage storage.StorageServe
69-}
70-
71-func (hc *ApiConfig) HasPrivilegedAccess(apiToken string) bool {
72- user, err := hc.Dbpool.FindUserByToken(apiToken)
73- if err != nil {
74- return false
19+ for _, method := range []string{"GET", "POST"} {
20+ mux.HandleFunc(method+" /debug/pprof/", pprof.Index)
21+ mux.HandleFunc(method+" /debug/pprof/cmdline", pprof.Cmdline)
22+ mux.HandleFunc(method+" /debug/pprof/profile", pprof.Profile)
23+ mux.HandleFunc(method+" /debug/pprof/symbol", pprof.Symbol)
24+ mux.HandleFunc(method+" /debug/pprof/trace", pprof.Trace)
7525 }
76- return hc.Dbpool.HasFeatureByUser(user.ID, "auth")
7726 }
7827
79-func (hc *ApiConfig) HasPlusOrSpace(user *db.User, space string) bool {
80- return db.HasPlusOrSpace(hc.Dbpool, user.ID, space)
81-}
82-
83-func (hc *ApiConfig) CreateCtx(prevCtx context.Context, subdomain string) context.Context {
84- ctx := context.WithValue(prevCtx, ctxLoggerKey{}, hc.Cfg.Logger)
85- ctx = context.WithValue(ctx, CtxSubdomainKey{}, subdomain)
86- ctx = context.WithValue(ctx, ctxDBKey{}, hc.Dbpool)
87- ctx = context.WithValue(ctx, ctxStorageKey{}, hc.Storage)
88- ctx = context.WithValue(ctx, ctxCfg{}, hc.Cfg)
89- return ctx
90-}
91-
92-func CreateServeBasic(routes []Route, ctx context.Context) http.HandlerFunc {
28+// Cors adds CORS headers to h's responses.
29+func Cors(h http.HandlerFunc) http.HandlerFunc {
9330 return func(w http.ResponseWriter, r *http.Request) {
94- var allow []string
95- for _, route := range routes {
96- matches := route.Regex.FindStringSubmatch(r.URL.Path)
97- if len(matches) > 0 {
98- if r.Method == "OPTIONS" && route.CorsEnabled {
99- CorsHeaders(w.Header())
100- w.WriteHeader(http.StatusOK)
101- return
102- } else if r.Method != route.Method {
103- allow = append(allow, route.Method)
104- continue
105- }
106-
107- if route.CorsEnabled {
108- CorsHeaders(w.Header())
109- }
110-
111- finctx := context.WithValue(ctx, ctxKey{}, matches[1:])
112- route.Handler(w, r.WithContext(finctx))
113- return
114- }
115- }
116- if len(allow) > 0 {
117- w.Header().Set("Allow", strings.Join(allow, ", "))
118- http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
119- return
120- }
121- http.NotFound(w, r)
31+ CorsHeaders(w.Header())
32+ h(w, r)
12233 }
12334 }
12435
36+// CorsPreflight answers a CORS preflight request.
37+func CorsPreflight(w http.ResponseWriter, r *http.Request) {
38+ CorsHeaders(w.Header())
39+ w.WriteHeader(http.StatusOK)
40+}
41+
12542 func GetSubdomainFromRequest(r *http.Request, domain, space string) string {
12643 hostDomain := strings.ToLower(strings.Split(r.Host, ":")[0])
12744 appDomain := strings.ToLower(strings.Split(domain, ":")[0])
......@@ -139,61 +56,36 @@ func GetSubdomainFromRequest(r *http.Request, domain, space string) string {
13956 return ""
14057 }
14158
142-func findRouteConfig(r *http.Request, routes []Route, subdomainRoutes []Route, cfg *shared.ConfigSite) ([]Route, string) {
143- if len(subdomainRoutes) == 0 {
144- return routes, ""
145- }
146-
147- subdomain := GetSubdomainFromRequest(r, cfg.Domain, cfg.Space)
148- if subdomain == "" {
149- return routes, subdomain
150- }
151- return subdomainRoutes, subdomain
152-}
153-
154-func CreateServe(routes []Route, subdomainRoutes []Route, apiConfig *ApiConfig) http.HandlerFunc {
155- return func(w http.ResponseWriter, r *http.Request) {
156- curRoutes, subdomain := findRouteConfig(r, routes, subdomainRoutes, apiConfig.Cfg)
157- ctx := apiConfig.CreateCtx(r.Context(), subdomain)
158- router := CreateServeBasic(curRoutes, ctx)
159- router(w, r)
160- }
161-}
162-
163-type ctxDBKey struct{}
164-type ctxStorageKey struct{}
165-type ctxLoggerKey struct{}
166-type ctxCfg struct{}
167-
168-type CtxSubdomainKey struct{}
169-type ctxKey struct{}
170-
171-func GetCfg(r *http.Request) *shared.ConfigSite {
172- return r.Context().Value(ctxCfg{}).(*shared.ConfigSite)
173-}
174-
175-func GetLogger(r *http.Request) *slog.Logger {
176- return r.Context().Value(ctxLoggerKey{}).(*slog.Logger)
177-}
178-
179-func GetDB(r *http.Request) db.DB {
180- return r.Context().Value(ctxDBKey{}).(db.DB)
59+// NewHostRouter serves requests for users' sites with sites, and the rest
60+// with main. A site is a subdomain of domain, or a custom domain whose TXT
61+// record for txtPrefix names one. GetSubdomain returns the site's name. A
62+// nil sites serves everything with main.
63+func NewHostRouter(domain, txtPrefix string, main, sites http.Handler) http.Handler {
64+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65+ if sites == nil {
66+ main.ServeHTTP(w, r)
67+ return
68+ }
69+ subdomain := GetSubdomainFromRequest(r, domain, txtPrefix)
70+ if subdomain == "" {
71+ main.ServeHTTP(w, r)
72+ return
73+ }
74+ sites.ServeHTTP(w, r.WithContext(WithSubdomain(r.Context(), subdomain)))
75+ })
18176 }
18277
183-func GetStorage(r *http.Request) storage.StorageServe {
184- return r.Context().Value(ctxStorageKey{}).(storage.StorageServe)
185-}
78+type ctxSubdomainKey struct{}
18679
187-func GetField(r *http.Request, index int) string {
188- fields := r.Context().Value(ctxKey{}).([]string)
189- if index >= len(fields) {
190- return ""
191- }
192- return fields[index]
80+// WithSubdomain marks ctx as a request for the site subdomain.
81+func WithSubdomain(ctx context.Context, subdomain string) context.Context {
82+ return context.WithValue(ctx, ctxSubdomainKey{}, subdomain)
19383 }
19484
85+// GetSubdomain is the site a request is for, or "" for the main domain.
19586 func GetSubdomain(r *http.Request) string {
196- return r.Context().Value(CtxSubdomainKey{}).(string)
87+ subdomain, _ := r.Context().Value(ctxSubdomainKey{}).(string)
88+ return subdomain
19789 }
19890
19991 var txtCache = expirable.NewLRU[string, string](2048, nil, shared.CacheTimeout)
+38, -0
......@@ -3,6 +3,8 @@ package router
33 import (
44 "errors"
55 "net"
6+ "net/http"
7+ "net/http/httptest"
68 "testing"
79 )
810
......@@ -50,3 +52,39 @@ func TestGetCustomDomainCachesMisses(t *testing.T) {
5052 t.Fatalf("lookup after a transient failure got %q", got)
5153 }
5254 }
55+
56+func TestNewHostRouter(t *testing.T) {
57+ serve := func(name string) http.Handler {
58+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59+ _, _ = w.Write([]byte(name + ":" + GetSubdomain(r)))
60+ })
61+ }
62+ get := func(h http.Handler, url string) string {
63+ res := httptest.NewRecorder()
64+ h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, url, nil))
65+ return res.Body.String()
66+ }
67+
68+ h := NewHostRouter("pico.test", "prose", serve("main"), serve("sites"))
69+ if got := get(h, "https://pico.test/"); got != "main:" {
70+ t.Errorf("main domain = %q", got)
71+ }
72+ if got := get(h, "https://tester.pico.test/"); got != "sites:tester" {
73+ t.Errorf("subdomain = %q", got)
74+ }
75+ if got := get(NewHostRouter("pico.test", "prose", serve("main"), nil), "https://tester.pico.test/"); got != "main:" {
76+ t.Errorf("subdomain without sites = %q", got)
77+ }
78+}
79+
80+func TestCreatePProfRoutesMux(t *testing.T) {
81+ mux := http.NewServeMux()
82+ CreatePProfRoutesMux(mux)
83+ for _, path := range []string{"/debug/pprof/", "/debug/pprof/heap", "/debug/pprof/cmdline"} {
84+ res := httptest.NewRecorder()
85+ mux.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil))
86+ if res.Code != http.StatusOK {
87+ t.Errorf("%s = %d", path, res.Code)
88+ }
89+ }
90+}
+2, -29
......@@ -13,19 +13,12 @@ import (
1313 const adminPrefix = "admin__"
1414
1515 type SshAuthHandler struct {
16- DB AuthFindUser
16+ DB db.AuthDB
1717 Logger *slog.Logger
1818 Principal string
1919 }
2020
21-type AuthFindUser interface {
22- FindUserByPubkey(key string) (*db.User, error)
23- FindUserByName(name string) (*db.User, error)
24- FindFeature(userID, name string) (*db.FeatureFlag, error)
25- InsertAccessLog(log *db.AccessLog) error
26-}
27-
28-func NewSshAuthHandler(dbh AuthFindUser, logger *slog.Logger, principal string) *SshAuthHandler {
21+func NewSshAuthHandler(dbh db.AuthDB, logger *slog.Logger, principal string) *SshAuthHandler {
2922 return &SshAuthHandler{
3023 DB: dbh,
3124 Logger: logger,
......@@ -150,23 +143,3 @@ func (r *SshAuthHandler) PubkeyAuthHandler(conn ssh.ConnMetadata, key ssh.Public
150143
151144 return perms, nil
152145 }
153-
154-func FindPlusFF(dbpool db.DB, cfg *ConfigSite, userID string) *db.FeatureFlag {
155- ff, _ := dbpool.FindFeature(userID, "plus")
156- // we have free tiers so users might not have a feature flag
157- // in which case we set sane defaults
158- if ff == nil {
159- ff = db.NewFeatureFlag(
160- userID,
161- "plus",
162- cfg.MaxSize,
163- cfg.MaxAssetSize,
164- cfg.MaxSpecialFileSize,
165- )
166- }
167- // this is jank
168- ff.Data.StorageMax = ff.FindStorageMax(cfg.MaxSize)
169- ff.Data.FileMax = ff.FindFileMax(cfg.MaxAssetSize)
170- ff.Data.SpecialFileMax = ff.FindSpecialFileMax(cfg.MaxSpecialFileSize)
171- return ff
172-}
+2, -2
......@@ -472,12 +472,12 @@ func (m *TunsPage) fetchEventLogs() {
472472 }
473473
474474 func (m *TunsPage) fetchTuns() {
475- tMap, err := fetch("tuns.sh", m.shared.Cfg.TunsSecret)
475+ tMap, err := fetch("tuns.sh", m.shared.TunsSecret)
476476 if err != nil {
477477 m.err = err
478478 return
479479 }
480- nMap, err := fetch("nue.tuns.sh", m.shared.Cfg.TunsSecret)
480+ nMap, err := fetch("nue.tuns.sh", m.shared.TunsSecret)
481481 if err != nil {
482482 m.err = err
483483 return
+3, -3
......@@ -24,7 +24,7 @@ const (
2424 type SharedModel struct {
2525 Logger *slog.Logger
2626 Session *pssh.SSHServerConnSession
27- Cfg *shared.ConfigSite
27+ TunsSecret string
2828 Dbpool db.DB
2929 PgsDB pgsdb.PgsDB
3030 User *db.User
......@@ -282,7 +282,7 @@ func initData(shrd *SharedModel) error {
282282 }
283283
284284 func FindUser(shrd *SharedModel) (*db.User, error) {
285- logger := shrd.Cfg.Logger
285+ logger := shrd.Logger
286286 var user *db.User
287287 usr := shrd.Session.User()
288288
......@@ -307,7 +307,7 @@ func FindUser(shrd *SharedModel) (*db.User, error) {
307307
308308 // impersonation
309309 if strings.HasPrefix(usr, adminPrefix) {
310- hasFeature := shrd.Dbpool.HasFeatureByUser(user.ID, "admin")
310+ hasFeature := db.HasFeature(shrd.Dbpool, user.ID, "admin")
311311 if hasFeature {
312312 impersonate := strings.TrimPrefix(usr, adminPrefix)
313313 user, err = shrd.Dbpool.FindUserByName(impersonate)